diff --git a/.github/workflows/pytest_github_actions.yml b/.github/workflows/pytest_github_actions.yml
deleted file mode 100644
index accdaa1d..00000000
--- a/.github/workflows/pytest_github_actions.yml
+++ /dev/null
@@ -1,72 +0,0 @@
-name: Run pytest Tests with supertokens
-
-on:
- push:
- branches:
- - '**'
- pull_request:
- branches:
- - '**'
- workflow_dispatch:
-
-jobs:
- pytest:
- runs-on: ubuntu-latest
- env:
- # === Vachan Admin DB ===
- VACHAN_ADMIN_POSTGRES_USER: postgres
- VACHAN_ADMIN_POSTGRES_PASSWORD: postgres
- VACHAN_ADMIN_POSTGRES_DATABASE: vachan_admin_db
- VACHAN_ADMIN_POSTGRES_PORT: 5432
- VACHAN_DOMAIN: http://localhost
-
- #SUPERTOKEN CONFIG
- SUPERTOKENS_CONNECTION_URI: http://supertokens:3567
- SUPERTOKENS_API_KEY: Akjnv3iunvsoi8=-sackjij3ncisds
- SUPERTOKENS_DB_USER: supertokens_user
- SUPERTOKENS_DB_PASSWORD: somePassword
- SUPERTOKENS_DB_NAME: supertokens
- SUPERTOKENS_DB_CONNECTION_URI: postgresql://${SUPERTOKENS_DB_USER}:${SUPERTOKENS_DB_PASSWORD}@supertokens-db:5432/${SUPERTOKENS_DB_NAME}
-
- SUPERTOKENS_API_DOMAIN: http://localhost:8000
- SUPERTOKENS_WEBSITE_DOMAIN: http://localhost:5174
- SUPERTOKENS_ANTI_CSRF: NONE
- SUPERTOKENS_COOKIE_SECURE: true
- SUPERTOKENS_COOKIE_DOMAIN: http://localhost:8000
- SUPERTOKENS_COOKIE_SAME_SITE: lax
-
-
- # === Dummy SMTP (safe defaults for CI) ===
- SMTP_HOST: localhost
- SMTP_PORT: "2525"
- SMTP_NAME: "CI Mailer"
- SMTP_EMAIL: "ci@example.com"
- SMTP_PASSWORD: "dummy"
- SMTP_SECURE: "false"
-
- steps:
- - run: echo "🎉 Job triggered by ${{ github.event_name }}"
- - run: echo "🐧 Running on ${{ runner.os }} hosted by GitHub"
- - run: echo "🔎 Checking out repository..."
- - uses: actions/checkout@v4
-
- # Start services
- - name: Start Docker services
- working-directory: ./docker/docker_backend
- run: docker compose -f docker-compose-prod.yml up -d --build
-
- # Show logs (non-blocking)
- - name: Show container logs (for debugging)
- working-directory: ./docker/docker_backend
- run: docker compose -f docker-compose-prod.yml logs vachan_admin_app || true
-
- # Run pytest inside the app container
- - name: Run tests
- working-directory: ./docker/docker_backend
- run: docker compose -f docker-compose-prod.yml exec vachan_admin_app python -m pytest test/test_resources.py
-
- # Cleanup
- - name: Cleanup
- if: always()
- working-directory: ./docker/docker_backend
- run: docker compose -f docker-compose-prod.yml down -v
diff --git a/backend/Readme.md b/backend/Readme.md
index af31d1e5..8da0c1b4 100644
--- a/backend/Readme.md
+++ b/backend/Readme.md
@@ -229,3 +229,33 @@ SMTP_PASSWORD=your_app_password
- **Dev:** use MailHog → safe, no real emails.
- **Prod:** use real SMTP → sends to inbox.
- Don’t commit real credentials to Git.
+
+
+## M2M Authentication
+### Credential generation script
+
+Create client credentials once using the script below.
+The script prints the plain secret once and stores only the hash in DB.
+
+```bash
+backend/app/scripts/generate_credentials.py
+```
+Run command:
+```bash
+python scripts/generate_credentials.py --client-id vachan-nextjs-prod --name "Vachan Online Next.js Prod"
+```
+Example output:
+```bash
+M2M credentials created successfully
+client_id: vachan-nextjs-prod
+client_secret:
+name: Vachan Online Next.js Prod
+```
+
+### env
+```bash
+JWT_SECRET_KEY=
+JWT_ALGORITHM=HS256
+M2M_TOKEN_EXPIRE_MINUTES=60
+M2M_REFRESH_TOKEN_EXPIRE_DAYS=30
+```
\ No newline at end of file
diff --git a/backend/app/auth.py b/backend/app/auth.py
index 8280d87e..1e556394 100644
--- a/backend/app/auth.py
+++ b/backend/app/auth.py
@@ -1,14 +1,111 @@
"""Authentication and authorization functions."""
-from typing import Optional, Tuple, Any, Dict
+from typing import Optional, Tuple, Any, Dict, TypedDict
+import os
+from fastapi import Depends, HTTPException, Request, status,Security
from sqlalchemy.orm import Session
from supertokens_python.recipe.session import SessionContainer
from supertokens_python.asyncio import get_user
-from dependencies import logger
+from dependencies import logger,get_db
import db_models
-
+from datetime import datetime, timezone, timedelta
from custom_exceptions import (
PermissionException,
)
+from pydantic import BaseModel
+from jose import jwt, JWTError
+from passlib.context import CryptContext
+from dotenv import load_dotenv
+from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials, APIKeyHeader
+
+load_dotenv()
+_VALID_API_KEYS_CACHE = None
+
+pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
+_DEFAULT_M2M_SCOPES = ["read:content"]
+ACCESS_TOKEN_EXPIRE_MINUTES = 60
+bearer_scheme = HTTPBearer(auto_error=False)
+api_key_scheme = APIKeyHeader(name="X-API-Key", auto_error=False)
+
+
+
+def load_api_keys() -> Dict[str, Dict[str, Any]]:
+ """Load API keys from environment variable"""
+ global _VALID_API_KEYS_CACHE
+
+ # Return cached version if already loaded
+ if _VALID_API_KEYS_CACHE is not None:
+ return _VALID_API_KEYS_CACHE
+
+ api_keys_str = os.getenv("VALID_API_KEYS", "")
+
+ if not api_keys_str:
+ logger.warning("No API keys configured in environment")
+ _VALID_API_KEYS_CACHE = {}
+ return {}
+
+ # Split by comma if multiple keys
+ keys = [k.strip() for k in api_keys_str.split(",")]
+
+ # Create dictionary
+ result = {
+ key: {"name": f"API Key {i+1}", "active": True}
+ for i, key in enumerate(keys)
+ }
+
+ _VALID_API_KEYS_CACHE = result
+ return result
+
+
+class AuthContext(TypedDict):
+ auth_type: str
+ session: Optional[SessionContainer]
+
+
+async def verify_session_or_api_key(
+ request: Request,
+ bearer: HTTPAuthorizationCredentials | None = Security(bearer_scheme),
+ x_api_key: str | None = Security(api_key_scheme),
+ db_session: Session = Depends(get_db),
+) -> Dict[str, Any]:
+ """
+ Supports:
+ 1. SuperTokens session -> auth_type = "session"
+ 2. Static X-API-Key -> auth_type = "api_key"
+ 3. M2M Bearer JWT -> auth_type = "m2m"
+ """
+
+ cookies = request.cookies
+
+ # 1. M2M bearer token
+ if bearer and bearer.scheme and bearer.scheme.lower() == "bearer":
+ token = bearer.credentials
+ return verify_m2m_token(token, db_session)
+
+ # 2. Static API key fallback
+ VALID_API_KEYS = load_api_keys()
+ if x_api_key:
+ if x_api_key in VALID_API_KEYS and VALID_API_KEYS[x_api_key]["active"]:
+ return {"auth_type": "api_key", "session": None}
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Invalid API key",
+ )
+
+ # 3. SuperTokens session
+ if "sAccessToken" in cookies or "sRefreshToken" in cookies:
+ try:
+ from supertokens_python.recipe.session.asyncio import get_session
+ session = await get_session(request)
+ if session:
+ return {"auth_type": "session", "session": session}
+ except Exception:
+ pass
+
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Unauthorized",
+ )
+
def validate_admin_only(session: SessionContainer):
"""Validate that the user is an admin."""
@@ -111,3 +208,192 @@ async def ensure_user_from_session_async(
db.commit()
db.refresh(user)
return user.id, user
+
+#-------------------------jwt-token----------------------
+
+
+
+
+class TokenRequest(BaseModel):
+ client_id: str
+ client_secret: str
+
+
+class RefreshTokenRequest(BaseModel):
+ refresh_token: str
+
+
+class TokenResponse(BaseModel):
+ access_token: str
+ refresh_token: str
+ token_type: str
+ expires_in: int
+
+
+def _secret_key() -> str:
+ key = os.environ.get("JWT_SECRET_KEY", "")
+ if not key:
+ raise RuntimeError("JWT_SECRET_KEY environment variable is not set")
+ return key
+
+
+def _jwt_algorithm() -> str:
+ return os.environ.get("JWT_ALGORITHM", "HS256")
+
+
+def _m2m_token_expire_minutes() -> int:
+ return int(os.environ.get("M2M_TOKEN_EXPIRE_MINUTES", "60"))
+
+
+def _m2m_refresh_expire_days() -> int:
+ return int(os.environ.get("M2M_REFRESH_TOKEN_EXPIRE_DAYS", "30"))
+
+
+def _create_m2m_token(
+ client_id: str,
+ token_type: str,
+ expires_delta: timedelta,
+ scopes: Optional[list[str]] = None,
+) -> str:
+ now = datetime.now(timezone.utc)
+ payload = {
+ "sub": client_id,
+ "type": token_type,
+ "iat": int(now.timestamp()),
+ "exp": int((now + expires_delta).timestamp()),
+ }
+
+ if token_type == "client_credentials":
+ payload["scopes"] = scopes or _DEFAULT_M2M_SCOPES
+
+ return jwt.encode(payload, _secret_key(), algorithm=_jwt_algorithm())
+
+
+def create_m2m_token_pair(client_id: str, scopes: Optional[list[str]] = None) -> tuple[str, str, int]:
+ expire_minutes = _m2m_token_expire_minutes()
+
+ access_token = _create_m2m_token(
+ client_id=client_id,
+ token_type="client_credentials",
+ expires_delta=timedelta(minutes=expire_minutes),
+ scopes=scopes,
+ )
+
+ refresh_token = _create_m2m_token(
+ client_id=client_id,
+ token_type="refresh",
+ expires_delta=timedelta(days=_m2m_refresh_expire_days()),
+ )
+
+ return access_token, refresh_token, expire_minutes * 60
+
+
+def get_active_m2m_client_by_client_id(db: Session, client_id: str) -> db_models.M2MClient:
+ client = (
+ db.query(db_models.M2MClient)
+ .filter_by(client_id=client_id, is_active=True)
+ .first()
+ )
+ if not client:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="M2M client inactive or not found",
+ )
+ return client
+
+
+def authenticate_m2m_client(
+ db: Session,
+ client_id: str,
+ client_secret: str,
+) -> db_models.M2MClient:
+ client = (
+ db.query(db_models.M2MClient)
+ .filter_by(client_id=client_id, is_active=True)
+ .first()
+ )
+
+ if not client:
+ logger.warning("Unknown or inactive M2M client_id: %s", client_id)
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Invalid client credentials",
+ )
+
+ if not pwd_context.verify(client_secret, client.client_secret_hash):
+ logger.warning("Invalid M2M client secret for client_id: %s", client_id)
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Invalid client credentials",
+ )
+
+ return client
+
+
+def verify_m2m_token(token: str, db: Session) -> Dict[str, Any]:
+ try:
+ payload = jwt.decode(
+ token,
+ _secret_key(),
+ algorithms=[_jwt_algorithm()],
+ )
+ except JWTError:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Invalid or expired bearer token",
+ )
+
+ if payload.get("type") != "client_credentials":
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Invalid token type",
+ )
+
+ client_id = payload.get("sub")
+ if not client_id:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Invalid token payload",
+ )
+
+ get_active_m2m_client_by_client_id(db, client_id)
+
+ return {
+ "auth_type": "m2m",
+ "session": None,
+ "client_id": client_id,
+ "scopes": payload.get("scopes", []),
+ }
+
+
+def verify_m2m_refresh_token(refresh_token: str, db: Session) -> db_models.M2MClient:
+ try:
+ payload = jwt.decode(
+ refresh_token,
+ _secret_key(),
+ algorithms=[_jwt_algorithm()],
+ )
+ except JWTError:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Invalid or expired refresh token",
+ )
+
+ if payload.get("type") != "refresh":
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Invalid refresh token type",
+ )
+
+ client_id = payload.get("sub")
+ if not client_id:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Invalid refresh token payload",
+ )
+
+ # Confirm client is still active before issuing new tokens
+ client = get_active_m2m_client_by_client_id(db, client_id)
+ return client
+
+
diff --git a/backend/app/config.py b/backend/app/config.py
index 57c26243..6f6cb197 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -41,7 +41,7 @@
host=SMTP_HOST,
port=SMTP_PORT,
from_=SMTPSettingsFrom(name=SMTP_NAME, email=SMTP_EMAIL),
- password=SMTP_EMAIL,
+ password=SMTP_PASSWORD,
secure=SMTP_SECURE,
)
diff --git a/backend/app/crud.py b/backend/app/crud.py
deleted file mode 100644
index 0ebea20a..00000000
--- a/backend/app/crud.py
+++ /dev/null
@@ -1,11637 +0,0 @@
-"""CRUD operations for the model."""
-import os
-import re
-import csv
-import json
-import zipfile
-import io
-import asyncio
-import unicodedata
-from collections import Counter
-from datetime import datetime, time, timezone
-from io import StringIO
-from typing import Optional, Tuple, List, Dict, Any
-from bs4 import BeautifulSoup
-import sqlalchemy
-from sqlalchemy import text, or_, func, cast, Integer
-from sqlalchemy.orm import Session, aliased
-from sqlalchemy.exc import IntegrityError, SQLAlchemyError
-from fastapi import HTTPException, UploadFile
-from fastapi.concurrency import run_in_threadpool
-from fastapi.responses import FileResponse, StreamingResponse
-from usfm_grammar import USFMParser
-import requests
-import httpx
-from dependencies import logger
-import db_models
-import schema
-import tempfile
-from schema import BibleEntrySchema
-from custom_exceptions import (
- AlreadyExistsException,
- NotAvailableException,
- UnprocessableException,
- DatabaseException,
- BadRequestException,
- TypeException,
- MultiStatus,
- GenericException
-)
-# Known USFM markers (basic subset; you can expand this)
-VALID_MARKERS = {
- "\\id", "\\usfm", "\\c", "\\v", "\\p", "\\q1", "\\s", "\\m", "\\b", "\\nb", "\\toc1", "\\toc2", "\\toc3"
-}
-
-REQUEST_TIMEOUT = 10
-RETRY_DELAY = 0.15 # seconds
-
-def utcnow():
- """Returns current UTC datetime"""
- return datetime.now(timezone.utc)
-
-# if os.environ.get("DOCKER_RUN")=='True':
-# LOG_DIR = "/app/logs" # will be mounted to docker volume
-# else:
-# LOG_DIR = os.path.join(os.path.dirname(__file__), "logs")
-
-# os.makedirs(LOG_DIR, exist_ok=True)
-
-# #--- Content Type CRUD
-# # def get_all_content_types(db_session: Session):
-# # """Retrieve all content types from the database."""
-# # return db_session.query(db_models.ContentType).order_by(db_models.ContentType.content_id).all()
-
-# # def get_content_type(db_session: Session, content_id: int):
-# # """Retrieve a single content type by ID."""
-# # return db_session.query(db_models.ContentType).filter(db_models.ContentType.content_id == content_id).first()
-
-# # def create_content_type(db_session: Session, content: schema.ContentTypeCreate):
-# # """Create a new content type if it does not already exist."""
-# # existing = db_session.query(db_models.ContentType).filter(
-# # db_models.ContentType.content_name == content.content_name
-# # ).first()
-# # if existing:
-# # raise HTTPException(status_code=400, detail="Content type already exists")
-# # db_obj = db_models.ContentType(**content.model_dump())
-# # db_session.add(db_obj)
-# # db_session.commit()
-# # db_session.refresh(db_obj)
-# # return db_obj
-
-# # def update_content_type(db_session: Session, content_id: int, content: schema.ContentTypeUpdate):
-# # """Update an existing content type by ID with duplicate name check."""
-# # db_obj = db_session.query(db_models.ContentType).filter(db_models.ContentType.content_id == content_id).first()
-# # if not db_obj:
-# # raise HTTPException(status_code=404, detail="Content type not found")
-# # # Check for name duplication in other rows
-# # duplicate = db_session.query(db_models.ContentType).filter(
-# # db_models.ContentType.content_name == content.content_name,
-# # db_models.ContentType.content_id != content_id
-# # ).first()
-# # if duplicate:
-# # logger.error("Content type name already exists")
-# # raise HTTPException(status_code=400, detail="Content type name already exists")
-# # db_obj.content_name = content.content_name
-# # db_session.commit()
-# # db_session.refresh(db_obj)
-# # return db_obj
-
-# # def delete_content_type(db_session: Session, content_id: int):
-# # """Delete a content type by ID if it exists."""
-# # db_obj = db_session.query(db_models.ContentType).filter(db_models.ContentType.content_id == content_id).first()
-# # if not db_obj:
-# # logger.error("Content type not found")
-# # raise HTTPException(status_code=404, detail="Content type not found")
-# # # if db_session.query(db_models.Resource).filter_by(content_id=content_id).first():
-# # # raise HTTPException(status_code=400, detail="Content type is in use and cannot be deleted")
-# # if db_obj:
-# # db_session.delete(db_obj)
-# # db_session.commit()
-# # return db_obj
-
-# # --- Version CRUD ---
-# # def get_all_versions(db_session: Session):
-# # """Retrieve all versions from the database."""
-# # return db_session.query(db_models.Version).order_by(db_models.Version.version_id).all()
-
-# # def get_version(db_session: Session, version_id: int,abbreviation: Optional[str] = None):
-# # """Retrieve a single version by ID."""
-# # query = db_session.query(db_models.Version)
-# # if version_id is not None:
-# # query = query.filter(db_models.Version.version_id == version_id)
-
-# # if abbreviation is not None:
-# # query = query.filter(db_models.Version.abbreviation == abbreviation)
-
-# # return query.first()
-
-# # def create_version(db_session: Session, version: schema.VersionCreate):
-# # """Create a new version with checks for duplicate name and abbreviation."""
-# # # Check for duplicate name
-# # if db_session.query(db_models.Version).filter(
-# # func.lower(db_models.Version.name) == func.lower(version.name)).first():
-# # logger.error("Version with the same name already exists")
-# # raise AlreadyExistsException(detail="Version with the same name already exists")
-
-# # # Check for duplicate abbreviation
-# # existing = db_session.query(db_models.Version).filter(
-# # func.lower(db_models.Version.abbreviation) == func.lower(version.abbreviation)).first()
-# # if existing:
-# # logger.error("Version with the same abbreviation already exists")
-# # raise AlreadyExistsException(detail="Version with the same abbreviation already exists")
-# # # db_obj = db_models.Version(**version.model_dump(by_alias=True))
-# # db_obj = db_models.Version(
-# # name=version.name,
-# # abbreviation=version.abbreviation,
-# # meta_data=version.metadata
-# # )
-# # db_session.add(db_obj)
-# # db_session.commit()
-# # db_session.refresh(db_obj)
-# # return db_obj
-
-# # def update_version(db_session: Session, version_id: int, version: schema.VersionUpdate):
-# # """Update an existing version by ID with detailed duplicate checks."""
-# # db_obj = get_version(db_session, version_id)
-# # if not db_obj:
-# # logger.error("Version not found")
-# # raise NotAvailableException(detail="Version not found")
-# # # Check for duplicate name
-# # if db_session.query(db_models.Version).filter(
-# # func.lower(db_models.Version.name) == func.lower(version.name),
-# # db_models.Version.version_id != version_id
-# # ).first():
-# # logger.error("Version with the same name already exists")
-# # raise AlreadyExistsException(detail="Version with the same name already exists")
-# # # Check for duplicate abbreviation
-# # if db_session.query(db_models.Version).filter(
-# # func.lower(db_models.Version.abbreviation)== func.lower(version.abbreviation),
-# # db_models.Version.version_id != version_id
-# # ).first():
-# # logger.error("Version with the same abbreviation already exists")
-# # raise AlreadyExistsException(detail="Version with the same abbreviation already exists")
-# # db_obj.name = version.name
-# # db_obj.abbreviation = version.abbreviation
-# # db_obj.meta_data = version.metadata
-# # db_session.commit()
-# # db_session.refresh(db_obj)
-# # return db_obj
-
-# def _get_version_usage_details(db_session: Session, version_id: int):
-# """Get detailed resource usage information for a version."""
-# lang_alias = aliased(db_models.Language)
-# version_alias = aliased(db_models.Version)
-# license_alias = aliased(db_models.License)
-
-# resources_with_details = (
-# db_session.query(db_models.Resource, lang_alias, version_alias, license_alias)
-# .join(lang_alias, lang_alias.language_id == db_models.Resource.language_id)
-# .join(version_alias, version_alias.version_id == db_models.Resource.version_id)
-# .join(license_alias, license_alias.license_id == db_models.Resource.license_id)
-# .filter(db_models.Resource.version_id == version_id)
-# .all()
-# )
-
-# used_by = []
-# for resource, language, version, _ in resources_with_details:
-# version_code = getattr(version, "abbreviation", getattr(version, "code", None))
-# content_val = schema.ContentTypeEnum(resource.content_type).value
-
-# resource_name = _build_resource_name(
-# language_code=language.language_code,
-# version_code=version_code,
-# revision=resource.revision,
-# content_type_value=content_val,
-# )
-
-# used_by.append(
-# schema.ResourceUsageDetail(
-# resourceId=resource.resource_id,
-# resourceName=resource_name,
-# )
-# )
-
-# return used_by
-# def delete_versions_bulk(db_session: Session, version_ids: List[int]):
-# deleted_ids = []
-# errors = []
-
-# for vid in version_ids:
-# obj = get_version(db_session, vid)
-# if not obj:
-# errors.append(f"Version {vid} not found")
-# continue
-
-# # used_resources = (
-# # db_session.query(db_models.Resource)
-# # .filter_by(version_id=vid)
-# # .all()
-# # )
-
-# if used_resources:
-# errors.append(f"Version {vid} is in use and cannot be deleted")
-# continue
-
-# try:
-# db_session.delete(obj)
-# deleted_ids.append(vid)
-# except Exception as exc:
-# errors.append(f"Version {vid} could not be deleted: {str(exc)}")
-
-# # db_session.commit()
-
-# # Follow exact pattern of delete_videos
-# all_failed = len(deleted_ids) == 0 and len(errors) > 0
-# has_errors = len(errors) > 0
-
-# return {
-# "data": {
-# "deletedCount": len(deleted_ids),
-# "deletedIds": deleted_ids,
-# "errors": errors if errors else None,
-# },
-# "all_failed": all_failed,
-# "has_errors": has_errors,
-# }
-
-
-
-# # --- Language CRUD ---
-# # def get_languages_with_pagination(
-# # db_session: Session,
-# # page: int = 0,
-# # page_size: int = 100,
-# # language_name: Optional[str] = None,
-# # language_code: Optional[str] = None
-# # ) -> Tuple[List[db_models.Language], int]:
-# # """Retrieve languages with pagination and optional filtering."""
-# # query = db_session.query(db_models.Language)
-# # # Apply filters if provided
-# # filters = []
-# # if language_name:
-# # filters.append(db_models.Language.language_name.ilike(f"%{language_name}%"))
-# # if language_code:
-# # filters.append(db_models.Language.language_code.ilike(f"%{language_code}%"))
-# # if filters:
-# # query = query.filter(or_(*filters))
-
-# # # Get total count before pagination
-# # total_items = query.count()
-# # # Apply ordering and pagination
-# # offset = page * page_size
-# # languages = query.order_by(
-# # db_models.Language.language_id.asc()).offset(offset).limit(page_size).all()
-
-# # return languages, total_items
-# # def get_language(db_session: Session, language_id: int):
-# # """Retrieve a single language by ID."""
-# # return db_session.query(db_models.Language).filter(
-# # db_models.Language.language_id == language_id
-# # ).first()
-
-# # def create_language(db_session: Session, lang: schema.LanguageCreate):
-# # """Create a new language if it does not already exist."""
-# # # Additional validation for required fields
-# # if not lang.language_name or lang.language_name.strip() == "":
-# # logger.error("Language name is required")
-# # raise UnprocessableException(detail="Language name is required")
-# # if not lang.language_code or lang.language_code.strip() == "":
-# # logger.error("Language code is required")
-# # raise UnprocessableException(detail="Language code is required")
-
-# # # Check for existing language code
-# # if db_session.query(db_models.Language).filter(
-# # db_models.Language.language_code == lang.language_code
-# # ).first():
-# # raise AlreadyExistsException(detail="Language code already exists")
-
-# # # Check for existing language name
-# # if db_session.query(db_models.Language).filter(
-# # db_models.Language.language_name == lang.language_name
-# # ).first():
-# # raise AlreadyExistsException(detail="Language name already exists")
-
-# # # Create new language object
-# # db_obj = db_models.Language(
-# # language_code=lang.language_code,
-# # language_name=lang.language_name,
-# # meta_data=lang.metadata
-# # )
-# # db_session.add(db_obj)
-# # db_session.commit()
-# # db_session.refresh(db_obj)
-# # return db_obj
-
-# # def update_language(db_session: Session, language_id: int, lang: schema.LanguageUpdate):
-# # """Update an existing language by ID with duplicate code check."""
-# # db_obj = get_language(db_session, language_id)
-# # if not db_obj:
-# # raise NotAvailableException(detail="Language not found")
-
-# # # Additional validation for required fields
-# # if not lang.language_name or lang.language_name.strip() == "":
-# # raise UnprocessableException(detail="Language name is required")
-# # if not lang.language_code or lang.language_code.strip() == "":
-# # raise UnprocessableException(detail="Language code is required")
-
-# # # Check duplicate code (exclude current record)
-# # if db_session.query(db_models.Language).filter(
-# # db_models.Language.language_code == lang.language_code,
-# # db_models.Language.language_id != language_id
-# # ).first():
-# # raise AlreadyExistsException(detail="Language code already exists")
-
-# # # Check duplicate name (exclude current record)
-# # if db_session.query(db_models.Language).filter(
-# # db_models.Language.language_name == lang.language_name,
-# # db_models.Language.language_id != language_id
-# # ).first():
-# # raise AlreadyExistsException(detail="Language name already exists")
-
-# # # Update fields
-# # db_obj.language_code = lang.language_code
-# # db_obj.language_name = lang.language_name
-# # db_obj.meta_data = lang.metadata
-# # db_session.commit()
-# # db_session.refresh(db_obj)
-# # return db_obj
-# def _get_language_usage_details(db_session: Session, language_id: int):
-# """Get detailed information about resources using a language."""
-# lang_alias = aliased(db_models.Language)
-# version_alias = aliased(db_models.Version)
-# license_alias = aliased(db_models.License)
-
-# resources_with_details = (
-# db_session.query(db_models.Resource, lang_alias, version_alias, license_alias)
-# .join(lang_alias, lang_alias.language_id == db_models.Resource.language_id)
-# .join(version_alias, version_alias.version_id == db_models.Resource.version_id)
-# .join(license_alias, license_alias.license_id == db_models.Resource.license_id)
-# .filter(db_models.Resource.language_id == language_id)
-# .all()
-# )
-
-# used_by = []
-# for resource, language, version,_ in resources_with_details:
-# version_code = getattr(version, "abbreviation", getattr(version, "code", None))
-# content_val = schema.ContentTypeEnum(resource.content_type).value
-# resource_name = _build_resource_name(
-# language_code=language.language_code,
-# version_code=version_code,
-# revision=resource.revision,
-# content_type_value=content_val,
-# )
-# used_by.append(schema.ResourceUsageDetail(
-# resourceId=resource.resource_id,
-# resourceName=resource_name
-# ))
-
-# return used_by
-
-
-# # def delete_languages_bulk(db_session: Session, language_ids: List[int]):
-# # deleted_ids = []
-# # errors = []
-
-# # for lid in language_ids:
-# # try:
-# # db_obj = get_language(db_session, lid)
-# # if not db_obj:
-# # errors.append(f"Language {lid} not found")
-# # continue
-
-# # used_resources = (
-# # db_session.query(db_models.Resource)
-# # .filter_by(language_id=lid)
-# # .all()
-# # )
-
-# # if used_resources:
-# # errors.append(
-# # f"Language {lid} ('{db_obj.language_name}') is in use and cannot be deleted"
-# # )
-# # continue
-
-# # db_session.delete(db_obj)
-# # deleted_ids.append(lid)
-
-# # except Exception as exc:
-# # errors.append(f"Error deleting language {lid}: {str(exc)}")
-
-# # db_session.commit()
-
-# # # Consistent structure like delete_videos & delete_versions_bulk
-# # all_failed = len(deleted_ids) == 0 and len(errors) > 0
-# # has_errors = len(errors) > 0
-
-# # return {
-# # "data": {
-# # "deletedCount": len(deleted_ids),
-# # "deletedIds": deleted_ids,
-# # "errors": errors if errors else None,
-# # },
-# # "all_failed": all_failed,
-# # "has_errors": has_errors,
-# # }
-
-
-
-# # --- License CRUD ---
-# # def get_licenses_with_filters(
-# # db_session: Session,
-# # license_id: Optional[int] = None,
-# # name: Optional[str] = None
-# # ) -> List[db_models.License]:
-# # """Retrieve licenses with optional filtering."""
-# # query = db_session.query(db_models.License)
-
-# # # Apply filters if provided
-# # if license_id is not None:
-# # query = query.filter(db_models.License.license_id == license_id)
-
-# # if name:
-# # query = query.filter(db_models.License.license_name.ilike(f"%{name}%"))
-
-# # # Order by license_id for consistent results
-# # return query.order_by(db_models.License.license_id.asc()).all()
-
-# # def get_license(db_session: Session, license_id: int):
-# # """Retrieve a single license by ID."""
-# # return db_session.query(db_models.License).filter(
-# # db_models.License.license_id == license_id
-# # ).first()
-
-# # def create_license(db_session: Session, license_: schema.LicenseCreate):
-# # """Create a new license if it does not already exist."""
-# # # Additional validation for required fields
-# # if not license_.license_name or license_.license_name.strip() == "":
-# # raise UnprocessableException(detail="License name is required")
-# # if not hasattr(license_, 'details') or not license_.details or license_.details.strip() == "":
-# # raise UnprocessableException(detail="License details are required")
-
-# # existing = db_session.query(db_models.License).filter(
-# # db_models.License.license_name == license_.license_name
-# # ).first()
-# # if existing:
-# # raise AlreadyExistsException(detail="License already exists")
-
-# # db_obj = db_models.License(
-# # license_name=license_.license_name,
-# # details=license_.details
-# # )
-# # db_session.add(db_obj)
-# # db_session.commit()
-# # db_session.refresh(db_obj)
-# # return db_obj
-
-# # def update_license(db_session: Session, license_id: int, license_: schema.LicenseUpdate):
-# # """Update an existing license by ID with duplicate name check."""
-# # db_obj = get_license(db_session, license_id)
-# # if not db_obj:
-# # raise NotAvailableException(detail="License not found")
-
-# # # Additional validation for required fields
-# # if not license_.license_name or license_.license_name.strip() == "":
-# # raise UnprocessableException(detail="License name is required")
-# # if not hasattr(license_, 'details') or not license_.details or license_.details.strip() == "":
-# # raise UnprocessableException(detail="License details are required")
-
-# # # Check duplicate name (exclude current record)
-# # duplicate = db_session.query(db_models.License).filter(
-# # db_models.License.license_name == license_.license_name,
-# # db_models.License.license_id != license_id
-# # ).first()
-# # if duplicate:
-# # raise AlreadyExistsException(detail="License name already exists")
-
-# # # Update fields
-# # db_obj.license_name = license_.license_name
-# # db_obj.details = license_.details
-
-# # db_session.commit()
-# # db_session.refresh(db_obj)
-# # return db_obj
-
-# def _get_resource_usage_details(db_session: Session, filter_field: str, filter_value: int):
-# """Get detailed information about resources using a specific entity (language/version/license).
-
-# Args:
-# db_session: Database session
-# filter_field: Field name to filter on ('language_id', 'version_id', or 'license_id')
-# filter_value: Value to filter by
-
-# Returns:
-# List of ResourceUsageDetail objects
-# """
-# lang_alias = aliased(db_models.Language)
-# version_alias = aliased(db_models.Version)
-# license_alias = aliased(db_models.License)
-
-# resources_with_details = (
-# db_session.query(db_models.Resource, lang_alias, version_alias, license_alias)
-# .join(lang_alias, lang_alias.language_id == db_models.Resource.language_id)
-# .join(version_alias, version_alias.version_id == db_models.Resource.version_id)
-# .join(license_alias, license_alias.license_id == db_models.Resource.license_id)
-# .filter(getattr(db_models.Resource, filter_field) == filter_value)
-# .all()
-# )
-
-# used_by = []
-# for resource, language, version, _ in resources_with_details:
-# version_code = getattr(version, "abbreviation", getattr(version, "code", None))
-# content_val = schema.ContentTypeEnum(resource.content_type).value
-# resource_name = _build_resource_name(
-# language_code=language.language_code,
-# version_code=version_code,
-# revision=resource.revision,
-# content_type_value=content_val,
-# )
-# used_by.append(schema.ResourceUsageDetail(
-# resourceId=resource.resource_id,
-# resourceName=resource_name
-# ))
-
-# return used_by
-
-
-# # def delete_licenses_bulk(db_session: Session, license_ids: List[int]):
-# # deleted_ids = []
-# # errors = []
-
-# # for lid in license_ids:
-# # try:
-# # db_obj = get_license(db_session, lid)
-# # if not db_obj:
-# # errors.append(f"License {lid} not found")
-# # continue
-
-# # used_resources = (
-# # db_session.query(db_models.Resource)
-# # .filter_by(license_id=lid)
-# # .all()
-# # )
-
-# # if used_resources:
-# # errors.append(
-# # f"License {lid} ('{db_obj.license_name}') is in use and cannot be deleted"
-# # )
-# # continue
-
-# # db_session.delete(db_obj)
-# # deleted_ids.append(lid)
-
-# # except Exception as exc:
-# # errors.append(f"Error deleting license {lid}: {str(exc)}")
-
-# # db_session.commit()
-
-# # # Consistent response structure
-# # all_failed = len(deleted_ids) == 0 and len(errors) > 0
-# # has_errors = len(errors) > 0
-
-# # return {
-# # "data": {
-# # "deletedCount": len(deleted_ids),
-# # "deletedIds": deleted_ids,
-# # "errors": errors if errors else None,
-# # },
-# # "all_failed": all_failed,
-# # "has_errors": has_errors,
-# # }
-
-# # --- Resource CRUD ---
-
-# # def _build_resource_name(language_code: str,
-# # version_code: str | None,
-# # revision: str | None,
-# # content_type_value: str) -> str:
-# # """
-# # Format: ___
-# # e.g., "hin_HINREV_1.1_bible"
-
-# # - Skips empty parts.
-# # - All separated by underscores.
-# # """
-# # parts = [language_code or "", version_code or "", revision or "", content_type_value]
-# # return "_".join([p for p in parts if p])
-
-
-# # def get_resources(
-# # db: Session,
-# # filters: schema.ResourceFilter
-# # ) -> List[schema.LanguageGroupOut]:
-# # """
-# # Retrieve a list of resources grouped by language.
-# # """
-
-# # lang_alias = aliased(db_models.Language)
-# # ver_alias = aliased(db_models.Version)
-# # lic_alias = aliased(db_models.License)
-
-# # query = (
-# # db.query(db_models.Resource, lang_alias, ver_alias, lic_alias)
-# # .join(lang_alias, lang_alias.language_id == db_models.Resource.language_id)
-# # .join(ver_alias, ver_alias.version_id == db_models.Resource.version_id)
-# # .join(lic_alias, lic_alias.license_id == db_models.Resource.license_id)
-# # )
-
-# # if filters.resource_id:
-# # query = query.filter(db_models.Resource.resource_id == filters.resource_id)
-
-# # if filters.content_type:
-# # query = query.filter(db_models.Resource.content_type == filters.content_type.lower())
-
-# # if filters.published is not None:
-# # query = query.filter(db_models.Resource.published == filters.published)
-
-# # rows = (
-# # query.order_by(db_models.Resource.resource_id.asc())
-# # .offset(filters.page * filters.page_size)
-# # .limit(filters.page_size)
-# # .all()
-# # )
-
-# # if filters.resource_id and not rows:
-# # raise NotAvailableException(detail="Resource not found")
-
-# # return _group_resources(rows)
-# # def _group_resources(rows: List[tuple]) -> List[schema.LanguageGroupOut]:
-# # groups: Dict[int, Dict[str, Any]] = {}
-
-# # for resource, lang, version, lic in rows:
-# # lid = lang.language_id
-
-# # if lid not in groups:
-# # groups[lid] = {
-# # "language": schema.LanguageBrief(
-# # id=lid,
-# # code=lang.language_code,
-# # name=lang.language_name,
-# # ),
-# # "versions": []
-# # }
-
-# # version_code = getattr(version, "abbreviation", getattr(version, "code", None))
-# # content_val = schema.ContentTypeEnum(resource.content_type).value
-
-# # resource_name = _build_resource_name(
-# # language_code=lang.language_code,
-# # version_code=version_code,
-# # revision=resource.revision,
-# # content_type_value=content_val,
-# # )
-
-# # groups[lid]["versions"].append(
-# # schema.ResourceRowResponse(
-# # resourceId=resource.resource_id,
-# # resourceName=resource_name,
-# # revision=resource.revision,
-# # version=schema.VersionRef(
-# # id=version.version_id,
-# # name=version.name,
-# # code=version_code,
-# # ),
-# # content=schema.ContentRef(
-# # contentType=schema.ContentTypeEnum(resource.content_type)
-# # ),
-# # license=schema.LicenseRef(
-# # id=lic.license_id,
-# # name=lic.license_name
-# # ),
-# # language=schema.LanguageBrief(
-# # id=lang.language_id,
-# # code=lang.language_code,
-# # name=lang.language_name
-# # ),
-# # metadata=json.loads(resource.meta_data) if resource.meta_data else None,
-# # published=bool(resource.published),
-# # createdBy=resource.created_by,
-# # createdTime=resource.created_at,
-# # updatedBy=resource.updated_by,
-# # updatedTime=resource.updated_at
-# # )
-# # )
-
-# # return [schema.LanguageGroupOut(**g) for g in groups.values()]
-
-
-# # def create_resource(
-# # db: Session,
-# # payload: schema.ResourceCreate,
-# # created_by: Optional[int] = None) -> schema.ResourceResponse:
-# # """Create a new resource and return response schema."""
-# # # Check if resource already exists
-# # ct = payload.content_type.value.lower()
-# # now = utcnow()
-# # existing = (
-# # db.query(db_models.Resource)
-# # .filter_by(
-# # version_id=payload.version_id,
-# # language_id=payload.language_id,
-# # license_id=payload.license_id,
-# # revision=payload.revision,
-# # content_type=payload.content_type.value.lower(),
-# # )
-# # .first()
-# # )
-# # if existing:
-# # raise AlreadyExistsException(detail="Resource already exists")
-# # version = db.query(db_models.Version).filter_by(version_id=payload.version_id).first()
-# # if not version:
-# # raise NotAvailableException(detail="versionId not found")
-# # language = db.query(db_models.Language).filter_by(language_id=payload.language_id).first()
-# # if not language:
-# # raise NotAvailableException(detail="languageId not found")
-# # license_ = db.query(db_models.License).filter_by(license_id=payload.license_id).first()
-# # if not license_:
-# # raise NotAvailableException(detail="licenseId not found")
-# # ct = payload.content_type.value.lower()
-
-# # db_obj = db_models.Resource(
-# # version_id=payload.version_id,
-# # revision=payload.revision,
-# # content_type=ct,
-# # language_id=payload.language_id,
-# # license_id=payload.license_id,
-# # meta_data=json.dumps(payload.metadata, ensure_ascii=False) if payload.metadata else None,
-# # created_by=created_by,
-# # created_at=now,
-# # published=bool(getattr(payload, "published", False)),
-# # # published=False, # always default to False on POST
-# # )
-# # db.add(db_obj)
-# # db.commit()
-# # db.refresh(db_obj)
-# # version_code = getattr(version, "abbreviation", getattr(version, "code", None))
-# # resource_name = _build_resource_name(
-# # language_code=language.language_code,
-# # version_code=version_code,
-# # revision=db_obj.revision,
-# # content_type_value=ct,
-# # )
-# # now = utcnow()
-
-# # return schema.ResourceResponse(
-# # resourceId=db_obj.resource_id,
-# # resourceName=resource_name,
-# # revision=db_obj.revision,
-# # version=schema.VersionRef(
-# # id=version.version_id,
-# # name=version.name,
-# # code=getattr(version, "abbreviation", getattr(version, "code", "")),
-# # ),
-# # language=schema.LanguageBrief(
-# # id=language.language_id,
-# # code=language.language_code,
-# # name=language.language_name
-# # ),
-# # content=schema.ContentRef(contentType=schema.ContentTypeEnum(db_obj.content_type)),
-# # license=schema.LicenseRef(id=license_.license_id, name=license_.license_name),
-# # metadata=json.loads(db_obj.meta_data) if db_obj.meta_data else None,
-# # published=bool(db_obj.published),
-# # createdBy=db_obj.created_by,
-# # createdTime=db_obj.created_at,
-# # updatedBy=None,
-# # updatedTime=None,
-# # )
-
-
-# # def update_resource(
-# # db: Session,
-# # payload: schema.ResourceUpdate,
-# # user_id: Optional[int] = None
-# # ) -> schema.ResourceResponse:
-# # """Update resource and return response schema."""
-# # db_obj = _get_resource_or_404(db, payload.resource_id)
-
-# # # Determine final values for uniqueness validation
-# # final_values = _resolve_final_values(db_obj, payload)
-
-# # _validate_uniqueness(db, payload.resource_id, final_values)
-
-# # # Update main resource fields
-# # version, language, license_ = _apply_updates(db, db_obj, payload)
-
-# # # Extra fields
-# # if payload.metadata is not None:
-# # db_obj.meta_data = json.dumps(payload.metadata, ensure_ascii=False)
-# # if payload.published is not None:
-# # db_obj.published = bool(payload.published)
-
-# # db_obj.updated_by = user_id
-# # db_obj.updated_at = utcnow()
-
-# # db.commit()
-# # db.refresh(db_obj)
-
-# # return _build_response(db_obj, version, language, license_)
-# # def _get_resource_or_404(db: Session, resource_id: int):
-# # obj = db.query(db_models.Resource).filter_by(resource_id=resource_id).first()
-# # if not obj:
-# # raise NotAvailableException(detail="Resource not found")
-# # return obj
-# # def _resolve_final_values(db_obj, payload):
-# # return {
-# # "version_id": payload.version_id or db_obj.version_id,
-# # "language_id": payload.language_id or db_obj.language_id,
-# # "license_id": payload.license_id or db_obj.license_id,
-# # "revision": payload.revision or db_obj.revision,
-# # "content_type": payload.content_type.value.lower()
-# # if payload.content_type else db_obj.content_type,
-# # }
-# # def _validate_uniqueness(db: Session, current_id: int, vals: dict):
-# # existing = (
-# # db.query(db_models.Resource)
-# # .filter(
-# # db_models.Resource.version_id == vals["version_id"],
-# # db_models.Resource.language_id == vals["language_id"],
-# # db_models.Resource.license_id == vals["license_id"],
-# # db_models.Resource.revision == vals["revision"],
-# # db_models.Resource.content_type == vals["content_type"],
-# # db_models.Resource.resource_id != current_id,
-# # )
-# # .first()
-# # )
-# # if existing:
-# # raise AlreadyExistsException(detail="Resource already exists")
-# # def _apply_updates(db: Session, db_obj, payload):
-# # # Version
-# # version = _validate_and_get(
-# # db,
-# # db_models.Version,
-# # payload.version_id or db_obj.version_id, "versionId")
-# # db_obj.version_id = version.version_id
-
-# # # Language
-# # language = _validate_and_get(
-# # db,
-# # db_models.Language,
-# # payload.language_id or db_obj.language_id, "languageId"
-# # )
-# # db_obj.language_id = language.language_id
-
-# # # License
-# # license_ = _validate_and_get(
-# # db,
-# # db_models.License,
-# # payload.license_id or db_obj.license_id, "licenseId"
-# # )
-# # db_obj.license_id = license_.license_id
-
-# # # Revision & content type
-# # if payload.revision is not None:
-# # db_obj.revision = payload.revision
-# # if payload.content_type is not None:
-# # db_obj.content_type = payload.content_type.value.lower()
-
-# # return version, language, license_
-# # def _validate_and_get(db, model, id_value, field_name):
-# # pk_column = model.__mapper__.primary_key[0].name
-
-# # obj = db.query(model).filter(getattr(model, pk_column) == id_value).first()
-# # if not obj:
-# # raise NotAvailableException(detail=f"{field_name} not found")
-# # return obj
-
-# # def _build_response(db_obj, version, language, license_):
-# # resource_name = _build_resource_name(
-# # language_code=language.language_code,
-# # version_code=getattr(version, "abbreviation", getattr(version, "code", None)),
-# # revision=db_obj.revision,
-# # content_type_value=schema.ContentTypeEnum(db_obj.content_type).value,
-# # )
-
-# # return schema.ResourceResponse(
-# # resourceId=db_obj.resource_id,
-# # resourceName=resource_name,
-# # revision=db_obj.revision,
-# # version=schema.VersionRef(
-# # id=version.version_id,
-# # name=version.name,
-# # code=getattr(version, "abbreviation", getattr(version, "code", "")),
-# # ),
-# # language=schema.LanguageBrief(
-# # id=language.language_id,
-# # code=language.language_code,
-# # name=language.language_name,
-# # ),
-# # content=schema.ContentRef(contentType=schema.ContentTypeEnum(db_obj.content_type)),
-# # license=schema.LicenseRef(id=license_.license_id, name=license_.license_name),
-# # metadata=json.loads(db_obj.meta_data) if db_obj.meta_data else None,
-# # published=bool(db_obj.published),
-# # createdBy=db_obj.created_by,
-# # createdTime=db_obj.created_at,
-# # updatedBy=db_obj.updated_by,
-# # updatedTime=db_obj.updated_at,
-# # )
-
-# # def delete_resources_bulk(db: Session, resource_ids: List[int]):
-# # deleted_ids = []
-# # errors = []
-
-# # related_models = [
-# # db_models.Bible,
-# # db_models.CleanBible,
-# # db_models.Video,
-# # db_models.Commentary,
-# # db_models.Dictionary,
-# # db_models.AudioBible,
-# # db_models.Obs,
-# # db_models.Infographic,
-# # ]
-
-# # for rid in resource_ids:
-# # try:
-# # db_obj = (
-# # db.query(db_models.Resource)
-# # .filter_by(resource_id=rid)
-# # .first()
-# # )
-
-# # if not db_obj:
-# # errors.append(f"Resource {rid} not found")
-# # continue
-
-# # # Delete dependent entities first
-# # for model in related_models:
-# # db.query(model).filter_by(resource_id=rid).delete(
-# # synchronize_session=False
-# # )
-
-# # # Delete main resource
-# # db.delete(db_obj)
-# # deleted_ids.append(rid)
-
-# # except IntegrityError:
-# # db.rollback()
-# # errors.append(
-# # f"Resource {rid} could not be deleted due to database constraints"
-# # )
-
-# # except Exception as exc:
-# # db.rollback()
-# # errors.append(f"Error deleting resource {rid}: {str(exc)}")
-
-# # db.commit()
-
-# # # ---- Consistent structure ----
-# # all_failed = len(deleted_ids) == 0 and len(errors) > 0
-# # has_errors = len(errors) > 0
-
-# # return {
-# # "data": {
-# # "deletedCount": len(deleted_ids),
-# # "deletedIds": deleted_ids,
-# # "errors": errors if errors else None,
-# # },
-# # "all_failed": all_failed,
-# # "has_errors": has_errors,
-# # }
-
-# # # --- Log files CRUD ---
-
-# # def latest_log_file():
-# # """Get latest log file"""
-# # # Find newest log file
-# # files = sorted(
-# # [f for f in os.listdir(LOG_DIR) if f.startswith("vachan_admin_app.log")],
-# # key=lambda x: os.path.getmtime(os.path.join(LOG_DIR, x)),
-# # reverse=True
-# # )
-# # if not files:
-# # raise NotAvailableException(detail="No log files found")
-# # path = os.path.join(LOG_DIR, files[0])
-# # return FileResponse(
-# # path=path,
-# # media_type="text/plain",
-# # filename=files[0]
-# # )
-
-
-# # def get_logfile_by_number(log_file_no):
-# # """Get log file by number"""
-# # if log_file_no < 0 or log_file_no > 10:
-# # raise BadRequestException(detail="log_file_no must be 0–10")
-# # filename = "vachan_admin_app.log" if log_file_no == 0 else f"vachan_admin_app.log.{log_file_no}"
-# # path = os.path.join(LOG_DIR, filename)
-# # if not os.path.exists(path):
-# # raise NotAvailableException(detail=f"Log file {filename} not found")
-# # return FileResponse(
-# # path=path,
-# # media_type="text/plain",
-# # filename=filename
-# # )
-
-# def latest_log_file():
-# """Get latest log file"""
-# # Find newest log file
-# files = sorted(
-# [f for f in os.listdir(LOG_DIR) if f.startswith("vachan_admin_app.log")],
-# key=lambda x: os.path.getmtime(os.path.join(LOG_DIR, x)),
-# reverse=True
-# )
-# if not files:
-# raise NotAvailableException(detail="No log files found")
-# return FileResponse(os.path.join(LOG_DIR, files[0]), media_type='text/plain')
-
-# # tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".zip")
-# # tmp_path = tmp.name
-# # tmp.close()
-
-# def get_logfile_by_number(log_file_no):
-# """Get log file by number"""
-# if log_file_no < 0 or log_file_no > 10:
-# raise BadRequestException(detail="log_file_no must be 0–10")
-# filename = "vachan_admin_app.log" if log_file_no == 0 else f"vachan_admin_app.log.{log_file_no}"
-# path = os.path.join(LOG_DIR, filename)
-# if not os.path.exists(path):
-# raise NotAvailableException(detail=f"Log file {filename} not found")
-# return FileResponse(path, media_type='text/plain')
-
-# def get_all_logfiles():
-# """Get all log files in a zip format"""
-# # Zip all logs into memory
-# buf = io.BytesIO()
-# with zipfile.ZipFile(buf, 'w') as zf:
-# for fname in os.listdir(LOG_DIR):
-# if fname.startswith("vachan_admin_app.log"):
-# zf.write(os.path.join(LOG_DIR, fname), arcname=fname)
-# buf.seek(0)
-# return StreamingResponse(buf, media_type='application/zip',
-# headers={"Content-Disposition": "attachment; filename=logs.zip"})
-
-# # def create_videos(db: Session, data: schema.VideoBulkCreate, actor_user_id: int):
-# # """Create a new video entry"""
-# # # Resource must exist
-# # resource = db.query(db_models.Resource).filter_by(resource_id=data.resourceId).first()
-# # if not resource:
-# # raise NotAvailableException(detail=f"Resource {data.resourceId} not found")
-# # # Resource content_type must be 'video'
-# # if resource.content_type.lower() != "video":
-# # raise BadRequestException(
-# # f"Resource {data.resourceId} is not of type 'video' (found '{resource.content_type}')"
-# # )
-# # created = []
-# # for v in data.videos:
-# # # Check duplicates (resource_id + book + chapter + url)
-# # existing = (
-# # db.query(db_models.Video)
-# # .filter_by(resource_id=data.resourceId, book=v.book, chapter=v.chapter, title=v.title)
-# # .first()
-# # )
-# # if existing:
-# # raise AlreadyExistsException(
-# # detail=(
-# # f"Video {v.book} {v.chapter} {v.title} "
-# # f"already exists in resource {data.resourceId}"
-# # )
-# # )
-# # # Create new video record
-# # video = db_models.Video(
-# # resource_id=data.resourceId,
-# # book=v.book,
-# # chapter=v.chapter,
-# # url=v.url,
-# # title=v.title,
-# # description=v.description,
-# # )
-# # db.add(video)
-# # created.append(video)
-
-# # touch_resource(db, data.resourceId, actor_user_id)
-# # db.commit()
-
-# # # Return structured response
-# # return {
-# # "resource_id": data.resourceId,
-# # "videos": created
-# # }
-
-# def create_videos(db: Session, data: schema.VideoBulkCreate, actor_user_id: int):
-# """Create a new video entry"""
-# # Resource must exist
-# resource = db.query(db_models.Resource).filter_by(resource_id=data.resourceId).first()
-# if not resource:
-# raise NotAvailableException(detail=f"Resource {data.resourceId} not found")
-# # Resource content_type must be 'video'
-# if resource.content_type.lower() != "video":
-# raise BadRequestException(
-# f"Resource {data.resourceId} is not of type 'video' (found '{resource.content_type}')"
-# )
-# created = []
-# for v in data.videos:
-# # Check duplicates (resource_id + book + chapter + url)
-# existing = (
-# db.query(db_models.Video)
-# .filter_by(resource_id=data.resourceId, book=v.book, chapter=v.chapter, url=v.url)
-# .first()
-# )
-# if existing:
-# raise AlreadyExistsException(
-# detail=(
-# f"Video {v.book} {v.chapter} {v.url} "
-# f"already exists in resource {data.resourceId}"
-# )
-# )
-# # Title must be unique per resource
-# title_clash = (
-# db.query(db_models.Video)
-# .filter_by(resource_id=data.resourceId, title=v.title)
-# .first()
-# )
-# if title_clash:
-# raise AlreadyExistsException(
-# detail=f"Video title '{v.title}' already exists in resource {data.resourceId}"
-# )
-# # Create new video record
-# video = db_models.Video(
-# resource_id=data.resourceId,
-# book=v.book,
-# chapter=v.chapter,
-# url=v.url,
-# title=v.title,
-# description=v.description,
-# )
-# db.add(video)
-# created.append(video)
-
-
-# # def update_videos(db: Session, data: schema.VideoBulkUpdate, actor_user_id: int):
-# # """Update a video entry"""
-# # # Resource must exist
-# # resource = db.query(db_models.Resource).filter_by(resource_id=data.resourceId).first()
-# # if not resource:
-# # raise NotAvailableException(detail=f"Resource {data.resourceId} not found")
-
-# # # Resource content_type must be 'video'
-# # if resource.content_type.lower() != "video":
-# # raise BadRequestException(
-# # detail=(
-# # f"Resource {data.resourceId} is not of type 'video'"
-# # f" (found '{resource.content_type}')"
-# # )
-# # )
-
-# # updated = []
-# # for v in data.videos:
-# # # Find the video by id + resource
-# # video = (
-# # db.query(db_models.Video)
-# # .filter_by(video_id=v.id, resource_id=data.resourceId)
-# # .first()
-# # )
-# # if not video:
-# # raise NotAvailableException(
-# # detail=f"Video {v.id} not found in resource {data.resourceId}"
-# # )
-
-# def update_videos(db: Session, data: schema.VideoBulkUpdate, actor_user_id: int):
-# """Update a video entry"""
-# # Resource must exist
-# resource = db.query(db_models.Resource).filter_by(resource_id=data.resourceId).first()
-# if not resource:
-# raise NotAvailableException(detail=f"Resource {data.resourceId} not found")
-
-# # Resource content_type must be 'video'
-# if resource.content_type.lower() != "video":
-# raise BadRequestException(
-# detail=(
-# f"Resource {data.resourceId} is not of type 'video'"
-# f" (found '{resource.content_type}')"
-# )
-# )
-
-# updated = []
-# for v in data.videos:
-# # Find the video by id + resource
-# video = (
-# db.query(db_models.Video)
-# .filter_by(video_id=v.id, resource_id=data.resourceId)
-# .first()
-# )
-# if not video:
-# raise NotAvailableException(
-# detail=f"Video {v.id} not found in resource {data.resourceId}"
-# )
-
-# # Check duplicates (exclude the current video itself)
-# duplicate = (
-# db.query(db_models.Video)
-# .filter(
-# db_models.Video.resource_id == data.resourceId,
-# db_models.Video.book == v.book,
-# db_models.Video.chapter == v.chapter,
-# db_models.Video.url == v.url,
-# db_models.Video.video_id != v.id, # exclude self
-# )
-# .first()
-# )
-# if duplicate:
-# raise AlreadyExistsException(
-# detail= (
-# f"Video {v.book} {v.chapter} {v.url} already exists"
-# f" in resource {data.resourceId}"
-# )
-# )
-# # Title unique per resource
-# title_clash = (
-# db.query(db_models.Video)
-# .filter(
-# db_models.Video.resource_id == data.resourceId,
-# db_models.Video.title == v.title,
-# db_models.Video.video_id != v.id,
-# )
-# .first()
-# )
-# if title_clash:
-# raise AlreadyExistsException(
-# detail=f"Video title '{v.title}' already exists in resource {data.resourceId}"
-# )
-
-# # Update fields
-# video.book = v.book
-# video.chapter = v.chapter
-# video.url = v.url
-# video.title = v.title
-# video.description = v.description
-# updated.append(video)
-# touch_resource(db, data.resourceId, actor_user_id)
-# db.commit()
-# return {
-# "resource_id": data.resourceId,
-# "videos": updated
-# }
-
-# # # Update fields
-# # video.book = v.book
-# # video.chapter = v.chapter
-# # video.url = v.url
-# # video.title = v.title
-# # video.description = v.description
-# # updated.append(video)
-# # touch_resource(db, data.resourceId, actor_user_id)
-# # db.commit()
-# # return {
-# # "resource_id": data.resourceId,
-# # "videos": updated
-# # }
-
-# # def get_videos_filtered(
-# # db: Session,
-# # resource_id: int = None,
-# # language_code: str = None,
-# # book_code: str = None,
-# # chapter: int = None
-# # ):
-# # """Get videos filtered by resource, language, book, and chapter."""
-
-# # # Validate resource if provided
-# # if resource_id:
-# # resource = db.query(db_models.Resource).filter(
-# # db_models.Resource.resource_id == resource_id
-# # ).first()
-# # if not resource:
-# # raise NotAvailableException(detail=f"Resource {resource_id} not found")
-
-# # if resource.content_type.lower() != "video":
-# # raise BadRequestException(
-# # detail=f"Resource {resource_id} is not of type 'video' (found '{resource.content_type}')"
-# # )
-
-# # # Validate language if provided
-# # if language_code:
-# # language = db.query(db_models.Language).filter(
-# # db_models.Language.language_code == language_code
-# # ).first()
-# # if not language:
-# # raise NotAvailableException(detail=f"Language '{language_code}' not found")
-
-
-# # if book_code:
-# # book_code_lower = book_code.lower()
-
-# # # Check if book exists in video table
-# # book_exists = db.query(db_models.Video).filter(
-# # func.lower(db_models.Video.book) == book_code_lower
-# # ).first()
-
-# # if not book_exists:
-# # raise NotAvailableException(
-# # detail=f"Book code '{book_code}' not found in videos"
-# # )
-
-# # # Chapter validation
-# # if chapter is not None:
-# # chapter_exists = db.query(db_models.Video).filter(
-# # func.lower(db_models.Video.book) == book_code_lower,
-# # db_models.Video.chapter == chapter
-# # ).first()
-
-# # if not chapter_exists:
-# # raise NotAvailableException(
-# # detail=f"Chapter {chapter} not found for book '{book_code}'"
-# # )
-
-# # query = db.query(db_models.Video)
-
-# # if resource_id:
-# # query = query.filter(db_models.Video.resource_id == resource_id)
-
-# # if book_code:
-# # query = query.filter(func.lower(db_models.Video.book) == book_code_lower)
-
-# # if chapter is not None:
-# # query = query.filter(db_models.Video.chapter == chapter)
-
-# # videos = query.all()
-
-# # result = {"books": {}}
-
-# # for v in videos:
-# # book_key = (v.book or "").lower().strip()
-# # chapter_key = str(v.chapter)
-
-# # if book_key not in result["books"]:
-# # result["books"][book_key] = {}
-
-# # if chapter_key not in result["books"][book_key]:
-# # result["books"][book_key][chapter_key] = []
-
-# # result["books"][book_key][chapter_key].append({
-# # "video_id": v.video_id,
-# # "title": v.title,
-# # "description": v.description,
-# # "url": v.url
-# # })
-
-# # return result
-
-# # def delete_videos(db: Session, resource_id: int, video_ids: List[int]):
-# # """Delete multiple videos from a resource"""
-# # # Check if resource exists
-# # resource = db.query(db_models.Resource).filter_by(resource_id=resource_id).first()
-# # if not resource:
-# # raise NotAvailableException(detail=f"Resource {resource_id} not found")
-
-# # deleted_ids = []
-# # invalid_ids = []
-
-# # for video_id in video_ids:
-# # video = (
-# # db.query(db_models.Video)
-# # .filter(
-# # db_models.Video.video_id == video_id,
-# # db_models.Video.resource_id == resource_id,
-# # )
-# # .first()
-# # )
-
-# # if video:
-# # db.delete(video)
-# # deleted_ids.append(video_id)
-# # else:
-# # invalid_ids.append(video_id)
-
-# # db.commit()
-
-# # response = {
-# # "deletedCount": len(deleted_ids),
-# # "deletedIds": deleted_ids,
-# # "message": (
-# # f"Successfully deleted {len(deleted_ids)}"
-# # f" video{'s' if len(deleted_ids) != 1 else ''}"
-# # )
-# # }
-
-# # if invalid_ids:
-# # response["error"] = f"Invalid video_ids: {', '.join(map(str, invalid_ids))}"
-
-# # # Return response with status code indicator
-# # return {
-# # "data": response,
-# # "has_errors": len(invalid_ids) > 0,
-# # "all_failed": len(deleted_ids) == 0
-# # }
-# # ---- Bibles ----
-
-
-# # # Utility functions for API operations
-
-# # def extract_book_code_from_usfm(usfm_content: str) -> str:
-# # """Extract book code from USFM content using usfm-grammar"""
-# # try:
-# # parser = USFMParser(usfm_content)
-# # usj_data = parser.to_usj()
-
-# # for item in usj_data.get("content", []):
-# # if item.get("type") == "book" and item.get("marker") == "id":
-# # return item.get("code")
-
-# # raise UnprocessableException("No book code found in USFM content")
-
-# # except Exception as e:
-# # raise UnprocessableException(
-# # f"USFM parsing error: {str(e)}"
-# # ) from e
-
-# def validate_chapter_count(db_session: Session,
-# book_code: str, chapter_count: int):
-# """Validate chapter count against DB table"""
-# book = (
-# db_session.query(db_models.BookLookup)
-# .filter(db_models.BookLookup.book_code == book_code.lower())
-# .first()
-# )
-
-# if not book:
-# raise NotAvailableException(detail=f"Book {book_code} not found")
-
-# if chapter_count > book.chapter_count:
-# raise BadRequestException(
-# detail=(
-# f"Invalid chapter count {chapter_count} for book {book_code}. "
-# f"Max allowed: {book.chapter_count}"
-# )
-# )
-
-# # def parse_verse_number(verse_str: str) -> List[int]:
-# # """
-# # Parse verse number strings that might contain ranges.
-
-# # Examples:
-# # - "1" -> [1]
-# # - "23-24" -> [23, 24]
-# # """
-# # verses = []
-
-# # if not verse_str:
-# # return verses
-
-# # verse_str = str(verse_str).strip()
-
-# # try:
-# # # Split by comma for multiple groups
-# # groups = verse_str.split(',')
-
-# # for group in groups:
-# # group = group.strip()
-
-# # if '-' in group:
-# # # Handle ranges like "23-24"
-# # parts = group.split('-')
-# # if len(parts) == 2:
-# # try:
-# # start = int(parts[0].strip())
-# # end = int(parts[1].strip())
-# # verses.extend(range(start, end + 1))
-# # except ValueError:
-# # # Fallback: treat as single verse
-# # verses.append(int(group))
-# # else:
-# # verses.append(int(group))
-# # else:
-# # # Single verse
-# # verses.append(int(group))
-
-# # return sorted(set(verses)) # Remove duplicates and sort
-
-# # except (ValueError, AttributeError) as e:
-# # raise ValueError(f"Could not parse verse number: {verse_str}") from e
-
-
-# # def parse_usfm_to_clean_verses(usj_data: Dict[str, Any]) -> List[Dict[str, Any]]:
-# # """Parse USJ data to extract clean verse-by-verse content, handling verse ranges"""
-# # verses = []
-# # chapter = None
-
-# # for item in usj_data.get("content", []):
-# # item_type = item.get("type")
-
-# # if item_type == "chapter":
-# # chapter = item.get("number")
-# # continue
-
-# # if item_type == "para":
-# # _process_paragraph(item.get("content", []), chapter, verses)
-
-# # return verses
-
-# # def _process_paragraph(para_content: List[Any], chapter: int, verses: List[Dict[str, Any]]) -> None:
-# # """Process paragraph and extract individual verses."""
-# # i = 0
-# # length = len(para_content)
-
-# # while i < length:
-# # element = para_content[i]
-
-# # if not _is_verse_marker(element):
-# # i += 1
-# # continue
-
-# # verse_str = element.get("number")
-# # i += 1
-
-# # verse_text, i = _collect_verse_text(para_content, i)
-
-# # if verse_text:
-# # _expand_and_add_verses(verse_str, verse_text, chapter, verses)
-
-# # def _is_verse_marker(element: Any) -> bool:
-# # return isinstance(element, dict) and element.get("type") == "verse"
-
-# # def _collect_verse_text(para_content: List[Any], index: int) -> tuple[str, int]:
-# # parts = []
-# # length = len(para_content)
-
-# # while index < length and isinstance(para_content[index], str):
-# # parts.append(para_content[index])
-# # index += 1
-
-# # return " ".join(parts).strip(), index
-
-# # def _expand_and_add_verses(
-# # verse_str: str,
-# # verse_text: str,
-# # chapter: int,
-# # verses: List[Dict[str, Any]]
-# # ):
-# # try:
-# # verse_numbers = parse_verse_number(verse_str)
-# # for number in verse_numbers:
-# # verses.append({
-# # "chapter": chapter,
-# # "verse": number,
-# # "text": verse_text,
-# # })
-# # except ValueError as e:
-# # print(f"Warning: Could not parse verse '{verse_str}' in chapter {chapter}: {e}")
-
-
-# # CRUD Operations
-# def upload_bible_book(
-# db_session: Session,
-# resource_id: int,
-# usfm_file: UploadFile,
-# actor_user_id: int
-# ) -> Dict[str, str]:
-# """Upload and process a new bible book"""
-
-# _get_resource(db_session, resource_id)
-# usfm_content = _read_usfm_file(usfm_file)
-# book_code = extract_book_code_from_usfm(usfm_content)
-# book = _lookup_book_or_404(db_session, book_code)
-
-# _check_book_not_exists(db_session, resource_id, book.book_id)
-
-# usj_data = _parse_usfm_to_usj(usfm_content)
-
-# content_items = usj_data.get("content", [])
-# chapter_count = _count_chapters(content_items)
-# validate_chapter_count(db_session, book_code, chapter_count)
-
-# entry_data = BibleEntrySchema(
-# resource_id=resource_id,
-# book_id=book.book_id,
-# usfm_content=usfm_content,
-# usj_data=usj_data,
-# chapter_count=chapter_count
-# )
-
-# bible_record = _create_bible_entry(db_session, entry_data)
-
-# _save_clean_verses(
-# db_session=db_session,
-# resource_id=resource_id,
-# book_id=book.book_id,
-# usj_data=usj_data
-# )
-
-# touch_resource(db_session, resource_id=bible_record.resource_id, actor_user_id=actor_user_id)
-# db_session.commit()
-
-# return {
-# "message": "Bible book uploaded successfully",
-# "bible_book_id": bible_record.bible_book_id
-# }
-# def _get_resource(db_session: Session, resource_id: int):
-# resource = db_session.query(db_models.Resource).filter_by(resource_id=resource_id).first()
-# if not resource:
-# raise NotAvailableException(detail="Resource not found")
-# return resource
-# def _read_usfm_file(usfm_file: UploadFile) -> str:
-# return usfm_file.file.read().decode("utf-8")
-
-
-# def _lookup_book_or_404(db_session: Session, book_code: str):
-# book = db_session.query(db_models.BookLookup).filter(
-# func.lower(db_models.BookLookup.book_code) == book_code.lower()
-# ).first()
-# if not book:
-# raise NotAvailableException(detail=f"Book {book_code} not found")
-# return book
-
-
-# def _check_book_not_exists(db_session: Session, resource_id: int, book_id: int):
-# existing = db_session.query(db_models.Bible).filter_by(
-# resource_id=resource_id,
-# book_id=book_id
-# ).first()
-# if existing:
-# raise AlreadyExistsException(
-# detail=f"Book already exists for resource {resource_id}"
-# )
-
-
-# # def _parse_usfm_to_usj(usfm_content: str) -> Dict[str, Any]:
-# # try:
-# # return USFMParser(usfm_content).to_usj()
-# # except Exception as e:
-# # raise UnprocessableException(detail=f"Error parsing USFM: {str(e)}") from e
-
-
-# # def _count_chapters(content_items: List[Dict[str, Any]]) -> int:
-# # return len([item for item in content_items if item.get("type") == "chapter"])
-
-
-# def _create_bible_entry(db_session: Session, data: BibleEntrySchema):
-# bible_record = db_models.Bible(
-# resource_id=data.resource_id,
-# book_id=data.book_id,
-# usfm=data.usfm_content,
-# json=data.usj_data,
-# chapters=data.chapter_count,
-# )
-# db_session.add(bible_record)
-# db_session.flush()
-# return bible_record
-
-# def _save_clean_verses(
-# db_session: Session,
-# resource_id: int,
-# book_id: int,
-# usj_data: Dict[str, Any]
-# ):
-# verses = parse_usfm_to_clean_verses(usj_data)
-# for verse in verses:
-# db_session.add(
-# db_models.CleanBible(
-# resource_id=resource_id,
-# book_id=book_id,
-# chapter=verse["chapter"],
-# verse=verse["verse"],
-# text=verse["text"],
-# )
-# )
-
-
-# def update_bible_book(
-# db_session: Session,
-# bible_book_id: int,
-# usfm_file: UploadFile,
-# actor_user_id: int
-# ) -> Dict[str, str]:
-# """Update an existing bible book"""
-
-# bible_record = _get_bible_record_or_404(db_session, bible_book_id)
-# usfm_content = _read_usfm_file(usfm_file)
-
-# book_code = extract_book_code_from_usfm(usfm_content)
-# _validate_book_code_matches(db_session, bible_record.book_id, book_code)
-
-# usj_data = _parse_usfm_to_usj(usfm_content)
-
-# content_items = usj_data.get("content", [])
-# chapter_count = _count_chapters(content_items)
-# validate_chapter_count(db_session, book_code, chapter_count)
-
-# _update_bible_entry(
-# bible_record=bible_record,
-# usfm_content=usfm_content,
-# usj_data=usj_data,
-# chapter_count=chapter_count,
-# )
-
-# _replace_clean_verses(
-# db_session=db_session,
-# resource_id=bible_record.resource_id,
-# book_id=bible_record.book_id,
-# usj_data=usj_data
-# )
-
-# touch_resource(db_session, resource_id=bible_record.resource_id, actor_user_id=actor_user_id)
-# db_session.commit()
-
-# return {"message": "Bible book updated successfully"}
-# def _get_bible_record_or_404(db_session: Session, bible_book_id: int):
-# record = db_session.query(db_models.Bible).filter_by(bible_book_id=bible_book_id).first()
-# if not record:
-# raise NotAvailableException(detail=f"Bible book {bible_book_id} not found")
-# return record
-# def _validate_book_code_matches(db_session: Session, book_id: int, new_code: str):
-# book = db_session.query(db_models.BookLookup).filter_by(book_id=book_id).first()
-# if book.book_code.lower() != new_code.lower():
-# raise BadRequestException(
-# detail=f"Book code mismatch: {new_code} != {book.book_code}"
-# )
-# def _update_bible_entry(
-# bible_record,
-# usfm_content: str,
-# usj_data: Dict[str, Any],
-# chapter_count: int
-# ):
-# bible_record.usfm = usfm_content
-# bible_record.json = usj_data
-# bible_record.chapters = chapter_count
-# def _replace_clean_verses(
-# db_session: Session,
-# resource_id: int,
-# book_id: int,
-# usj_data: Dict[str, Any]
-# ):
-# db_session.query(db_models.CleanBible).filter_by(
-# resource_id=resource_id,
-# book_id=book_id
-# ).delete()
-
-# verses = parse_usfm_to_clean_verses(usj_data)
-# for v in verses:
-# db_session.add(
-# db_models.CleanBible(
-# resource_id=resource_id,
-# book_id=book_id,
-# chapter=v["chapter"],
-# verse=v["verse"],
-# text=v["text"]
-# )
-# )
-# def delete_bible_books(
-# db_session: Session,
-# resource_id: int,
-# book_codes: List[str]
-# ):
-# """Delete multiple Bible books in a standardized structure."""
-
-# # Check if resource exists
-# resource = (
-# db_session.query(db_models.Resource)
-# .filter_by(resource_id=resource_id)
-# .first()
-# )
-# if not resource:
-# raise NotAvailableException(detail=f"Resource {resource_id} not found")
-
-# def delete_content_type(db_session: Session, content_id: int):
-# """Delete a content type by ID if it exists."""
-# db_obj = db_session.query(db_models.ContentType).filter(db_models.ContentType.content_id == content_id).first()
-# if not db_obj:
-# logger.error("Content type not found")
-# raise HTTPException(status_code=404, detail="Content type not found")
-# # if db_session.query(db_models.Resource).filter_by(content_id=content_id).first():
-# # raise HTTPException(status_code=400, detail="Content type is in use and cannot be deleted")
-# if db_obj:
-# db_session.delete(db_obj)
-# db_session.commit()
-# return db_obj
-
-# --- Version CRUD ---
-def get_all_versions(db_session: Session):
- """Retrieve all versions from the database."""
- return db_session.query(db_models.Version).order_by(db_models.Version.version_id).all()
-
-def get_version(db_session: Session, version_id: int,abbreviation: Optional[str] = None):
- """Retrieve a single version by ID."""
- query = db_session.query(db_models.Version)
- if version_id is not None:
- query = query.filter(db_models.Version.version_id == version_id)
-
- if abbreviation is not None:
- query = query.filter(db_models.Version.abbreviation == abbreviation)
-
- return query.first()
-
-def create_version(db_session: Session, version: schema.VersionCreate):
- """Create a new version with checks for duplicate name and abbreviation."""
- # Check for duplicate name
- if db_session.query(db_models.Version).filter(
- func.lower(db_models.Version.name) == func.lower(version.name)).first():
- logger.error("Version with the same name already exists")
- raise AlreadyExistsException(detail="Version with the same name already exists")
-
- # Check for duplicate abbreviation
- existing = db_session.query(db_models.Version).filter(
- func.lower(db_models.Version.abbreviation) == func.lower(version.abbreviation)).first()
- if existing:
- logger.error("Version with the same abbreviation already exists")
- raise AlreadyExistsException(detail="Version with the same abbreviation already exists")
- # db_obj = db_models.Version(**version.model_dump(by_alias=True))
- db_obj = db_models.Version(
- name=version.name,
- abbreviation=version.abbreviation,
- meta_data=version.metadata
- )
- db_session.add(db_obj)
- db_session.commit()
- db_session.refresh(db_obj)
- return db_obj
-
-def update_version(db_session: Session, version_id: int, version: schema.VersionUpdate):
- """Update an existing version by ID with detailed duplicate checks."""
- db_obj = get_version(db_session, version_id)
- if not db_obj:
- logger.error("Version not found")
- raise NotAvailableException(detail="Version not found")
- # Check for duplicate name
- if db_session.query(db_models.Version).filter(
- func.lower(db_models.Version.name) == func.lower(version.name),
- db_models.Version.version_id != version_id
- ).first():
- logger.error("Version with the same name already exists")
- raise AlreadyExistsException(detail="Version with the same name already exists")
- # Check for duplicate abbreviation
- if db_session.query(db_models.Version).filter(
- func.lower(db_models.Version.abbreviation)== func.lower(version.abbreviation),
- db_models.Version.version_id != version_id
- ).first():
- logger.error("Version with the same abbreviation already exists")
- raise AlreadyExistsException(detail="Version with the same abbreviation already exists")
- db_obj.name = version.name
- db_obj.abbreviation = version.abbreviation
- db_obj.meta_data = version.metadata
- db_session.commit()
- db_session.refresh(db_obj)
- return db_obj
-
-def _get_version_usage_details(db_session: Session, version_id: int):
- """Get detailed resource usage information for a version."""
- lang_alias = aliased(db_models.Language)
- version_alias = aliased(db_models.Version)
- license_alias = aliased(db_models.License)
-
- resources_with_details = (
- db_session.query(db_models.Resource, lang_alias, version_alias, license_alias)
- .join(lang_alias, lang_alias.language_id == db_models.Resource.language_id)
- .join(version_alias, version_alias.version_id == db_models.Resource.version_id)
- .join(license_alias, license_alias.license_id == db_models.Resource.license_id)
- .filter(db_models.Resource.version_id == version_id)
- .all()
- )
-
- used_by = []
- for resource, language, version, _ in resources_with_details:
- version_code = getattr(version, "abbreviation", getattr(version, "code", None))
- content_val = schema.ContentTypeEnum(resource.content_type).value
-
- resource_name = _build_resource_name(
- language_code=language.language_code,
- version_code=version_code,
- revision=resource.revision,
- content_type_value=content_val,
- )
-
- used_by.append(
- schema.ResourceUsageDetail(
- resourceId=resource.resource_id,
- resourceName=resource_name,
- )
- )
-
- return used_by
-def delete_versions_bulk(db_session: Session, version_ids: List[int]):
- deleted_ids = []
- errors = []
- not_found = []
- conflicts = []
-
- for vid in version_ids:
- obj = get_version(db_session, vid)
- if not obj:
- not_found.append(vid)
- errors.append(f"Version {vid} not found")
- continue
-
- used_resources = (
- db_session.query(db_models.Resource)
- .filter_by(version_id=vid)
- .all()
- )
-
- if used_resources:
- conflicts.append(vid)
- errors.append(
- f"Version {obj.name} (id: {vid}) is in use and cannot be deleted"
- )
- continue
-
- try:
- db_session.delete(obj)
- deleted_ids.append(vid)
- except Exception as exc:
- errors.append(
- f"Version {obj.name} (id: {vid}) could not be deleted: {str(exc)}"
- )
-
- db_session.commit()
-
- return {
- "data": {
- "deletedCount": len(deleted_ids),
- "deletedIds": deleted_ids,
- "errors": errors if errors else None,
- },
- "meta": {
- "not_found": not_found,
- "conflicts": conflicts,
- }
- }
-
-
-# --- Language CRUD ---
-def get_languages_with_pagination(
- db_session: Session,
- page: int = 0,
- page_size: int = 100,
- language_name: Optional[str] = None,
- language_code: Optional[str] = None
-) -> Tuple[List[db_models.Language], int]:
- """Retrieve languages with pagination and optional filtering."""
- query = db_session.query(db_models.Language)
- # Apply filters if provided
- filters = []
- if language_name:
- filters.append(db_models.Language.language_name.ilike(f"%{language_name}%"))
- if language_code:
- filters.append(db_models.Language.language_code.ilike(f"%{language_code}%"))
- if filters:
- query = query.filter(or_(*filters))
-
- # Get total count before pagination
- total_items = query.count()
- # Apply ordering and pagination
- offset = page * page_size
- languages = query.order_by(
- db_models.Language.language_id.asc()).offset(offset).limit(page_size).all()
-
- return languages, total_items
-def get_language(db_session: Session, language_id: int):
- """Retrieve a single language by ID."""
- return db_session.query(db_models.Language).filter(
- db_models.Language.language_id == language_id
- ).first()
-
-def create_language(db_session: Session, lang: schema.LanguageCreate):
- """Create a new language if it does not already exist."""
- # Additional validation for required fields
- if not lang.language_name or lang.language_name.strip() == "":
- logger.error("Language name is required")
- raise UnprocessableException(detail="Language name is required")
- if not lang.language_code or lang.language_code.strip() == "":
- logger.error("Language code is required")
- raise UnprocessableException(detail="Language code is required")
-
- # Check for existing language code
- if db_session.query(db_models.Language).filter(
- db_models.Language.language_code == lang.language_code
- ).first():
- raise AlreadyExistsException(detail="Language code already exists")
-
- # Check for existing language name
- if db_session.query(db_models.Language).filter(
- db_models.Language.language_name == lang.language_name
- ).first():
- raise AlreadyExistsException(detail="Language name already exists")
-
- # Create new language object
- db_obj = db_models.Language(
- language_code=lang.language_code,
- language_name=lang.language_name,
- meta_data=lang.metadata
- )
- db_session.add(db_obj)
- db_session.commit()
- db_session.refresh(db_obj)
- return db_obj
-
-def update_language(db_session: Session, language_id: int, lang: schema.LanguageUpdate):
- """Update an existing language by ID with duplicate code check."""
- db_obj = get_language(db_session, language_id)
- if not db_obj:
- raise NotAvailableException(detail="Language not found")
-
- # Additional validation for required fields
- if not lang.language_name or lang.language_name.strip() == "":
- raise UnprocessableException(detail="Language name is required")
- if not lang.language_code or lang.language_code.strip() == "":
- raise UnprocessableException(detail="Language code is required")
-
- # Check duplicate code (exclude current record)
- if db_session.query(db_models.Language).filter(
- db_models.Language.language_code == lang.language_code,
- db_models.Language.language_id != language_id
- ).first():
- raise AlreadyExistsException(detail="Language code already exists")
-
- # Check duplicate name (exclude current record)
- if db_session.query(db_models.Language).filter(
- db_models.Language.language_name == lang.language_name,
- db_models.Language.language_id != language_id
- ).first():
- raise AlreadyExistsException(detail="Language name already exists")
-
- # Update fields
- db_obj.language_code = lang.language_code
- db_obj.language_name = lang.language_name
- db_obj.meta_data = lang.metadata
- db_session.commit()
- db_session.refresh(db_obj)
- return db_obj
-def _get_language_usage_details(db_session: Session, language_id: int):
- """Get detailed information about resources using a language."""
- lang_alias = aliased(db_models.Language)
- version_alias = aliased(db_models.Version)
- license_alias = aliased(db_models.License)
-
- resources_with_details = (
- db_session.query(db_models.Resource, lang_alias, version_alias, license_alias)
- .join(lang_alias, lang_alias.language_id == db_models.Resource.language_id)
- .join(version_alias, version_alias.version_id == db_models.Resource.version_id)
- .join(license_alias, license_alias.license_id == db_models.Resource.license_id)
- .filter(db_models.Resource.language_id == language_id)
- .all()
- )
-
- used_by = []
- for resource, language, version,_ in resources_with_details:
- version_code = getattr(version, "abbreviation", getattr(version, "code", None))
- content_val = schema.ContentTypeEnum(resource.content_type).value
- resource_name = _build_resource_name(
- language_code=language.language_code,
- version_code=version_code,
- revision=resource.revision,
- content_type_value=content_val,
- )
- used_by.append(schema.ResourceUsageDetail(
- resourceId=resource.resource_id,
- resourceName=resource_name
- ))
-
- return used_by
-
-
-def delete_languages_bulk(db_session: Session, language_ids: List[int]):
- deleted_ids = []
- errors = []
-
- for lid in language_ids:
- try:
- db_obj = get_language(db_session, lid)
- if not db_obj:
- errors.append(f"Language {lid} not found")
- continue
-
- used_resources = (
- db_session.query(db_models.Resource)
- .filter_by(language_id=lid)
- .all()
- )
-
- if used_resources:
- errors.append(
- f"Language {lid} ('{db_obj.language_name}') is in use and cannot be deleted"
- )
- continue
-
- db_session.delete(db_obj)
- deleted_ids.append(lid)
-
- except Exception as exc:
- errors.append(f"Error deleting language {lid}: {str(exc)}")
-
- db_session.commit()
-
- # Consistent structure like delete_videos & delete_versions_bulk
- all_failed = len(deleted_ids) == 0 and len(errors) > 0
- has_errors = len(errors) > 0
-
- return {
- "data": {
- "deletedCount": len(deleted_ids),
- "deletedIds": deleted_ids,
- "errors": errors if errors else None,
- },
- "all_failed": all_failed,
- "has_errors": has_errors,
- }
-
-
-
-# --- License CRUD ---
-def get_licenses_with_filters(
- db_session: Session,
- license_id: Optional[int] = None,
- name: Optional[str] = None
-) -> List[db_models.License]:
- """Retrieve licenses with optional filtering."""
- query = db_session.query(db_models.License)
-
- # Apply filters if provided
- if license_id is not None:
- query = query.filter(db_models.License.license_id == license_id)
-
- if name:
- query = query.filter(db_models.License.license_name.ilike(f"%{name}%"))
-
- # Order by license_id for consistent results
- return query.order_by(db_models.License.license_id.asc()).all()
-
-def get_license(db_session: Session, license_id: int):
- """Retrieve a single license by ID."""
- return db_session.query(db_models.License).filter(
- db_models.License.license_id == license_id
- ).first()
-
-def create_license(db_session: Session, license_: schema.LicenseCreate):
- """Create a new license if it does not already exist."""
- # Additional validation for required fields
- if not license_.license_name or license_.license_name.strip() == "":
- raise UnprocessableException(detail="License name is required")
- if not hasattr(license_, 'details') or not license_.details or license_.details.strip() == "":
- raise UnprocessableException(detail="License details are required")
-
- existing = db_session.query(db_models.License).filter(
- db_models.License.license_name == license_.license_name
- ).first()
- if existing:
- raise AlreadyExistsException(detail="License already exists")
-
- db_obj = db_models.License(
- license_name=license_.license_name,
- details=license_.details
- )
- db_session.add(db_obj)
- db_session.commit()
- db_session.refresh(db_obj)
- return db_obj
-
-def update_license(db_session: Session, license_id: int, license_: schema.LicenseUpdate):
- """Update an existing license by ID with duplicate name check."""
- db_obj = get_license(db_session, license_id)
- if not db_obj:
- raise NotAvailableException(detail="License not found")
-
- # Additional validation for required fields
- if not license_.license_name or license_.license_name.strip() == "":
- raise UnprocessableException(detail="License name is required")
- if not hasattr(license_, 'details') or not license_.details or license_.details.strip() == "":
- raise UnprocessableException(detail="License details are required")
-
- # Check duplicate name (exclude current record)
- duplicate = db_session.query(db_models.License).filter(
- db_models.License.license_name == license_.license_name,
- db_models.License.license_id != license_id
- ).first()
- if duplicate:
- raise AlreadyExistsException(detail="License name already exists")
-
- # Update fields
- db_obj.license_name = license_.license_name
- db_obj.details = license_.details
-
- db_session.commit()
- db_session.refresh(db_obj)
- return db_obj
-
-def _get_resource_usage_details(db_session: Session, filter_field: str, filter_value: int):
- """Get detailed information about resources using a specific entity (language/version/license).
-
- Args:
- db_session: Database session
- filter_field: Field name to filter on ('language_id', 'version_id', or 'license_id')
- filter_value: Value to filter by
-
- Returns:
- List of ResourceUsageDetail objects
- """
- lang_alias = aliased(db_models.Language)
- version_alias = aliased(db_models.Version)
- license_alias = aliased(db_models.License)
-
- resources_with_details = (
- db_session.query(db_models.Resource, lang_alias, version_alias, license_alias)
- .join(lang_alias, lang_alias.language_id == db_models.Resource.language_id)
- .join(version_alias, version_alias.version_id == db_models.Resource.version_id)
- .join(license_alias, license_alias.license_id == db_models.Resource.license_id)
- .filter(getattr(db_models.Resource, filter_field) == filter_value)
- .all()
- )
-
- used_by = []
- for resource, language, version, _ in resources_with_details:
- version_code = getattr(version, "abbreviation", getattr(version, "code", None))
- content_val = schema.ContentTypeEnum(resource.content_type).value
- resource_name = _build_resource_name(
- language_code=language.language_code,
- version_code=version_code,
- revision=resource.revision,
- content_type_value=content_val,
- )
- used_by.append(schema.ResourceUsageDetail(
- resourceId=resource.resource_id,
- resourceName=resource_name
- ))
-
- return used_by
-
-
-def delete_licenses_bulk(db_session: Session, license_ids: List[int]):
- deleted_ids = []
- errors = []
-
- for lid in license_ids:
- try:
- db_obj = get_license(db_session, lid)
- if not db_obj:
- errors.append(f"License {lid} not found")
- continue
-
- used_resources = (
- db_session.query(db_models.Resource)
- .filter_by(license_id=lid)
- .all()
- )
-
- if used_resources:
- errors.append(
- f"License {lid} ('{db_obj.license_name}') is in use and cannot be deleted"
- )
- continue
-
- db_session.delete(db_obj)
- deleted_ids.append(lid)
-
- except Exception as exc:
- errors.append(f"Error deleting license {lid}: {str(exc)}")
-
- db_session.commit()
-
- # Consistent response structure
- all_failed = len(deleted_ids) == 0 and len(errors) > 0
- has_errors = len(errors) > 0
-
- return {
- "data": {
- "deletedCount": len(deleted_ids),
- "deletedIds": deleted_ids,
- "errors": errors if errors else None,
- },
- "all_failed": all_failed,
- "has_errors": has_errors,
- }
-
-# --- Resource CRUD ---
-
-def _build_resource_name(language_code: str,
- version_code: str | None,
- revision: str | None,
- content_type_value: str) -> str:
- """
- Format: ___
- e.g., "hin_HINREV_1.1_bible"
-
- - Skips empty parts.
- - All separated by underscores.
- """
- parts = [language_code or "", version_code or "", revision or "", content_type_value]
- return "_".join([p for p in parts if p])
-
-
-def get_resources(
- db: Session,
- filters: schema.ResourceFilter
-) -> List[schema.LanguageGroupOut]:
- """
- Retrieve a list of resources grouped by language.
- """
-
- lang_alias = aliased(db_models.Language)
- ver_alias = aliased(db_models.Version)
- lic_alias = aliased(db_models.License)
-
- query = (
- db.query(db_models.Resource, lang_alias, ver_alias, lic_alias)
- .join(lang_alias, lang_alias.language_id == db_models.Resource.language_id)
- .join(ver_alias, ver_alias.version_id == db_models.Resource.version_id)
- .join(lic_alias, lic_alias.license_id == db_models.Resource.license_id)
- )
-
- if filters.resource_id:
- query = query.filter(db_models.Resource.resource_id == filters.resource_id)
-
- if filters.content_type:
- query = query.filter(db_models.Resource.content_type == filters.content_type.lower())
-
- if filters.published is not None:
- query = query.filter(db_models.Resource.published == filters.published)
-
- rows = (
- query.order_by(db_models.Resource.resource_id.asc())
- .offset(filters.page * filters.page_size)
- .limit(filters.page_size)
- .all()
- )
-
- if filters.resource_id and not rows:
- raise NotAvailableException(detail="Resource not found")
-
- return _group_resources(rows)
-def _group_resources(rows: List[tuple]) -> List[schema.LanguageGroupOut]:
- groups: Dict[int, Dict[str, Any]] = {}
-
- for resource, lang, version, lic in rows:
- lid = lang.language_id
-
- if lid not in groups:
- groups[lid] = {
- "language": schema.LanguageBrief(
- id=lid,
- code=lang.language_code,
- name=lang.language_name,
- ),
- "versions": []
- }
-
- version_code = getattr(version, "abbreviation", getattr(version, "code", None))
- content_val = schema.ContentTypeEnum(resource.content_type).value
-
- resource_name = _build_resource_name(
- language_code=lang.language_code,
- version_code=version_code,
- revision=resource.revision,
- content_type_value=content_val,
- )
-
- groups[lid]["versions"].append(
- schema.ResourceRowResponse(
- resourceId=resource.resource_id,
- resourceName=resource_name,
- revision=resource.revision,
- version=schema.VersionRef(
- id=version.version_id,
- name=version.name,
- code=version_code,
- ),
- content=schema.ContentRef(
- contentType=schema.ContentTypeEnum(resource.content_type)
- ),
- license=schema.LicenseRef(
- id=lic.license_id,
- name=lic.license_name
- ),
- language=schema.LanguageBrief(
- id=lang.language_id,
- code=lang.language_code,
- name=lang.language_name
- ),
- metadata=json.loads(resource.meta_data) if resource.meta_data else None,
- published=bool(resource.published),
- createdBy=resource.created_by,
- createdTime=resource.created_at,
- updatedBy=resource.updated_by,
- updatedTime=resource.updated_at
- )
- )
-
- return [schema.LanguageGroupOut(**g) for g in groups.values()]
-
-
-def create_resource(
- db: Session,
- payload: schema.ResourceCreate,
- created_by: Optional[int] = None) -> schema.ResourceResponse:
- """Create a new resource and return response schema."""
- # Check if resource already exists
- ct = payload.content_type.value.lower()
- now = utcnow()
- existing = (
- db.query(db_models.Resource)
- .filter_by(
- version_id=payload.version_id,
- language_id=payload.language_id,
- license_id=payload.license_id,
- revision=payload.revision,
- content_type=payload.content_type.value.lower(),
- )
- .first()
- )
- if existing:
- raise AlreadyExistsException(detail="Resource already exists")
- version = db.query(db_models.Version).filter_by(version_id=payload.version_id).first()
- if not version:
- raise NotAvailableException(detail="versionId not found")
- language = db.query(db_models.Language).filter_by(language_id=payload.language_id).first()
- if not language:
- raise NotAvailableException(detail="languageId not found")
- license_ = db.query(db_models.License).filter_by(license_id=payload.license_id).first()
- if not license_:
- raise NotAvailableException(detail="licenseId not found")
- ct = payload.content_type.value.lower()
-
- db_obj = db_models.Resource(
- version_id=payload.version_id,
- revision=payload.revision,
- content_type=ct,
- language_id=payload.language_id,
- license_id=payload.license_id,
- meta_data=json.dumps(payload.metadata, ensure_ascii=False) if payload.metadata else None,
- created_by=created_by,
- created_at=now,
- published=bool(getattr(payload, "published", False)),
- # published=False, # always default to False on POST
- )
- db.add(db_obj)
- db.commit()
- db.refresh(db_obj)
- version_code = getattr(version, "abbreviation", getattr(version, "code", None))
- resource_name = _build_resource_name(
- language_code=language.language_code,
- version_code=version_code,
- revision=db_obj.revision,
- content_type_value=ct,
- )
- now = utcnow()
-
- return schema.ResourceResponse(
- resourceId=db_obj.resource_id,
- resourceName=resource_name,
- revision=db_obj.revision,
- version=schema.VersionRef(
- id=version.version_id,
- name=version.name,
- code=getattr(version, "abbreviation", getattr(version, "code", "")),
- ),
- language=schema.LanguageBrief(
- id=language.language_id,
- code=language.language_code,
- name=language.language_name
- ),
- content=schema.ContentRef(contentType=schema.ContentTypeEnum(db_obj.content_type)),
- license=schema.LicenseRef(id=license_.license_id, name=license_.license_name),
- metadata=json.loads(db_obj.meta_data) if db_obj.meta_data else None,
- published=bool(db_obj.published),
- createdBy=db_obj.created_by,
- createdTime=db_obj.created_at,
- updatedBy=None,
- updatedTime=None,
- )
-
-
-def update_resource(
- db: Session,
- payload: schema.ResourceUpdate,
- user_id: Optional[int] = None
-) -> schema.ResourceResponse:
- """Update resource and return response schema."""
- db_obj = _get_resource_or_404(db, payload.resource_id)
-
- # Determine final values for uniqueness validation
- final_values = _resolve_final_values(db_obj, payload)
-
- _validate_uniqueness(db, payload.resource_id, final_values)
-
- # Update main resource fields
- version, language, license_ = _apply_updates(db, db_obj, payload)
-
- # Extra fields
- if payload.metadata is not None:
- db_obj.meta_data = json.dumps(payload.metadata, ensure_ascii=False)
- if payload.published is not None:
- db_obj.published = bool(payload.published)
-
- db_obj.updated_by = user_id
- db_obj.updated_at = utcnow()
-
- db.commit()
- db.refresh(db_obj)
-
- return _build_response(db_obj, version, language, license_)
-def _get_resource_or_404(db: Session, resource_id: int):
- obj = db.query(db_models.Resource).filter_by(resource_id=resource_id).first()
- if not obj:
- raise NotAvailableException(detail="Resource not found")
- return obj
-def _resolve_final_values(db_obj, payload):
- return {
- "version_id": payload.version_id or db_obj.version_id,
- "language_id": payload.language_id or db_obj.language_id,
- "license_id": payload.license_id or db_obj.license_id,
- "revision": payload.revision or db_obj.revision,
- "content_type": payload.content_type.value.lower()
- if payload.content_type else db_obj.content_type,
- }
-def _validate_uniqueness(db: Session, current_id: int, vals: dict):
- existing = (
- db.query(db_models.Resource)
- .filter(
- db_models.Resource.version_id == vals["version_id"],
- db_models.Resource.language_id == vals["language_id"],
- db_models.Resource.license_id == vals["license_id"],
- db_models.Resource.revision == vals["revision"],
- db_models.Resource.content_type == vals["content_type"],
- db_models.Resource.resource_id != current_id,
- )
- .first()
- )
- if existing:
- raise AlreadyExistsException(detail="Resource already exists")
-def _apply_updates(db: Session, db_obj, payload):
- # Version
- version = _validate_and_get(
- db,
- db_models.Version,
- payload.version_id or db_obj.version_id, "versionId")
- db_obj.version_id = version.version_id
-
- # Language
- language = _validate_and_get(
- db,
- db_models.Language,
- payload.language_id or db_obj.language_id, "languageId"
- )
- db_obj.language_id = language.language_id
-
- # License
- license_ = _validate_and_get(
- db,
- db_models.License,
- payload.license_id or db_obj.license_id, "licenseId"
- )
- db_obj.license_id = license_.license_id
-
- # Revision & content type
- if payload.revision is not None:
- db_obj.revision = payload.revision
- if payload.content_type is not None:
- db_obj.content_type = payload.content_type.value.lower()
-
- return version, language, license_
-def _validate_and_get(db, model, id_value, field_name):
- pk_column = model.__mapper__.primary_key[0].name
-
- obj = db.query(model).filter(getattr(model, pk_column) == id_value).first()
- if not obj:
- raise NotAvailableException(detail=f"{field_name} not found")
- return obj
-
-def _build_response(db_obj, version, language, license_):
- resource_name = _build_resource_name(
- language_code=language.language_code,
- version_code=getattr(version, "abbreviation", getattr(version, "code", None)),
- revision=db_obj.revision,
- content_type_value=schema.ContentTypeEnum(db_obj.content_type).value,
- )
-
- return schema.ResourceResponse(
- resourceId=db_obj.resource_id,
- resourceName=resource_name,
- revision=db_obj.revision,
- version=schema.VersionRef(
- id=version.version_id,
- name=version.name,
- code=getattr(version, "abbreviation", getattr(version, "code", "")),
- ),
- language=schema.LanguageBrief(
- id=language.language_id,
- code=language.language_code,
- name=language.language_name,
- ),
- content=schema.ContentRef(contentType=schema.ContentTypeEnum(db_obj.content_type)),
- license=schema.LicenseRef(id=license_.license_id, name=license_.license_name),
- metadata=json.loads(db_obj.meta_data) if db_obj.meta_data else None,
- published=bool(db_obj.published),
- createdBy=db_obj.created_by,
- createdTime=db_obj.created_at,
- updatedBy=db_obj.updated_by,
- updatedTime=db_obj.updated_at,
- )
-
-def delete_resources_bulk(db: Session, resource_ids: List[int]):
- deleted_ids = []
- errors = []
-
- related_models = [
- db_models.Bible,
- db_models.CleanBible,
- db_models.Video,
- db_models.Commentary,
- db_models.Dictionary,
- db_models.AudioBible,
- db_models.Obs,
- db_models.Infographic,
- ]
-
- for rid in resource_ids:
- try:
- db_obj = (
- db.query(db_models.Resource)
- .filter_by(resource_id=rid)
- .first()
- )
-
- if not db_obj:
- errors.append(f"Resource {rid} not found")
- continue
-
- # Delete dependent entities first
- for model in related_models:
- db.query(model).filter_by(resource_id=rid).delete(
- synchronize_session=False
- )
-
- # Delete main resource
- db.delete(db_obj)
- deleted_ids.append(rid)
-
- except IntegrityError:
- db.rollback()
- errors.append(
- f"Resource {rid} could not be deleted due to database constraints"
- )
-
- except Exception as exc:
- db.rollback()
- errors.append(f"Error deleting resource {rid}: {str(exc)}")
-
- db.commit()
-
- # ---- Consistent structure ----
- all_failed = len(deleted_ids) == 0 and len(errors) > 0
- has_errors = len(errors) > 0
-
- return {
- "data": {
- "deletedCount": len(deleted_ids),
- "deletedIds": deleted_ids,
- "errors": errors if errors else None,
- },
- "all_failed": all_failed,
- "has_errors": has_errors,
- }
-
-# --- Log files CRUD ---
-
-def latest_log_file():
- """Get latest log file"""
- # Find newest log file
- files = sorted(
- [f for f in os.listdir(LOG_DIR) if f.startswith("vachan_admin_app.log")],
- key=lambda x: os.path.getmtime(os.path.join(LOG_DIR, x)),
- reverse=True
- )
- if not files:
- raise NotAvailableException(detail="No log files found")
- path = os.path.join(LOG_DIR, files[0])
- return FileResponse(
- path=path,
- media_type="text/plain",
- filename=files[0]
- )
-
-
-def get_logfile_by_number(log_file_no):
- """Get log file by number"""
- if log_file_no < 0 or log_file_no > 10:
- raise BadRequestException(detail="log_file_no must be 0–10")
- filename = "vachan_admin_app.log" if log_file_no == 0 else f"vachan_admin_app.log.{log_file_no}"
- path = os.path.join(LOG_DIR, filename)
- if not os.path.exists(path):
- raise NotAvailableException(detail=f"Log file {filename} not found")
- return FileResponse(
- path=path,
- media_type="text/plain",
- filename=filename
- )
-
-def get_all_logfiles():
- """Get all log files in a zip format"""
-
- tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".zip")
- tmp_path = tmp.name
- tmp.close()
-
- with zipfile.ZipFile(tmp_path, "w", zipfile.ZIP_DEFLATED) as zf:
- for fname in os.listdir(LOG_DIR):
- if fname.startswith("vachan_admin_app.log"):
- zf.write(os.path.join(LOG_DIR, fname), arcname=fname)
-
- return FileResponse(
- path=tmp_path,
- media_type="application/zip",
- filename="logs.zip"
- )
-
-
-def touch_resource(db: Session, resource_id: int, actor_user_id: Optional[int]) -> None:
- """
- Update only the resource row's updated_by/updated_at.
- Call this from child-table CRUD whenever they modify rows.
- """
- res = db.query(db_models.Resource).filter_by(resource_id=resource_id).first()
- if not res:
- # If this is ever hit, caller already verified resource existence; keep consistent
- raise NotAvailableException(detail=f"Resource {resource_id} not found")
- res.updated_by = actor_user_id
- res.updated_at = utcnow()
- db.add(res)
-
-# --- Video CRUD ---
-
-def create_videos(db: Session, data: schema.VideoBulkCreate, actor_user_id: int):
- """Create a new video entry"""
- # Resource must exist
- resource = db.query(db_models.Resource).filter_by(resource_id=data.resourceId).first()
- if not resource:
- raise NotAvailableException(detail=f"Resource {data.resourceId} not found")
- # Resource content_type must be 'video'
- if resource.content_type.lower() != "video":
- raise BadRequestException(
- f"Resource {data.resourceId} is not of type 'video' (found '{resource.content_type}')"
- )
- created = []
- for v in data.videos:
- # Check duplicates (resource_id + book + chapter + url)
- existing = (
- db.query(db_models.Video)
- .filter_by(resource_id=data.resourceId, book=v.book, chapter=v.chapter, title=v.title)
- .first()
- )
- if existing:
- raise AlreadyExistsException(
- detail=(
- f"Video {v.book} {v.chapter} {v.title} "
- f"already exists in resource {data.resourceId}"
- )
- )
- # Create new video record
- video = db_models.Video(
- resource_id=data.resourceId,
- book=v.book,
- chapter=v.chapter,
- url=v.url,
- title=v.title,
- description=v.description,
- )
- db.add(video)
- created.append(video)
-
- touch_resource(db, data.resourceId, actor_user_id)
- db.commit()
-
- # Return structured response
- return {
- "resource_id": data.resourceId,
- "videos": created
- }
-
-
-
-def update_videos(db: Session, data: schema.VideoBulkUpdate, actor_user_id: int):
- """Update a video entry"""
- # Resource must exist
- resource = db.query(db_models.Resource).filter_by(resource_id=data.resourceId).first()
- if not resource:
- raise NotAvailableException(detail=f"Resource {data.resourceId} not found")
-
- # Resource content_type must be 'video'
- if resource.content_type.lower() != "video":
- raise BadRequestException(
- detail=(
- f"Resource {data.resourceId} is not of type 'video'"
- f" (found '{resource.content_type}')"
- )
- )
-
- updated = []
- for v in data.videos:
- # Find the video by id + resource
- video = (
- db.query(db_models.Video)
- .filter_by(video_id=v.id, resource_id=data.resourceId)
- .first()
- )
- if not video:
- raise NotAvailableException(
- detail=f"Video {v.id} not found in resource {data.resourceId}"
- )
-
- # Check duplicates (exclude the current video itself)
- duplicate = (
- db.query(db_models.Video)
- .filter(
- db_models.Video.resource_id == data.resourceId,
- db_models.Video.book == v.book,
- db_models.Video.chapter == v.chapter,
- db_models.Video.url == v.title,
- db_models.Video.video_id != v.id, # exclude self
- )
- .first()
- )
- if duplicate:
- raise AlreadyExistsException(
- detail= (
- f"Video {v.book} {v.chapter} {v.url} already exists"
- f" in resource {data.resourceId}"
- )
- )
-
- # Update fields
- video.book = v.book
- video.chapter = v.chapter
- video.url = v.url
- video.title = v.title
- video.description = v.description
- updated.append(video)
- touch_resource(db, data.resourceId, actor_user_id)
- db.commit()
- return {
- "resource_id": data.resourceId,
- "videos": updated
- }
-
-def get_videos_filtered(
- db: Session,
- resource_id: int = None,
- language_code: str = None,
- book_code: str = None,
- chapter: int = None
-):
- """Get videos filtered by resource, language, book, and chapter."""
-
- # Validate resource if provided
- if resource_id:
- resource = db.query(db_models.Resource).filter(
- db_models.Resource.resource_id == resource_id
- ).first()
- if not resource:
- raise NotAvailableException(detail=f"Resource {resource_id} not found")
-
- if resource.content_type.lower() != "video":
- raise BadRequestException(
- detail=f"Resource {resource_id} is not of type 'video' (found '{resource.content_type}')"
- )
-
- # Validate language if provided
- if language_code:
- language = db.query(db_models.Language).filter(
- db_models.Language.language_code == language_code
- ).first()
- if not language:
- raise NotAvailableException(detail=f"Language '{language_code}' not found")
-
-
- if book_code:
- book_code_lower = book_code.lower()
-
- # Check if book exists in video table
- book_exists = db.query(db_models.Video).filter(
- func.lower(db_models.Video.book) == book_code_lower
- ).first()
-
- if not book_exists:
- raise NotAvailableException(
- detail=f"Book code '{book_code}' not found in videos"
- )
-
- # Chapter validation
- if chapter is not None:
- chapter_exists = db.query(db_models.Video).filter(
- func.lower(db_models.Video.book) == book_code_lower,
- db_models.Video.chapter == chapter
- ).first()
-
- if not chapter_exists:
- raise NotAvailableException(
- detail=f"Chapter {chapter} not found for book '{book_code}'"
- )
-
- query = db.query(db_models.Video)
-
- if resource_id:
- query = query.filter(db_models.Video.resource_id == resource_id)
-
- if book_code:
- query = query.filter(func.lower(db_models.Video.book) == book_code_lower)
-
- if chapter is not None:
- query = query.filter(db_models.Video.chapter == chapter)
-
- videos = query.all()
-
- result = {"books": {}}
-
- for v in videos:
- book_key = (v.book or "").lower().strip()
- chapter_key = str(v.chapter)
-
- if book_key not in result["books"]:
- result["books"][book_key] = {}
-
- if chapter_key not in result["books"][book_key]:
- result["books"][book_key][chapter_key] = []
-
- result["books"][book_key][chapter_key].append({
- "video_id": v.video_id,
- "title": v.title,
- "description": v.description,
- "url": v.url
- })
-
- return result
-
-def delete_videos(db: Session, resource_id: int, video_ids: List[int]):
- """Delete multiple videos from a resource"""
- # Check if resource exists
- resource = db.query(db_models.Resource).filter_by(resource_id=resource_id).first()
- if not resource:
- raise NotAvailableException(detail=f"Resource {resource_id} not found")
-
- deleted_ids = []
- invalid_ids = []
-
- for video_id in video_ids:
- video = (
- db.query(db_models.Video)
- .filter(
- db_models.Video.video_id == video_id,
- db_models.Video.resource_id == resource_id,
- )
- .first()
- )
-
- if video:
- db.delete(video)
- deleted_ids.append(video_id)
- else:
- invalid_ids.append(video_id)
-
- db.commit()
-
- response = {
- "deletedCount": len(deleted_ids),
- "deletedIds": deleted_ids,
- "message": (
- f"Successfully deleted {len(deleted_ids)}"
- f" video{'s' if len(deleted_ids) != 1 else ''}"
- )
- }
-
- if invalid_ids:
- response["error"] = f"Invalid video_ids: {', '.join(map(str, invalid_ids))}"
-
- # Return response with status code indicator
- return {
- "data": response,
- "has_errors": len(invalid_ids) > 0,
- "all_failed": len(deleted_ids) == 0
- }
-# ---- Bibles ----
-
-
-# Utility functions for API operations
-
-def extract_book_code_from_usfm(usfm_content: str) -> str:
- """Extract book code from USFM content using usfm-grammar"""
- try:
- parser = USFMParser(usfm_content)
- usj_data = parser.to_usj()
-
- for item in usj_data.get("content", []):
- if item.get("type") == "book" and item.get("marker") == "id":
- return item.get("code")
-
- raise UnprocessableException("No book code found in USFM content")
-
- except Exception as e:
- raise UnprocessableException(
- f"USFM parsing error: {str(e)}"
- ) from e
-
-def validate_chapter_count(db_session: Session,
- book_code: str, chapter_count: int):
- """Validate chapter count against DB table"""
- book = (
- db_session.query(db_models.BookLookup)
- .filter(db_models.BookLookup.book_code == book_code.lower())
- .first()
- )
-
- if not book:
- raise NotAvailableException(detail=f"Book {book_code} not found")
-
- if chapter_count > book.chapter_count:
- raise BadRequestException(
- detail=(
- f"Invalid chapter count {chapter_count} for book {book_code}. "
- f"Max allowed: {book.chapter_count}"
- )
- )
-
-def parse_verse_number(verse_str: str) -> List[int]:
- """
- Parse verse number strings that might contain ranges.
-
- Examples:
- - "1" -> [1]
- - "23-24" -> [23, 24]
- """
- verses = []
-
- if not verse_str:
- return verses
-
- verse_str = str(verse_str).strip()
-
- try:
- # Split by comma for multiple groups
- groups = verse_str.split(',')
-
- for group in groups:
- group = group.strip()
-
- if '-' in group:
- # Handle ranges like "23-24"
- parts = group.split('-')
- if len(parts) == 2:
- try:
- start = int(parts[0].strip())
- end = int(parts[1].strip())
- verses.extend(range(start, end + 1))
- except ValueError:
- # Fallback: treat as single verse
- verses.append(int(group))
- else:
- verses.append(int(group))
- else:
- # Single verse
- verses.append(int(group))
-
- return sorted(set(verses)) # Remove duplicates and sort
-
- except (ValueError, AttributeError) as e:
- raise ValueError(f"Could not parse verse number: {verse_str}") from e
-
-
-def parse_usfm_to_clean_verses(usj_data: Dict[str, Any]) -> List[Dict[str, Any]]:
- """Parse USJ data to extract clean verse-by-verse content, handling verse ranges"""
- verses = []
- chapter = None
-
- for item in usj_data.get("content", []):
- item_type = item.get("type")
-
- if item_type == "chapter":
- chapter = item.get("number")
- continue
-
- if item_type == "para":
- _process_paragraph(item.get("content", []), chapter, verses)
-
- return verses
-
-def _process_paragraph(para_content: List[Any], chapter: int, verses: List[Dict[str, Any]]) -> None:
- """Process paragraph and extract individual verses."""
- i = 0
- length = len(para_content)
-
- while i < length:
- element = para_content[i]
-
- if not _is_verse_marker(element):
- i += 1
- continue
-
- verse_str = element.get("number")
- i += 1
-
- verse_text, i = _collect_verse_text(para_content, i)
-
- if verse_text:
- _expand_and_add_verses(verse_str, verse_text, chapter, verses)
-
-def _is_verse_marker(element: Any) -> bool:
- return isinstance(element, dict) and element.get("type") == "verse"
-
-def _collect_verse_text(para_content: List[Any], index: int) -> tuple[str, int]:
- parts = []
- length = len(para_content)
-
- while index < length and isinstance(para_content[index], str):
- parts.append(para_content[index])
- index += 1
-
- return " ".join(parts).strip(), index
-
-def _expand_and_add_verses(
- verse_str: str,
- verse_text: str,
- chapter: int,
- verses: List[Dict[str, Any]]
-):
- try:
- verse_numbers = parse_verse_number(verse_str)
- for number in verse_numbers:
- verses.append({
- "chapter": chapter,
- "verse": number,
- "text": verse_text,
- })
- except ValueError as e:
- print(f"Warning: Could not parse verse '{verse_str}' in chapter {chapter}: {e}")
-
-
-# CRUD Operations
-def upload_bible_book(
- db_session: Session,
- resource_id: int,
- usfm_file: UploadFile,
- actor_user_id: int
-) -> Dict[str, str]:
- """Upload and process a new bible book"""
-
- _get_resource(db_session, resource_id)
- usfm_content = _read_usfm_file(usfm_file)
- book_code = extract_book_code_from_usfm(usfm_content)
- book = _lookup_book_or_404(db_session, book_code)
-
- _check_book_not_exists(db_session, resource_id, book.book_id)
-
- usj_data = _parse_usfm_to_usj(usfm_content)
-
- content_items = usj_data.get("content", [])
- chapter_count = _count_chapters(content_items)
- validate_chapter_count(db_session, book_code, chapter_count)
-
- entry_data = BibleEntrySchema(
- resource_id=resource_id,
- book_id=book.book_id,
- usfm_content=usfm_content,
- usj_data=usj_data,
- chapter_count=chapter_count
- )
-
- bible_record = _create_bible_entry(db_session, entry_data)
-
- _save_clean_verses(
- db_session=db_session,
- resource_id=resource_id,
- book_id=book.book_id,
- usj_data=usj_data
- )
-
- touch_resource(db_session, resource_id=bible_record.resource_id, actor_user_id=actor_user_id)
- db_session.commit()
-
- return {
- "message": "Bible book uploaded successfully",
- "bible_book_id": bible_record.bible_book_id
- }
-def _get_resource(db_session: Session, resource_id: int):
- resource = db_session.query(db_models.Resource).filter_by(resource_id=resource_id).first()
- if not resource:
- raise NotAvailableException(detail="Resource not found")
- return resource
-def _read_usfm_file(usfm_file: UploadFile) -> str:
- return usfm_file.file.read().decode("utf-8")
-
-
-def _lookup_book_or_404(db_session: Session, book_code: str):
- book = db_session.query(db_models.BookLookup).filter(
- func.lower(db_models.BookLookup.book_code) == book_code.lower()
- ).first()
- if not book:
- raise NotAvailableException(detail=f"Book {book_code} not found")
- return book
-
-
-def _check_book_not_exists(db_session: Session, resource_id: int, book_id: int):
- existing = db_session.query(db_models.Bible).filter_by(
- resource_id=resource_id,
- book_id=book_id
- ).first()
- if existing:
- raise AlreadyExistsException(
- detail=f"Book already exists for resource {resource_id}"
- )
-
-
-def _parse_usfm_to_usj(usfm_content: str) -> Dict[str, Any]:
- try:
- return USFMParser(usfm_content).to_usj()
- except Exception as e:
- raise UnprocessableException(detail=f"Error parsing USFM: {str(e)}") from e
-
-
-def _count_chapters(content_items: List[Dict[str, Any]]) -> int:
- return len([item for item in content_items if item.get("type") == "chapter"])
-
-
-def _create_bible_entry(db_session: Session, data: BibleEntrySchema):
- bible_record = db_models.Bible(
- resource_id=data.resource_id,
- book_id=data.book_id,
- usfm=data.usfm_content,
- json=data.usj_data,
- chapters=data.chapter_count,
- )
- db_session.add(bible_record)
- db_session.flush()
- return bible_record
-
-def _save_clean_verses(
- db_session: Session,
- resource_id: int,
- book_id: int,
- usj_data: Dict[str, Any]
-):
- verses = parse_usfm_to_clean_verses(usj_data)
- for verse in verses:
- db_session.add(
- db_models.CleanBible(
- resource_id=resource_id,
- book_id=book_id,
- chapter=verse["chapter"],
- verse=verse["verse"],
- text=verse["text"],
- )
- )
-
-
-def update_bible_book(
- db_session: Session,
- bible_book_id: int,
- usfm_file: UploadFile,
- actor_user_id: int
-) -> Dict[str, str]:
- """Update an existing bible book"""
-
- bible_record = _get_bible_record_or_404(db_session, bible_book_id)
- usfm_content = _read_usfm_file(usfm_file)
-
- book_code = extract_book_code_from_usfm(usfm_content)
- _validate_book_code_matches(db_session, bible_record.book_id, book_code)
-
- usj_data = _parse_usfm_to_usj(usfm_content)
-
- content_items = usj_data.get("content", [])
- chapter_count = _count_chapters(content_items)
- validate_chapter_count(db_session, book_code, chapter_count)
-
- _update_bible_entry(
- bible_record=bible_record,
- usfm_content=usfm_content,
- usj_data=usj_data,
- chapter_count=chapter_count,
- )
-
- _replace_clean_verses(
- db_session=db_session,
- resource_id=bible_record.resource_id,
- book_id=bible_record.book_id,
- usj_data=usj_data
- )
-
- touch_resource(db_session, resource_id=bible_record.resource_id, actor_user_id=actor_user_id)
- db_session.commit()
-
- return {"message": "Bible book updated successfully"}
-def _get_bible_record_or_404(db_session: Session, bible_book_id: int):
- record = db_session.query(db_models.Bible).filter_by(bible_book_id=bible_book_id).first()
- if not record:
- raise NotAvailableException(detail=f"Bible book {bible_book_id} not found")
- return record
-def _validate_book_code_matches(db_session: Session, book_id: int, new_code: str):
- book = db_session.query(db_models.BookLookup).filter_by(book_id=book_id).first()
- if book.book_code.lower() != new_code.lower():
- raise BadRequestException(
- detail=f"Book code mismatch: {new_code} != {book.book_code}"
- )
-def _update_bible_entry(
- bible_record,
- usfm_content: str,
- usj_data: Dict[str, Any],
- chapter_count: int
-):
- bible_record.usfm = usfm_content
- bible_record.json = usj_data
- bible_record.chapters = chapter_count
-def _replace_clean_verses(
- db_session: Session,
- resource_id: int,
- book_id: int,
- usj_data: Dict[str, Any]
-):
- db_session.query(db_models.CleanBible).filter_by(
- resource_id=resource_id,
- book_id=book_id
- ).delete()
-
- verses = parse_usfm_to_clean_verses(usj_data)
- for v in verses:
- db_session.add(
- db_models.CleanBible(
- resource_id=resource_id,
- book_id=book_id,
- chapter=v["chapter"],
- verse=v["verse"],
- text=v["text"]
- )
- )
-def delete_bible_books(
- db_session: Session,
- resource_id: int,
- book_codes: List[str]
-):
- """Delete multiple Bible books in a standardized structure."""
-
- # Check if resource exists
- resource = (
- db_session.query(db_models.Resource)
- .filter_by(resource_id=resource_id)
- .first()
- )
- if not resource:
- raise NotAvailableException(detail=f"Resource {resource_id} not found")
-
- deleted_ids = []
- errors = []
- processed = set()
-
- for code in book_codes:
- code_lower = code.lower()
-
- # Duplicate check
- if code_lower in processed:
- errors.append(f"Duplicate book code: {code}")
- continue
-
- processed.add(code_lower)
-
- # Lookup the book
- book = (
- db_session.query(db_models.BookLookup)
- .filter(func.lower(db_models.BookLookup.book_code) == code_lower)
- .first()
- )
-
- if not book:
- errors.append(f"Book code '{code}' not found in lookup")
- continue
-
- # Check bible table
- bible_row = (
- db_session.query(db_models.Bible)
- .filter_by(resource_id=resource_id, book_id=book.book_id)
- .first()
- )
-
- if not bible_row:
- errors.append(f"Book '{code}' not found for resource {resource_id}")
- continue
-
- # Delete clean bible rows
- db_session.query(db_models.CleanBible).filter_by(
- resource_id=resource_id, book_id=book.book_id
- ).delete()
-
- # Delete bible entry
- db_session.delete(bible_row)
- deleted_ids.append(code)
-
- if deleted_ids:
- db_session.commit()
-
- # Standardized flags
- all_failed = len(deleted_ids) == 0 and len(errors) > 0
- has_errors = len(errors) > 0
-
- return {
- "data": {
- "deletedCount": len(deleted_ids),
- "deletedIds": deleted_ids,
- "errors": errors if errors else None,
- },
- "all_failed": all_failed,
- "has_errors": has_errors,
- }
-
-
-
-def get_bible_books(db_session: Session, resource_id: int) -> schema.BibleBooksListResponse:
- """Get list of books for a bible resource"""
- # Find resource
- resource = db_session.query(db_models.Resource).filter_by(resource_id=resource_id).first()
- if not resource:
- raise NotAvailableException(detail=f"Resource {resource_id} not found")
-
- books = db_session.query(db_models.Bible, db_models.BookLookup).join(
- db_models.BookLookup, db_models.Bible.book_id == db_models.BookLookup.book_id
- ).filter(db_models.Bible.resource_id == resource_id).all()
-
- book_responses = []
- for bible, book_lookup in books:
- book_responses.append(schema.BibleBookResponse(
- bible_book_id=bible.bible_book_id,
- book_code=book_lookup.book_code,
- book_id=book_lookup.book_id,
- short=book_lookup.book_code, # You may want to add these fields to BookLookup
- long=book_lookup.book_name,
- abbr=book_lookup.book_code[:3]
- ))
-
- return schema.BibleBooksListResponse(
- resource_id=resource_id,
- books=book_responses
- )
-
-def get_full_bible_content(
- db_session: Session,
- resource_id: int,
- output_format: str
-) -> schema.BibleFullContentResponse:
- """Get full content of all books in a resource in specified format"""
-
- # Find resource
- resource = db_session.query(db_models.Resource).filter_by(resource_id=resource_id).first()
- if not resource:
- raise NotAvailableException(detail=f"Resource {resource_id} not found")
-
- # Find all bible records for this resource, ordered by book_id
- bible_records = db_session.query(db_models.Bible).join(
- db_models.BookLookup, db_models.Bible.book_id == db_models.BookLookup.book_id
- ).filter(
- db_models.Bible.resource_id == resource_id
- ).order_by(db_models.BookLookup.book_id).all()
-
- if not bible_records:
- raise NotAvailableException(detail=f"No bible records found for resource {resource_id}")
-
- # Prepare books data
- books = []
- for bible_record in bible_records:
- # Get book details
- book = db_session.query(db_models.BookLookup).filter_by(
- book_id=bible_record.book_id
- ).first()
-
- if output_format.lower() == "json":
- content = bible_record.json
- elif output_format.lower() == "usfm":
- content = bible_record.usfm
- else:
- raise BadRequestException(detail=f"Unsupported format: {format}")
-
- books.append({
- "bible_book_id": bible_record.bible_book_id,
- "book_id": bible_record.book_id,
- "book_code": book.book_code,
- "book_name": book.book_name,
- "chapters": bible_record.chapters,
- "content": content
- })
-
- return schema.BibleFullContentResponse(
- resource_id=resource_id,
- total_books=len(books),
- books=books
- )
-
-
-def get_bible_book_content(
- db_session: Session,
- resource_id: int,
- book_code: str,
- output_format: str
-) -> schema.BibleBookContentResponse:
- """Get full content of a book in specified format"""
-
- # Find resource
- resource = db_session.query(db_models.Resource).filter_by(resource_id=resource_id).first()
- if not resource:
- raise NotAvailableException(detail=f"Resource {resource_id} not found")
-
- # Find book
- book = db_session.query(db_models.BookLookup).filter(
- func.lower(db_models.BookLookup.book_code) == book_code.lower()
- ).first()
-
- if not book:
- raise NotAvailableException(detail=f"Book {book_code} not found")
-
- # Find bible record
- bible_record = db_session.query(db_models.Bible).filter_by(
- resource_id=resource_id,
- book_id=book.book_id
- ).first()
-
- if not bible_record:
- raise NotAvailableException(
- detail=f"Book {book_code} not found for resource {resource_id}"
- )
-
-
- if output_format.lower() == "json":
- content = bible_record.json
- elif output_format.lower() == "usfm":
- content = bible_record.usfm
- else:
- raise TypeException("Format must be 'json' or 'usfm'")
-
-
- return schema.BibleBookContentResponse(
- resource_id=resource_id,
- book_id=bible_record.bible_book_id,
- book_code=book_code,
- book_content=content
- )
-
-
-
-def get_available_books(db_session: Session, resource_id: int):
- """Get all available books for a resource, ordered by book_id"""
- return db_session.query(db_models.BookLookup).join(
- db_models.Bible, db_models.BookLookup.book_id == db_models.Bible.book_id
- ).filter(
- db_models.Bible.resource_id == resource_id
- ).order_by(db_models.BookLookup.book_id).all()
-
-def get_available_clean_books(db_session: Session, resource_id: int):
- """Get all available books for a resource from clean_bible, ordered by book_id"""
- return db_session.query(db_models.BookLookup).join(
- db_models.CleanBible, db_models.BookLookup.book_id == db_models.CleanBible.book_id
- ).filter(
- db_models.CleanBible.resource_id == resource_id
- ).distinct().order_by(db_models.BookLookup.book_id).all()
-
-def get_bible_chapter(
- db_session: Session,
- resource_id: int,
- book_code: str,
- chapter: int
-) -> schema.BibleChapterResponse:
- """Get chapter content from bible table with cross-book navigation"""
-
- # Helper: Resource
- def get_resource():
- res = db_session.query(db_models.Resource).filter_by(resource_id=resource_id).first()
- if not res:
- raise NotAvailableException(detail=f"Resource {resource_id} not found")
- return res
-
- # Helper: Book
- def get_book():
- book_obj = db_session.query(db_models.BookLookup).filter(
- func.lower(db_models.BookLookup.book_code) == book_code.lower()
- ).first()
- if not book_obj:
- raise NotAvailableException(detail=f"Book {book_code} not found")
- return book_obj
-
- # Helper: Bible record
- def get_bible_record(book_id):
- record = db_session.query(db_models.Bible).filter_by(
- resource_id=resource_id,
- book_id=book_id
- ).first()
- if not record:
- raise NotAvailableException(
- detail=f"Book {book_code} not found for resource {resource_id}"
- )
- return record
-
- # Helper: Extract chapter content
- def extract_chapter_content(usj_content):
- chapter_items = []
- current_ch_num = None
- chapter_found = False
-
- for item in usj_content:
- if item.get("type") == "chapter":
- num = item.get("number")
- current_ch_num = int(num) if num and num.isdigit() else None
-
- if current_ch_num == chapter:
- chapter_found = True
- chapter_items = []
- elif chapter_found:
- break
- elif chapter_found:
- chapter_items.append(item)
-
- if not chapter_found:
- raise NotAvailableException(detail=f"Chapter {chapter} not found")
-
- return chapter_items
-
- # Helper: Build navigation links
- def build_navigation(book, bible_record, available_books):
- current_index = next(
- (i for i, b in enumerate(available_books) if b.book_id == book.book_id),
- None
- )
-
- # Previous
- if chapter > 1:
- previous = {
- "resourceId": str(resource_id),
- "bibleBookCode": book_code,
- "chapterId": chapter - 1
- }
- elif current_index and current_index > 0:
- prev_book = available_books[current_index - 1]
- previous = {
- "resourceId": str(resource_id),
- "bibleBookCode": prev_book.book_code,
- "chapterId": prev_book.chapter_count
- }
- else:
- previous = None
-
- # Next
- if chapter < bible_record.chapters:
- nxt = {
- "resourceId": str(resource_id),
- "bibleBookCode": book_code,
- "chapterId": chapter + 1
- }
- elif current_index is not None and current_index < len(available_books) - 1:
- next_book = available_books[current_index + 1]
- nxt = {
- "resourceId": str(resource_id),
- "bibleBookCode": next_book.book_code,
- "chapterId": 1
- }
- else:
- nxt = None
-
- return previous, nxt
-
- # Main logic
- get_resource()
- book = get_book()
- bible_record = get_bible_record(book.book_id)
-
- chapter_items = extract_chapter_content(bible_record.json.get("content", []))
- available_books = get_available_books(db_session, resource_id)
- previous, nxt = build_navigation(book, bible_record, available_books)
-
- return schema.BibleChapterResponse(
- resource_id=resource_id,
- bible_book_code=book_code,
- chapter=chapter,
- previous=previous,
- next=nxt,
- chapter_content=chapter_items
- )
-
-def _clean_get_resource(db_session, resource_id):
- resource = db_session.query(db_models.Resource).filter_by(resource_id=resource_id).first()
- if not resource:
- raise NotAvailableException(detail=f"Resource {resource_id} not found")
- return resource
-
-
-def _clean_get_book(db_session, book_code):
- book = db_session.query(db_models.BookLookup).filter(
- func.lower(db_models.BookLookup.book_code) == book_code.lower()
- ).first()
- if not book:
- raise NotAvailableException(detail=f"Book {book_code} not found")
- return book
-
-
-def _clean_get_verses(db_session, resource_id, book_id, chapter, book_code):
- verses = db_session.query(db_models.CleanBible).filter_by(
- resource_id=resource_id,
- book_id=book_id,
- chapter=chapter
- ).order_by(db_models.CleanBible.verse).all()
-
- if not verses:
- raise NotAvailableException(
- detail=f"Chapter {chapter} not found for book {book_code}"
- )
- return verses
-
-
-def _clean_get_bible_record(db_session, resource_id, book_id):
- return db_session.query(db_models.Bible).filter_by(
- resource_id=resource_id,
- book_id=book_id
- ).first()
-
-
-def _clean_find_book_index(available_books, book_id):
- for i, b in enumerate(available_books):
- if b.book_id == book_id:
- return i
- return None
-
-
-def _clean_build_navigation(data: schema.CleanNavigationInput):
- """Build navigation links"""
- resource_id = data.resource_id
- book_code = data.book_code
- chapter = data.chapter
- bible_record = data.bible_record
- available_books = data.available_books
- idx = data.idx
-
- previous = None
- next_chapter = None
-
-
- # Previous
- if chapter > 1:
- previous = {
- "resourceId": str(resource_id),
- "bibleBookCode": book_code,
- "chapterId": chapter - 1
- }
- elif idx is not None and idx > 0:
- prev_book = available_books[idx - 1]
- previous = {
- "resourceId": str(resource_id),
- "bibleBookCode": prev_book.book_code,
- "chapterId": prev_book.chapter_count
- }
-
- # Next
- if bible_record and chapter < bible_record.chapters:
- next_chapter = {
- "resourceId": str(resource_id),
- "bibleBookCode": book_code,
- "chapterId": chapter + 1
- }
- elif idx is not None and idx < len(available_books) - 1:
- next_book = available_books[idx + 1]
- next_chapter = {
- "resourceId": str(resource_id),
- "bibleBookCode": next_book.book_code,
- "chapterId": 1
- }
-
- return previous, next_chapter
-
-
-def get_clean_bible_chapter(
- db_session: Session,
- resource_id: int,
- book_code: str,
- chapter: int
-) -> schema.CleanBibleChapterResponse:
- """Get full content of a chapter with book intro."""
-
- _clean_get_resource(db_session, resource_id)
- book = _clean_get_book(db_session, book_code)
- verses = _clean_get_verses(db_session, resource_id, book.book_id, chapter, book_code)
- bible_record = _clean_get_bible_record(db_session, resource_id, book.book_id)
-
- available_books = get_available_clean_books(db_session, resource_id)
- idx = _clean_find_book_index(available_books, book.book_id)
-
- nav_input = schema.CleanNavigationInput(
- resource_id=resource_id,
- book_code=book_code,
- chapter=chapter,
- bible_record=bible_record,
- available_books=available_books,
- idx=idx
- )
-
- previous, next_chapter = _clean_build_navigation(nav_input)
-
-
- verse_content = [
- schema.CleanVerseContent(verse=v.verse, text=v.text)
- for v in verses
- ]
-
- return schema.CleanBibleChapterResponse(
- resource_id=resource_id,
- bible_book_code=book_code,
- chapter=chapter,
- previous=previous,
- next=next_chapter,
- chapter_content=verse_content
- )
-
-def get_bible_verse(
- db_session: Session,
- resource_id: int,
- book_code: str,
- chapter: int,
- verse: int
-) -> schema.BibleVerseResponse:
- """Get specific verse content with enhanced navigation"""
-
- _get_resource(db_session, resource_id)
- book = _get_book_or_404(db_session, book_code)
- verse_record = _get_clean_verse_or_404(db_session, resource_id, book.book_id, chapter, verse)
- bible_record = _get_bible_record(db_session, resource_id, book.book_id)
-
- available_books = get_available_clean_books(db_session, resource_id)
- current_book_index = _find_book_index(available_books, book.book_id)
-
- prev_input = schema.CleanPreviousVerseInput(
- db_session=db_session,
- resource_id=resource_id,
- book=book,
- chapter=chapter,
- verse=verse,
- available_books=available_books,
- book_index=current_book_index
- )
-
- previous = _compute_previous_verse(prev_input)
-
-
- next_input = schema.CleanNextVerseInput(
- db_session=db_session,
- resource_id=resource_id,
- book=book,
- chapter=chapter,
- verse=verse,
- available_books=available_books,
- book_index=current_book_index,
- bible_record=bible_record
- )
-
- next_verse = _compute_next_verse(next_input)
-
-
- return schema.BibleVerseResponse(
- resource_id=resource_id,
- bible_book_code=book_code,
- chapter=chapter,
- verse_number=verse,
- previous=previous,
- next=next_verse,
- verse_content=verse_record.text
- )
-
-def _get_book_or_404(db_session: Session, book_code: str):
- book = db_session.query(db_models.BookLookup).filter(
- func.lower(db_models.BookLookup.book_code) == book_code.lower()
- ).first()
- if not book:
- raise NotAvailableException(detail=f"Book {book_code} not found")
- return book
-
-
-def _get_clean_verse_or_404(
- db_session: Session,
- resource_id: int,
- book_id: int,
- chapter: int,
- verse: int
-):
- verse_record = db_session.query(db_models.CleanBible).filter_by(
- resource_id=resource_id,
- book_id=book_id,
- chapter=chapter,
- verse=verse
- ).first()
-
- if not verse_record:
- raise NotAvailableException(
- detail=f"Verse {book_id}.{chapter}.{verse} not found"
- )
- return verse_record
-
-
-def _get_bible_record(db_session: Session, resource_id: int, book_id: int):
- return db_session.query(db_models.Bible).filter_by(
- resource_id=resource_id,
- book_id=book_id
- ).first()
-
-
-def _find_book_index(available_books, book_id):
- for i, book in enumerate(available_books):
- if book.book_id == book_id:
- return i
- return None
-
-def _compute_previous_verse(payload: schema.CleanPreviousVerseInput):
- db_session = payload.db_session
- resource_id = payload.resource_id
- book = payload.book
- chapter = payload.chapter
- verse = payload.verse
- available_books = payload.available_books
- book_index = payload.book_index
-
- # Case 1: Previous verse in same chapter
- if verse > 1:
- return {
- "resourceId": str(resource_id),
- "bibleBookCode": book.book_code,
- "chapterId": chapter,
- "verse": verse - 1
- }
-
- # Case 2: Last verse of previous chapter
- if chapter > 1:
- prev_chap_last = db_session.query(db_models.CleanBible).filter_by(
- resource_id=resource_id,
- book_id=book.book_id,
- chapter=chapter - 1
- ).order_by(db_models.CleanBible.verse.desc()).first()
-
- if prev_chap_last:
- return {
- "resourceId": str(resource_id),
- "bibleBookCode": book.book_code,
- "chapterId": chapter - 1,
- "verse": prev_chap_last.verse
- }
-
- # Case 3: Last verse of previous book
- if book_index is not None and book_index > 0:
- prev_book = available_books[book_index - 1]
- last_verse = db_session.query(db_models.CleanBible).filter_by(
- resource_id=resource_id,
- book_id=prev_book.book_id,
- chapter=prev_book.chapter_count
- ).order_by(db_models.CleanBible.verse.desc()).first()
-
- if last_verse:
- return {
- "resourceId": str(resource_id),
- "bibleBookCode": prev_book.book_code,
- "chapterId": prev_book.chapter_count,
- "verse": last_verse.verse
- }
-
- return None
-
-
-def _compute_next_verse(payload: schema.CleanNextVerseInput):
- db_session = payload.db_session
- resource_id = payload.resource_id
- book = payload.book
- chapter = payload.chapter
- verse = payload.verse
- available_books = payload.available_books
- book_index = payload.book_index
- bible_record = payload.bible_record
-
- # CASE 1: Next verse exists in same chapter
- next_verse_record = db_session.query(db_models.CleanBible).filter_by(
- resource_id=resource_id,
- book_id=book.book_id,
- chapter=chapter,
- verse=verse + 1
- ).first()
-
- if next_verse_record:
- return {
- "resourceId": str(resource_id),
- "bibleBookCode": book.book_code,
- "chapterId": chapter,
- "verse": verse + 1
- }
-
- # CASE 2: Next chapter exists in same book
- if bible_record and chapter < bible_record.chapters:
- return {
- "resourceId": str(resource_id),
- "bibleBookCode": book.book_code,
- "chapterId": chapter + 1,
- "verse": 1
- }
-
- # CASE 3: Next book exists
- if book_index is not None and book_index < len(available_books) - 1:
- next_book = available_books[book_index + 1]
- return {
- "resourceId": str(resource_id),
- "bibleBookCode": next_book.book_code,
- "chapterId": 1,
- "verse": 1
- }
-
- return None
-
-
-# --- Commentary CRUD ---
-
-def _ensure_commentary_resource(db: Session, resource_id: int):
- resource = db.query(db_models.Resource).filter_by(resource_id=resource_id).first()
- if not resource:
- raise NotAvailableException(detail=f"Resource {resource_id} not found")
- if (resource.content_type or "").lower() != "commentary":
- raise NotAvailableException(
- detail= (
- f"Resource {resource_id} is not of type 'commentary'"
- f"(found '{resource.content_type}')"
- )
- )
- return resource
-
-def _get_book_by_code(db: Session, book_code: str):
- return db.query(db_models.BookLookup)\
- .filter(db_models.BookLookup.book_code.ilike(book_code)).first()
-
-
-
-def _intro_text(db: Session, resource_id: int, book_id: int) -> str:
- row = (
- db.query(db_models.Commentary.text)
- .filter(
- db_models.Commentary.resource_id == resource_id,
- db_models.Commentary.book_id == book_id,
- db_models.Commentary.chapter == 0,
- db_models.Commentary.verse == "0", # string
- )
- .first()
- )
- return row[0] if row else ""
-
-
-def create_commentaries(
- db: Session,
- payload: schema.CommentaryBulkCreate,
- actor_user_id: int
-) -> schema.CommentaryCreateResponse:
- """
- Create commentary rows.
- """
- #now = utcnow()
-
- resource = db.query(db_models.Resource).filter_by(resource_id=payload.resource_id).first()
- if not resource:
- raise NotAvailableException(detail=f"Resource {payload.resource_id} not found")
-
- if resource.content_type.lower() != "commentary":
- raise NotAvailableException(
- detail=(
- f"Resource {payload.resource_id} is not of type 'commentary'"
- f"(found '{resource.content_type}')"
- )
- )
-
- created_rows = []
-
- for item in payload.commentary:
- verse_str = str(item.verse).strip()
-
- exists_book = (
- db.query(db_models.BookLookup.book_id)
- .filter_by(book_id=item.book_id)
- .first()
- )
- if not exists_book:
- raise NotAvailableException(detail=f"BookId {item.book_id} not found")
-
- exists_row = db.query(db_models.Commentary.commentary_id).filter_by(
- resource_id=payload.resource_id,
- book_id=item.book_id,
- chapter=item.chapter,
- verse=verse_str,
- ).first()
- if exists_row:
- raise AlreadyExistsException(
- detail=(
- "Row already exists. Use PUT to update: "
- f"(resource_id={payload.resource_id}, book_id={item.book_id}, "
- f"chapter={item.chapter}, verse={item.verse})"
- )
- )
-
- row = db_models.Commentary(
- resource_id=payload.resource_id,
- book_id=item.book_id,
- chapter=item.chapter,
- verse=verse_str,
- text=item.text.strip(),
- )
- db.add(row)
- created_rows.append(row)
-
- touch_resource(db, resource_id=payload.resource_id, actor_user_id=actor_user_id)
-
- try:
- db.commit()
- except IntegrityError as exc:
- db.rollback()
- raise AlreadyExistsException("Unique constraint violated (row already exists).") from exc
-
-
- return schema.CommentaryCreateResponse(
- resource_id=payload.resource_id,
- created=[
- schema.CommentaryCreatedItem(
- commentary_id=r.commentary_id,
- book_id=r.book_id,
- chapter=r.chapter,
- verse=r.verse,
- text=r.text,
- )
- for r in created_rows
- ],
- )
-
-
-
-# --- PUT ---
-def update_commentaries(
- db: Session,
- payload: schema.CommentaryBulkUpdate,
- actor_user_id: int
-) -> schema.CommentaryUpdateResponse:
- """Update commentary rows and return same response shape as POST."""
- resource = db.query(db_models.Resource).filter_by(resource_id=payload.resource_id).first()
- if not resource:
- raise NotAvailableException(detail=f"Resource {payload.resource_id} not found")
- #now = utcnow()
- # Resource content_type must be 'commentary'
- if resource.content_type.lower() != "commentary":
- raise BadRequestException(
- detail=(
- f"Resource {payload.resource_id} is not of type 'commentary' "
- f"(found '{resource.content_type}')"
- )
- )
- updated_rows: List[db_models.Commentary] = []
-
- for item in payload.commentary:
- # Find row
- row = db.query(db_models.Commentary).filter_by(
- commentary_id=item.commentary_id, resource_id=payload.resource_id
- ).first()
- if not row:
- raise NotAvailableException(
- detail=(
- f"commentary_id {item.commentary_id} not found for resource "
- f"{payload.resource_id}"
- )
- )
- # Target values (keep existing if field omitted)
- tgt_book_id = item.book_id if item.book_id is not None else row.book_id
- tgt_chapter = item.chapter if item.chapter is not None else row.chapter
- verse_str = str(item.verse).strip() if item.verse is not None else row.verse
- # Validate book_id
- exists_book = (
- db.query(db_models.BookLookup.book_id)
- .filter_by(book_id=tgt_book_id)
- .first()
- )
- if not exists_book:
- raise NotAvailableException(detail=f"BookId {tgt_book_id} not found")
- # Uniqueness against other rows
- exists_row = (
- db.query(db_models.Commentary.commentary_id)
- .filter_by(
- resource_id=payload.resource_id,
- book_id=tgt_book_id,
- chapter=tgt_chapter,
- verse=verse_str,
- )
- .filter(db_models.Commentary.commentary_id != item.commentary_id)
- .first()
- )
- if exists_row:
- raise AlreadyExistsException(
- detail=(
- "Update would violate uniqueness: "
- f"(resource_id={payload.resource_id}, book_id={tgt_book_id}, "
- f"chapter={tgt_chapter}, verse={verse_str}) already exists."
- )
- )
-
- row.book_id = tgt_book_id
- row.chapter = tgt_chapter
- row.verse = verse_str
- if item.text is not None:
- row.text = item.text.strip()
- updated_rows.append(row)
- touch_resource(db, resource_id=payload.resource_id, actor_user_id=actor_user_id)
- try:
- db.commit()
- except IntegrityError as e:
- db.rollback()
- raise AlreadyExistsException(
- detail=(
- "Update would violate uniqueness of "
- f"(resource_id={payload.resource_id}, "
- f"book_id={tgt_book_id}, chapter={tgt_chapter}, verse={verse_str})."
- )
- ) from e
-
- # Reuse the POST response schema
- return schema.CommentaryUpdateResponse(
- resource_id=payload.resource_id,
- updated=[
- schema.CommentaryUpdatedItem(
- commentary_id=r.commentary_id,
- book_id=r.book_id,
- chapter=r.chapter,
- verse=r.verse,
- text=r.text,
- )
- for r in updated_rows
- ],
- )
-
-
-
-# --- GET: full content for a resource ---
-def get_full_commentary(db: Session, resource_id: int) -> schema.CommentaryListResponse:
- """Get full content of a resource."""
- _ensure_commentary_resource(db, resource_id)
-
- v_start = cast(func.split_part(db_models.Commentary.verse, '-', 1), Integer)
- v_end = cast(
- func.coalesce(
- func.nullif(func.split_part(db_models.Commentary.verse, '-', 2), ''),
- func.split_part(db_models.Commentary.verse, '-', 1)
- ),
- Integer
- )
- # If verse/chapter are INT in DB, you can drop the cast() calls below.
- q = (
- db.query(
- db_models.Commentary,
- db_models.BookLookup.book_code.label("book_code"),
- )
- .join(db_models.BookLookup, db_models.BookLookup.book_id == db_models.Commentary.book_id)
- .filter(db_models.Commentary.resource_id == resource_id)
- .order_by(
- db_models.Commentary.book_id,
- cast(db_models.Commentary.chapter, Integer),
- v_start,
- v_end,
- )
- .all()
- )
- content = [
- schema.CommentaryRow(
- commentary_id=cm.commentary_id,
- bookCode=book_code,
- chapter=int(cm.chapter),
- verse=str(cm.verse),
- text=cm.text,
- )
- for cm, book_code in q
- ]
- return schema.CommentaryListResponse(resourceId=resource_id, content=content)
-
-
-
-# --- GET: full content of a chapter with book intro ---
-def get_commentary_chapter(
- db: Session,
- resource_id: int,
- book_code: str,
- chapter: int
-) -> schema.ChapterResponse:
- """Get full content of a chapter with book intro."""
- _ensure_commentary_resource(db, resource_id)
- book = _get_book_by_code(db, book_code)
- if not book:
- raise NotAvailableException(detail=f"Unknown book_code '{book_code}'")
- # book intro = chapter 0, verse 0 (single text)
- book_intro = _intro_text(db, resource_id, book.book_id)
- # chapter content: chapter=N, include verse >= 0 (0 is chapter intro)
- rows = (
- db.query(db_models.Commentary)
- .filter(
- db_models.Commentary.resource_id == resource_id,
- db_models.Commentary.book_id == book.book_id,
- db_models.Commentary.chapter == chapter,
- )
- .order_by(db_models.Commentary.verse.asc())
- .all()
- )
- # If chapter truly doesn’t exist in commentary, raise 404
- if not rows:
- raise NotAvailableException(
- detail=(
- f"Chapter {chapter} not found in commentary for book_code '"
- f"{book.book_code}' (resource {resource_id})"
- ),
- )
- content = [schema.ChapterContentItem(verse=str(r.verse), text=r.text) for r in rows]
- return schema.ChapterResponse(
- bookCode=book.book_code,
- bookIntro=book_intro,
- chapter=chapter,
- resourceId=resource_id,
- content=content,
- )
-
-
-# --- DELETE ---
-def delete_commentary_bulk(db: Session, commentary_ids: List[int]):
- deleted_ids = []
- errors = []
-
- for cid in commentary_ids:
- row = (
- db.query(db_models.Commentary)
- .filter_by(commentary_id=cid)
- .first()
- )
-
- if not row:
- errors.append(f"commentary_id {cid} not found")
- continue
-
- try:
- db.delete(row)
- deleted_ids.append(cid)
-
- except Exception as exc:
- db.rollback()
- errors.append(f"Failed to delete commentary_id {cid}: {str(exc)}")
-
- # Commit all successful deletes
- try:
- db.commit()
- except Exception as exc:
- db.rollback()
- errors.append(f"Bulk commit failed: {str(exc)}")
-
- return deleted_ids, errors
-
-# ---Dictionary CRUD-------------
-
-def normalize_unicode(value: str) -> str:
- """Normalize Unicode characters for dictionary words."""
- if value is None or value == "":
- return value
- return unicodedata.normalize('NFC', value)
-def upload_dictionary_words(db_: Session, resource_id: int, dictionary_words: List,actor_id: int):
- '''Adds rows to the dictionary table for the specified resource_id.
- Throws error if resource_id + keyword combination already exists.'''
-
- # Verify resource exists
- resource_db_content = db_.query(db_models.Resource).filter(
- db_models.Resource.resource_id == resource_id).first()
- if not resource_db_content:
- raise NotAvailableException(detail=f"Resource {resource_id} not found")
- for item in dictionary_words:
- # Check duplicates (resource_id + dictionary items)
- existing = (
- db_.query(db_models.Dictionary)
- .filter_by(resource_id=resource_id,
- keyword=normalize_unicode(item.keyword))
- .first()
- )
- if existing:
- raise AlreadyExistsException(
- detail=f"resoure {resource_id} with keyword {item.keyword} already exists"
- )
- # Verify resource is of type DICTIONARY
- if (resource_db_content.content_type or "").lower() != "dictionary":
- raise BadRequestException(
- detail=(
- f"Resource {resource_id} is not of type 'dictionary'"
- f"(found '{resource_db_content.content_type}')"
- )
- )
- model_cls = db_models.Dictionary # Assuming Dictionary model exists
- db_content = []
-
- for item in dictionary_words:
- # Check if keyword already exists for this resource
- existing_word = db_.query(model_cls).filter(
- model_cls.resource_id == resource_id,
- model_cls.keyword == normalize_unicode(item.keyword)
- ).first()
-
- if existing_word:
- raise AlreadyExistsException(
- detail= f"Keyword '{item.keyword}' already exists for resource {resource_id}."
- )
-
- row = model_cls(
- resource_id=resource_id,
- keyword=normalize_unicode(item.keyword),
- word_forms=item.wordForms,
- strongs=item.strongs,
- definition=item.definition,
- translation_help=item.translationHelp,
- see_also=item.seeAlso,
- ref=item.ref,
- examples=item.examples
- )
- db_.add(row)
- db_.flush()
-
- db_content.append({
- 'wordId': row.word_id,
- 'keyword': row.keyword,
- 'wordForms': row.word_forms,
- 'strongs': row.strongs,
- 'definition': row.definition,
- 'translationHelp': row.translation_help,
- 'seeAlso': row.see_also,
- 'ref': row.ref,
- 'examples': row.examples
- })
- touch_resource(db_, resource_id=resource_db_content.resource_id, actor_user_id=actor_id)
- db_.commit()
-
- response = {
- 'db_content': db_content,
- 'resource_content': resource_db_content
- }
- return response
-
-
-def update_dictionary_words(db_: Session, resource_id: int, dictionary_words: List, actor_id: int):
- '''Updates rows in the dictionary table using wordId.
- All fields except wordId can be updated.'''
-
- # Verify resource exists and is of type DICTIONARY
- resource_db_content = _validate_dictionary_resource(db_, resource_id)
-
- # Check for duplicate keywords (AFTER resource validation)
- #_check_duplicate_keywords(db_, resource_id, dictionary_words)
-
- # Update dictionary words
- db_content = _update_dictionary_entries(db_, resource_id, dictionary_words)
-
- # Touch resource and commit
- touch_resource(db_, resource_id=resource_db_content.resource_id, actor_user_id=actor_id)
- db_.commit()
-
- # Build response
- return _build_dictionary_response(db_content, resource_db_content)
-
-
-def _validate_dictionary_resource(db_: Session, resource_id: int):
- """Verify resource exists and is of type DICTIONARY."""
- resource = db_.query(db_models.Resource).filter(
- db_models.Resource.resource_id == resource_id
- ).first()
-
- if not resource:
- raise NotAvailableException(
- detail=f'Resource {resource_id} not found in database'
- )
-
- if (resource.content_type or "").lower() != "dictionary":
- raise BadRequestException(
- detail=(
- f"Resource {resource_id} is not of type 'dictionary'"
- f" (found '{resource.content_type}')"
- )
- )
-
- return resource
-
-
-def _check_duplicate_keywords(db_: Session, resource_id: int, dictionary_words: List):
- """Check for duplicate keywords in the resource."""
- for item in dictionary_words:
- existing = (
- db_.query(db_models.Dictionary)
- .filter_by(
- resource_id=resource_id,
- keyword=normalize_unicode(item.keyword)
- )
- .first()
- )
- if existing:
- raise AlreadyExistsException(
- detail=f"Resource {resource_id} with keyword {item.keyword} already exists"
- )
-
-
-def _update_dictionary_entries(db_: Session, resource_id: int, dictionary_words: List):
- """Update dictionary entries with provided data."""
- db_content = []
-
- for item in dictionary_words:
- # Find row by wordId and resourceId
- row = db_.query(db_models.Dictionary).filter(
- db_models.Dictionary.word_id == item.wordId,
- db_models.Dictionary.resource_id == resource_id
- ).first()
-
- if not row:
- raise NotAvailableException(
- detail=f"Dictionary word with id {item.wordId} not found in resource {resource_id}"
- )
-
- # Update fields if provided (preserves original logic exactly)
- if item.keyword is not None:
- row.keyword = normalize_unicode(item.keyword)
- if item.wordForms is not None:
- row.word_forms = item.wordForms
- if item.strongs is not None:
- row.strongs = item.strongs
- if item.definition is not None:
- row.definition = item.definition
- if item.translationHelp is not None:
- row.translation_help = item.translationHelp
- if item.seeAlso is not None:
- row.see_also = item.seeAlso
- if item.ref is not None:
- row.ref = item.ref
- if item.examples is not None:
- row.examples = item.examples
-
- db_.flush()
- db_content.append(row)
-
- return db_content
-
-
-def _build_dictionary_response(db_content: List, resource_db_content):
- """Build the response dictionary."""
- response_data = [
- {
- "wordId": row.word_id,
- "keyword": row.keyword,
- "wordForms": row.word_forms,
- "strongs": row.strongs,
- "definition": row.definition,
- "translationHelp": row.translation_help,
- "seeAlso": row.see_also,
- "ref": row.ref,
- "examples": row.examples
- }
- for row in db_content
- ]
-
- return {
- 'db_content': response_data,
- 'resource_content': resource_db_content
- }
-
-
-def get_dictionary_words(db_: Session, resource_id: int, skip: int = 0, limit: int = 100):
- '''Fetches all dictionary words for a given resource_id.'''
-
- # Verify resource exists
- resource_db_content = db_.query(db_models.Resource).filter(
- db_models.Resource.resource_id == resource_id).first()
- if not resource_db_content:
- raise NotAvailableException(detail=f"Resource {resource_id} not found")
-
- # Verify resource is of type DICTIONARY
- if (resource_db_content.content_type or "").lower() != "dictionary":
- raise BadRequestException(
- detail= (
- f"Resource {resource_id} is not of type 'dictionary' "
- f"(found '{resource_db_content.content_type}')"
- )
- )
- model_cls = db_models.Dictionary
- query = db_.query(model_cls).filter(model_cls.resource_id == resource_id)
- query = query.order_by(model_cls.keyword)
-
- if skip is not None:
- query = query.offset(skip)
- if limit is not None:
- query = query.limit(limit)
- rows = query.all()
- content = [
- {
- "wordId": row.word_id,
- "keyword": row.keyword,
- "wordForms": row.word_forms,
- "strongs": row.strongs,
- "definition": row.definition,
- "translationHelp": row.translation_help,
- "seeAlso": row.see_also,
- "ref": row.ref,
- "examples": row.examples
- }
- for row in rows
- ]
-
- return {
- "resourceId": resource_id,
- "content": content
- }
-
-
-def get_dictionary_index(db_: Session, resource_id: int):
- '''Fetches dictionary index grouped by first letter of keywords.'''
-
- # Verify resource exists
- resource_db_content = db_.query(db_models.Resource).filter(
- db_models.Resource.resource_id == resource_id).first()
- if not resource_db_content:
- raise NotAvailableException(detail=f"Resource {resource_id} not found")
-
- # Verify resource is of type DICTIONARY
- if (resource_db_content.content_type or "").lower() != "dictionary":
- raise BadRequestException(
- detail=(
- f"Resource {resource_id} is not of type 'dictionary' "
- f"(found '{resource_db_content.content_type}')"
- )
- )
-
- model_cls = db_models.Dictionary
- words = db_.query(model_cls.word_id, model_cls.keyword).filter(
- model_cls.resource_id == resource_id
- ).order_by(model_cls.keyword).all()
-
- # Group by first letter
- index_dict = {}
- for word in words:
- if word.keyword:
- first_letter = word.keyword[0].upper()
- if first_letter not in index_dict:
- index_dict[first_letter] = []
- index_dict[first_letter].append({
- 'wordId': word.word_id,
- 'word': word.keyword
- })
-
- # Convert to list format
- index_list = []
- for letter in sorted(index_dict.keys()):
- index_list.append({
- 'letter': letter,
- 'words': index_dict[letter]
- })
-
- return {
- 'index': index_list,
- 'resource_content': resource_db_content
- }
-
-
-def get_dictionary_word_by_id(db_: Session, resource_id: int, word_id: int):
- '''Fetches a specific dictionary word by wordId and resource_id.'''
-
- # Verify resource exists
- resource_db_content = db_.query(db_models.Resource).filter(
- db_models.Resource.resource_id == resource_id).first()
- if not resource_db_content:
- raise NotAvailableException(detail=f"Resource {resource_id} not found")
-
- # Verify resource is of type DICTIONARY
- if (resource_db_content.content_type or "").lower() != "dictionary":
- raise BadRequestException(
- detail=(
- f"Resource {resource_id} is not of type 'dictionary' "
- f"(found '{resource_db_content.content_type}')"
- )
- )
- model_cls = db_models.Dictionary
- word = db_.query(model_cls).filter(
- model_cls.resource_id == resource_id,
- model_cls.word_id == word_id
- ).first()
-
- return {
- 'db_content': word,
- 'resource_content': resource_db_content
- }
-
-def delete_dictionary_words(db_: Session, resource_id: int, word_ids: List[int], user_id=None):
- """
- Deletes multiple dictionary words by their wordIds.
- Returns count, deleted IDs, and errors (for duplicates or invalid IDs).
- """
-
- # Verify resource exists
- resource_db_content = (
- db_.query(db_models.Resource)
- .filter(db_models.Resource.resource_id == resource_id)
- .first()
- )
- if not resource_db_content:
- raise NotAvailableException(detail=f"Resource {resource_id} not found")
-
- # Verify dictionary
- if (resource_db_content.content_type or "").lower() != "dictionary":
- raise BadRequestException(
- detail=(
- f"Resource {resource_id} is not of type 'dictionary'"
- f"(found '{resource_db_content.content_type}')"
- )
- )
-
- model_cls = db_models.Dictionary
- deleted_ids = []
- errors = []
- processed = set()
-
- for word_id in word_ids:
-
- # Duplicate within same request
- if word_id in processed:
- errors.append({"id": word_id, "error": "already_deleted"})
- continue
- processed.add(word_id)
-
- # Check existence
- word = (
- db_.query(model_cls)
- .filter(
- model_cls.resource_id == resource_id,
- model_cls.word_id == word_id,
- )
- .first()
- )
-
- if not word:
- errors.append({"id": word_id, "error": "not_found"})
- continue
-
- # Delete word
- db_.delete(word)
- deleted_ids.append(word_id)
-
- # Commit all successful deletes
- db_.commit()
-
- # Construct error message
- error_msg = None
- if errors:
- not_found = [e["id"] for e in errors if e["error"] == "not_found"]
- dup = [e["id"] for e in errors if e["error"] == "already_deleted"]
- parts = []
- if not_found:
- parts.append(f"Invalid word IDs: {not_found}")
- if dup:
- parts.append(f"Already deleted IDs: {dup}")
- error_msg = "; ".join(parts)
-
- return {
- "message": f"Successfully deleted {len(deleted_ids)} words",
- "deleted_count": len(deleted_ids),
- "deleted_ids": deleted_ids,
- "error": error_msg,
- "has_errors": bool(errors),
- }
-
-
-# --- AudioBible CRUD ---
-
-def validate_books(db: Session, books: dict):
- """
- Validate that all book codes exist in BookLookup table
- and chapter counts are within valid range
- """
- # Get all valid book codes and their chapter counts
- book_lookup = db.query(
- db_models.BookLookup.book_code,
- db_models.BookLookup.chapter_count
- ).all()
-
- # Create a dictionary for easy lookup (case-insensitive)
- valid_books = {
- row.book_code.lower(): row.chapter_count
- for row in book_lookup
- }
-
- # Validate each book in the input
- for book_code, chapter_count in books.items():
- book_code_lower = book_code.lower()
-
- # Check if book code exists
- if book_code_lower not in valid_books:
- raise BadRequestException(
- detail=(
- f"Invalid book code '{book_code}'. "
- f"Must match book_code from BookLookup table."
- )
- )
-
- # Check if chapter count is within valid range
- max_chapters = valid_books[book_code_lower]
- if chapter_count > max_chapters:
- raise BadRequestException(
- detail=f"Invalid chapter count for '{book_code}': {chapter_count}. "
- f"Maximum chapters for this book is {max_chapters}."
- )
-
-def create_audio_bible(db: Session, data: schema.AudioBibleCreate, actor_user_id: int):
- """Create a new audio bible entry"""
- # Check if resource exists
- resource = db.query(db_models.Resource).filter_by(resource_id=data.resource_id).first()
- if not resource:
- raise NotAvailableException(
- detail=f"Resource with id {data.resource_id} does not exist."
- )
-
- # Resource content_type must be 'bible'
- if resource.content_type.lower() != "bible":
- raise BadRequestException(
- detail=(
- f"Resource {data.resource_id} is not of type 'bible' "
- f"(found '{resource.content_type}')"
- )
- )
-
- # Check if audio bible already exists for this resource
- existing = db.query(db_models.AudioBible).filter_by(resource_id=data.resource_id).first()
- if existing:
- raise AlreadyExistsException(
- detail=(
- f"AudioBible with resource_id {data.resource_id} already exists."
- f" Use PUT to update."
- )
- )
-
- # Validate book codes
- validate_books(db, data.books)
-
- # Create audio bible
- audio_bible = db_models.AudioBible(**data.model_dump())
- db.add(audio_bible)
- touch_resource(db, data.resource_id, actor_user_id)
- db.commit()
- db.refresh(audio_bible)
- return audio_bible
-
-def _is_files_missing_empty(obj) -> bool:
- """
- Return True if files_missing is empty ({} or None),
- False if it contains missing entries.
- """
- if obj is None:
- return True
- # if empty mapping
- try:
- if isinstance(obj, dict) and len(obj) == 0:
- return True
- # JSONB may also arrive as string by accident; handle that defensively
- return False
- except Exception:
- return False
-
-def list_audio_bibles(
- db: Session,
- resource_id: Optional[int] = None,
- limit: int = 50,
- offset: int = 0,
- files_missing: Optional[bool] = None,
- test_date: Optional[datetime] = None
-) -> List[dict]:
- """List audio bibles with optional filtering and pagination"""
- query = db.query(db_models.AudioBible)
-
- if resource_id is not None:
- query = query.filter(db_models.AudioBible.resource_id == resource_id)
-
- query = query.limit(limit).offset(offset)
- rows = query.all()
- out = []
- for ab in rows:
- fm = ab.files_missing # could be None, {}, or dict
- td = ab.test_date # could be None or datetime
-
- # files_missing filter
- if files_missing is not None:
- has_missing = not _is_files_missing_empty(fm)
- if files_missing and not has_missing:
- # caller wants only audio bibles that *have* missing files
- continue
- if (not files_missing) and has_missing:
- # caller wants only audio bibles with no missing files
- continue
-
- # test_date filter (keep rows tested ON or AFTER the given timestamp)
- if test_date is not None:
- if td is None:
- # row has no test_date -> skip when filtering by test_date
- continue
- # ensure timezone-aware comparision: convert to UTC if naive
- # assume incoming test_date is timezone-aware (FastAPI will parse RFC datetimes)
- if td.tzinfo is None:
- td = td.replace(tzinfo=timezone.utc)
- if test_date.tzinfo is None:
- test_date = test_date.replace(tzinfo=timezone.utc)
- if td < test_date:
- continue
- out.append({
- "resourceId": ab.resource_id,
- "name": ab.name,
- "url": ab.base_url,
- "books": ab.books,
- "format": ab.format,
- # normalize empty dict -> {} ; None left as None
- "files_missing": (
- ab.files_missing
- if ab.files_missing
- else {}
- ),
- "test_date": ab.test_date,
- })
-
- return out
-
-
-def update_audio_bible(
- db: Session,
- resource_id: int,
- update_data: schema.AudioBibleUpdate,
- actor_user_id: int
-):
- """Update an existing audio bible"""
- # Check if resource exists
- resource = db.query(db_models.Resource).filter_by(resource_id=resource_id).first()
- if not resource:
- raise NotAvailableException(
- detail=f"Resource with id {resource_id} does not exist."
- )
- # Resource content_type must be 'bible'
- if resource.content_type.lower() != "bible":
- raise BadRequestException(
- detail=(
- f"Resource {resource_id} is not of type 'bible' "
- f"(found '{resource.content_type}')"
- )
- )
-
- # Validate book codes if books are being updated
- update_dict = update_data.model_dump(exclude_unset=True)
- if "books" in update_dict and update_dict["books"] is not None:
- validate_books(db, update_dict["books"])
-
- audio_bible = db.query(db_models.AudioBible).filter_by(resource_id=resource_id).first()
- # Update fields
- for field, value in update_dict.items():
- if value is not None:
- setattr(audio_bible, field, value)
- touch_resource(db, resource_id=resource_id, actor_user_id=actor_user_id)
- db.commit()
- db.refresh(audio_bible)
- return audio_bible
-
-
-def delete_audio_bible(db: Session, resource_id: int):
- """Delete an audio bible"""
- audio_bible = db.query(db_models.AudioBible).filter(
- db_models.AudioBible.resource_id == resource_id
- ).first()
-
- if not audio_bible:
- return None
-
- db.delete(audio_bible)
- db.commit()
- return audio_bible
-# def bulk_delete_audio_bibles(db: Session, resource_id: int, audio_bible_ids: List[int]):
-# deleted_ids = []
-# errors = []
-# processed = set()
-
-# for code in book_codes:
-# code_lower = code.lower()
-
-# # Duplicate check
-# if code_lower in processed:
-# errors.append(f"Duplicate book code: {code}")
-# continue
-
-# processed.add(code_lower)
-
-# # Lookup the book
-# book = (
-# db_session.query(db_models.BookLookup)
-# .filter(func.lower(db_models.BookLookup.book_code) == code_lower)
-# .first()
-# )
-
-# if not book:
-# errors.append(f"Book code '{code}' not found in lookup")
-# continue
-
-# # Check bible table
-# bible_row = (
-# db_session.query(db_models.Bible)
-# .filter_by(resource_id=resource_id, book_id=book.book_id)
-# .first()
-# )
-
-# if not bible_row:
-# errors.append(f"Book '{code}' not found for resource {resource_id}")
-# continue
-
-# # Delete clean bible rows
-# db_session.query(db_models.CleanBible).filter_by(
-# resource_id=resource_id, book_id=book.book_id
-# ).delete()
-
-# # Delete bible entry
-# db_session.delete(bible_row)
-# deleted_ids.append(code)
-
-# if deleted_ids:
-# db_session.commit()
-
-# # Standardized flags
-# all_failed = len(deleted_ids) == 0 and len(errors) > 0
-# has_errors = len(errors) > 0
-
-# return {
-# "data": {
-# "deletedCount": len(deleted_ids),
-# "deletedIds": deleted_ids,
-# "errors": errors if errors else None,
-# },
-# "all_failed": all_failed,
-# "has_errors": has_errors,
-# }
-
-
-
-# def get_bible_books(db_session: Session, resource_id: int) -> schema.BibleBooksListResponse:
-# """Get list of books for a bible resource"""
-# # Find resource
-# resource = db_session.query(db_models.Resource).filter_by(resource_id=resource_id).first()
-# if not resource:
-# raise NotAvailableException(detail=f"Resource {resource_id} not found")
-
-# books = db_session.query(db_models.Bible, db_models.BookLookup).join(
-# db_models.BookLookup, db_models.Bible.book_id == db_models.BookLookup.book_id
-# ).filter(db_models.Bible.resource_id == resource_id).all()
-
-# book_responses = []
-# for bible, book_lookup in books:
-# book_responses.append(schema.BibleBookResponse(
-# bible_book_id=bible.bible_book_id,
-# book_code=book_lookup.book_code,
-# book_id=book_lookup.book_id,
-# short=book_lookup.book_code, # You may want to add these fields to BookLookup
-# long=book_lookup.book_name,
-# abbr=book_lookup.book_code[:3]
-# ))
-
-# return schema.BibleBooksListResponse(
-# resource_id=resource_id,
-# books=book_responses
-# )
-
-# def get_full_bible_content(
-# db_session: Session,
-# resource_id: int,
-# output_format: str
-# ) -> schema.BibleFullContentResponse:
-# """Get full content of all books in a resource in specified format"""
-
-# # Find resource
-# resource = db_session.query(db_models.Resource).filter_by(resource_id=resource_id).first()
-# if not resource:
-# raise NotAvailableException(detail=f"Resource {resource_id} not found")
-
-# # Find all bible records for this resource, ordered by book_id
-# bible_records = db_session.query(db_models.Bible).join(
-# db_models.BookLookup, db_models.Bible.book_id == db_models.BookLookup.book_id
-# ).filter(
-# db_models.Bible.resource_id == resource_id
-# ).order_by(db_models.BookLookup.book_id).all()
-
-# if not bible_records:
-# raise NotAvailableException(detail=f"No bible records found for resource {resource_id}")
-
-# # Prepare books data
-# books = []
-# for bible_record in bible_records:
-# # Get book details
-# book = db_session.query(db_models.BookLookup).filter_by(
-# book_id=bible_record.book_id
-# ).first()
-
-# if output_format.lower() == "json":
-# content = bible_record.json
-# elif output_format.lower() == "usfm":
-# content = bible_record.usfm
-# else:
-# raise BadRequestException(detail=f"Unsupported format: {format}")
-
-# books.append({
-# "bible_book_id": bible_record.bible_book_id,
-# "book_id": bible_record.book_id,
-# "book_code": book.book_code,
-# "book_name": book.book_name,
-# "chapters": bible_record.chapters,
-# "content": content
-# })
-
-# return schema.BibleFullContentResponse(
-# resource_id=resource_id,
-# total_books=len(books),
-# books=books
-# )
-
-
-# def get_bible_book_content(
-# db_session: Session,
-# resource_id: int,
-# book_code: str,
-# output_format: str
-# ) -> schema.BibleBookContentResponse:
-# """Get full content of a book in specified format"""
-
-# # Find resource
-# resource = db_session.query(db_models.Resource).filter_by(resource_id=resource_id).first()
-# if not resource:
-# raise NotAvailableException(detail=f"Resource {resource_id} not found")
-
-# # Find book
-# book = db_session.query(db_models.BookLookup).filter(
-# func.lower(db_models.BookLookup.book_code) == book_code.lower()
-# ).first()
-
-# if not book:
-# raise NotAvailableException(detail=f"Book {book_code} not found")
-
-# # Find bible record
-# bible_record = db_session.query(db_models.Bible).filter_by(
-# resource_id=resource_id,
-# book_id=book.book_id
-# ).first()
-
-# if not bible_record:
-# raise NotAvailableException(
-# detail=f"Book {book_code} not found for resource {resource_id}"
-# )
-
-
-# if output_format.lower() == "json":
-# content = bible_record.json
-# elif output_format.lower() == "usfm":
-# content = bible_record.usfm
-# else:
-# raise TypeException("Format must be 'json' or 'usfm'")
-
-
-# return schema.BibleBookContentResponse(
-# resource_id=resource_id,
-# book_id=bible_record.bible_book_id,
-# book_code=book_code,
-# book_content=content
-# )
-
-
-
-# def get_available_books(db_session: Session, resource_id: int):
-# """Get all available books for a resource, ordered by book_id"""
-# return db_session.query(db_models.BookLookup).join(
-# db_models.Bible, db_models.BookLookup.book_id == db_models.Bible.book_id
-# ).filter(
-# db_models.Bible.resource_id == resource_id
-# ).order_by(db_models.BookLookup.book_id).all()
-
-# def get_available_clean_books(db_session: Session, resource_id: int):
-# """Get all available books for a resource from clean_bible, ordered by book_id"""
-# return db_session.query(db_models.BookLookup).join(
-# db_models.CleanBible, db_models.BookLookup.book_id == db_models.CleanBible.book_id
-# ).filter(
-# db_models.CleanBible.resource_id == resource_id
-# ).distinct().order_by(db_models.BookLookup.book_id).all()
-
-# def get_bible_chapter(
-# db_session: Session,
-# resource_id: int,
-# book_code: str,
-# chapter: int
-# ) -> schema.BibleChapterResponse:
-# """Get chapter content from bible table with cross-book navigation"""
-
-# # Helper: Resource
-# def get_resource():
-# res = db_session.query(db_models.Resource).filter_by(resource_id=resource_id).first()
-# if not res:
-# raise NotAvailableException(detail=f"Resource {resource_id} not found")
-# return res
-
-# # Helper: Book
-# def get_book():
-# book_obj = db_session.query(db_models.BookLookup).filter(
-# func.lower(db_models.BookLookup.book_code) == book_code.lower()
-# ).first()
-# if not book_obj:
-# raise NotAvailableException(detail=f"Book {book_code} not found")
-# return book_obj
-
-# # Helper: Bible record
-# def get_bible_record(book_id):
-# record = db_session.query(db_models.Bible).filter_by(
-# resource_id=resource_id,
-# book_id=book_id
-# ).first()
-# if not record:
-# raise NotAvailableException(
-# detail=f"Book {book_code} not found for resource {resource_id}"
-# )
-# return record
-
-# # Helper: Extract chapter content
-# def extract_chapter_content(usj_content):
-# chapter_items = []
-# current_ch_num = None
-# chapter_found = False
-
-# for item in usj_content:
-# if item.get("type") == "chapter":
-# num = item.get("number")
-# current_ch_num = int(num) if num and num.isdigit() else None
-
-# if current_ch_num == chapter:
-# chapter_found = True
-# chapter_items = []
-# elif chapter_found:
-# break
-# elif chapter_found:
-# chapter_items.append(item)
-
-# if not chapter_found:
-# raise NotAvailableException(detail=f"Chapter {chapter} not found")
-
-# return chapter_items
-
-# # Helper: Build navigation links
-# def build_navigation(book, bible_record, available_books):
-# current_index = next(
-# (i for i, b in enumerate(available_books) if b.book_id == book.book_id),
-# None
-# )
-
-# # Previous
-# if chapter > 1:
-# previous = {
-# "resourceId": str(resource_id),
-# "bibleBookCode": book_code,
-# "chapterId": chapter - 1
-# }
-# elif current_index and current_index > 0:
-# prev_book = available_books[current_index - 1]
-# previous = {
-# "resourceId": str(resource_id),
-# "bibleBookCode": prev_book.book_code,
-# "chapterId": prev_book.chapter_count
-# }
-# else:
-# previous = None
-
-# # Next
-# if chapter < bible_record.chapters:
-# nxt = {
-# "resourceId": str(resource_id),
-# "bibleBookCode": book_code,
-# "chapterId": chapter + 1
-# }
-# elif current_index is not None and current_index < len(available_books) - 1:
-# next_book = available_books[current_index + 1]
-# nxt = {
-# "resourceId": str(resource_id),
-# "bibleBookCode": next_book.book_code,
-# "chapterId": 1
-# }
-# else:
-# nxt = None
-
-# return previous, nxt
-
-# # Main logic
-# get_resource()
-# book = get_book()
-# bible_record = get_bible_record(book.book_id)
-
-# chapter_items = extract_chapter_content(bible_record.json.get("content", []))
-# available_books = get_available_books(db_session, resource_id)
-# previous, nxt = build_navigation(book, bible_record, available_books)
-
-# return schema.BibleChapterResponse(
-# resource_id=resource_id,
-# bible_book_code=book_code,
-# chapter=chapter,
-# previous=previous,
-# next=nxt,
-# chapter_content=chapter_items
-# )
-
-# def _clean_get_resource(db_session, resource_id):
-# resource = db_session.query(db_models.Resource).filter_by(resource_id=resource_id).first()
-# if not resource:
-# raise NotAvailableException(detail=f"Resource {resource_id} not found")
-# return resource
-
-
-# def _clean_get_book(db_session, book_code):
-# book = db_session.query(db_models.BookLookup).filter(
-# func.lower(db_models.BookLookup.book_code) == book_code.lower()
-# ).first()
-# if not book:
-# raise NotAvailableException(detail=f"Book {book_code} not found")
-# return book
-
-
-# def _clean_get_verses(db_session, resource_id, book_id, chapter, book_code):
-# verses = db_session.query(db_models.CleanBible).filter_by(
-# resource_id=resource_id,
-# book_id=book_id,
-# chapter=chapter
-# ).order_by(db_models.CleanBible.verse).all()
-
-# if not verses:
-# raise NotAvailableException(
-# detail=f"Chapter {chapter} not found for book {book_code}"
-# )
-# return verses
-
-
-# def _clean_get_bible_record(db_session, resource_id, book_id):
-# return db_session.query(db_models.Bible).filter_by(
-# resource_id=resource_id,
-# book_id=book_id
-# ).first()
-
-
-# def _clean_find_book_index(available_books, book_id):
-# for i, b in enumerate(available_books):
-# if b.book_id == book_id:
-# return i
-# return None
-
-
-# # def _clean_build_navigation(data: schema.CleanNavigationInput):
-# # """Build navigation links"""
-# # resource_id = data.resource_id
-# # book_code = data.book_code
-# # chapter = data.chapter
-# # bible_record = data.bible_record
-# # available_books = data.available_books
-# # idx = data.idx
-
-# # previous = None
-# # next_chapter = None
-
-
-# # # Previous
-# # if chapter > 1:
-# # previous = {
-# # "resourceId": str(resource_id),
-# # "bibleBookCode": book_code,
-# # "chapterId": chapter - 1
-# # }
-# # elif idx is not None and idx > 0:
-# # prev_book = available_books[idx - 1]
-# # previous = {
-# # "resourceId": str(resource_id),
-# # "bibleBookCode": prev_book.book_code,
-# # "chapterId": prev_book.chapter_count
-# # }
-
-# # # Next
-# # if bible_record and chapter < bible_record.chapters:
-# # next_chapter = {
-# # "resourceId": str(resource_id),
-# # "bibleBookCode": book_code,
-# # "chapterId": chapter + 1
-# # }
-# # elif idx is not None and idx < len(available_books) - 1:
-# # next_book = available_books[idx + 1]
-# # next_chapter = {
-# # "resourceId": str(resource_id),
-# # "bibleBookCode": next_book.book_code,
-# # "chapterId": 1
-# # }
-
-# # return previous, next_chapter
-
-
-# def get_clean_bible_chapter(
-# db_session: Session,
-# resource_id: int,
-# book_code: str,
-# chapter: int
-# ) -> schema.CleanBibleChapterResponse:
-# """Get full content of a chapter with book intro."""
-
-# _clean_get_resource(db_session, resource_id)
-# book = _clean_get_book(db_session, book_code)
-# verses = _clean_get_verses(db_session, resource_id, book.book_id, chapter, book_code)
-# bible_record = _clean_get_bible_record(db_session, resource_id, book.book_id)
-
-# available_books = get_available_clean_books(db_session, resource_id)
-# idx = _clean_find_book_index(available_books, book.book_id)
-
-# nav_input = schema.CleanNavigationInput(
-# resource_id=resource_id,
-# book_code=book_code,
-# chapter=chapter,
-# bible_record=bible_record,
-# available_books=available_books,
-# idx=idx
-# )
-
-# previous, next_chapter = _clean_build_navigation(nav_input)
-
-
-# verse_content = [
-# schema.CleanVerseContent(verse=v.verse, text=v.text)
-# for v in verses
-# ]
-
-# return schema.CleanBibleChapterResponse(
-# resource_id=resource_id,
-# bible_book_code=book_code,
-# chapter=chapter,
-# previous=previous,
-# next=next_chapter,
-# chapter_content=verse_content
-# )
-
-# def get_bible_verse(
-# db_session: Session,
-# resource_id: int,
-# book_code: str,
-# chapter: int,
-# verse: int
-# ) -> schema.BibleVerseResponse:
-# """Get specific verse content with enhanced navigation"""
-
-# _get_resource(db_session, resource_id)
-# book = _get_book_or_404(db_session, book_code)
-# verse_record = _get_clean_verse_or_404(db_session, resource_id, book.book_id, chapter, verse)
-# bible_record = _get_bible_record(db_session, resource_id, book.book_id)
-
-# available_books = get_available_clean_books(db_session, resource_id)
-# current_book_index = _find_book_index(available_books, book.book_id)
-
-# prev_input = schema.CleanPreviousVerseInput(
-# db_session=db_session,
-# resource_id=resource_id,
-# book=book,
-# chapter=chapter,
-# verse=verse,
-# available_books=available_books,
-# book_index=current_book_index
-# )
-
-# previous = _compute_previous_verse(prev_input)
-
-
-# next_input = schema.CleanNextVerseInput(
-# db_session=db_session,
-# resource_id=resource_id,
-# book=book,
-# chapter=chapter,
-# verse=verse,
-# available_books=available_books,
-# book_index=current_book_index,
-# bible_record=bible_record
-# )
-
-# next_verse = _compute_next_verse(next_input)
-
-
-# return schema.BibleVerseResponse(
-# resource_id=resource_id,
-# bible_book_code=book_code,
-# chapter=chapter,
-# verse_number=verse,
-# previous=previous,
-# next=next_verse,
-# verse_content=verse_record.text
-# )
-
-# def _get_book_or_404(db_session: Session, book_code: str):
-# book = db_session.query(db_models.BookLookup).filter(
-# func.lower(db_models.BookLookup.book_code) == book_code.lower()
-# ).first()
-# if not book:
-# raise NotAvailableException(detail=f"Book {book_code} not found")
-# return book
-
-
-# def _get_clean_verse_or_404(
-# db_session: Session,
-# resource_id: int,
-# book_id: int,
-# chapter: int,
-# verse: int
-# ):
-# verse_record = db_session.query(db_models.CleanBible).filter_by(
-# resource_id=resource_id,
-# book_id=book_id,
-# chapter=chapter,
-# verse=verse
-# ).first()
-
-# if not verse_record:
-# raise NotAvailableException(
-# detail=f"Verse {book_id}.{chapter}.{verse} not found"
-# )
-# return verse_record
-
-
-# def _get_bible_record(db_session: Session, resource_id: int, book_id: int):
-# return db_session.query(db_models.Bible).filter_by(
-# resource_id=resource_id,
-# book_id=book_id
-# ).first()
-
-
-# def _find_book_index(available_books, book_id):
-# for i, book in enumerate(available_books):
-# if book.book_id == book_id:
-# return i
-# return None
-
-# # def _compute_previous_verse(payload: schema.CleanPreviousVerseInput):
-# # db_session = payload.db_session
-# # resource_id = payload.resource_id
-# # book = payload.book
-# # chapter = payload.chapter
-# # verse = payload.verse
-# # available_books = payload.available_books
-# # book_index = payload.book_index
-
-# # # Case 1: Previous verse in same chapter
-# # if verse > 1:
-# # return {
-# # "resourceId": str(resource_id),
-# # "bibleBookCode": book.book_code,
-# # "chapterId": chapter,
-# # "verse": verse - 1
-# # }
-
-# # # Case 2: Last verse of previous chapter
-# # if chapter > 1:
-# # prev_chap_last = db_session.query(db_models.CleanBible).filter_by(
-# # resource_id=resource_id,
-# # book_id=book.book_id,
-# # chapter=chapter - 1
-# # ).order_by(db_models.CleanBible.verse.desc()).first()
-
-# # if prev_chap_last:
-# # return {
-# # "resourceId": str(resource_id),
-# # "bibleBookCode": book.book_code,
-# # "chapterId": chapter - 1,
-# # "verse": prev_chap_last.verse
-# # }
-
-# # # Case 3: Last verse of previous book
-# # if book_index is not None and book_index > 0:
-# # prev_book = available_books[book_index - 1]
-# # last_verse = db_session.query(db_models.CleanBible).filter_by(
-# # resource_id=resource_id,
-# # book_id=prev_book.book_id,
-# # chapter=prev_book.chapter_count
-# # ).order_by(db_models.CleanBible.verse.desc()).first()
-
-# # if last_verse:
-# # return {
-# # "resourceId": str(resource_id),
-# # "bibleBookCode": prev_book.book_code,
-# # "chapterId": prev_book.chapter_count,
-# # "verse": last_verse.verse
-# # }
-
-# # return None
-
-
-# # def _compute_next_verse(payload: schema.CleanNextVerseInput):
-# # db_session = payload.db_session
-# # resource_id = payload.resource_id
-# # book = payload.book
-# # chapter = payload.chapter
-# # verse = payload.verse
-# # available_books = payload.available_books
-# # book_index = payload.book_index
-# # bible_record = payload.bible_record
-
-# # # CASE 1: Next verse exists in same chapter
-# # next_verse_record = db_session.query(db_models.CleanBible).filter_by(
-# # resource_id=resource_id,
-# # book_id=book.book_id,
-# # chapter=chapter,
-# # verse=verse + 1
-# # ).first()
-
-# # if next_verse_record:
-# # return {
-# # "resourceId": str(resource_id),
-# # "bibleBookCode": book.book_code,
-# # "chapterId": chapter,
-# # "verse": verse + 1
-# # }
-
-# # # CASE 2: Next chapter exists in same book
-# # if bible_record and chapter < bible_record.chapters:
-# # return {
-# # "resourceId": str(resource_id),
-# # "bibleBookCode": book.book_code,
-# # "chapterId": chapter + 1,
-# # "verse": 1
-# # }
-
-# # # CASE 3: Next book exists
-# # if book_index is not None and book_index < len(available_books) - 1:
-# # next_book = available_books[book_index + 1]
-# # return {
-# # "resourceId": str(resource_id),
-# # "bibleBookCode": next_book.book_code,
-# # "chapterId": 1,
-# # "verse": 1
-# # }
-
-# # return None
-
-
-# # # --- Commentary CRUD ---
-
-# # def _ensure_commentary_resource(db: Session, resource_id: int):
-# # resource = db.query(db_models.Resource).filter_by(resource_id=resource_id).first()
-# # if not resource:
-# # raise NotAvailableException(detail=f"Resource {resource_id} not found")
-# # if (resource.content_type or "").lower() != "commentary":
-# # raise NotAvailableException(
-# # detail= (
-# # f"Resource {resource_id} is not of type 'commentary'"
-# # f"(found '{resource.content_type}')"
-# # )
-# # )
-# # return resource
-
-# # def _get_book_by_code(db: Session, book_code: str):
-# # return db.query(db_models.BookLookup)\
-# # .filter(db_models.BookLookup.book_code.ilike(book_code)).first()
-
-
-
-# # def _intro_text(db: Session, resource_id: int, book_id: int) -> str:
-# # row = (
-# # db.query(db_models.Commentary.text)
-# # .filter(
-# # db_models.Commentary.resource_id == resource_id,
-# # db_models.Commentary.book_id == book_id,
-# # db_models.Commentary.chapter == 0,
-# # db_models.Commentary.verse == "0", # string
-# # )
-# # .first()
-# # )
-# # return row[0] if row else ""
-
-
-# # def create_commentaries(
-# # db: Session,
-# # payload: schema.CommentaryBulkCreate,
-# # actor_user_id: int
-# # ) -> schema.CommentaryCreateResponse:
-# # """
-# # Create commentary rows.
-# # """
-# # #now = utcnow()
-
-# # resource = db.query(db_models.Resource).filter_by(resource_id=payload.resource_id).first()
-# # if not resource:
-# # raise NotAvailableException(detail=f"Resource {payload.resource_id} not found")
-
-# # if resource.content_type.lower() != "commentary":
-# # raise NotAvailableException(
-# # detail=(
-# # f"Resource {payload.resource_id} is not of type 'commentary'"
-# # f"(found '{resource.content_type}')"
-# # )
-# # )
-
-# # created_rows = []
-
-# # for item in payload.commentary:
-# # verse_str = str(item.verse).strip()
-
-# # exists_book = (
-# # db.query(db_models.BookLookup.book_id)
-# # .filter_by(book_id=item.book_id)
-# # .first()
-# # )
-# # if not exists_book:
-# # raise NotAvailableException(detail=f"BookId {item.book_id} not found")
-
-# # exists_row = db.query(db_models.Commentary.commentary_id).filter_by(
-# # resource_id=payload.resource_id,
-# # book_id=item.book_id,
-# # chapter=item.chapter,
-# # verse=verse_str,
-# # ).first()
-# # if exists_row:
-# # raise AlreadyExistsException(
-# # detail=(
-# # "Row already exists. Use PUT to update: "
-# # f"(resource_id={payload.resource_id}, book_id={item.book_id}, "
-# # f"chapter={item.chapter}, verse={item.verse})"
-# # )
-# # )
-
-# # row = db_models.Commentary(
-# # resource_id=payload.resource_id,
-# # book_id=item.book_id,
-# # chapter=item.chapter,
-# # verse=verse_str,
-# # text=item.text.strip(),
-# # )
-# # db.add(row)
-# # created_rows.append(row)
-
-# # touch_resource(db, resource_id=payload.resource_id, actor_user_id=actor_user_id)
-
-# # try:
-# # db.commit()
-# # except IntegrityError as exc:
-# # db.rollback()
-# # raise AlreadyExistsException("Unique constraint violated (row already exists).") from exc
-
-
-# # return schema.CommentaryCreateResponse(
-# # resource_id=payload.resource_id,
-# # created=[
-# # schema.CommentaryCreatedItem(
-# # commentary_id=r.commentary_id,
-# # book_id=r.book_id,
-# # chapter=r.chapter,
-# # verse=r.verse,
-# # text=r.text,
-# # )
-# # for r in created_rows
-# # ],
-# # )
-
-
-
-# # # --- PUT ---
-# # def update_commentaries(
-# # db: Session,
-# # payload: schema.CommentaryBulkUpdate,
-# # actor_user_id: int
-# # ) -> schema.CommentaryUpdateResponse:
-# # """Update commentary rows and return same response shape as POST."""
-# # resource = db.query(db_models.Resource).filter_by(resource_id=payload.resource_id).first()
-# # if not resource:
-# # raise NotAvailableException(detail=f"Resource {payload.resource_id} not found")
-# # #now = utcnow()
-# # # Resource content_type must be 'commentary'
-# # if resource.content_type.lower() != "commentary":
-# # raise BadRequestException(
-# # detail=(
-# # f"Resource {payload.resource_id} is not of type 'commentary' "
-# # f"(found '{resource.content_type}')"
-# # )
-# # )
-# # updated_rows: List[db_models.Commentary] = []
-
-# # for item in payload.commentary:
-# # # Find row
-# # row = db.query(db_models.Commentary).filter_by(
-# # commentary_id=item.commentary_id, resource_id=payload.resource_id
-# # ).first()
-# # if not row:
-# # raise NotAvailableException(
-# # detail=(
-# # f"commentary_id {item.commentary_id} not found for resource "
-# # f"{payload.resource_id}"
-# # )
-# # )
-# # # Target values (keep existing if field omitted)
-# # tgt_book_id = item.book_id if item.book_id is not None else row.book_id
-# # tgt_chapter = item.chapter if item.chapter is not None else row.chapter
-# # verse_str = str(item.verse).strip() if item.verse is not None else row.verse
-# # # Validate book_id
-# # exists_book = (
-# # db.query(db_models.BookLookup.book_id)
-# # .filter_by(book_id=tgt_book_id)
-# # .first()
-# # )
-# # if not exists_book:
-# # raise NotAvailableException(detail=f"BookId {tgt_book_id} not found")
-# # # Uniqueness against other rows
-# # exists_row = (
-# # db.query(db_models.Commentary.commentary_id)
-# # .filter_by(
-# # resource_id=payload.resource_id,
-# # book_id=tgt_book_id,
-# # chapter=tgt_chapter,
-# # verse=verse_str,
-# # )
-# # .filter(db_models.Commentary.commentary_id != item.commentary_id)
-# # .first()
-# # )
-# # if exists_row:
-# # raise AlreadyExistsException(
-# # detail=(
-# # "Update would violate uniqueness: "
-# # f"(resource_id={payload.resource_id}, book_id={tgt_book_id}, "
-# # f"chapter={tgt_chapter}, verse={verse_str}) already exists."
-# # )
-# # )
-
-# # row.book_id = tgt_book_id
-# # row.chapter = tgt_chapter
-# # row.verse = verse_str
-# # if item.text is not None:
-# # row.text = item.text.strip()
-# # updated_rows.append(row)
-# # touch_resource(db, resource_id=payload.resource_id, actor_user_id=actor_user_id)
-# # try:
-# # db.commit()
-# # except IntegrityError as e:
-# # db.rollback()
-# # raise AlreadyExistsException(
-# # detail=(
-# # "Update would violate uniqueness of "
-# # f"(resource_id={payload.resource_id}, "
-# # f"book_id={tgt_book_id}, chapter={tgt_chapter}, verse={verse_str})."
-# # )
-# # ) from e
-
-# # # Reuse the POST response schema
-# # return schema.CommentaryUpdateResponse(
-# # resource_id=payload.resource_id,
-# # updated=[
-# # schema.CommentaryUpdatedItem(
-# # commentary_id=r.commentary_id,
-# # book_id=r.book_id,
-# # chapter=r.chapter,
-# # verse=r.verse,
-# # text=r.text,
-# # )
-# # for r in updated_rows
-# # ],
-# # )
-
-
-
-# # # --- GET: full content for a resource ---
-# # def get_full_commentary(db: Session, resource_id: int) -> schema.CommentaryListResponse:
-# # """Get full content of a resource."""
-# # _ensure_commentary_resource(db, resource_id)
-
-# # v_start = cast(func.split_part(db_models.Commentary.verse, '-', 1), Integer)
-# # v_end = cast(
-# # func.coalesce(
-# # func.nullif(func.split_part(db_models.Commentary.verse, '-', 2), ''),
-# # func.split_part(db_models.Commentary.verse, '-', 1)
-# # ),
-# # Integer
-# # )
-# # # If verse/chapter are INT in DB, you can drop the cast() calls below.
-# # q = (
-# # db.query(
-# # db_models.Commentary,
-# # db_models.BookLookup.book_code.label("book_code"),
-# # )
-# # .join(db_models.BookLookup, db_models.BookLookup.book_id == db_models.Commentary.book_id)
-# # .filter(db_models.Commentary.resource_id == resource_id)
-# # .order_by(
-# # db_models.Commentary.book_id,
-# # cast(db_models.Commentary.chapter, Integer),
-# # v_start,
-# # v_end,
-# # )
-# # .all()
-# # )
-# # content = [
-# # schema.CommentaryRow(
-# # commentary_id=cm.commentary_id,
-# # bookCode=book_code,
-# # chapter=int(cm.chapter),
-# # verse=str(cm.verse),
-# # text=cm.text,
-# # )
-# # for cm, book_code in q
-# # ]
-# # return schema.CommentaryListResponse(resourceId=resource_id, content=content)
-
-
-
-# # # --- GET: full content of a chapter with book intro ---
-# # def get_commentary_chapter(
-# # db: Session,
-# # resource_id: int,
-# # book_code: str,
-# # chapter: int
-# # ) -> schema.ChapterResponse:
-# # """Get full content of a chapter with book intro."""
-# # _ensure_commentary_resource(db, resource_id)
-# # book = _get_book_by_code(db, book_code)
-# # if not book:
-# # raise NotAvailableException(detail=f"Unknown book_code '{book_code}'")
-# # # book intro = chapter 0, verse 0 (single text)
-# # book_intro = _intro_text(db, resource_id, book.book_id)
-# # # chapter content: chapter=N, include verse >= 0 (0 is chapter intro)
-# # rows = (
-# # db.query(db_models.Commentary)
-# # .filter(
-# # db_models.Commentary.resource_id == resource_id,
-# # db_models.Commentary.book_id == book.book_id,
-# # db_models.Commentary.chapter == chapter,
-# # )
-# # .order_by(db_models.Commentary.verse.asc())
-# # .all()
-# # )
-# # # If chapter truly doesn’t exist in commentary, raise 404
-# # if not rows:
-# # raise NotAvailableException(
-# # detail=(
-# # f"Chapter {chapter} not found in commentary for book_code '"
-# # f"{book.book_code}' (resource {resource_id})"
-# # ),
-# # )
-# # content = [schema.ChapterContentItem(verse=str(r.verse), text=r.text) for r in rows]
-# # return schema.ChapterResponse(
-# # bookCode=book.book_code,
-# # bookIntro=book_intro,
-# # chapter=chapter,
-# # resourceId=resource_id,
-# # content=content,
-# # )
-
-
-# # # --- DELETE ---
-# # def delete_commentary_bulk(db: Session, commentary_ids: List[int]):
-# # deleted_ids = []
-# # errors = []
-
-# # for cid in commentary_ids:
-# # row = (
-# # db.query(db_models.Commentary)
-# # .filter_by(commentary_id=cid)
-# # .first()
-# # )
-
-# # if not row:
-# # errors.append(f"commentary_id {cid} not found")
-# # continue
-
-# # try:
-# # db.delete(row)
-# # deleted_ids.append(cid)
-
-# # except Exception as exc:
-# # db.rollback()
-# # errors.append(f"Failed to delete commentary_id {cid}: {str(exc)}")
-
-# row.book_id = tgt_book_id
-# row.chapter = tgt_chapter
-# row.verse = verse_str
-# if item.text is not None:
-# row.text = item.text.strip()
-# updated_rows.append(row)
-# touch_resource(db, resource_id=payload.resource_id, actor_user_id=actor_user_id)
-# try:
-# db.commit()
-# except IntegrityError as e:
-# db.rollback()
-# raise AlreadyExistsException(
-# detail=(
-# "Update would violate uniqueness of "
-# f"(resource_id={payload.resource_id}, "
-# f"book_id={book_id}, chapter={chapter}, verse={verse})."
-# )
-# ) from e
-
-# # Reuse the POST response schema
-# return schema.CommentaryUpdateResponse(
-# resource_id=payload.resource_id,
-# updated=[
-# schema.CommentaryUpdatedItem(
-# commentary_id=r.commentary_id,
-# book_id=r.book_id,
-# chapter=r.chapter,
-# verse=r.verse,
-# text=r.text,
-# )
-# for r in updated_rows
-# ],
-# )
-
-# # return deleted_ids, errors
-
-# # ---Dictionary CRUD-------------
-
-# # def normalize_unicode(value: str) -> str:
-# # """Normalize Unicode characters for dictionary words."""
-# # if value is None or value == "":
-# # return value
-# # return unicodedata.normalize('NFC', value)
-# # def upload_dictionary_words(db_: Session, resource_id: int, dictionary_words: List,actor_id: int):
-# # '''Adds rows to the dictionary table for the specified resource_id.
-# # Throws error if resource_id + keyword combination already exists.'''
-
-# # # Verify resource exists
-# # resource_db_content = db_.query(db_models.Resource).filter(
-# # db_models.Resource.resource_id == resource_id).first()
-# # if not resource_db_content:
-# # raise NotAvailableException(detail=f"Resource {resource_id} not found")
-# # for item in dictionary_words:
-# # # Check duplicates (resource_id + dictionary items)
-# # existing = (
-# # db_.query(db_models.Dictionary)
-# # .filter_by(resource_id=resource_id,
-# # keyword=normalize_unicode(item.keyword))
-# # .first()
-# # )
-# # if existing:
-# # raise AlreadyExistsException(
-# # detail=f"resoure {resource_id} with keyword {item.keyword} already exists"
-# # )
-# # # Verify resource is of type DICTIONARY
-# # if (resource_db_content.content_type or "").lower() != "dictionary":
-# # raise BadRequestException(
-# # detail=(
-# # f"Resource {resource_id} is not of type 'dictionary'"
-# # f"(found '{resource_db_content.content_type}')"
-# # )
-# # )
-# # model_cls = db_models.Dictionary # Assuming Dictionary model exists
-# # db_content = []
-
-# # for item in dictionary_words:
-# # # Check if keyword already exists for this resource
-# # existing_word = db_.query(model_cls).filter(
-# # model_cls.resource_id == resource_id,
-# # model_cls.keyword == normalize_unicode(item.keyword)
-# # ).first()
-
-# # if existing_word:
-# # raise AlreadyExistsException(
-# # detail= f"Keyword '{item.keyword}' already exists for resource {resource_id}."
-# # )
-
-# # row = model_cls(
-# # resource_id=resource_id,
-# # keyword=normalize_unicode(item.keyword),
-# # word_forms=item.wordForms,
-# # strongs=item.strongs,
-# # definition=item.definition,
-# # translation_help=item.translationHelp,
-# # see_also=item.seeAlso,
-# # ref=item.ref,
-# # examples=item.examples
-# # )
-# # db_.add(row)
-# # db_.flush()
-
-# # db_content.append({
-# # 'wordId': row.word_id,
-# # 'keyword': row.keyword,
-# # 'wordForms': row.word_forms,
-# # 'strongs': row.strongs,
-# # 'definition': row.definition,
-# # 'translationHelp': row.translation_help,
-# # 'seeAlso': row.see_also,
-# # 'ref': row.ref,
-# # 'examples': row.examples
-# # })
-# # touch_resource(db_, resource_id=resource_db_content.resource_id, actor_user_id=actor_id)
-# # db_.commit()
-
-# # response = {
-# # 'db_content': db_content,
-# # 'resource_content': resource_db_content
-# # }
-# # return response
-
-
-# # def update_dictionary_words(db_: Session, resource_id: int, dictionary_words: List, actor_id: int):
-# # '''Updates rows in the dictionary table using wordId.
-# # All fields except wordId can be updated.'''
-
-# # # Verify resource exists and is of type DICTIONARY
-# # resource_db_content = _validate_dictionary_resource(db_, resource_id)
-
-# # # Check for duplicate keywords (AFTER resource validation)
-# # #_check_duplicate_keywords(db_, resource_id, dictionary_words)
-
-# # # Update dictionary words
-# # db_content = _update_dictionary_entries(db_, resource_id, dictionary_words)
-
-# # # Touch resource and commit
-# # touch_resource(db_, resource_id=resource_db_content.resource_id, actor_user_id=actor_id)
-# # db_.commit()
-
-# # # Build response
-# # return _build_dictionary_response(db_content, resource_db_content)
-
-
-# # def _validate_dictionary_resource(db_: Session, resource_id: int):
-# # """Verify resource exists and is of type DICTIONARY."""
-# # resource = db_.query(db_models.Resource).filter(
-# # db_models.Resource.resource_id == resource_id
-# # ).first()
-
-# # if not resource:
-# # raise NotAvailableException(
-# # detail=f'Resource {resource_id} not found in database'
-# # )
-
-# # if (resource.content_type or "").lower() != "dictionary":
-# # raise BadRequestException(
-# # detail=(
-# # f"Resource {resource_id} is not of type 'dictionary'"
-# # f" (found '{resource.content_type}')"
-# # )
-# # )
-
-# # return resource
-
-
-# # def _check_duplicate_keywords(db_: Session, resource_id: int, dictionary_words: List):
-# # """Check for duplicate keywords in the resource."""
-# # for item in dictionary_words:
-# # existing = (
-# # db_.query(db_models.Dictionary)
-# # .filter_by(
-# # resource_id=resource_id,
-# # keyword=normalize_unicode(item.keyword)
-# # )
-# # .first()
-# # )
-# # if existing:
-# # raise AlreadyExistsException(
-# # detail=f"Resource {resource_id} with keyword {item.keyword} already exists"
-# # )
-
-
-# # def _update_dictionary_entries(db_: Session, resource_id: int, dictionary_words: List):
-# # """Update dictionary entries with provided data."""
-# # db_content = []
-
-# # for item in dictionary_words:
-# # # Find row by wordId and resourceId
-# # row = db_.query(db_models.Dictionary).filter(
-# # db_models.Dictionary.word_id == item.wordId,
-# # db_models.Dictionary.resource_id == resource_id
-# # ).first()
-
-# # if not row:
-# # raise NotAvailableException(
-# # detail=f"Dictionary word with id {item.wordId} not found in resource {resource_id}"
-# # )
-
-# # # Update fields if provided (preserves original logic exactly)
-# # if item.keyword is not None:
-# # row.keyword = normalize_unicode(item.keyword)
-# # if item.wordForms is not None:
-# # row.word_forms = item.wordForms
-# # if item.strongs is not None:
-# # row.strongs = item.strongs
-# # if item.definition is not None:
-# # row.definition = item.definition
-# # if item.translationHelp is not None:
-# # row.translation_help = item.translationHelp
-# # if item.seeAlso is not None:
-# # row.see_also = item.seeAlso
-# # if item.ref is not None:
-# # row.ref = item.ref
-# # if item.examples is not None:
-# # row.examples = item.examples
-
-# # db_.flush()
-# # db_content.append(row)
-
-# # return db_content
-
-
-# # def _build_dictionary_response(db_content: List, resource_db_content):
-# # """Build the response dictionary."""
-# # response_data = [
-# # {
-# # "wordId": row.word_id,
-# # "keyword": row.keyword,
-# # "wordForms": row.word_forms,
-# # "strongs": row.strongs,
-# # "definition": row.definition,
-# # "translationHelp": row.translation_help,
-# # "seeAlso": row.see_also,
-# # "ref": row.ref,
-# # "examples": row.examples
-# # }
-# # for row in db_content
-# # ]
-
-# # return {
-# # 'db_content': response_data,
-# # 'resource_content': resource_db_content
-# # }
-
-
-# # def get_dictionary_words(db_: Session, resource_id: int, skip: int = 0, limit: int = 100):
-# # '''Fetches all dictionary words for a given resource_id.'''
-
-# # # Verify resource exists
-# # resource_db_content = db_.query(db_models.Resource).filter(
-# # db_models.Resource.resource_id == resource_id).first()
-# # if not resource_db_content:
-# # raise NotAvailableException(detail=f"Resource {resource_id} not found")
-
-# # # Verify resource is of type DICTIONARY
-# # if (resource_db_content.content_type or "").lower() != "dictionary":
-# # raise BadRequestException(
-# # detail= (
-# # f"Resource {resource_id} is not of type 'dictionary' "
-# # f"(found '{resource_db_content.content_type}')"
-# # )
-# # )
-# # model_cls = db_models.Dictionary
-# # query = db_.query(model_cls).filter(model_cls.resource_id == resource_id)
-# # query = query.order_by(model_cls.keyword)
-
-# # if skip is not None:
-# # query = query.offset(skip)
-# # if limit is not None:
-# # query = query.limit(limit)
-# # rows = query.all()
-# # content = [
-# # {
-# # "wordId": row.word_id,
-# # "keyword": row.keyword,
-# # "wordForms": row.word_forms,
-# # "strongs": row.strongs,
-# # "definition": row.definition,
-# # "translationHelp": row.translation_help,
-# # "seeAlso": row.see_also,
-# # "ref": row.ref,
-# # "examples": row.examples
-# # }
-# # for row in rows
-# # ]
-
-# # return {
-# # "resourceId": resource_id,
-# # "content": content
-# # }
-
-# if not resource:
-# raise NotAvailableException(
-# detail=f'Resource {resource_id} not found in database'
-# )
-
-# if (resource.content_type or "").lower() != "dictionary":
-# raise BadRequestException(
-# detail=(
-# f"Resource {resource_id} is not of type 'dictionary'"
-# f" (found '{resource.content_type}')"
-# )
-# )
-
-# return resource
-
-
-# def _check_duplicate_keywords(db_: Session, resource_id: int, dictionary_words: List):
-# """Check for duplicate keywords in the resource."""
-# for item in dictionary_words:
-# existing = (
-# db_.query(db_models.Dictionary)
-# .filter_by(
-# resource_id=resource_id,
-# keyword=normalize_unicode(item.keyword)
-# )
-# .first()
-# )
-# if existing:
-# raise AlreadyExistsException(
-# detail=f"Resource {resource_id} with keyword {item.keyword} already exists"
-# )
-
-
-# def _update_dictionary_entries(db_: Session, resource_id: int, dictionary_words: List):
-# """Update dictionary entries with provided data."""
-# db_content = []
-
-# for item in dictionary_words:
-# # Find row by wordId and resourceId
-# row = db_.query(db_models.Dictionary).filter(
-# db_models.Dictionary.word_id == item.wordId,
-# db_models.Dictionary.resource_id == resource_id
-# ).first()
-
-# if not row:
-# raise NotAvailableException(
-# detail=f"Dictionary word with id {item.wordId} not found in resource {resource_id}"
-# )
-
-# # Update fields if provided (preserves original logic exactly)
-# if item.keyword is not None:
-# row.keyword = normalize_unicode(item.keyword)
-# if item.wordForms is not None:
-# row.wordForms = item.wordForms
-# if item.strongs is not None:
-# row.strongs = item.strongs
-# if item.definition is not None:
-# row.definition = item.definition
-# if item.translationHelp is not None:
-# row.translationHelp = item.translationHelp
-# if item.seeAlso is not None:
-# row.seeAlso = item.seeAlso
-# if item.ref is not None:
-# row.ref = item.ref
-# if item.examples is not None:
-# row.examples = item.examples
-
-# db_.flush()
-# db_content.append(row)
-
-# return db_content
-
-
-# def _build_dictionary_response(db_content: List, resource_db_content):
-# """Build the response dictionary."""
-# response_data = [
-# {
-# "wordId": row.word_id,
-# "keyword": row.keyword,
-# "wordForms": row.word_forms,
-# "strongs": row.strongs,
-# "definition": row.definition,
-# "translationHelp": row.translation_help,
-# "seeAlso": row.see_also,
-# "ref": row.ref,
-# "examples": row.examples
-# }
-# for row in db_content
-# ]
-
-# return {
-# 'db_content': response_data,
-# 'resource_content': resource_db_content
-# }
-
-# # def get_dictionary_index(db_: Session, resource_id: int):
-# # '''Fetches dictionary index grouped by first letter of keywords.'''
-
-# # # Verify resource exists
-# # resource_db_content = db_.query(db_models.Resource).filter(
-# # db_models.Resource.resource_id == resource_id).first()
-# # if not resource_db_content:
-# # raise NotAvailableException(detail=f"Resource {resource_id} not found")
-
-# # # Verify resource is of type DICTIONARY
-# # if (resource_db_content.content_type or "").lower() != "dictionary":
-# # raise BadRequestException(
-# # detail=(
-# # f"Resource {resource_id} is not of type 'dictionary' "
-# # f"(found '{resource_db_content.content_type}')"
-# # )
-# # )
-
-# # model_cls = db_models.Dictionary
-# # words = db_.query(model_cls.word_id, model_cls.keyword).filter(
-# # model_cls.resource_id == resource_id
-# # ).order_by(model_cls.keyword).all()
-
-# # # Group by first letter
-# # index_dict = {}
-# # for word in words:
-# # if word.keyword:
-# # first_letter = word.keyword[0].upper()
-# # if first_letter not in index_dict:
-# # index_dict[first_letter] = []
-# # index_dict[first_letter].append({
-# # 'wordId': word.word_id,
-# # 'word': word.keyword
-# # })
-
-# # # Convert to list format
-# # index_list = []
-# # for letter in sorted(index_dict.keys()):
-# # index_list.append({
-# # 'letter': letter,
-# # 'words': index_dict[letter]
-# # })
-
-# # return {
-# # 'index': index_list,
-# # 'resource_content': resource_db_content
-# # }
-
-
-# # def get_dictionary_word_by_id(db_: Session, resource_id: int, word_id: int):
-# # '''Fetches a specific dictionary word by wordId and resource_id.'''
-
-# # # Verify resource exists
-# # resource_db_content = db_.query(db_models.Resource).filter(
-# # db_models.Resource.resource_id == resource_id).first()
-# # if not resource_db_content:
-# # raise NotAvailableException(detail=f"Resource {resource_id} not found")
-
-# # # Verify resource is of type DICTIONARY
-# # if (resource_db_content.content_type or "").lower() != "dictionary":
-# # raise BadRequestException(
-# # detail=(
-# # f"Resource {resource_id} is not of type 'dictionary' "
-# # f"(found '{resource_db_content.content_type}')"
-# # )
-# # )
-# # model_cls = db_models.Dictionary
-# # word = db_.query(model_cls).filter(
-# # model_cls.resource_id == resource_id,
-# # model_cls.word_id == word_id
-# # ).first()
-
-# # return {
-# # 'db_content': word,
-# # 'resource_content': resource_db_content
-# # }
-
-# # def delete_dictionary_words(db_: Session, resource_id: int, word_ids: List[int], user_id=None):
-# # """
-# # Deletes multiple dictionary words by their wordIds.
-# # Returns count, deleted IDs, and errors (for duplicates or invalid IDs).
-# # """
-
-# # # Verify resource exists
-# # resource_db_content = (
-# # db_.query(db_models.Resource)
-# # .filter(db_models.Resource.resource_id == resource_id)
-# # .first()
-# # )
-# # if not resource_db_content:
-# # raise NotAvailableException(detail=f"Resource {resource_id} not found")
-
-# # # Verify dictionary
-# # if (resource_db_content.content_type or "").lower() != "dictionary":
-# # raise BadRequestException(
-# # detail=(
-# # f"Resource {resource_id} is not of type 'dictionary'"
-# # f"(found '{resource_db_content.content_type}')"
-# # )
-# # )
-
-# # model_cls = db_models.Dictionary
-# # deleted_ids = []
-# # errors = []
-# # processed = set()
-
-# # for word_id in word_ids:
-
-# # # Duplicate within same request
-# # if word_id in processed:
-# # errors.append({"id": word_id, "error": "already_deleted"})
-# # continue
-# # processed.add(word_id)
-
-# # # Check existence
-# # word = (
-# # db_.query(model_cls)
-# # .filter(
-# # model_cls.resource_id == resource_id,
-# # model_cls.word_id == word_id,
-# # )
-# # .first()
-# # )
-
-# # if not word:
-# # errors.append({"id": word_id, "error": "not_found"})
-# # continue
-
-# # # Delete word
-# # db_.delete(word)
-# # deleted_ids.append(word_id)
-
-# # # Commit all successful deletes
-# # db_.commit()
-
-# # # Construct error message
-# # error_msg = None
-# # if errors:
-# # not_found = [e["id"] for e in errors if e["error"] == "not_found"]
-# # dup = [e["id"] for e in errors if e["error"] == "already_deleted"]
-# # parts = []
-# # if not_found:
-# # parts.append(f"Invalid word IDs: {not_found}")
-# # if dup:
-# # parts.append(f"Already deleted IDs: {dup}")
-# # error_msg = "; ".join(parts)
-
-# # return {
-# # "message": f"Successfully deleted {len(deleted_ids)} words",
-# # "deleted_count": len(deleted_ids),
-# # "deleted_ids": deleted_ids,
-# # "error": error_msg,
-# # "has_errors": bool(errors),
-# # }
-
-
-# # # --- AudioBible CRUD ---
-
-# # def validate_books(db: Session, books: dict):
-# # """
-# # Validate that all book codes exist in BookLookup table
-# # and chapter counts are within valid range
-# # """
-# # # Get all valid book codes and their chapter counts
-# # book_lookup = db.query(
-# # db_models.BookLookup.book_code,
-# # db_models.BookLookup.chapter_count
-# # ).all()
-
-# # # Create a dictionary for easy lookup (case-insensitive)
-# # valid_books = {
-# # row.book_code.lower(): row.chapter_count
-# # for row in book_lookup
-# # }
-
-# # # Validate each book in the input
-# # for book_code, chapter_count in books.items():
-# # book_code_lower = book_code.lower()
-
-# # # Check if book code exists
-# # if book_code_lower not in valid_books:
-# # raise BadRequestException(
-# # detail=(
-# # f"Invalid book code '{book_code}'. "
-# # f"Must match book_code from BookLookup table."
-# # )
-# # )
-
-# # # Check if chapter count is within valid range
-# # max_chapters = valid_books[book_code_lower]
-# # if chapter_count > max_chapters:
-# # raise BadRequestException(
-# # detail=f"Invalid chapter count for '{book_code}': {chapter_count}. "
-# # f"Maximum chapters for this book is {max_chapters}."
-# # )
-
-# # def create_audio_bible(db: Session, data: schema.AudioBibleCreate, actor_user_id: int):
-# # """Create a new audio bible entry"""
-# # # Check if resource exists
-# # resource = db.query(db_models.Resource).filter_by(resource_id=data.resource_id).first()
-# # if not resource:
-# # raise NotAvailableException(
-# # detail=f"Resource with id {data.resource_id} does not exist."
-# # )
-
-# # # Resource content_type must be 'bible'
-# # if resource.content_type.lower() != "bible":
-# # raise BadRequestException(
-# # detail=(
-# # f"Resource {data.resource_id} is not of type 'bible' "
-# # f"(found '{resource.content_type}')"
-# # )
-# # )
-
-# # # Check if audio bible already exists for this resource
-# # existing = db.query(db_models.AudioBible).filter_by(resource_id=data.resource_id).first()
-# # if existing:
-# # raise AlreadyExistsException(
-# # detail=(
-# # f"AudioBible with resource_id {data.resource_id} already exists."
-# # f" Use PUT to update."
-# # )
-# # )
-
-# # # Validate book codes
-# # validate_books(db, data.books)
-
-# # # Create audio bible
-# # audio_bible = db_models.AudioBible(**data.model_dump())
-# # db.add(audio_bible)
-# # touch_resource(db, data.resource_id, actor_user_id)
-# # db.commit()
-# # db.refresh(audio_bible)
-# # return audio_bible
-
-# # def _is_files_missing_empty(obj) -> bool:
-# # """
-# # Return True if files_missing is empty ({} or None),
-# # False if it contains missing entries.
-# # """
-# # if obj is None:
-# # return True
-# # # if empty mapping
-# # try:
-# # if isinstance(obj, dict) and len(obj) == 0:
-# # return True
-# # # JSONB may also arrive as string by accident; handle that defensively
-# # return False
-# # except Exception:
-# # return False
-
-# # def list_audio_bibles(
-# # db: Session,
-# # resource_id: Optional[int] = None,
-# # limit: int = 50,
-# # offset: int = 0,
-# # files_missing: Optional[bool] = None,
-# # test_date: Optional[datetime] = None
-# # ) -> List[dict]:
-# # """List audio bibles with optional filtering and pagination"""
-# # query = db.query(db_models.AudioBible)
-
-# # if resource_id is not None:
-# # query = query.filter(db_models.AudioBible.resource_id == resource_id)
-
-# # query = query.limit(limit).offset(offset)
-# # rows = query.all()
-# # out = []
-# # for ab in rows:
-# # fm = ab.files_missing # could be None, {}, or dict
-# # td = ab.test_date # could be None or datetime
-
-# # # files_missing filter
-# # if files_missing is not None:
-# # has_missing = not _is_files_missing_empty(fm)
-# # if files_missing and not has_missing:
-# # # caller wants only audio bibles that *have* missing files
-# # continue
-# # if (not files_missing) and has_missing:
-# # # caller wants only audio bibles with no missing files
-# # continue
-
-# # # test_date filter (keep rows tested ON or AFTER the given timestamp)
-# # if test_date is not None:
-# # if td is None:
-# # # row has no test_date -> skip when filtering by test_date
-# # continue
-# # # ensure timezone-aware comparision: convert to UTC if naive
-# # # assume incoming test_date is timezone-aware (FastAPI will parse RFC datetimes)
-# # if td.tzinfo is None:
-# # td = td.replace(tzinfo=timezone.utc)
-# # if test_date.tzinfo is None:
-# # test_date = test_date.replace(tzinfo=timezone.utc)
-# # if td < test_date:
-# # continue
-# # out.append({
-# # "resourceId": ab.resource_id,
-# # "name": ab.name,
-# # "url": ab.base_url,
-# # "books": ab.books,
-# # "format": ab.format,
-# # # normalize empty dict -> {} ; None left as None
-# # "files_missing": (
-# # ab.files_missing
-# # if ab.files_missing
-# # else {}
-# # ),
-# # "test_date": ab.test_date,
-# # })
-
-# # return out
-
-
-# # def update_audio_bible(
-# # db: Session,
-# # resource_id: int,
-# # update_data: schema.AudioBibleUpdate,
-# # actor_user_id: int
-# # ):
-# # """Update an existing audio bible"""
-# # # Check if resource exists
-# # resource = db.query(db_models.Resource).filter_by(resource_id=resource_id).first()
-# # if not resource:
-# # raise NotAvailableException(
-# # detail=f"Resource with id {resource_id} does not exist."
-# # )
-# # # Resource content_type must be 'bible'
-# # if resource.content_type.lower() != "bible":
-# # raise BadRequestException(
-# # detail=(
-# # f"Resource {resource_id} is not of type 'bible' "
-# # f"(found '{resource.content_type}')"
-# # )
-# # )
-
-# # # Validate book codes if books are being updated
-# # update_dict = update_data.model_dump(exclude_unset=True)
-# # if "books" in update_dict and update_dict["books"] is not None:
-# # validate_books(db, update_dict["books"])
-
-# # audio_bible = db.query(db_models.AudioBible).filter_by(resource_id=resource_id).first()
-# # # Update fields
-# # for field, value in update_dict.items():
-# # if value is not None:
-# # setattr(audio_bible, field, value)
-# # touch_resource(db, resource_id=resource_id, actor_user_id=actor_user_id)
-# # db.commit()
-# # db.refresh(audio_bible)
-# # return audio_bible
-
-# def _is_files_missing_empty(obj) -> bool:
-# """
-# Return True if files_missing is empty ({} or None),
-# False if it contains missing entries.
-# """
-# if obj is None:
-# return True
-# # if empty mapping
-# try:
-# if isinstance(obj, dict) and len(obj) == 0:
-# return True
-# # JSONB may also arrive as string by accident; handle that defensively
-# return False
-# except Exception:
-# return False
-
-# def list_audio_bibles(
-# db: Session,
-# resource_id: Optional[int] = None,
-# limit: int = 50,
-# offset: int = 0,
-# files_missing: Optional[bool] = None,
-# test_date: Optional[datetime] = None
-# ) -> List[dict]:
-# """List audio bibles with optional filtering and pagination"""
-# query = db.query(db_models.AudioBible)
-
-# if resource_id is not None:
-# query = query.filter(db_models.AudioBible.resource_id == resource_id)
-
-# query = query.limit(limit).offset(offset)
-# rows = query.all()
-# out = []
-# for ab in rows:
-# fm = ab.files_missing # could be None, {}, or dict
-# td = ab.test_date # could be None or datetime
-
-# # files_missing filter
-# if files_missing is not None:
-# has_missing = not _is_files_missing_empty(fm)
-# if files_missing and not has_missing:
-# # caller wants only audio bibles that *have* missing files
-# continue
-# if (not files_missing) and has_missing:
-# # caller wants only audio bibles with no missing files
-# continue
-
-# # test_date filter (keep rows tested ON or AFTER the given timestamp)
-# if test_date is not None:
-# if td is None:
-# # row has no test_date -> skip when filtering by test_date
-# continue
-# # ensure timezone-aware comparision: convert to UTC if naive
-# # assume incoming test_date is timezone-aware (FastAPI will parse RFC datetimes)
-# if td.tzinfo is None:
-# td = td.replace(tzinfo=timezone.utc)
-# if test_date.tzinfo is None:
-# test_date = test_date.replace(tzinfo=timezone.utc)
-# if td < test_date:
-# continue
-# out.append({
-# "resourceId": ab.resource_id,
-# "name": ab.name,
-# "url": ab.base_url,
-# "books": ab.books,
-# "format": ab.format,
-# # normalize empty dict -> {} ; None left as None
-# "files_missing": (
-# ab.files_missing
-# if ab.files_missing
-# else {}
-# ),
-# })
-
-# return out
-
-
-# def update_audio_bible(
-# db: Session,
-# resource_id: int,
-# update_data: schema.AudioBibleUpdate,
-# actor_user_id: int
-# ):
-# """Update an existing audio bible"""
-# # Check if resource exists
-# resource = db.query(db_models.Resource).filter_by(resource_id=resource_id).first()
-# if not resource:
-# raise NotAvailableException(
-# detail=f"Resource with id {resource_id} does not exist."
-# )
-# # Resource content_type must be 'bible'
-# if resource.content_type.lower() != "bible":
-# raise BadRequestException(
-# detail=(
-# f"Resource {resource_id} is not of type 'bible' "
-# f"(found '{resource.content_type}')"
-# )
-# )
-
-# # def delete_audio_bible(db: Session, resource_id: int):
-# # """Delete an audio bible"""
-# # audio_bible = db.query(db_models.AudioBible).filter(
-# # db_models.AudioBible.resource_id == resource_id
-# # ).first()
-
-# # if not audio_bible:
-# # return None
-
-# # db.delete(audio_bible)
-# # db.commit()
-# # return audio_bible
-# # def bulk_delete_audio_bibles(db: Session, resource_id: int, audio_bible_ids: List[int]):
-# # deleted_ids = []
-# # errors = []
-# # processed = set()
-
-# # # Ensure resource exists
-# # resource = (
-# # db.query(db_models.Resource)
-# # .filter(db_models.Resource.resource_id == resource_id)
-# # .first()
-# # )
-# # if not resource:
-# # raise NotAvailableException(detail=f"Resource {resource_id} not found")
-
-# # # Ensure content type = audio_bible
-# # if (resource.content_type or "").lower() != "audiobible":
-# # raise BadRequestException(
-# # detail=(
-# # f"Resource {resource_id} is not of type 'audiobible'"
-# # f" (found '{resource.content_type}')"
-# # )
-# # )
-
-# # for ab_id in audio_bible_ids:
-
-# # # Prevent duplicates in the same request
-# # if ab_id in processed:
-# # errors.append({"id": ab_id, "error": "already_deleted"})
-# # continue
-# # processed.add(ab_id)
-
-# # # Check existence
-# # row = (
-# # db.query(db_models.AudioBible)
-# # .filter(
-# # db_models.AudioBible.audio_bible_id == ab_id,
-# # db_models.AudioBible.resource_id == resource_id
-# # )
-# # .first()
-# # )
-
-# # if not row:
-# # errors.append({"id": ab_id, "error": "not_found"})
-# # continue
-
-# # # Delete the row
-# # db.delete(row)
-# # deleted_ids.append(ab_id)
-
-# # # Commit once
-# # db.commit()
-
-# # # Build error message
-# # error_msg = None
-# # if errors:
-# # nf = [e["id"] for e in errors if e["error"] == "not_found"]
-# # dup = [e["id"] for e in errors if e["error"] == "already_deleted"]
-# # parts = []
-# # if nf:
-# # parts.append(f"Invalid audio_bible_ids: {nf}")
-# # if dup:
-# # parts.append(f"Duplicate IDs in request: {dup}")
-# # error_msg = "; ".join(parts)
-
-# # return {
-# # "deletedCount": len(deleted_ids),
-# # "deletedIds": deleted_ids,
-# # "error": error_msg,
-# # "message": f"Successfully deleted {len(deleted_ids)} audio bible(s)"
-# # }
-
-
-
-# # ---- OBS ----
-# # def create_obs_story(
-# # db_session: Session,
-# # language_code: int,
-# # story_data: schema.OBSStoryCreate
-# # ) -> dict:
-# # """
-# # Create a new OBS story for a specific language.
-
-# # Args:
-# # db_session: Database session
-# # language_code: Language identifier
-# # story_data: Story creation data
-
-# # Returns:
-# # Dictionary with success, message, and data
-
-# # Raises:
-# # HTTPException: If validation fails or resource doesn't exist
-# # """
-
-# # # 1. Validate language exists
-# # language = db_session.query(db_models.Language).filter_by(
-# # language_code=language_code
-# # ).first()
-
-# # if not language:
-# # logger.error("Language code %s not found", language_code)
-# # raise NotAvailableException(
-# # detail=f"Language with code {language_code} not found"
-# # )
-
-# # # 2. Validate resource (resource_id) exists
-# # resource = db_session.query(db_models.Resource).filter_by(
-# # resource_id=story_data.resource_id
-# # ).first()
-
-# # if not resource:
-# # logger.error("Resource ID %s not found", story_data.resource_id)
-# # raise NotAvailableException(
-# # detail=f"Resource with ID {story_data.resource_id} not found"
-# # )
-
-# # # 3. Validate resource belongs to the specified language
-# # if resource.language_id != language.language_id:
-# # logger.error(
-# # "Resource %s does not belong to language %s",
-# # story_data.resource_id,
-# # language_code
-# # )
-
-# # raise BadRequestException(
-# # detail=f"Resource {story_data.resource_id} does not belong to language {language_code}"
-# # )
-
-# # # 4. Validate resource content type is 'obs'
-# # if resource.content_type.lower() != "obs":
-# # logger.error("Resource %s is not of type 'obs'", story_data.resource_id)
-# # raise BadRequestException(
-# # detail=(
-# # f"Resource {story_data.resource_id} is not of type 'obs' "
-# # f"(found '{resource.content_type}')"
-# # )
-# # )
-
-# # # 5. Check for duplicate story_no within the same resource
-# # existing_story = db_session.query(db_models.Obs).filter_by(
-# # resource_id=story_data.resource_id,
-# # story_no=story_data.story_no
-# # ).first()
-
-# # if existing_story:
-# # logger.error(
-# # "Story number %s already exists for resource %s",
-# # story_data.story_no,
-# # story_data.resource_id
-# # )
-# # raise AlreadyExistsException(
-# # detail=(
-# # f"Story number {story_data.story_no} already exists for resource "
-# # f"{story_data.resource_id}"
-# # )
-# # )
-
-# # # 6. Create new OBS story
-# # new_story = db_models.Obs(
-# # resource_id=story_data.resource_id,
-# # story_no=story_data.story_no,
-# # title=story_data.title.strip(),
-# # url=story_data.url.strip() if story_data.url else None,
-# # text=story_data.text.strip()
-# # )
-
-# # db_session.add(new_story)
-# # db_session.commit()
-# # db_session.refresh(new_story)
-
-# # logger.info(
-# # "Successfully created OBS story %s for resource %s",
-# # new_story.obs_id,
-# # story_data.resource_id
-# # )
-
-# # # Return formatted response
-# # return {
-# # "success": True,
-# # "message": "Story created successfully",
-# # "data": {
-# # "id": new_story.obs_id,
-# # "resource_id": new_story.resource_id,
-# # "story_no": new_story.story_no,
-# # "title": new_story.title,
-# # "url": new_story.url,
-# # "text": new_story.text
-# # }
-# # }
-
-# # def get_languages_with_obs(db_session: Session) -> schema.OBSLanguageListResponse:
-# # """
-# # Get all languages that have OBS stories available.
-# # Returns formatted response with languages and their story counts.
-# # """
-# # # Query to get languages with OBS resources and count their stories
-# # result = (
-# # db_session.query(
-# # db_models.Language.language_code,
-# # db_models.Language.language_name,
-# # func.count(db_models.Obs.obs_id).label("story_count"), #pylint: disable=not-callable
-
-# # )
-# # .join(
-# # db_models.Resource,
-# # db_models.Resource.language_id == db_models.Language.language_id,
-# # )
-# # .join(
-# # db_models.Obs,
-# # db_models.Obs.resource_id == db_models.Resource.resource_id,
-# # )
-# # .filter(db_models.Resource.content_type == "obs")
-# # .group_by(
-# # db_models.Language.language_code,
-# # db_models.Language.language_name,
-# # )
-# # .order_by(db_models.Language.language_name)
-# # .all()
-# # )
-
-# # # Transform result into response schema
-# # language_list = [
-# # schema.LanguageWithStoryCount(
-# # language_code=row.language_code,
-# # language_name=row.language_name,
-# # story_count=row.story_count,
-# # )
-# # for row in result
-# # ]
-
-# # return schema.OBSLanguageListResponse(
-# # success=True,
-# # data=language_list,
-# # count=len(language_list),
-# # )
-
-
-
-# # def get_obs_stories_by_language(
-# # db_session: Session,
-# # language_code: int,
-# # page: int = 1,
-# # limit: int = 50
-# # ) -> schema.OBSStoriesListResponse:
-# # """
-# # Get all OBS stories for a specific language with pagination.
-# # Returns formatted response with stories list and pagination info.
-# # """
-# # # 1. Validate language exists
-# # language = db_session.query(db_models.Language).filter_by(
-# # language_code=language_code
-# # ).first()
-
-# # if not language:
-# # logger.error("Language ID %s not found", language_code)
-# # raise NotAvailableException(
-# # detail=f"Language with ID {language_code} not found"
-# # )
-# # language_id = language.language_id
-# # # 2. Get total count of stories for this language
-# # total_count = (
-# # db_session.query(func.count(db_models.Obs.obs_id).label("total")) #pylint: disable=not-callable
-# # .join(
-# # db_models.Resource,
-# # db_models.Resource.resource_id == db_models.Obs.resource_id
-# # )
-# # .filter(
-# # db_models.Resource.language_id == language_id,
-# # db_models.Resource.content_type == "obs"
-# # ).scalar()
-# # )
-# # # 3. Get paginated stories
-# # offset = (page - 1) * limit
-# # stories = db_session.query(db_models.Obs).join(
-# # db_models.Resource,
-# # db_models.Resource.resource_id == db_models.Obs.resource_id
-# # ).filter(
-# # db_models.Resource.language_id == language_id,
-# # db_models.Resource.content_type == 'obs'
-# # ).order_by(
-# # db_models.Obs.story_no
-# # ).offset(offset).limit(limit).all()
-
-# # # 4. Build story list
-# # story_list = [
-# # schema.OBSStoryBrief(
-# # id=story.obs_id,
-# # resource_id=story.resource_id,
-# # story_no=story.story_no,
-# # title=story.title,
-# # url=story.url,
-# # text=story.text
-# # )
-# # for story in stories
-# # ]
-
-# # # 5. Return formatted response
-# # return schema.OBSStoriesListResponse(
-# # success=True,
-# # data=schema.OBSStoriesListData(
-# # language_code=language.language_code,
-# # language_name=language.language_name,
-# # stories=story_list
-# # ),
-# # pagination=schema.PaginationInfo(
-# # page=page,
-# # limit=limit,
-# # total=total_count
-# # )
-# # )
-
-
-# # def get_obs_story_by_id(
-# # db_session: Session,
-# # language_code: int,
-# # story_id: int
-# # ) -> schema.OBSStoryDetailResponse:
-# # """
-# # Get a specific OBS story by ID and language.
-# # Returns formatted response with full story details.
-# # """
-# # # 1. Validate language exists
-# # language = db_session.query(db_models.Language).filter_by(
-# # language_code=language_code
-# # ).first()
-
-# # if not language:
-# # logger.error("Language code %s not found", language_code)
-# # raise NotAvailableException(
-# # detail=f"Language with code {language_code} not found"
-# # )
-
-# # # 2. Get story with language validation
-# # language_id = language.language_id
-# # story = db_session.query(db_models.Obs).join(
-# # db_models.Resource,
-# # db_models.Resource.resource_id == db_models.Obs.resource_id
-# # ).filter(
-# # db_models.Obs.obs_id == story_id,
-# # db_models.Resource.language_id == language_id,
-# # db_models.Resource.content_type == 'obs'
-# # ).first()
-
-# # if not story:
-# # logger.error("Story ID %s not found for language %s", story_id, language_code)
-# # raise NotAvailableException(
-# # detail=f"Story with ID {story_id} not found for language {language_code}"
-# # )
-
-# # # 3. Return formatted response
-# # return schema.OBSStoryDetailResponse(
-# # success=True,
-# # data=schema.OBSStoryResponse(
-# # id=story.obs_id,
-# # resource_id=story.resource_id,
-# # story_no=story.story_no,
-# # title=story.title,
-# # url=story.url,
-# # text=story.text
-# # )
-# # )
-
-# # def _get_language_or_404(db_session, language_code):
-# # language = (
-# # db_session.query(db_models.Language)
-# # .filter_by(language_code=language_code)
-# # .first()
-# # )
-# # if not language:
-# # logger.error("Language code %s not found", language_code)
-# # raise NotAvailableException(
-# # detail=f"Language with code {language_code} not found",
-# # )
-# # return language
-
-
-# # def _get_story_or_404(db_session, story_id, language_id, language_code):
-# # story = (
-# # db_session.query(db_models.Obs)
-# # .join(
-# # db_models.Resource,
-# # db_models.Resource.resource_id == db_models.Obs.resource_id,
-# # )
-# # .filter(
-# # db_models.Obs.obs_id == story_id,
-# # db_models.Resource.language_id == language_id,
-# # db_models.Resource.content_type == "obs",
-# # )
-# # .first()
-# # )
-# # if not story:
-# # logger.error(
-# # "Story ID %s not found for language %s", story_id, language_code
-# # )
-# # raise NotAvailableException(
-# # detail=(
-# # f"Story with ID {story_id} not found for language {language_code}"
-# # ),
-# # )
-# # return story
-
-
-# # def _validate_resource_update(db_session, story_data, language):
-# # if story_data.resource_id is None:
-# # return None
-
-# # new_resource = (
-# # db_session.query(db_models.Resource)
-# # .filter_by(resource_id=story_data.resource_id)
-# # .first()
-# # )
-
-# # if not new_resource:
-# # logger.error(
-# # "Resource ID %s not found", story_data.resource_id
-# # )
-# # raise NotAvailableException(
-# # detail=f"Resource with ID {story_data.resource_id} not found",
-# # )
-
-# # if new_resource.language_id != language.language_id:
-# # logger.error(
-# # "Resource %s does not belong to language %s",
-# # story_data.resource_id,
-# # language.language_code,
-# # )
-# # raise BadRequestException(
-# # detail=(
-# # f"Resource {story_data.resource_id} does not belong to language "
-# # f"{language.language_code}"
-# # ),
-# # )
-
-# # if new_resource.content_type.lower() != "obs":
-# # logger.error(
-# # "Resource %s is not of type 'obs'", story_data.resource_id
-# # )
-# # raise BadRequestException(
-# # detail=(
-# # f"Resource {story_data.resource_id} is not of type 'obs' "
-# # f"(found '{new_resource.content_type}')"
-# # ),
-# # )
-
-# # return new_resource
-
-
-# # def _ensure_story_no_unique(db_session, story_data, story):
-# # if story_data.story_no is None:
-# # return
-
-# # target_resource_id = (
-# # story_data.resource_id
-# # if story_data.resource_id is not None
-# # else story.resource_id
-# # )
-
-# # duplicate_story = (
-# # db_session.query(db_models.Obs)
-# # .filter(
-# # db_models.Obs.resource_id == target_resource_id,
-# # db_models.Obs.story_no == story_data.story_no,
-# # db_models.Obs.obs_id != story.obs_id,
-# # )
-# # .first()
-# # )
-
-# # if duplicate_story:
-# # logger.error(
-# # "Story number %s already exists for resource %s",
-# # story_data.story_no,
-# # target_resource_id,
-# # )
-# # raise AlreadyExistsException(
-# # detail=(
-# # f"Story number {story_data.story_no} already exists for resource "
-# # f"{target_resource_id}"
-# # ),
-# # )
-
-
-# # def update_obs_story(
-# # db_session: Session,
-# # language_code: int,
-# # story_id: int,
-# # story_data: schema.OBSStoryUpdate,
-# # ) -> schema.OBSStoryUpdateResponse:
-# # """Update an existing OBS story."""
-
-# # language = _get_language_or_404(db_session, language_code)
-# # story = _get_story_or_404(
-# # db_session, story_id, language.language_id, language_code
-# # )
-
-# # # Validate resource update
-# # _ = _validate_resource_update(db_session, story_data, language)
-
-# # # Validate duplicate story number
-# # _ensure_story_no_unique(db_session, story_data, story)
-
-# # # Apply updates
-# # if story_data.resource_id is not None:
-# # story.resource_id = story_data.resource_id
-# # if story_data.story_no is not None:
-# # story.story_no = story_data.story_no
-# # if story_data.title is not None:
-# # story.title = story_data.title.strip()
-# # if story_data.url is not None:
-# # story.url = story_data.url.strip() if story_data.url else None
-# # if story_data.text is not None:
-# # story.text = story_data.text.strip()
-
-# # db_session.commit()
-# # db_session.refresh(story)
-
-# # logger.info("Successfully updated OBS story %s", story_id)
-
-# # return schema.OBSStoryUpdateResponse(
-# # success=True,
-# # message="Story updated successfully",
-# # data=schema.OBSStoryUpdate(
-# # id=story.obs_id,
-# # resource_id=story.resource_id,
-# # story_no=story.story_no,
-# # title=story.title,
-# # url=story.url,
-# # text=story.text,
-# # ),
-# # )
-
-# # # ===== DELETE =====
-# # def delete_obs_story(
-# # db_session: Session,
-# # language_code: str,
-# # story_nos: List[int]
-# # ) -> dict:
-# # """
-# # Bulk delete OBS stories by story numbers and language.
-# # Returns a response containing deleted & invalid story numbers.
-# # """
-
-# # # 1. Validate language exists
-# # language = db_session.query(db_models.Language).filter_by(
-# # language_code=language_code
-# # ).first()
-
-# # if not language:
-# # raise NotAvailableException(
-# # detail=f"Language with code {language_code} not found"
-# # )
-
-# # language_id = language.language_id
-
-# # deleted = []
-# # invalid = []
-# # processed = set()
-
-# # # 2. Iterate through story numbers
-# # for story_no in story_nos:
-# # if story_no in processed:
-# # invalid.append(story_no)
-# # continue
-# # processed.add(story_no)
-
-# # story = (
-# # db_session.query(db_models.Obs)
-# # .join(db_models.Resource, db_models.Resource.resource_id == db_models.Obs.resource_id)
-# # .filter(
-# # db_models.Obs.story_no == story_no,
-# # db_models.Resource.language_id == language_id,
-# # db_models.Resource.content_type == "obs"
-# # )
-# # .first()
-# # )
-
-# # if not story:
-# # invalid.append(story_no)
-# # continue
-
-# # db_session.delete(story)
-# # deleted.append(story_no)
-
-# # # 3. Commit all deletes
-# # db_session.commit()
-
-# # # 4. Prepare response
-# # response = {
-# # "deletedCount": len(deleted),
-# # "deletedStoryNos": deleted,
-# # "message": f"Successfully deleted {len(deleted)} stories",
-# # }
-
-# # if invalid:
-# # response["error"] = f"Invalid story_nos: {', '.join(map(str, invalid))}"
-
-# # return response
-
-
-# ### CRUD for InfoGraphics
-
-# # ALLOWED_EXT = {".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp"}
-
-# # def validate_item(idx: int, item) -> Optional[dict]:
-# # """Return an error dict if invalid, else None. Collects *all* field errors."""
-# # msgs = []
-# # data_dump = item.model_dump(by_alias=True)
-# # # 1. book_id validation
-# # if not isinstance(item.book_id, int) or not 1 <= item.book_id <= 67:
-# # msgs.append("book_id must be between 1 to 67")
-# # # title
-# # title = (item.title or "").strip()
-# # if not title:
-# # msgs.append("title cannot be empty")
-# # fn = (item.file_name or "").strip()
-# # if not fn:
-# # msgs.append("filename cannot be empty")
-# # else:
-# # m = re.search(r"(\.[a-zA-Z0-9]+)$", fn)
-# # if not m or m.group(1).lower() not in ALLOWED_EXT:
-# # msgs.append("file_name must end with one of: .jpg, .jpeg, .png, .gif, .svg, .webp")
-# # if ".." in fn or fn.startswith("/"):
-# # msgs.append("file_name must not contain path traversal")
-# # if not msgs:
-# # return None
-# # return {
-# # "index": idx,
-# # "data": data_dump,
-# # "error": {
-# # "code": "VALIDATION_ERROR",
-# # "message": msgs,
-# # },
-# # }
-
-# # def _extract_base_url(resource) -> Optional[str]:
-# # """Extract base_url from resource.meta_data JSON."""
-# # if not resource or not getattr(resource, "meta_data", None):
-# # return None
-
-# # try:
-# # raw = resource.meta_data
-# # data = raw if isinstance(raw, dict) else json.loads(raw)
-
-# # base_url = data.get("base_url")
-# # if isinstance(base_url, dict):
-# # base_url = base_url.get("base_url")
-
-# # return base_url.rstrip("/") if base_url else None
-
-# # except (json.JSONDecodeError, TypeError, AttributeError):
-# # return None
-
-# # def _image_url(resource, file_name: str) -> Optional[str]:
-# # """Combine base_url and file_name."""
-# # base = _extract_base_url(resource)
-# # return f"{base}/{file_name}" if base else None
-
-
-
-# # --- CRUD operations ---
-
-# # def create_infographic_batch(
-# # db: Session,
-# # payload: schema.BatchInfographicCreateIn,
-# # actor_user_id: int
-# # ):
-# # """Create a batch of infographics."""
-# # resource_id = payload.resource_id
-
-# # # --- 1. Validate resource ---
-# # res = db.query(db_models.Resource).filter_by(resource_id=resource_id).first()
-# # if not res:
-# # raise NotAvailableException(detail=f"Resource {resource_id} not found")
-
-# # if (res.content_type or "").lower() != "infographics":
-# # raise BadRequestException(
-# # detail=(
-# # f"Resource {resource_id} is not of type 'infographics'"
-# # f" (found '{res.content_type}')"
-# # )
-# # )
-
-# # # --- 2. Validate book ids ---
-# # bk_ids = {i.book_id for i in payload.infographics}
-# # valid_books = {
-# # b.book_id for b in db.query(db_models.BookLookup)
-# # .filter(db_models.BookLookup.book_id.in_(bk_ids)).all()
-# # }
-
-# # created_rows = []
-# # errors = []
-
-# # # --- 3. Process each row ---
-# # for idx, item in enumerate(payload.infographics):
-
-# # # Validate title, file extension, filename rules
-# # err = validate_item(idx, item)
-# # if err:
-# # errors.append(err)
-# # continue
-
-# # # Check valid book id
-# # if item.book_id not in valid_books:
-# # errors.append({
-# # "index": idx,
-# # "data": item.model_dump(),
-# # "error": {
-# # "code": "INVALID_BOOK",
-# # "message": f"BookId {item.book_id} not found"
-# # }
-# # })
-# # continue
-
-# # # Duplicate check
-# # duplicate = db.query(db_models.Infographic).filter(
-# # db_models.Infographic.resource_id == resource_id,
-# # db_models.Infographic.book_id == item.book_id,
-# # db_models.Infographic.title == item.title,
-# # db_models.Infographic.file_name == item.file_name,
-# # ).first()
-
-# # if duplicate:
-# # errors.append({
-# # "index": idx,
-# # "data": item.model_dump(),
-# # "error": {
-# # "code": "DUPLICATE_ENTRY",
-# # "message": "Infographic with same book_id, title, and file_name already exists"
-# # }
-# # })
-# # continue
-
-# # # Create row (in-memory only)
-# # row = db_models.Infographic(
-# # resource_id=resource_id,
-# # book_id=item.book_id,
-# # title=item.title,
-# # file_name=item.file_name,
-# # )
-
-# # db.add(row)
-# # db.flush()
-
-# # created_rows.append({
-# # "id": row.id,
-# # "resource_id": row.resource_id,
-# # "book_id": row.book_id,
-# # "title": row.title,
-# # "file_name": row.file_name,
-# # "image_url": _image_url(res, row.file_name),
-# # })
-
-# # # --- 4. Commit ONCE ---
-# # touch_resource(db, resource_id, actor_user_id)
-# # db.commit()
-
-# # return {"created": created_rows}, errors
-
-
-# # def list_infographic_items(
-# # db: Session,
-# # params: schema.InfographicListParams
-# # ) -> Tuple[List[schema.InfographicOut], schema.Pagination, int]:
-
-# # """List infographic items."""
-# # page = params.page
-# # limit = params.limit
-# # book_id = params.book_id
-# # resource_id = params.resource_id
-# # search = params.search
-
-# # if resource_id is not None:
-# # res = db.query(db_models.Resource).filter_by(resource_id=resource_id).first()
-# # if not res:
-# # raise ValueError("RESOURCE_NOT_FOUND")
-# # if (res.content_type or "").lower() != "infographics":
-# # raise ValueError("INVALID_RESOURCE_TYPE")
-
-# # q = db.query(db_models.Infographic)
-
-# # if book_id:
-# # q = q.filter(db_models.Infographic.book_id == book_id)
-
-# # if resource_id:
-# # q = q.filter(db_models.Infographic.resource_id == resource_id)
-
-# # if search:
-# # q = q.filter(
-# # sqlalchemy.func.lower(db_models.Infographic.title)
-# # .like(f"%{search.lower()}%")
-# # )
-
-# # total = q.count()
-# # items = q.offset((page - 1) * limit).limit(limit).all()
-
-# # res_map = {
-# # r.resource_id: r
-# # for r in db.query(db_models.Resource)
-# # .filter(db_models.Resource.resource_id.in_({i.resource_id for i in items}))
-# # .all()
-# # }
-
-# # data = [
-# # schema.InfographicOut(
-# # id=i.id,
-# # resource_id=i.resource_id,
-# # book_id=i.book_id,
-# # title=i.title,
-# # file_name=i.file_name,
-# # image_url=_image_url(res_map.get(i.resource_id), i.file_name),
-# # )
-# # for i in items
-# # ]
-
-# # total_pages = (total + limit - 1) // limit
-
-# # pagination = schema.Pagination(
-# # current_page=page,
-# # total_pages=total_pages,
-# # total_items=total,
-# # items_per_page=limit,
-# # has_next=page < total_pages,
-# # has_previous=page > 1,
-# # )
-
-# # return data, pagination, total
-
-
-# # def get_one_infographics(
-# # db: Session,
-# # infographic_id: int
-# # ) -> Optional[schema.InfographicOut]:
-# # """
-# # Get single infographic by ID.
-# # """
-# # row = db.query(db_models.Infographic).filter_by(id=infographic_id).first()
-# # if not row:
-# # return None
-
-# # res = (
-# # db.query(db_models.Resource)
-# # .filter_by(resource_id=row.resource_id)
-# # .first()
-# # )
-
-# # return schema.InfographicOut(
-# # id=row.id,
-# # resource_id=row.resource_id,
-# # book_id=row.book_id,
-# # title=row.title,
-# # file_name=row.file_name,
-# # image_url=_image_url(res, row.file_name),
-# # )
-
-
-# # def _validate_resource(db: Session, resource_id: int) -> db_models.Resource:
-# # """Validate resource exists and is of type 'infographics'."""
-# # res = db.query(db_models.Resource).filter_by(resource_id=resource_id).first()
-# # if not res:
-# # raise ValueError("RESOURCE_NOT_FOUND")
-# # if (res.content_type or "").lower() != "infographics":
-# # raise ValueError("INVALID_RESOURCE_TYPE")
-# # return res
-
-# # def _get_valid_book_ids(db: Session, book_ids: set) -> set:
-# # """Return set of valid book IDs from BookLookup."""
-# # if not book_ids:
-# # return set()
-# # result = db.query(db_models.BookLookup.book_id).filter(
-# # db_models.BookLookup.book_id.in_(book_ids)
-# # ).all()
-# # return {b.book_id for b in result}
-
-# # def _process_infographic_item(
-# # item: schema.InfographicUpdateItem,
-# # ctx: schema.InfographicProcessContext
-# # ) -> tuple[dict | None, dict | None]:
-# # """
-# # Process one infographic item.
-# # Returns tuple: (updated_item_dict, error_dict)
-# # Only one of the two is non-None.
-# # """
-# # try:
-# # row = ctx.db.query(db_models.Infographic).filter(
-# # db_models.Infographic.id == item.id,
-# # db_models.Infographic.resource_id == ctx.resource_id
-# # ).with_for_update(nowait=False).first()
-
-# # if not row:
-# # return None, {
-# # "index": ctx.idx,
-# # "data": item.model_dump(by_alias=True),
-# # "error": {
-# # "code": "NOT_FOUND",
-# # "message": f"Infographic id {item.id} not found for resource {ctx.resource_id}"
-# # },
-# # }
-
-# # tgt_book = item.book_id if item.book_id is not None else row.book_id
-# # tgt_title = item.title if item.title is not None else row.title
-# # tgt_file = item.file_name if item.file_name is not None else row.file_name
-
-# # tmp_obj = schema.InfographicUpdateItem(
-# # id=item.id,
-# # book_id=tgt_book,
-# # title=tgt_title,
-# # file_name=tgt_file,
-# # )
-# # v_err = validate_item(ctx.idx, tmp_obj)
-# # if v_err:
-# # return None, v_err
-
-# # if tgt_book not in ctx.valid_books and ctx.valid_books:
-# # return None, {
-# # "index": ctx.idx,
-# # "data": item.model_dump(by_alias=True),
-# # "error": {"code": "INVALID_BOOK", "message": f"Invalid book_id {tgt_book}"}
-# # }
-
-# # dup = ctx.db.query(db_models.Infographic).filter(
-# # db_models.Infographic.resource_id == ctx.resource_id,
-# # db_models.Infographic.book_id == tgt_book,
-# # db_models.Infographic.title == tgt_title,
-# # db_models.Infographic.file_name == tgt_file,
-# # db_models.Infographic.id != row.id
-# # ).first()
-# # if dup:
-# # return None, {
-# # "index": ctx.idx,
-# # "data": item.model_dump(by_alias=True),
-# # "error": {"code": "DUPLICATE_ENTRY", "message": "Duplicate infographic exists"}
-# # }
-
-# # row.book_id = tgt_book
-# # row.title = tgt_title
-# # row.file_name = tgt_file
-# # row.updated_by = ctx.actor_user_id
-# # ctx.db.add(row)
-# # ctx.db.flush()
-
-# # updated_item = {
-# # "id": row.id,
-# # "resource_id": row.resource_id,
-# # "book_id": row.book_id,
-# # "title": row.title,
-# # "file_name": row.file_name,
-# # "image_url": _image_url(ctx.res, row.file_name),
-# # }
-# # return updated_item, None
-
-# # except (IntegrityError, SQLAlchemyError, ValueError, TypeError) as ex:
-# # ctx.db.rollback()
-# # return None, {
-# # "index": ctx.idx,
-# # "data": item.model_dump(by_alias=True),
-# # "error": {"code": "INTERNAL_SERVER_ERROR", "message": str(ex)}
-# # }
-
-
-# # def update_infographic_batch(
-# # db: Session,
-# # payload: schema.BatchInfographicUpdateIn,
-# # actor_user_id: int
-# # ):
-# # """
-# # Update infographic batch using ctx pattern.
-# # """
-# # resource_id = payload.resource_id
-
-# # # --- 1. Validate resource ---
-# # res = _validate_resource(db, resource_id)
-
-# # # --- 2. Preload valid book ids ---
-# # all_book_ids = {i.book_id for i in payload.infographics if i.book_id is not None}
-# # valid_books = _get_valid_book_ids(db, all_book_ids)
-
-# # updated = []
-# # errors = []
-
-# # # --- 3. Process each infographic item ---
-# # for idx, item in enumerate(payload.infographics):
-# # # Build context object
-# # ctx = schema.InfographicProcessContext(
-# # db=db,
-# # res=res,
-# # resource_id=resource_id,
-# # valid_books=valid_books,
-# # actor_user_id=actor_user_id,
-# # idx=idx
-# # )
-# # u, e = _process_infographic_item(item, ctx)
-# # if u:
-# # updated.append(u)
-# # if e:
-# # errors.append(e)
-
-# # # --- 4. Commit updates if any ---
-# # if updated:
-# # try:
-# # touch_resource(db, resource_id, actor_user_id)
-# # db.commit()
-# # except (SQLAlchemyError, ValueError, TypeError) as ex:
-# # db.rollback()
-# # # convert all updated rows into errors
-# # for idx, item in enumerate(updated):
-# # errors.append({
-# # "index": idx,
-# # "data": item,
-# # "error": {"code": "INTERNAL_SERVER_ERROR", "message": str(ex)}
-# # })
-# # updated = []
-
-# # # --- 5. Return normalized shape ---
-# # return {"updated": updated, "errors": errors}
-
-# # def delete_bulk(db: Session, ids: List[int]) -> List[int]:
-# # """
-# # Delete multiple infographics by IDs.
-# # """
-# # rows = db.query(db_models.Infographic).filter(db_models.Infographic.id.in_(ids)).all()
-# # if not rows:
-# # return []
-# # deleted = [r.id for r in rows]
-# # for r in rows:
-# # db.delete(r)
-# # db.commit()
-# # return deleted
-
-# # def delete_bulk_details(db: Session, ids: List[int]) -> dict:
-# # """Delete infographics in bulk, return response with deleted & invalid IDs."""
-# # deleted_ids = []
-# # invalid_ids = []
-
-# # # fetch existing rows
-# # rows = db.query(db_models.Infographic).filter(db_models.Infographic.id.in_(ids)).all()
-# # existing_ids = {r.id for r in rows}
-
-# # # delete existing rows
-# # for r in rows:
-# # db.delete(r)
-# # deleted_ids.append(r.id)
-# # db.commit()
-
-# # # collect invalid / not found IDs
-# # invalid_ids = [i for i in ids if i not in existing_ids]
-
-# # response = {
-# # "deletedCount": len(deleted_ids),
-# # "deletedIds": deleted_ids,
-# # "message": f"Successfully deleted {len(deleted_ids)} infographic(s)"
-# # }
-
-# # if invalid_ids:
-# # response["error"] = f"Invalid infographic_ids: {', '.join(map(str, invalid_ids))}"
-
-# # return response
-
-
-
-# # verse of the day CRUD functions
-
-
-
-
-# # def get_all_verse_of_the_day(db_session: Session):
-# # """
-# # Fetch all verses from the VerseOfTheDay table.
-# # Return year, month, date, book_code, chapter, verse, id from DB.
-# # """
-# # verses = (
-# # db_session.query(db_models.VerseOfTheDay)
-# # .order_by(db_models.VerseOfTheDay.year,
-# # db_models.VerseOfTheDay.month,
-# # db_models.VerseOfTheDay.day)
-# # .all()
-# # )
-
-# # verse_list = [
-# # {
-# # "id": str(v.id),
-# # "year": v.year,
-# # "month": v.month,
-# # "date": v.day,
-# # "book_code": v.book_code,
-# # "chapter": v.chapter,
-# # "verse": v.verse
-# # }
-# # for v in verses
-# # ]
-# # return {
-# # "success": True,
-# # "data": {
-# # "verses": verse_list,
-# # "total": len(verse_list)
-# # }
-# # }
-
-
-# # def get_verse_for_date(db_session: Session, year: int, month: int, day: int):
-# # """
-# # Get one verse (with id) for a specific date from VerseOfTheDay table.
-# # """
-# # verse_entry = (
-# # db_session.query(db_models.VerseOfTheDay)
-# # .filter_by(year=year, month=month, day=day)
-# # .first()
-# # )
-
-# # if not verse_entry:
-# # raise NotAvailableException(detail=f"No verse found for {year}-{month}-{day}")
-
-# # return {
-# # "success": True,
-# # "data": {
-# # "id": str(verse_entry.id),
-# # "year": verse_entry.year,
-# # "month": verse_entry.month,
-# # "day": verse_entry.day,
-# # "book_code": verse_entry.book_code,
-# # "chapter": verse_entry.chapter,
-# # "verse": verse_entry.verse
-# # }
-# # }
-
-
-# # def upload_verse_of_the_day_csv(db_session: Session, file: UploadFile):
-# # """
-# # Deletes all old entries and uploads CSV for Verse Of The Day.
-# # Returns 200, 207, or 400 depending on outcome.
-# # """
-
-# # # --- Step 1: Read + Validate CSV ---
-# # reader = _read_votd_csv(file)
-
-# # # --- Step 2: Delete old records + Reset sequence ---
-# # deleted_count = _reset_votd_table(db_session)
-
-# # created_count = 0
-# # failed_count = 0
-# # errors = []
-
-# # # --- Step 3: Process CSV rows ---
-# # for row_num, row in enumerate(reader, start=2):
-# # try:
-# # parsed = _parse_votd_row(row)
-# # _validate_votd_row(db_session, parsed)
-
-# # new_entry = db_models.VerseOfTheDay(**parsed)
-# # db_session.add(new_entry)
-# # created_count += 1
-
-# # except (ValueError, KeyError, IntegrityError) as e:
-# # failed_count += 1
-# # errors.append({"row": row_num, "reason": str(e)})
-
-# # db_session.commit()
-
-# # # --- Step 4: Build Response ---
-# # return _build_votd_response(
-# # deleted_count=deleted_count,
-# # created_count=created_count,
-# # failed_count=failed_count,
-# # errors=errors
-# # )
-# # def _read_votd_csv(file: UploadFile):
-# # try:
-# # content = file.file.read().decode("utf-8")
-# # reader = csv.DictReader(StringIO(content))
-
-# # required = {"year", "month", "date", "book_code", "chapter", "verse"}
-# # if not reader.fieldnames or not required.issubset(set(reader.fieldnames)):
-# # raise ValueError("Invalid CSV file format or missing required columns")
-
-# # return reader
-
-# # except Exception as exc:
-# # raise TypeException(
-# # detail={
-# # "success": False,
-# # "error": {
-# # "code": "INVALID_FILE",
-# # "message": "Could not parse the CSV file"
-# # }
-# # }
-# # ) from exc
-
-# # def _reset_votd_table(db_session: Session):
-# # deleted = db_session.query(db_models.VerseOfTheDay).delete()
-# # db_session.commit()
-
-# # seq = db_session.execute(
-# # text("SELECT pg_get_serial_sequence('verse_of_the_day', 'id');")
-# # ).scalar()
-
-# # if seq:
-# # db_session.execute(text(f"ALTER SEQUENCE {seq} RESTART WITH 1;"))
-# # db_session.commit()
-
-# # return deleted
-# # def _parse_votd_row(row: dict) -> dict:
-# # try:
-# # return {
-# # "year": int(row["year"]),
-# # "month": int(row["month"]),
-# # "day": int(row["date"]),
-# # "book_code": row["book_code"].strip().lower(),
-# # "chapter": int(row["chapter"]),
-# # "verse": int(row["verse"]),
-# # }
-# # except Exception as ex:
-# # raise ValueError(f"Invalid row values: {ex}") from ex
-
-# # def _validate_votd_row(db_session: Session, parsed: dict):
-# # if not 1 <= parsed["month"] <= 12:
-# # raise ValueError(f"Invalid month: {parsed['month']}")
-
-# # if not 1 <= parsed["day"] <= 31:
-# # raise ValueError(f"Invalid date: {parsed['day']}")
-
-# # book_exists = db_session.query(db_models.BookLookup).filter(
-# # db_models.BookLookup.book_code.ilike(parsed["book_code"])
-# # ).first()
-
-# # if not book_exists:
-# # raise ValueError(
-# # f"Invalid book code: '{parsed['book_code']}' not found in book_lookup table")
-# # def _build_votd_response(deleted_count, created_count, failed_count, errors):
-# # if failed_count == 0:
-# # return {
-# # "success": True,
-# # "data": {
-# # "deleted_count": deleted_count,
-# # "created_count": created_count,
-# # "failed_count": 0,
-# # "errors": [],
-# # },
-# # "message": "CSV uploaded. Old entries cleared and new ones created.",
-# # }
-
-# # if created_count > 0:
-# # raise MultiStatus(
-# # detail={
-# # "success": False,
-# # "data": {
-# # "deleted_count": deleted_count,
-# # "created_count": created_count,
-# # "failed_count": failed_count,
-# # "errors": errors,
-# # },
-# # "message": "CSV uploaded with some errors. Check errors array for details.",
-# # },
-# # )
-
-# # raise UnprocessableException(
-# # detail={
-# # "success": False,
-# # "error": {
-# # "code": "INVALID_DATA",
-# # "message": "All rows in CSV invalid or could not be processed",
-# # "errors": errors,
-# # },
-# # },
-# # )
-
-
-
-# # def delete_all_verse_of_the_day(db_session: Session):
-# # """
-# # Deletes all entries from verse_of_the_day table.
-# # """
-# # try:
-# # deleted_count = db_session.query(db_models.VerseOfTheDay).delete()
-# # db_session.commit()
-# # # Reset sequence dynamically
-# # seq_name = db_session.execute(
-# # text("SELECT pg_get_serial_sequence('verse_of_the_day', 'id');")
-# # ).scalar()
-
-# # if seq_name:
-# # db_session.execute(text(f"ALTER SEQUENCE {seq_name} RESTART WITH 1;"))
-# # db_session.commit()
-# # return {
-# # "success": True,
-# # "data": {
-# # "deleted_count": deleted_count
-# # },
-# # "message": "All verse of the day entries deleted successfully"
-# # }
-# # except SQLAlchemyError as e:
-# # db_session.rollback()
-# # raise GenericException(
-# # detail={
-# # "success": False,
-# # "error": {
-# # "code": "INTERNAL_ERROR",
-# # "message": "Failed to delete entries"
-# # }
-# # },
-# # ) from e
-
-
-# # # --- Reading plan CRUD ---
-# # def parse_json_file(content: bytes) -> List[Dict[str, Any]]:
-# # """
-# # Parse a JSON file and return a list of entries."""
-# # try:
-# # raw = json.loads(content.decode("utf-8"))
-# # except json.JSONDecodeError as exc:
-# # raise TypeException(
-# # detail=f"Invalid JSON: {str(exc)}",
-# # ) from exc
-
-# # if not isinstance(raw, list):
-# # raise TypeException(
-# # detail="Invalid JSON: expected list of entries",
-# # )
-
-# # if not raw:
-# # raise TypeException(
-# # detail="JSON file is empty",
-# # )
-
-# # return raw
-
-
-# # def parse_csv_file(content: bytes) -> List[Dict[str, Any]]:
-# # """
-# # Parse a CSV file and return a list of entries."""
-# # try:
-# # csv_text = content.decode("utf-8")
-# # except UnicodeDecodeError as exc:
-# # raise TypeException(
-# # detail="File must be UTF-8 encoded",
-# # ) from exc
-
-# # rows = []
-# # reader = csv.DictReader(io.StringIO(csv_text))
-
-# # for row in reader:
-# # if "date" not in row or "reading" not in row:
-# # raise BadRequestException(
-# # detail="CSV must contain 'date' and 'reading' columns",
-# # )
-
-# # try:
-# # readings = (
-# # json.loads(row["reading"])
-# # if isinstance(row["reading"], str)
-# # else row["reading"]
-# # )
-# # except json.JSONDecodeError as exc:
-# # raise TypeException(
-# # detail=f"Invalid JSON in reading field for date {row.get('date')}",
-# # ) from exc
-
-# # rows.append({"date": row["date"], "reading": readings})
-
-# # if not rows:
-# # raise TypeException(
-# # detail="CSV contains no valid rows",
-# # )
-
-# # return rows
-
-
-# # def validate_and_process_entry(entry, index, db, counts):
-# # """
-# # Validate and process a single entry from the input file."""
-# # created, updated, skipped = counts
-
-# # if not isinstance(entry, dict):
-# # logger.warning("Entry %s is not a dict; skipping", index)
-# # return (created, updated, skipped + 1)
-
-# # date_str = entry.get("date")
-# # readings = entry.get("reading")
-
-# # if not date_str or not isinstance(readings, list) or not readings:
-# # logger.warning("Invalid entry at index %s; skipping", index)
-# # return (created, updated, skipped + 1)
-
-# # try:
-# # month, day = map(int, date_str.split("-"))
-# # datetime(2024, month, day)
-# # except (ValueError, TypeError):
-# # logger.warning("Invalid date format at index %s: %s", index, date_str)
-# # return (created, updated, skipped + 1)
-
-# # existing = (
-# # db.query(db_models.ReadingPlan)
-# # .filter_by(month=month, day=day)
-# # .first()
-# # )
-
-# # if existing:
-# # existing.readings = readings
-# # updated += 1
-# # else:
-# # db.add(db_models.ReadingPlan(month=month, day=day, readings=readings))
-# # created += 1
-
-# # return (created, updated, skipped)
-# # def upload_reading_plans(
-# # db: Session,
-# # file_content: bytes,
-# # file_type: str
-# # ) -> Dict[str, int]:
-# # """
-# # Upload reading plans from JSON or CSV file."""
-# # try:
-# # # Parse input file
-# # if file_type == "json":
-# # data = parse_json_file(file_content)
-# # elif file_type == "csv":
-# # data = parse_csv_file(file_content)
-# # else:
-# # raise TypeException(
-# # detail="Unsupported file type; must be JSON or CSV",
-# # )
-
-# # counts = (0, 0, 0) # created, updated, skipped
-
-# # # Process each entry
-# # for idx, entry in enumerate(data):
-# # counts = validate_and_process_entry(entry, idx, db, counts)
-
-# # created_count, updated_count, skipped_count = counts
-
-# # # Error if no valid entries
-# # if created_count == 0 and updated_count == 0:
-# # if skipped_count > 0:
-# # raise TypeException(
-# # detail=(
-# # f"All {skipped_count} entries were invalid. "
-# # "Expected format: {'date': 'MM-DD', 'reading': [...]}."
-# # ),
-# # )
-# # raise TypeException(
-# # detail="No valid entries found",
-# # )
-
-# # db.commit()
-
-# # return {
-# # "created": created_count,
-# # "updated": updated_count,
-# # "skipped": skipped_count,
-# # "total": created_count + updated_count,
-# # }
-
-# # except HTTPException:
-# # raise
-# # except Exception as exc:
-# # db.rollback()
-# # raise GenericException(
-# # detail=f"Unexpected error: {str(exc)}",
-# # ) from exc
-
-# # def get_reading_plans(
-# # db: Session,
-# # month: Optional[int] = None,
-# # day: Optional[int] = None
-# # ) -> List[db_models.ReadingPlan]:
-# # """
-# # Get reading plans. If month and day are provided, return specific date.
-# # Otherwise, return all reading plans.
-# # """
-# # query = db.query(db_models.ReadingPlan)
-
-# # if month is not None and day is not None:
-# # # Validate date
-# # try:
-# # datetime(2024, month, day)
-# # except ValueError as exc:
-# # raise TypeException(
-# # detail=f"Invalid date: month={month}, day={day}"
-# # ) from exc
-
-
-# # query = query.filter_by(month=month, day=day)
-# # result = query.first()
-
-# # if not result:
-# # raise NotAvailableException(
-# # detail=f"No reading plan found for {month:02d}-{day:02d}"
-# # )
-
-# # return [result]
-
-# # # Return all plans ordered by month and day
-# # return query.order_by(
-# # db_models.ReadingPlan.month,
-# # db_models.ReadingPlan.day
-# # ).all()
-
-
-# # def delete_all_reading_plans(db: Session) -> int:
-# # """
-# # Delete all reading plans from the database.
-# # Returns the count of deleted records.
-# # """
-# # try:
-# # count = db.query(db_models.ReadingPlan).count()
-# # db.query(db_models.ReadingPlan).delete()
-# # db.commit()
-# # return count
-# # except Exception as e:
-# # db.rollback()
-# # logger.error("Error deleting reading plans: %s", e)
-# # raise GenericException(
-# # detail=f"Error deleting reading plans: {str(e)}"
-# # ) from e
-
-
-# #validate_html for commentaries
-# # def validate_html(html_text: str):
-# # """
-# # Validates commentary HTML with strict rules:
-# # - No unclosed tags
-# # - No broken tags like
-# # - No missing closing ,
, etc.
-# # - Only allowed tags are permitted
-# # """
-# # if not html_text or not html_text.strip():
-# # return
-
-# # if "<" not in html_text and ">" not in html_text:
-# # raise UnprocessableException(
-# # detail="no html tags found"
-# # )
-
-# # allowed_tags = {"p", "strong", "img", "br", "sup", "em", "b", "i", "u"}
-# # void_tags = {"br", "img", "hr", "meta", "link", "input"}
-
-# # tag_pattern = re.compile(r"?([a-zA-Z0-9]+)[^>]*>")
-
-# # # ---- Helper function for stack-based tag validation ----
-# # def _check_tag_stack(content: str):
-# # stack = []
-# # for match in tag_pattern.finditer(content):
-# # tag = match.group(1).lower()
-# # full_tag = match.group(0)
-
-# # if tag in void_tags:
-# # continue
-
-# # if full_tag.startswith(""):
-# # if not stack or stack[-1] != tag:
-# # raise UnprocessableException(
-# # detail=f"Closing tag {tag}> is mismatched or missing opener"
-# # )
-# # stack.pop()
-# # else:
-# # stack.append(tag)
-
-# # if stack:
-# # raise UnprocessableException(
-# # detail=f"Unclosed tag(s): {stack}"
-# # )
-
-# # # ---- Validate each tag individually ----
-# # for match in tag_pattern.finditer(html_text):
-# # tag = match.group(1).lower()
-# # full_tag = match.group(0)
-
-# # if tag not in allowed_tags:
-# # raise UnprocessableException(
-# # detail=f"Invalid HTML tag '{full_tag}'"
-# # )
-
-# # if full_tag.startswith(f"<{tag}") and not re.match(rf"?{tag}\b", full_tag):
-# # raise UnprocessableException(
-# # detail=f"Malformed HTML tag '{full_tag}'"
-# # )
-
-# # # ---- Parse with html5lib ----
-# # try:
-# # BeautifulSoup(html_text, "html5lib")
-# # except Exception as e:
-# # raise UnprocessableException(
-# # detail=f"Invalid HTML: {str(e)}"
-# # ) from e
-
-# # # ---- Run stack-based tag check ----
-# # _check_tag_stack(html_text)
-
-# # # Convert GitHub URLs to raw URLs
-# # def convert_to_raw_url(url: str) -> str:
-# # """Convert GitHub tree/blob URLs into raw.githubusercontent.com URLs."""
-# # if not url:
-# # return None
-
-# # if "github.com" in url:
-# # url = url.replace("github.com", "raw.githubusercontent.com")
-# # url = url.replace("/tree/", "/")
-# # url = url.replace("/blob/", "/")
-
-# # return url.rstrip("/")
-
-# # # Extract base_url from resource.meta_data
-# # def extract_base_url(resource):
-# # """Extract base_url from resource.meta_data JSON."""
-# # try:
-# # raw = resource.meta_data
-# # data = raw if isinstance(raw, dict) else json.loads(raw)
-
-# # base = None
-
-# # # meta_data can have nested structure
-# # if isinstance(data.get("base_url"), dict):
-# # base = data["base_url"].get("base_url")
-# # else:
-# # base = data.get("base_url")
-
-# # if not base:
-# # raise ValueError("base_url missing")
-
-
-# # base = convert_to_raw_url(base)
-# # return base
-
-# # except (ValueError, TypeError):
-# # return None
-# # async def check_infographics_by_language(db: Session, language_code: str):
-# # """
-# # For a given language_code:
-# # - Collect ALL resources of type 'infographics'
-# # - For each resource:
-# # - If base_url is missing → mark ALL its infographic entries as missing
-# # - If base_url exists → perform URL HEAD checks for each file
-# # """
-
-# # # Fetch ALL infographic resources for this language
-# # resources = (
-# # db.query(db_models.Resource)
-# # .join(db_models.Language)
-# # .filter(
-# # db_models.Language.language_code == language_code,
-# # db_models.Resource.content_type == "infographics"
-# # )
-# # .all()
-# # )
-
-# # if not resources:
-# # raise NotAvailableException(
-# # detail="No infographic resource found for this language"
-# # )
-
-# # missing_list = []
-# # total_checked = 0
-
-# # async with httpx.AsyncClient(timeout=10.0) as client:
-
-# # for resource in resources:
-
-# # # Get all infographic entries for this resource
-# # entries = (
-# # db.query(db_models.Infographic)
-# # .filter(db_models.Infographic.resource_id == resource.resource_id)
-# # .all()
-# # )
-
-# # total_checked += len(entries)
-
-# # # CASE 1: NO BASE_URL
-# # base_url = extract_base_url(resource)
-
-# # if not base_url:
-# # for e in entries:
-# # missing_list.append(
-# # schema.MissingInfographic(
-# # id=e.id,
-# # resource_id=e.resource_id,
-# # file_name=e.file_name,
-# # missing_full=True,
-# # missing_thumb=True,
-# # reason="base_url not found in resource metadata"
-# # )
-# # )
-# # continue # move to next resource
-
-# # # CASE 2: BASE_URL EXISTS → check remote URLs
-# # async def check_file(entry, base_url=base_url):
-# # file_path = entry.file_name
-# # full_url = f"{base_url}/{file_path}"
-# # thumb_url = f"{base_url}/thumbs/{file_path}"
-
-
-# # try:
-# # full_head = await client.head(full_url)
-# # except httpx.RequestError:
-# # full_head = httpx.Response(status_code=404)
-
-# # try:
-# # thumb_head = await client.head(thumb_url)
-# # except httpx.RequestError:
-# # thumb_head = httpx.Response(status_code=404)
-
-# # missing_full = full_head.status_code != 200
-# # missing_thumb = thumb_head.status_code != 200
-
-# # if missing_full or missing_thumb:
-# # missing_list.append(
-# # schema.MissingInfographic(
-# # id=entry.id,
-# # resource_id=entry.resource_id,
-# # file_name=entry.file_name,
-# # missing_full=missing_full,
-# # missing_thumb=missing_thumb,
-# # reason=None
-# # )
-# # )
-
-# # await asyncio.gather(*(check_file(e) for e in entries))
-
-# # # Final combined response
-# # return schema.InfographicCheckResponse(
-# # success=True,
-# # language_code=language_code,
-# # checked=total_checked,
-# # missing=missing_list
-# # )
-
-# # def get_audit_logs(
-# # db_session: Session,
-# # page: int = 0,
-# # page_size: int = 100,
-# # **filters
-# # ) -> tuple[list[db_models.AuditLog], int]:
-# # """
-# # Retrieve audit logs with optional filtering and pagination.
-
-# # Filters supported via kwargs:
-# # - user_id: int
-# # - method: str
-# # - path: str (partial match)
-# # - status_code: str or int
-# # - date_from: datetime
-# # - date_to: datetime
-
-# # Returns:
-# # tuple of (list of AuditLog rows, total count)
-# # """
-# # query = db_session.query(db_models.AuditLog)
-
-# # user_id = filters.get("user_id")
-# # method = filters.get("method")
-# # path = filters.get("path")
-# # status_code = filters.get("status_code")
-# # date_from = filters.get("date_from")
-# # date_to = filters.get("date_to")
-
-# # if user_id is not None:
-# # query = query.filter(db_models.AuditLog.user_id == user_id)
-# # if method:
-# # query = query.filter(db_models.AuditLog.method == method.upper())
-# # if path:
-# # query = query.filter(db_models.AuditLog.path.ilike(f"%{path}%"))
-# # if status_code:
-# # query = query.filter(db_models.AuditLog.status_code == int(status_code))
-# # if date_from is not None:
-# # query = query.filter(db_models.AuditLog.created_at >= date_from)
-# # if date_to is not None:
-# # query = query.filter(db_models.AuditLog.created_at <= date_to)
-
-# # total = query.count()
-
-# # rows = (
-# # query.order_by(db_models.AuditLog.created_at.desc())
-# # .offset(page * page_size)
-# # .limit(page_size)
-# # .all()
-# # )
-
-# # return rows, total
-
-
-# # def validate_video_item(db, item):
-# # """
-# # Validate a single video item."""
-# # errors = []
-
-# # book_val = (item.book or "").strip().lower()
-
-# # if book_val in ("ot", "nt"):
-# # # chapter is allowed to be anything (or None)
-# # if item.chapter is not None and item.chapter > 175:
-# # errors.append(f"Invalid chapter {item.chapter} for book {item.book}.For OT/NT, chapter cannot be greater than 175.")
-# # return errors
-
-# # # Check against book_code or book_name (case-insensitive)
-# # book_obj = (
-# # db.query(db_models.BookLookup)
-# # .filter(
-# # (db_models.BookLookup.book_code.ilike(book_val)) |
-# # (db_models.BookLookup.book_name.ilike(book_val))
-# # )
-# # .first()
-# # )
-
-# # if not book_obj:
-# # errors.append(
-# # f"Invalid book '{item.book}'. Must be OT, NT, or a valid Bible book "
-# # f"(book_code or book_name)."
-# # )
-# # return errors
-
-# # # --- 3) Chapter validation ---
-# # if item.chapter is not None:
-# # try:
-# # chapter_int = int(item.chapter)
-# # except ValueError:
-# # errors.append("chapter must be a number")
-# # return errors
-
-# # if chapter_int < 0:
-# # errors.append("chapter must be ≥ 0")
-# # elif chapter_int > book_obj.chapter_count:
-# # errors.append(
-# # f"chapter {chapter_int} exceeds max chapters "
-# # f"({book_obj.chapter_count}) for '{book_obj.book_name}'."
-# # )
-
-# # return errors
-# # def test_commentary_images(db, resource_id: int):
-# # """
-# # Test if all commentary images exist.
-# # """
-
-# # # 1. Fetch resource
-# # res = db.query(db_models.Resource).filter_by(resource_id=resource_id).first()
-# # if not res:
-# # raise NotAvailableException(detail="Resource not found")
-
-# # base_url = extract_base_url(res)
-# # if not base_url:
-# # raise UnprocessableException(detail="Base URL missing in resource metadata")
-
-# # # Helper: extract all image filenames
-# # def collect_filenames():
-# # filenames = set()
-# # for row in (
-# # db.query(db_models.Commentary)
-# # .filter(db_models.Commentary.resource_id == resource_id)
-# # .all()
-# # ):
-# # soup = BeautifulSoup(row.text, "html.parser")
-# # for img in soup.find_all("img"):
-# # src = img.get("src", "")
-# # fname = (
-# # src.split("/")[-1]
-# # if src.startswith(("http://", "https://"))
-# # else src.strip()
-# # )
-# # filenames.add((row.book_id, row.chapter, row.verse, fname))
-# # return filenames
-
-# # # Helper: remote check
-# # def remote_exists(url: str) -> bool:
-# # try:
-# # response = requests.head(url, timeout=5)
-# # return response.status_code == 200
-# # except requests.RequestException:
-# # return False
-
-# # all_filenames = collect_filenames()
-
-# # report = []
-# # for book_id, chapter, verse, fname in all_filenames:
-# # url = f"{base_url}/{fname}"
-# # exists = remote_exists(url)
-# # report.append(
-# # {
-# # "bookId": book_id,
-# # "chapter": chapter,
-# # "verse": verse,
-# # "file_name": fname,
-# # "present": exists,
-# # }
-# # )
-
-# # images_present = sum(1 for item in report if item["present"])
-
-# # return {
-# # "success": True,
-# # "resource_id": resource_id,
-# # "base_url": base_url,
-# # "total_images": len(all_filenames),
-# # "images_present": images_present,
-# # "images": report,
-# # }
-
-# # def validate_commentary_book_and_chapter(db: Session, book_id: int, chapter: int):
-# # """
-# # Validates that:
-# # - book_id exists in BookLookup table
-# # - chapter does NOT exceed chapter_count for that book
-# # """
-
-# # # 1. Check book exists
-# # book = (
-# # db.query(db_models.BookLookup)
-# # .filter(db_models.BookLookup.book_id == book_id)
-# # .first()
-# # )
-
-# # if not book:
-# # raise NotAvailableException(
-# # detail=f"Invalid book_id {book_id}. Book does not exist in BookLookup."
-# # )
-
-# # if chapter < 0:
-# # raise BadRequestException(
-# # detail=f"Chapter must be >= 0 for book_id {book_id}."
-# # )
-
-# # if chapter > book.chapter_count:
-# # raise BadRequestException(
-# # detail=(
-# # f"Invalid chapter {chapter} for book_id {book_id}. "
-# # f"Max allowed chapter is {book.chapter_count}."
-# # )
-# # )
-
-# # def validate_audio_bible_books(db, books: dict):
-# # """
-# # Validate Audio Bible books:
-# # - book_code must exist in BookLookup (case-insensitive)
-# # - chapter count must be <= chapter_count in BookLookup
-# # """
-
-# # errors = []
-
-# # for book_code, chapter_count in books.items():
-# # bc = book_code.strip().lower()
-
-# # # --- 1. Check if book code exists in BookLookup ---
-# # book_obj = (
-# # db.query(db_models.BookLookup)
-# # .filter(db_models.BookLookup.book_code.ilike(bc))
-# # .first()
-# # )
-
-# # if not book_obj:
-# # errors.append(
-# # f"Invalid book code '{book_code}'. Must match book_code in BookLookup table."
-# # )
-# # continue
-
-# # # --- 2. Validate chapter_count is integer ---
-# # if not isinstance(chapter_count, int) or chapter_count <= 0:
-# # errors.append(
-# # f"Chapter count for '{book_code}' must be a positive integer."
-# # )
-# # continue
-
-# # # --- 3. Validate chapter_count does not exceed Bible max ---
-# # if chapter_count > book_obj.chapter_count:
-# # errors.append(
-# # f"Chapter count {chapter_count} exceeds max chapters "
-# # f"({book_obj.chapter_count}) for '{book_obj.book_code}'."
-# # )
-
-# # return errors
-# # def check_audio_bible_remote(db, resource_id: int):
-# # """Checks remote DigitalOcean Spaces for missing audio files for a given Audio Bible."""
-
-# # ab = db.query(db_models.AudioBible).filter_by(resource_id=resource_id).first()
-# # if not ab:
-# # return {"error": f"Audio Bible not found for resource_id {resource_id}"}
-
-# # base = ab.base_url.rstrip("/")
-# # books = ab.books
-# # fmt = ab.format
-
-# # results = []
-# # books_found = len(books)
-# # full_books_present = 0
-# # all_missing_files = {}
-
-# # for book_code, total_chapters in books.items():
-# # missing = []
-# # present = 0
-
-# # for chap in range(1, total_chapters + 1):
-# # url = f"{base}/{book_code}/{chap}.{fmt}"
-
-# # # --- Step 1: First GET attempt ---
-# # file_exists = False
-# # try:
-# # r = requests.get(url, timeout=10, stream=True)
-# # if r.status_code == 200:
-# # file_exists = True
-# # except:
-# # pass
-
-# # # --- Step 2: Retry GET once if failed ---
-# # if not file_exists:
-# # try:
-# # time.sleep(0.15) # 150 ms CDN warm-up
-# # r = requests.get(url, timeout=10, stream=True)
-# # if r.status_code == 200:
-# # file_exists = True
-# # except:
-# # pass
-
-# # # --- Step 3: Fallback to HEAD request ---
-# # if not file_exists:
-# # try:
-# # h = requests.head(url, timeout=10, allow_redirects=True)
-# # if h.status_code == 200:
-# # file_exists = True
-# # except:
-# # pass
-
-# # # --- Final Result ---
-# # if file_exists:
-# # present += 1
-# # else:
-# # missing.append(chap)
-
-# # if len(missing) == 0:
-# # full_books_present += 1
-
-# # results.append({
-# # "book": book_code,
-# # "total_chapters": total_chapters,
-# # "present": present,
-# # "missing_chapters": missing
-# # })
-
-# # if missing:
-# # all_missing_files[book_code] = missing
-
-# # # Save results
-# # ab.files_missing = all_missing_files
-# # ab.test_date = datetime.now(timezone.utc)
-# # db.commit()
-# # return {
-# # "success": True,
-# # "resource_id": resource_id,
-# # "books_found": books_found,
-# # "full_books_present": full_books_present,
-# # "base_url": base,
-# # "test_date": ab.test_date.isoformat(),
-# # "audio_files": results
-# # }
-# # def _is_public_url(url: str) -> bool:
-# # try:
-# # r = requests.get(url, timeout=8, allow_redirects=True)
-
-# # # If non-YouTube URL → fallback logic
-# # if "youtube.com" not in r.url and "youtu.be" not in url:
-# # return r.status_code < 400
-
-# # # ---- YouTube-specific validation ----
-
-# # html = r.text.lower()
-
-# # # YouTube invalid-video markers
-# # if "video unavailable" in html or "this video is unavailable" in html:
-# # return False
-
-# # # If YouTube returns a watch page but without player → invalid
-# # if "player-unavailable" in html:
-# # return False
-
-# # # If YouTube returns 410, 404, 429 etc.
-# # if r.status_code >= 400:
-# # return False
-
-# # return True
-
-# # except requests.RequestException:
-# # return False
-
-# return {
-# "success": True,
-# "resource_id": resource_id,
-# "videos_found": len(videos),
-# "videos_public": public_count,
-# "videos": out_items
-# }
-# def validate_usfm_file(file: UploadFile) -> Dict[str, Any]:
-# """
-# Validates USFM file structure and returns validation result.
-# Returns dict with 'valid' (bool) and optional 'error' (str) keys.
-# """
-# try:
-# # 1. VALIDATE FILE EXTENSION (NEW)
-# if not file.filename:
-# raise HTTPException(status_code=422, detail="No filename provided")
-# filename_lower = file.filename.lower()
-# ACCEPTED_EXTENSIONS = ('.usfm', '.sfm')
-# if not filename_lower.endswith(ACCEPTED_EXTENSIONS):
-# raise HTTPException(status_code=422, detail=f"Invalid file type. Expected .usfm or .sfm file, got '{file.filename}'. Please upload a valid USFM file.")
-
-# # 2. READ FILE CONTENT
-# content = file.file.read()
-
-# # 3. CHECK FILE SIZE (NEW - prevent huge files)
-# max_size = 10 * 1024 * 1024 # 10 MB
-# if len(content) > max_size:
-# file.file.seek(0)
-# raise HTTPException(status_code=422, detail=f"File too large ({len(content)} bytes). Maximum allowed: {max_size} bytes")
-
-# # 4. RESET FILE POINTER
-# file.file.seek(0)
-
-# # 5. DECODE CONTENT (with better error handling)
-# try:
-# usfm_content = content.decode('utf-8')
-# except UnicodeDecodeError as e:
-# raise HTTPException(status_code=422, detail="File encoding error. USFM files must be UTF-8 encoded plain text. This may be a binary file or use unsupported encoding.")
-
-# # 6. CHECK FOR EMPTY/WHITESPACE-ONLY FILES
-# if not usfm_content.strip():
-# raise HTTPException(status_code=422, detail="USFM file is empty or contains only whitespace")
-
-# # 7. CHECK FOR BINARY CONTENT (NEW - detect non-text files)
-# # Look for null bytes or excessive control characters
-# null_count = usfm_content.count('\x00')
-# if null_count > 0:
-# raise HTTPException(status_code=422, detail="File appears to be binary, not text. USFM files must be plain text files.")
-
-# # 8. CHECK FOR REQUIRED \id MARKER
-# if '\\id' not in usfm_content:
-# raise HTTPException(status_code=422, detail="Not a valid USFM file. Missing required \\id marker. Please ensure this is a properly formatted USFM file.")
-
-# # 9. PARSE WITH USFM-GRAMMAR
-# try:
-# parser = USFMParser(usfm_content)
-# usj_data = parser.to_usj()
-
-# # Verify USJ structure
-# if not isinstance(usj_data, dict) or 'content' not in usj_data:
-# raise HTTPException(status_code=422, detail="Invalid USFM structure: Cannot parse to valid USJ format")
-
-# # Check for book code
-# book_code = None
-# for item in usj_data.get("content", []):
-# if item.get("type") == "book" and item.get("marker") == "id":
-# book_code = item.get("code")
-# break
-
-# if not book_code:
-# raise HTTPException(status_code=422, detail="USFM file must contain a valid book code in \\id marker")
-
-# # Check for chapters
-# chapter_count = len([
-# item for item in usj_data.get("content", [])
-# if item.get("type") == "chapter"
-# ])
-
-# if chapter_count == 0:
-# raise HTTPException(status_code=422, detail="USFM file must contain at least one chapter (\\c marker)")
-
-# return {
-# "valid": True,
-# "book_code": book_code,
-# "chapter_count": chapter_count
-# }
-# except HTTPException:
-# raise
-# except Exception as parse_error:
-# raise HTTPException(status_code=422, detail=f"USFM parsing error: {str(parse_error)}. Please ensure this is a valid USFM file.")
-
-# except HTTPException:
-# raise
-# except Exception as e:
-# raise HTTPException(status_code=422, detail=f"File validation error: {str(e)}")
-
-# def _resolve_book_id(db, book_code: str):
-# """Return BookLookup row (object) for given book_code (case-insensitive) or raise 422."""
-# if not book_code:
-# raise HTTPException(status_code=422, detail="book code is required")
-
-# book = (
-# db.query(db_models.BookLookup)
-# .filter(db_models.BookLookup.book_code.ilike(book_code))
-# .first()
-# )
-# if not book:
-# raise HTTPException(status_code=422, detail=f"Invalid book code '{book_code}'")
-# return book
-
-# def create_isl_videos(db, payload: schema.IslVideoCreateRequest):
-# resource = (
-# db.query(db_models.Resource)
-# .filter(db_models.Resource.resource_id == payload.resourceId)
-# .first()
-# )
-# if not resource:
-# raise NotAvailableException(detail=f"Resource {payload.resourceId} not found")
-# # Resource content_type must be 'isl'
-# if resource.content_type.lower() != "isl":
-# raise BadRequestException(
-# detail=f"Resource {payload.resourceId} is not of type 'video' (found '{resource.content_type}')"
-# )
-# created: List[schema.IslVideoResponseItem] = []
-
-# for item in payload.videos:
-# # resolve book
-# book_obj = _resolve_book_id(db, item.book)
-# # chapter validation: allow 0 .. chapter_count
-# if item.chapter < 0 or item.chapter > book_obj.chapter_count:
-# raise HTTPException(
-# status_code=422,
-# detail=f"Invalid chapter {item.chapter} for book '{item.book}'. Allowed 0..{book_obj.chapter_count}",
-# )
-
-# # check duplicate in DB
-# if item.title:
-# dup = (
-# db.query(db_models.IslVideo)
-# .filter_by(resource_id=payload.resourceId, book_id=book_obj.book_id, title=item.title)
-# .first()
-# )
-# if dup:
-# raise HTTPException(
-# status_code=409,
-# detail=f"Duplicate entry for resource_id={payload.resourceId}, book={item.book}, title={item.title}",
-# )
-
-# row = db_models.IslVideo(
-# resource_id=payload.resourceId,
-# book_id=book_obj.book_id,
-# chapter=item.chapter,
-# url=item.url,
-# title=item.title,
-# description=item.description,
-# )
-# db.add(row)
-# db.flush()
-
-# created.append(schema.IslVideoResponseItem(
-# video_id=row.id,
-# book=book_obj.book_code,
-# chapter=row.chapter,
-# url=row.url,
-# title=row.title,
-# description=row.description
-# ))
-
-# try:
-# db.commit()
-# except SQLAlchemyError as exc:
-# db.rollback()
-# raise HTTPException(status_code=500, detail=str(exc))
-
-# return {"resource_id": payload.resourceId, "videos": created}
-
-# # def _resolve_book_id(db, book_code: str):
-# # """Return BookLookup row (object) for given book_code (case-insensitive) or raise 422."""
-# # if not book_code:
-# # raise UnprocessableException(
-# # detail="book code is required")
-# # book = (
-# # db.query(db_models.BookLookup)
-# # .filter(db_models.BookLookup.book_code.ilike(book_code))
-# # .first()
-# # )
-# # if not book:
-# # raise UnprocessableException(
-# # detail=f"Invalid book code '{book_code}'")
-# # return book
-
-# def update_isl_videos(db, payload: schema.IslVideoUpdateRequest):
-# resource = (
-# db.query(db_models.Resource)
-# .filter(db_models.Resource.resource_id == payload.resourceId)
-# .first()
-# )
-# if not resource:
-# raise NotAvailableException(detail=f"Resource {payload.resourceId} not found")
-# # Resource content_type must be 'isl'
-# if resource.content_type.lower() != "isl":
-# raise BadRequestException(
-# detail=f"Resource {payload.resourceId} is not of type 'video' (found '{resource.content_type}')"
-# )
-# updated: List[schema.IslVideoResponseItem] = []
-
-# for item in payload.videos:
-# row = db.query(db_models.IslVideo).filter_by(id=item.id).first()
-# if not row:
-# raise HTTPException(status_code=404, detail=f"ISL Video id {item.id} not found")
-
-# book_obj = _resolve_book_id(db, item.book)
-
-# # chapter validation: allow 0 .. chapter_count
-# if item.chapter < 0 or item.chapter > book_obj.chapter_count:
-# raise HTTPException(
-# status_code=422,
-# detail=f"Invalid chapter {item.chapter} for book '{item.book}'. Allowed 0..{book_obj.chapter_count}",
-# )
-
-# # uniqueness check against other rows
-# if item.title:
-# conflict = (
-# db.query(db_models.IslVideo)
-# .filter(
-# db_models.IslVideo.resource_id == payload.resourceId,
-# db_models.IslVideo.book_id == book_obj.book_id,
-# db_models.IslVideo.title == item.title,
-# db_models.IslVideo.id != row.id
-# )
-# .first()
-# )
-# if conflict:
-# raise HTTPException(
-# status_code=409,
-# detail=f"Update would violate uniqueness for resource_id={payload.resourceId}, book={item.book}, title={item.title}"
-# )
-
-# # apply updates
-# row.resource_id = payload.resourceId
-# row.book_id = book_obj.book_id
-# row.chapter = item.chapter
-# row.url = item.url
-# row.title = item.title
-# row.description = item.description
-
-# updated.append(schema.IslVideoResponseItem(
-# video_id=row.id,
-# book=book_obj.book_code,
-# chapter=row.chapter,
-# url=row.url,
-# title=row.title,
-# description=row.description
-# ))
-
-# try:
-# db.commit()
-# except SQLAlchemyError as exc:
-# db.rollback()
-# raise HTTPException(status_code=500, detail=str(exc))
-
-# return {"resource_id": payload.resourceId, "videos": updated}
-
-# # # chapter validation: allow 0 .. chapter_count
-# # if item.chapter < 0 or item.chapter > book_obj.chapter_count:
-# # raise UnprocessableException(detail=f"Invalid chapter {item.chapter} for book '{item.book}'. Allowed 0 to {book_obj.chapter_count}")
-
-# def get_isl_videos(db: Session, resource_id: int,
-# book_code: Optional[str] = None,
-# chapter: Optional[int] = None):
-# """
-# Fetch ISL Bible videos grouped by book_code → chapter.
-# """
-
-# # Base query with join to BookLookup
-# query = (
-# db.query(db_models.IslVideo, db_models.BookLookup.book_code)
-# .join(db_models.BookLookup, db_models.BookLookup.book_id == db_models.IslVideo.book_id)
-# .filter(db_models.IslVideo.resource_id == resource_id)
-# )
-
-# # Filter by book_code (optional)
-# if book_code:
-# query = query.filter(db_models.BookLookup.book_code.ilike(book_code))
-
-# # Filter by chapter (optional)
-# if chapter is not None:
-# query = query.filter(db_models.IslVideo.chapter == chapter)
-
-# rows = query.all()
-
-# if not rows:
-# raise HTTPException(status_code=404, detail="No ISL videos found")
-
-# # Build nested output
-# result = {"books": {}}
-
-# for video, bk_code in rows:
-
-# if bk_code not in result["books"]:
-# result["books"][bk_code] = {}
-
-# chap = str(video.chapter)
-
-# if chap not in result["books"][bk_code]:
-# result["books"][bk_code][chap] = []
-
-# result["books"][bk_code][chap].append({
-# "video_id": video.id,
-# "title": video.title,
-# "description": video.description,
-# "url": video.url
-# })
-
-# return result
-
-
-# def delete_isl_videos(db: Session, resource_id: int, ids: List[int]) -> dict:
-# resource = (
-# db.query(db_models.Resource)
-# .filter(db_models.Resource.resource_id == resource_id)
-# .first()
-# )
-# if not resource:
-# raise NotAvailableException(detail=f"Resource {resource_id} not found")
-# # Resource content_type must be 'isl'
-# if resource.content_type.lower() != "isl":
-# raise BadRequestException(
-# detail=f"Resource {resource_id} is not of type 'video' (found '{resource.content_type}')"
-# )
-
-# for ab_id in audio_bible_ids:
-
-# # Prevent duplicates in the same request
-# if ab_id in processed:
-# errors.append({"id": ab_id, "error": "already_deleted"})
-# continue
-# processed.add(ab_id)
-
-# # Check existence
-# row = (
-# db.query(db_models.AudioBible)
-# .filter(
-# db_models.AudioBible.audio_bible_id == ab_id,
-# db_models.AudioBible.resource_id == resource_id
-# )
-# .first()
-# )
-
-# if not row:
-# errors.append({"id": ab_id, "error": "not_found"})
-# continue
-
-# # Delete the row
-# db.delete(row)
-# deleted_ids.append(ab_id)
-
-# # Commit once
-# db.commit()
-
-# # Build error message
-# error_msg = None
-# if errors:
-# nf = [e["id"] for e in errors if e["error"] == "not_found"]
-# dup = [e["id"] for e in errors if e["error"] == "already_deleted"]
-# parts = []
-# if nf:
-# parts.append(f"Invalid audio_bible_ids: {nf}")
-# if dup:
-# parts.append(f"Duplicate IDs in request: {dup}")
-# error_msg = "; ".join(parts)
-
-# return {
-# "deletedCount": len(deleted_ids),
-# "deletedIds": deleted_ids,
-# "error": error_msg,
-# "message": f"Successfully deleted {len(deleted_ids)} audio bible(s)"
-# }
-
-
-
-# ---- OBS ----
-def create_obs_bulk(
- db: Session,
- payload: schema.OBSBulkCreate,
- actor_user_id: int,
-):
- resource = (
- db.query(db_models.Resource)
- .filter(db_models.Resource.resource_id == payload.resource_id)
- .first()
- )
- if not resource:
- raise NotAvailableException(
- detail=f"Resource {payload.resource_id} not found"
- )
-
- if (resource.content_type or "").lower() != "obs":
- raise BadRequestException(
- detail=(
- f"Resource {payload.resource_id} is not of type 'obs' "
- f"(found '{resource.content_type}')"
- )
- )
-
- created_rows: list[db_models.Obs] = []
-
- for item in payload.obs:
- exists = (
- db.query(db_models.Obs.obs_id)
- .filter(
- db_models.Obs.resource_id == payload.resource_id,
- db_models.Obs.story_no == item.story_no,
- )
- .first()
- )
- if exists:
- raise AlreadyExistsException(
- detail=(
- f"OBS story_no {item.story_no} already exists "
- f"for resource {payload.resource_id}"
- )
- )
-
- row = db_models.Obs(
- resource_id=payload.resource_id,
- story_no=item.story_no,
- title=item.title.strip(),
- text=item.text.strip(),
- url=item.url.strip() if item.url else None,
- )
-
- db.add(row)
- created_rows.append(row)
-
- # IMPORTANT: assign IDs before response
- db.flush()
-
- touch_resource(db, payload.resource_id, actor_user_id)
- db.commit()
-
- return schema.OBSBulkCreateFullResponse(
- resource_id=payload.resource_id,
- createdCount=len(created_rows),
- stories=[
- schema.OBSBulkCreateStoryOut(
- id=row.obs_id,
- story_no=row.story_no,
- title=row.title,
- url=row.url,
- text=row.text,
- )
- for row in created_rows
- ],
- )
-
-def get_languages_with_obs(db_session: Session) -> schema.OBSLanguageListResponse:
- """
- Get all languages that have OBS stories available.
- Returns formatted response with languages and their story counts.
- """
- # Query to get languages with OBS resources and count their stories
- result = (
- db_session.query(
- db_models.Language.language_code,
- db_models.Language.language_name,
- func.count(db_models.Obs.obs_id).label("story_count"), #pylint: disable=not-callable
-
- )
- .join(
- db_models.Resource,
- db_models.Resource.language_id == db_models.Language.language_id,
- )
- .join(
- db_models.Obs,
- db_models.Obs.resource_id == db_models.Resource.resource_id,
- )
- .filter(db_models.Resource.content_type == "obs")
- .group_by(
- db_models.Language.language_code,
- db_models.Language.language_name,
- )
- .order_by(db_models.Language.language_name)
- .all()
- )
-
- # Transform result into response schema
- language_list = [
- schema.LanguageWithStoryCount(
- language_code=row.language_code,
- language_name=row.language_name,
- story_count=row.story_count,
- )
- for row in result
- ]
-
- return schema.OBSLanguageListResponse(
- success=True,
- data=language_list,
- count=len(language_list),
- )
-
-
-
-def get_obs_stories_by_language(
- db_session: Session,
- language_code: int,
- page: int = 1,
- limit: int = 50
-) -> schema.OBSStoriesListResponse:
- """
- Get all OBS stories for a specific language with pagination.
- Returns formatted response with stories list and pagination info.
- """
- # 1. Validate language exists
- language = db_session.query(db_models.Language).filter_by(
- language_code=language_code
- ).first()
-
- if not language:
- logger.error("Language ID %s not found", language_code)
- raise NotAvailableException(
- detail=f"Language with ID {language_code} not found"
- )
- language_id = language.language_id
- # 2. Get total count of stories for this language
- total_count = (
- db_session.query(func.count(db_models.Obs.obs_id).label("total")) #pylint: disable=not-callable
- .join(
- db_models.Resource,
- db_models.Resource.resource_id == db_models.Obs.resource_id
- )
- .filter(
- db_models.Resource.language_id == language_id,
- db_models.Resource.content_type == "obs"
- ).scalar()
-)
- # 3. Get paginated stories
- offset = (page - 1) * limit
- stories = db_session.query(db_models.Obs).join(
- db_models.Resource,
- db_models.Resource.resource_id == db_models.Obs.resource_id
- ).filter(
- db_models.Resource.language_id == language_id,
- db_models.Resource.content_type == 'obs'
- ).order_by(
- db_models.Obs.story_no
- ).offset(offset).limit(limit).all()
-
- # 4. Build story list
- story_list = [
- schema.OBSStoryBrief(
- id=story.obs_id,
- resource_id=story.resource_id,
- story_no=story.story_no,
- title=story.title,
- url=story.url,
- text=story.text
- )
- for story in stories
- ]
-
- # 5. Return formatted response
- return schema.OBSStoriesListResponse(
- success=True,
- data=schema.OBSStoriesListData(
- language_code=language.language_code,
- language_name=language.language_name,
- stories=story_list
- ),
- pagination=schema.PaginationInfo(
- page=page,
- limit=limit,
- total=total_count
- )
- )
-
-
-def get_obs_by_resource(
- db: Session,
- resource_id: int
-) -> schema.OBSGetResponse:
- # 1. Validate resource
- resource = (
- db.query(db_models.Resource)
- .filter(db_models.Resource.resource_id == resource_id)
- .first()
- )
- if not resource:
- raise NotAvailableException(detail=f"Resource {resource_id} not found")
-
- if (resource.content_type or "").lower() != "obs":
- raise BadRequestException(
- detail=(
- f"Resource {resource_id} is not of type 'obs' "
- f"(found '{resource.content_type}')"
- )
- )
-
- # 2. Fetch stories
- rows = (
- db.query(db_models.Obs)
- .filter(db_models.Obs.resource_id == resource_id)
- .order_by(db_models.Obs.story_no.asc())
- .all()
- )
-
- # 3. Build response
- stories = [
- schema.OBSStoryOut(
- id=row.obs_id,
- story_no=row.story_no,
- title=row.title,
- url=row.url,
- text=row.text,
- )
- for row in rows
- ]
-
- return schema.OBSGetResponse(
- resource_id=resource_id,
- stories=stories
- )
-
-
-
-def _validate_resource_update(db_session, resource_id):
- if resource_id is None:
- return None
-
- new_resource = (
- db_session.query(db_models.Resource)
- .filter_by(resource_id=resource_id)
- .first()
- )
-
- if not new_resource:
- logger.error(
- "Resource ID %s not found", resource_id
- )
- raise NotAvailableException(
- detail=f"Resource with ID {resource_id} not found",
- )
-
-
- if new_resource.content_type.lower() != "obs":
- logger.error(
- "Resource %s is not of type 'obs'", resource_id
- )
- raise BadRequestException(
- detail=(
- f"Resource {resource_id} is not of type 'obs' "
- f"(found '{new_resource.content_type}')"
- ),
- )
-
- return new_resource
-
-
-def _ensure_story_no_unique(db_session, resource_id, story_data, story):
- if story_data.story_no is None:
- return
-
- target_resource_id = (
- resource_id
- if resource_id is not None
- else story.resource_id
- )
-
- duplicate_story = (
- db_session.query(db_models.Obs)
- .filter(
- db_models.Obs.resource_id == target_resource_id,
- db_models.Obs.story_no == story_data.story_no,
- db_models.Obs.obs_id != story.obs_id,
- )
- .first()
- )
-
- if duplicate_story:
- logger.error(
- "Story number %s already exists for resource %s",
- story_data.story_no,
- target_resource_id,
- )
- raise AlreadyExistsException(
- detail=(
- f"Story number {story_data.story_no} already exists for resource "
- f"{target_resource_id}"
- ),
- )
-
-
-def update_obs_story(
- db_session: Session,
- resource_id: int,
- story_id: int,
- story_data: schema.OBSStoryUpdate,
-):
- """Update an existing OBS story."""
-
- # Validate resource update
- _ = _validate_resource_update(db_session, resource_id)
-
- story = (
- db_session.query(db_models.Obs)
- .filter(
- db_models.Obs.resource_id == resource_id,
- db_models.Obs.obs_id == story_id,
- )
- .first()
- )
- if not story:
- raise NotAvailableException(
- detail=f"OBS story {story_id} not found for resource {resource_id}"
- )
-
- # Validate duplicate story number
- _ensure_story_no_unique(db_session, resource_id,story_data, story)
-
- # Apply updates
- if resource_id is not None:
- story.resource_id = resource_id
- if story_data.story_no is not None:
- story.story_no = story_data.story_no
- if story_data.title is not None:
- story.title = story_data.title.strip()
- if story_data.url is not None:
- story.url = story_data.url.strip() if story_data.url else None
- if story_data.text is not None:
- story.text = story_data.text.strip()
-
- db_session.commit()
- db_session.refresh(story)
-
- logger.info("Successfully updated OBS story %s", story_id)
-
- return schema.OBSStoryUpdateResponse(
- message="Story updated successfully",
- data=schema.OBSStoryUpdate(
- id=story.obs_id,
- resource_id=story.resource_id,
- story_no=story.story_no,
- title=story.title,
- url=story.url,
- text=story.text,
- ),
- )
-
-# ===== DELETE =====
-def delete_obs_bulk(
- db: Session,
- resource_id: int,
- story_nos: list[int],
-):
- # Validate resource
- resource = (
- db.query(db_models.Resource)
- .filter(db_models.Resource.resource_id == resource_id)
- .first()
- )
- if not resource:
- raise NotAvailableException(detail=f"Resource {resource_id} not found")
-
- if (resource.content_type or "").lower() != "obs":
- raise BadRequestException(
- detail=(
- f"Resource {resource_id} is not of type 'obs' "
- f"(found '{resource.content_type}')"
- )
- )
-
- deleted = []
- invalid = []
- processed = set()
-
- for story_no in story_nos:
- # duplicate in request
- if story_no in processed:
- invalid.append(story_no)
- continue
- processed.add(story_no)
-
- row = (
- db.query(db_models.Obs)
- .filter(
- db_models.Obs.resource_id == resource_id,
- db_models.Obs.story_no == story_no,
- )
- .first()
- )
-
- if not row:
- invalid.append(story_no)
- continue
-
- db.delete(row)
- deleted.append(story_no)
-
- db.commit()
-
- return {
- "deletedStoryNos": deleted,
- "invalidStoryNos": invalid,
- }
-
-### CRUD for InfoGraphics
-
-ALLOWED_EXT = {".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp"}
-
-def validate_item(idx: int, item) -> Optional[dict]:
- """Return an error dict if invalid, else None. Collects *all* field errors."""
- msgs = []
- data_dump = item.model_dump(by_alias=True)
- # 1. book_id validation
- if not isinstance(item.book_id, int) or not 1 <= item.book_id <= 67:
- msgs.append("book_id must be between 1 to 67")
- # title
- title = (item.title or "").strip()
- if not title:
- msgs.append("title cannot be empty")
- fn = (item.file_name or "").strip()
- if not fn:
- msgs.append("filename cannot be empty")
- else:
- m = re.search(r"(\.[a-zA-Z0-9]+)$", fn)
- if not m or m.group(1).lower() not in ALLOWED_EXT:
- msgs.append("file_name must end with one of: .jpg, .jpeg, .png, .gif, .svg, .webp")
- if ".." in fn or fn.startswith("/"):
- msgs.append("file_name must not contain path traversal")
- if not msgs:
- return None
- return {
- "index": idx,
- "data": data_dump,
- "error": {
- "code": "VALIDATION_ERROR",
- "message": msgs,
- },
- }
-
-def _extract_base_url(resource) -> Optional[str]:
- """Extract base_url from resource.meta_data JSON."""
- if not resource or not getattr(resource, "meta_data", None):
- return None
-
- try:
- raw = resource.meta_data
- data = raw if isinstance(raw, dict) else json.loads(raw)
-
- base_url = data.get("base_url")
- if isinstance(base_url, dict):
- base_url = base_url.get("base_url")
-
- return base_url.rstrip("/") if base_url else None
-
- except (json.JSONDecodeError, TypeError, AttributeError):
- return None
-
-def _image_url(resource, file_name: str) -> Optional[str]:
- """Combine base_url and file_name."""
- base = _extract_base_url(resource)
- return f"{base}/{file_name}" if base else None
-
-
-
-# --- CRUD operations ---
-
-def create_infographic_batch(
- db: Session,
- payload: schema.BatchInfographicCreateIn,
- actor_user_id: int
-):
- """Create a batch of infographics."""
- resource_id = payload.resource_id
-
- # --- 1. Validate resource ---
- res = db.query(db_models.Resource).filter_by(resource_id=resource_id).first()
- if not res:
- raise NotAvailableException(detail=f"Resource {resource_id} not found")
-
- if (res.content_type or "").lower() != "infographics":
- raise BadRequestException(
- detail=(
- f"Resource {resource_id} is not of type 'infographics'"
- f" (found '{res.content_type}')"
- )
- )
-
- # --- 2. Validate book ids ---
- bk_ids = {i.book_id for i in payload.infographics}
- valid_books = {
- b.book_id for b in db.query(db_models.BookLookup)
- .filter(db_models.BookLookup.book_id.in_(bk_ids)).all()
- }
-
- created_rows = []
- errors = []
-
- # --- 3. Process each row ---
- for idx, item in enumerate(payload.infographics):
-
- # Validate title, file extension, filename rules
- err = validate_item(idx, item)
- if err:
- errors.append(err)
- continue
-
- # Check valid book id
- if item.book_id not in valid_books:
- errors.append({
- "index": idx,
- "data": item.model_dump(),
- "error": {
- "code": "INVALID_BOOK",
- "message": f"BookId {item.book_id} not found"
- }
- })
- continue
-
- # Duplicate check
- duplicate = db.query(db_models.Infographic).filter(
- db_models.Infographic.resource_id == resource_id,
- db_models.Infographic.book_id == item.book_id,
- db_models.Infographic.title == item.title,
- db_models.Infographic.file_name == item.file_name,
- ).first()
-
- if duplicate:
- errors.append({
- "index": idx,
- "data": item.model_dump(),
- "error": {
- "code": "DUPLICATE_ENTRY",
- "message": "Infographic with same book_id, title, and file_name already exists"
- }
- })
- continue
-
- # Create row (in-memory only)
- row = db_models.Infographic(
- resource_id=resource_id,
- book_id=item.book_id,
- title=item.title,
- file_name=item.file_name,
- )
-
- db.add(row)
- db.flush()
-
- created_rows.append({
- "id": row.id,
- "resource_id": row.resource_id,
- "book_id": row.book_id,
- "title": row.title,
- "file_name": row.file_name,
- "image_url": _image_url(res, row.file_name),
- })
-
- # --- 4. Commit ONCE ---
- touch_resource(db, resource_id, actor_user_id)
- db.commit()
-
- return {"created": created_rows}, errors
-
-
-def list_infographic_items(
- db: Session,
- params: schema.InfographicListParams
-) -> Tuple[List[schema.InfographicOut], schema.Pagination, int]:
-
- """List infographic items."""
- page = params.page
- limit = params.limit
- book_id = params.book_id
- resource_id = params.resource_id
- search = params.search
-
- if resource_id is not None:
- res = db.query(db_models.Resource).filter_by(resource_id=resource_id).first()
- if not res:
- raise ValueError("RESOURCE_NOT_FOUND")
- if (res.content_type or "").lower() != "infographics":
- raise ValueError("INVALID_RESOURCE_TYPE")
-
- q = db.query(db_models.Infographic)
-
- if book_id:
- q = q.filter(db_models.Infographic.book_id == book_id)
-
- if resource_id:
- q = q.filter(db_models.Infographic.resource_id == resource_id)
-
- if search:
- q = q.filter(
- sqlalchemy.func.lower(db_models.Infographic.title)
- .like(f"%{search.lower()}%")
- )
-
- total = q.count()
- items = q.offset((page - 1) * limit).limit(limit).all()
-
- res_map = {
- r.resource_id: r
- for r in db.query(db_models.Resource)
- .filter(db_models.Resource.resource_id.in_({i.resource_id for i in items}))
- .all()
- }
-
- data = [
- schema.InfographicOut(
- id=i.id,
- resource_id=i.resource_id,
- book_id=i.book_id,
- title=i.title,
- file_name=i.file_name,
- image_url=_image_url(res_map.get(i.resource_id), i.file_name),
- )
- for i in items
- ]
-
- total_pages = (total + limit - 1) // limit
-
- pagination = schema.Pagination(
- current_page=page,
- total_pages=total_pages,
- total_items=total,
- items_per_page=limit,
- has_next=page < total_pages,
- has_previous=page > 1,
- )
-
- return data, pagination, total
-
-
-def get_one_infographics(
- db: Session,
- infographic_id: int
-) -> Optional[schema.InfographicOut]:
- """
- Get single infographic by ID.
- """
- row = db.query(db_models.Infographic).filter_by(id=infographic_id).first()
- if not row:
- return None
-
- res = (
- db.query(db_models.Resource)
- .filter_by(resource_id=row.resource_id)
- .first()
- )
-
- return schema.InfographicOut(
- id=row.id,
- resource_id=row.resource_id,
- book_id=row.book_id,
- title=row.title,
- file_name=row.file_name,
- image_url=_image_url(res, row.file_name),
- )
-
-
-def _validate_resource(db: Session, resource_id: int) -> db_models.Resource:
- """Validate resource exists and is of type 'infographics'."""
- res = db.query(db_models.Resource).filter_by(resource_id=resource_id).first()
- if not res:
- raise ValueError("RESOURCE_NOT_FOUND")
- if (res.content_type or "").lower() != "infographics":
- raise ValueError("INVALID_RESOURCE_TYPE")
- return res
-
-def _get_valid_book_ids(db: Session, book_ids: set) -> set:
- """Return set of valid book IDs from BookLookup."""
- if not book_ids:
- return set()
- result = db.query(db_models.BookLookup.book_id).filter(
- db_models.BookLookup.book_id.in_(book_ids)
- ).all()
- return {b.book_id for b in result}
-
-def _process_infographic_item(
- item: schema.InfographicUpdateItem,
- ctx: schema.InfographicProcessContext
-) -> tuple[dict | None, dict | None]:
- """
- Process one infographic item.
- Returns tuple: (updated_item_dict, error_dict)
- Only one of the two is non-None.
- """
- try:
- row = ctx.db.query(db_models.Infographic).filter(
- db_models.Infographic.id == item.id,
- db_models.Infographic.resource_id == ctx.resource_id
- ).with_for_update(nowait=False).first()
-
- if not row:
- return None, {
- "index": ctx.idx,
- "data": item.model_dump(by_alias=True),
- "error": {
- "code": "NOT_FOUND",
- "message": f"Infographic id {item.id} not found for resource {ctx.resource_id}"
- },
- }
-
- tgt_book = item.book_id if item.book_id is not None else row.book_id
- tgt_title = item.title if item.title is not None else row.title
- tgt_file = item.file_name if item.file_name is not None else row.file_name
-
- tmp_obj = schema.InfographicUpdateItem(
- id=item.id,
- book_id=tgt_book,
- title=tgt_title,
- file_name=tgt_file,
- )
- v_err = validate_item(ctx.idx, tmp_obj)
- if v_err:
- return None, v_err
-
- if tgt_book not in ctx.valid_books and ctx.valid_books:
- return None, {
- "index": ctx.idx,
- "data": item.model_dump(by_alias=True),
- "error": {"code": "INVALID_BOOK", "message": f"Invalid book_id {tgt_book}"}
- }
-
- dup = ctx.db.query(db_models.Infographic).filter(
- db_models.Infographic.resource_id == ctx.resource_id,
- db_models.Infographic.book_id == tgt_book,
- db_models.Infographic.title == tgt_title,
- db_models.Infographic.file_name == tgt_file,
- db_models.Infographic.id != row.id
- ).first()
- if dup:
- return None, {
- "index": ctx.idx,
- "data": item.model_dump(by_alias=True),
- "error": {"code": "DUPLICATE_ENTRY", "message": "Duplicate infographic exists"}
- }
-
- row.book_id = tgt_book
- row.title = tgt_title
- row.file_name = tgt_file
- row.updated_by = ctx.actor_user_id
- ctx.db.add(row)
- ctx.db.flush()
-
- updated_item = {
- "id": row.id,
- "resource_id": row.resource_id,
- "book_id": row.book_id,
- "title": row.title,
- "file_name": row.file_name,
- "image_url": _image_url(ctx.res, row.file_name),
- }
- return updated_item, None
-
- except (IntegrityError, SQLAlchemyError, ValueError, TypeError) as ex:
- ctx.db.rollback()
- return None, {
- "index": ctx.idx,
- "data": item.model_dump(by_alias=True),
- "error": {"code": "INTERNAL_SERVER_ERROR", "message": str(ex)}
- }
-
-
-def update_infographic_batch(
- db: Session,
- payload: schema.BatchInfographicUpdateIn,
- actor_user_id: int
-):
- """
- Update infographic batch using ctx pattern.
- """
- resource_id = payload.resource_id
-
- # --- 1. Validate resource ---
- res = _validate_resource(db, resource_id)
-
- # --- 2. Preload valid book ids ---
- all_book_ids = {i.book_id for i in payload.infographics if i.book_id is not None}
- valid_books = _get_valid_book_ids(db, all_book_ids)
-
- updated = []
- errors = []
-
- # --- 3. Process each infographic item ---
- for idx, item in enumerate(payload.infographics):
- # Build context object
- ctx = schema.InfographicProcessContext(
- db=db,
- res=res,
- resource_id=resource_id,
- valid_books=valid_books,
- actor_user_id=actor_user_id,
- idx=idx
- )
- u, e = _process_infographic_item(item, ctx)
- if u:
- updated.append(u)
- if e:
- errors.append(e)
-
- # --- 4. Commit updates if any ---
- if updated:
- try:
- touch_resource(db, resource_id, actor_user_id)
- db.commit()
- except (SQLAlchemyError, ValueError, TypeError) as ex:
- db.rollback()
- # convert all updated rows into errors
- for idx, item in enumerate(updated):
- errors.append({
- "index": idx,
- "data": item,
- "error": {"code": "INTERNAL_SERVER_ERROR", "message": str(ex)}
- })
- updated = []
-
- # --- 5. Return normalized shape ---
- return {"updated": updated, "errors": errors}
-
-# def delete_bulk(db: Session, ids: List[int]) -> List[int]:
-# """
-# Delete multiple infographics by IDs.
-# """
-# rows = db.query(db_models.Infographic).filter(db_models.Infographic.id.in_(ids)).all()
-# if not rows:
-# return []
-# deleted = [r.id for r in rows]
-# for r in rows:
-# db.delete(r)
-# db.commit()
-# return deleted
-
-def delete_bulk_details(db: Session, ids: List[int]) -> dict:
- """Delete infographics in bulk, return response with deleted & invalid IDs."""
- deleted_ids = []
- invalid_ids = []
-
- # fetch existing rows
- rows = db.query(db_models.Infographic).filter(db_models.Infographic.id.in_(ids)).all()
- existing_ids = {r.id for r in rows}
-
- # delete existing rows
- for r in rows:
- db.delete(r)
- deleted_ids.append(r.id)
- db.commit()
-
- # collect invalid / not found IDs
- invalid_ids = [i for i in ids if i not in existing_ids]
-
- response = {
- "deletedCount": len(deleted_ids),
- "deletedIds": deleted_ids,
- "message": f"Successfully deleted {len(deleted_ids)} infographic(s)"
- }
-
- if invalid_ids:
- response["error"] = f"Invalid infographic_ids: {', '.join(map(str, invalid_ids))}"
-
- return response
-
-
-
-# verse of the day CRUD functions
-
-
-
-
-def get_all_verse_of_the_day(db_session: Session):
- """
- Fetch all verses from the VerseOfTheDay table.
- Return year, month, date, book_code, chapter, verse, id from DB.
- """
- verses = (
- db_session.query(db_models.VerseOfTheDay)
- .order_by(db_models.VerseOfTheDay.year,
- db_models.VerseOfTheDay.month,
- db_models.VerseOfTheDay.day)
- .all()
- )
-
- verse_list = [
- {
- "id": str(v.id),
- "year": v.year,
- "month": v.month,
- "date": v.day,
- "book_code": v.book_code,
- "chapter": v.chapter,
- "verse": v.verse
- }
- for v in verses
- ]
- return {
- "success": True,
- "data": {
- "verses": verse_list,
- "total": len(verse_list)
- }
- }
-
-
-def get_verse_for_date(db_session: Session, year: int, month: int, day: int):
- """
- Get one verse (with id) for a specific date from VerseOfTheDay table.
- """
- verse_entry = (
- db_session.query(db_models.VerseOfTheDay)
- .filter_by(year=year, month=month, day=day)
- .first()
- )
-
- if not verse_entry:
- raise NotAvailableException(detail=f"No verse found for {year}-{month}-{day}")
-
- return {
- "success": True,
- "data": {
- "id": str(verse_entry.id),
- "year": verse_entry.year,
- "month": verse_entry.month,
- "day": verse_entry.day,
- "book_code": verse_entry.book_code,
- "chapter": verse_entry.chapter,
- "verse": verse_entry.verse
- }
- }
-
-
-def upload_verse_of_the_day_csv(db_session: Session, file: UploadFile):
- """
- Deletes all old entries and uploads CSV for Verse Of The Day.
- Returns 200, 207, or 400 depending on outcome.
- """
-
- # --- Step 1: Read + Validate CSV ---
- reader = _read_votd_csv(file)
-
- # --- Step 2: Delete old records + Reset sequence ---
- deleted_count = _reset_votd_table(db_session)
-
- created_count = 0
- failed_count = 0
- errors = []
-
- # --- Step 3: Process CSV rows ---
- for row_num, row in enumerate(reader, start=2):
- try:
- parsed = _parse_votd_row(row)
- _validate_votd_row(db_session, parsed)
-
- new_entry = db_models.VerseOfTheDay(**parsed)
- db_session.add(new_entry)
- created_count += 1
-
- except (ValueError, KeyError, IntegrityError) as e:
- failed_count += 1
- errors.append({"row": row_num, "reason": str(e)})
-
- db_session.commit()
-
- # --- Step 4: Build Response ---
- return _build_votd_response(
- deleted_count=deleted_count,
- created_count=created_count,
- failed_count=failed_count,
- errors=errors
- )
-def _read_votd_csv(file: UploadFile):
- try:
- content = file.file.read().decode("utf-8")
- reader = csv.DictReader(StringIO(content))
-
- required = {"year", "month", "date", "book_code", "chapter", "verse"}
- if not reader.fieldnames or not required.issubset(set(reader.fieldnames)):
- raise ValueError("Invalid CSV file format or missing required columns")
-
- return reader
-
- except Exception as exc:
- raise TypeException(
- detail={
- "success": False,
- "error": {
- "code": "INVALID_FILE",
- "message": "Could not parse the CSV file"
- }
- }
- ) from exc
-
-def _reset_votd_table(db_session: Session):
- deleted = db_session.query(db_models.VerseOfTheDay).delete()
- db_session.commit()
-
- seq = db_session.execute(
- text("SELECT pg_get_serial_sequence('verse_of_the_day', 'id');")
- ).scalar()
-
- if seq:
- db_session.execute(text(f"ALTER SEQUENCE {seq} RESTART WITH 1;"))
- db_session.commit()
-
- return deleted
-def _parse_votd_row(row: dict) -> dict:
- try:
- return {
- "year": int(row["year"]),
- "month": int(row["month"]),
- "day": int(row["date"]),
- "book_code": row["book_code"].strip().lower(),
- "chapter": int(row["chapter"]),
- "verse": int(row["verse"]),
- }
- except Exception as ex:
- raise ValueError(f"Invalid row values: {ex}") from ex
-
-def _validate_votd_row(db_session: Session, parsed: dict):
- if not 1 <= parsed["month"] <= 12:
- raise ValueError(f"Invalid month: {parsed['month']}")
-
- if not 1 <= parsed["day"] <= 31:
- raise ValueError(f"Invalid date: {parsed['day']}")
-
- book_exists = db_session.query(db_models.BookLookup).filter(
- db_models.BookLookup.book_code.ilike(parsed["book_code"])
- ).first()
-
- if not book_exists:
- raise ValueError(
- f"Invalid book code: '{parsed['book_code']}' not found in book_lookup table")
-def _build_votd_response(deleted_count, created_count, failed_count, errors):
- if failed_count == 0:
- return {
- "success": True,
- "data": {
- "deleted_count": deleted_count,
- "created_count": created_count,
- "failed_count": 0,
- "errors": [],
- },
- "message": "CSV uploaded. Old entries cleared and new ones created.",
- }
-
- if created_count > 0:
- raise MultiStatus(
- detail={
- "success": False,
- "data": {
- "deleted_count": deleted_count,
- "created_count": created_count,
- "failed_count": failed_count,
- "errors": errors,
- },
- "message": "CSV uploaded with some errors. Check errors array for details.",
- },
- )
-
- raise UnprocessableException(
- detail={
- "success": False,
- "error": {
- "code": "INVALID_DATA",
- "message": "All rows in CSV invalid or could not be processed",
- "errors": errors,
- },
- },
- )
-
-
-
-def delete_all_verse_of_the_day(db_session: Session):
- """
- Deletes all entries from verse_of_the_day table.
- """
- try:
- deleted_count = db_session.query(db_models.VerseOfTheDay).delete()
- db_session.commit()
- # Reset sequence dynamically
- seq_name = db_session.execute(
- text("SELECT pg_get_serial_sequence('verse_of_the_day', 'id');")
- ).scalar()
-
- if seq_name:
- db_session.execute(text(f"ALTER SEQUENCE {seq_name} RESTART WITH 1;"))
- db_session.commit()
- return {
- "success": True,
- "data": {
- "deleted_count": deleted_count
- },
- "message": "All verse of the day entries deleted successfully"
- }
- except SQLAlchemyError as e:
- db_session.rollback()
- raise GenericException(
- detail={
- "success": False,
- "error": {
- "code": "INTERNAL_ERROR",
- "message": "Failed to delete entries"
- }
- },
- ) from e
-
-
-# --- Reading plan CRUD ---
-def parse_json_file(content: bytes) -> List[Dict[str, Any]]:
- """
- Parse a JSON file and return a list of entries."""
- try:
- raw = json.loads(content.decode("utf-8"))
- except json.JSONDecodeError as exc:
- raise TypeException(
- detail=f"Invalid JSON: {str(exc)}",
- ) from exc
-
- if not isinstance(raw, list):
- raise TypeException(
- detail="Invalid JSON: expected list of entries",
- )
-
- if not raw:
- raise TypeException(
- detail="JSON file is empty",
- )
-
- return raw
-
-
-def parse_csv_file(content: bytes) -> List[Dict[str, Any]]:
- """
- Parse a CSV file and return a list of entries."""
- try:
- csv_text = content.decode("utf-8")
- except UnicodeDecodeError as exc:
- raise TypeException(
- detail="File must be UTF-8 encoded",
- ) from exc
-
- rows = []
- reader = csv.DictReader(io.StringIO(csv_text))
-
- for row in reader:
- if "date" not in row or "reading" not in row:
- raise BadRequestException(
- detail="CSV must contain 'date' and 'reading' columns",
- )
-
- try:
- readings = (
- json.loads(row["reading"])
- if isinstance(row["reading"], str)
- else row["reading"]
- )
- except json.JSONDecodeError as exc:
- raise TypeException(
- detail=f"Invalid JSON in reading field for date {row.get('date')}",
- ) from exc
-
- rows.append({"date": row["date"], "reading": readings})
-
- if not rows:
- raise TypeException(
- detail="CSV contains no valid rows",
- )
-
- return rows
-
-
-def validate_and_process_entry(entry, index, db, counts):
- """
- Validate and process a single entry from the input file."""
- created, updated, skipped = counts
-
- if not isinstance(entry, dict):
- logger.warning("Entry %s is not a dict; skipping", index)
- return (created, updated, skipped + 1)
-
- date_str = entry.get("date")
- readings = entry.get("reading")
-
- if not date_str or not isinstance(readings, list) or not readings:
- logger.warning("Invalid entry at index %s; skipping", index)
- return (created, updated, skipped + 1)
-
- try:
- month, day = map(int, date_str.split("-"))
- datetime(2024, month, day)
- except (ValueError, TypeError):
- logger.warning("Invalid date format at index %s: %s", index, date_str)
- return (created, updated, skipped + 1)
-
- existing = (
- db.query(db_models.ReadingPlan)
- .filter_by(month=month, day=day)
- .first()
- )
-
- if existing:
- existing.readings = readings
- updated += 1
- else:
- db.add(db_models.ReadingPlan(month=month, day=day, readings=readings))
- created += 1
-
- return (created, updated, skipped)
-def upload_reading_plans(
- db: Session,
- file_content: bytes,
- file_type: str
-) -> Dict[str, int]:
- """
- Upload reading plans from JSON or CSV file."""
- try:
- # Parse input file
- if file_type == "json":
- data = parse_json_file(file_content)
- elif file_type == "csv":
- data = parse_csv_file(file_content)
- else:
- raise TypeException(
- detail="Unsupported file type; must be JSON or CSV",
- )
-
- counts = (0, 0, 0) # created, updated, skipped
-
- # Process each entry
- for idx, entry in enumerate(data):
- counts = validate_and_process_entry(entry, idx, db, counts)
-
- created_count, updated_count, skipped_count = counts
-
- # Error if no valid entries
- if created_count == 0 and updated_count == 0:
- if skipped_count > 0:
- raise TypeException(
- detail=(
- f"All {skipped_count} entries were invalid. "
- "Expected format: {'date': 'MM-DD', 'reading': [...]}."
- ),
- )
- raise TypeException(
- detail="No valid entries found",
- )
-
- db.commit()
-
- return {
- "created": created_count,
- "updated": updated_count,
- "skipped": skipped_count,
- "total": created_count + updated_count,
- }
-
- except HTTPException:
- raise
- except Exception as exc:
- db.rollback()
- raise GenericException(
- detail=f"Unexpected error: {str(exc)}",
- ) from exc
-
-def get_reading_plans(
- db: Session,
- month: Optional[int] = None,
- day: Optional[int] = None
-) -> List[db_models.ReadingPlan]:
- """
- Get reading plans. If month and day are provided, return specific date.
- Otherwise, return all reading plans.
- """
- query = db.query(db_models.ReadingPlan)
-
- if month is not None and day is not None:
- # Validate date
- try:
- datetime(2024, month, day)
- except ValueError as exc:
- raise TypeException(
- detail=f"Invalid date: month={month}, day={day}"
- ) from exc
-
-
- query = query.filter_by(month=month, day=day)
- result = query.first()
-
- if not result:
- raise NotAvailableException(
- detail=f"No reading plan found for {month:02d}-{day:02d}"
- )
-
- return [result]
-
- # Return all plans ordered by month and day
- return query.order_by(
- db_models.ReadingPlan.month,
- db_models.ReadingPlan.day
- ).all()
-
-
-def delete_all_reading_plans(db: Session) -> int:
- """
- Delete all reading plans from the database.
- Returns the count of deleted records.
- """
- try:
- count = db.query(db_models.ReadingPlan).count()
- db.query(db_models.ReadingPlan).delete()
- db.commit()
- return count
- except Exception as e:
- db.rollback()
- logger.error("Error deleting reading plans: %s", e)
- raise GenericException(
- detail=f"Error deleting reading plans: {str(e)}"
- ) from e
-
-
-#validate_html for commentaries
-def validate_html(html_text: str):
- """
- Validates commentary HTML with strict rules:
- - No unclosed tags
- - No broken tags like
- - No missing closing , , etc.
- - Only allowed tags are permitted
- """
- if not html_text or not html_text.strip():
- return
-
- if "<" not in html_text and ">" not in html_text:
- raise UnprocessableException(
- detail="no html tags found"
- )
-
- allowed_tags = {"p", "strong", "img", "br", "sup", "em", "b", "i", "u"}
- void_tags = {"br", "img", "hr", "meta", "link", "input"}
-
- tag_pattern = re.compile(r"?([a-zA-Z0-9]+)[^>]*>")
-
- # ---- Helper function for stack-based tag validation ----
- def _check_tag_stack(content: str):
- stack = []
- for match in tag_pattern.finditer(content):
- tag = match.group(1).lower()
- full_tag = match.group(0)
-
- if tag in void_tags:
- continue
-
- if full_tag.startswith(""):
- if not stack or stack[-1] != tag:
- raise UnprocessableException(
- detail=f"Closing tag {tag}> is mismatched or missing opener"
- )
- stack.pop()
- else:
- stack.append(tag)
-
- if stack:
- raise UnprocessableException(
- detail=f"Unclosed tag(s): {stack}"
- )
-
- # ---- Validate each tag individually ----
- for match in tag_pattern.finditer(html_text):
- tag = match.group(1).lower()
- full_tag = match.group(0)
-
- if tag not in allowed_tags:
- raise UnprocessableException(
- detail=f"Invalid HTML tag '{full_tag}'"
- )
-
- if full_tag.startswith(f"<{tag}") and not re.match(rf"?{tag}\b", full_tag):
- raise UnprocessableException(
- detail=f"Malformed HTML tag '{full_tag}'"
- )
-
- # ---- Parse with html5lib ----
- try:
- BeautifulSoup(html_text, "html5lib")
- except Exception as e:
- raise UnprocessableException(
- detail=f"Invalid HTML: {str(e)}"
- ) from e
-
- # ---- Run stack-based tag check ----
- _check_tag_stack(html_text)
-
-# Convert GitHub URLs to raw URLs
-def convert_to_raw_url(url: str) -> str:
- """Convert GitHub tree/blob URLs into raw.githubusercontent.com URLs."""
- if not url:
- return None
-
- if "github.com" in url:
- url = url.replace("github.com", "raw.githubusercontent.com")
- url = url.replace("/tree/", "/")
- url = url.replace("/blob/", "/")
-
- return url.rstrip("/")
-
-# Extract base_url from resource.meta_data
-def extract_base_url(resource):
- """Extract base_url from resource.meta_data JSON."""
- try:
- raw = resource.meta_data
- data = raw if isinstance(raw, dict) else json.loads(raw)
-
- base = None
-
- # meta_data can have nested structure
- if isinstance(data.get("base_url"), dict):
- base = data["base_url"].get("base_url")
- else:
- base = data.get("base_url")
-
- if not base:
- raise ValueError("base_url missing")
-
-
- base = convert_to_raw_url(base)
- return base
-
- except (ValueError, TypeError):
- return None
-
-
-def check_infographics_by_resource(db: Session, resource_id: int):
- """
- Remote validation for Infographics by resource_id.
-
- - Validates resource exists
- - Ensures resource content_type is infographics
- - Extracts base_url from resource.meta_data
- - Checks existence of each infographic file remotely
- - Returns detailed missing/present info
- """
-
- # 1. Fetch and validate resource
- resource = (
- db.query(db_models.Resource)
- .filter(db_models.Resource.resource_id == resource_id)
- .first()
- )
-
- if not resource:
- raise NotAvailableException(
- detail=f"Resource not found for resource_id {resource_id}"
- )
-
- if resource.content_type.lower() != "infographics":
- raise BadRequestException(
- detail=(
- f"Resource {resource_id} is not of type 'infographics' "
- f"(found '{resource.content_type}')"
- )
- )
-
- # 2. Extract base URL from resource.meta_data
- base_url = extract_base_url(resource)
-
- if not base_url:
- raise BadRequestException(
- detail=f"base_url missing or invalid in resource.meta_data "
- f"for resource_id {resource_id}"
- )
-
- base_url = base_url.rstrip("/")
-
- # 3. Fetch infographics for this resource
- infographics = (
- db.query(db_models.Infographic)
- .filter(db_models.Infographic.resource_id == resource_id)
- .all()
- )
-
- if not infographics:
- raise NotAvailableException(
- detail=f"No infographics found for resource_id {resource_id}"
- )
-
- # 4. Remote file existence check
- results: List[Dict] = []
- missing_files: List[str] = []
- present_count = 0
-
- for idx, info in enumerate(infographics, start=1):
- filename = info.file_name
- url = f"{base_url}/{filename}"
-
- file_exists = False
-
- # --- Attempt 1: GET ---
- try:
- r = requests.get(url, timeout=REQUEST_TIMEOUT, stream=True)
- if r.status_code == 200:
- file_exists = True
- except Exception:
- pass
-
- # --- Attempt 2: Retry GET ---
- if not file_exists:
- try:
- time.sleep(RETRY_DELAY)
- r = requests.get(url, timeout=REQUEST_TIMEOUT, stream=True)
- if r.status_code == 200:
- file_exists = True
- except Exception:
- pass
-
- # --- Attempt 3: HEAD fallback ---
- if not file_exists:
- try:
- h = requests.head(url, timeout=REQUEST_TIMEOUT, allow_redirects=True)
- if h.status_code == 200:
- file_exists = True
- except Exception:
- pass
-
- if file_exists:
- present_count += 1
- else:
- missing_files.append(filename)
-
- results.append(
- {
- "id": info.id,
- "book_id": info.book_id,
- "title": info.title,
- "file_name": filename,
- "url": url,
- "exists": file_exists,
- }
- )
-
- return schema.InfographicCheckResponse(
- success=True,
- resource_id=resource_id,
- base_url=base_url,
- total_infographics=len(infographics),
- present_files=present_count,
- missing_files_count=len(missing_files),
- checked_at=datetime.now(timezone.utc).isoformat(),
- infographics=results,
- )
-
-
-def get_audit_logs(
- db_session: Session,
- page: int = 0,
- page_size: int = 100,
- **filters
-) -> tuple[list[db_models.AuditLog], int]:
- """
- Retrieve audit logs with optional filtering and pagination.
-
- Filters supported via kwargs:
- - user_id: int
- - method: str
- - path: str (partial match)
- - status_code: str or int
- - date_from: datetime
- - date_to: datetime
-
- Returns:
- tuple of (list of AuditLog rows, total count)
- """
- query = db_session.query(db_models.AuditLog)
-
- user_id = filters.get("user_id")
- method = filters.get("method")
- path = filters.get("path")
- status_code = filters.get("status_code")
- date_from = filters.get("date_from")
- date_to = filters.get("date_to")
-
- if user_id is not None:
- query = query.filter(db_models.AuditLog.user_id == user_id)
- if method:
- query = query.filter(db_models.AuditLog.method == method.upper())
- if path:
- query = query.filter(db_models.AuditLog.path.ilike(f"%{path}%"))
- if status_code:
- query = query.filter(db_models.AuditLog.status_code == int(status_code))
- if date_from is not None:
- query = query.filter(db_models.AuditLog.created_at >= date_from)
- if date_to is not None:
- query = query.filter(db_models.AuditLog.created_at <= date_to)
-
- total = query.count()
-
- rows = (
- query.order_by(db_models.AuditLog.created_at.desc())
- .offset(page * page_size)
- .limit(page_size)
- .all()
- )
-
- return rows, total
-
-
-def validate_video_item(db, item):
- """
- Validate a single video item."""
- errors = []
-
- book_val = (item.book or "").strip().lower()
-
- if book_val in ("ot", "nt"):
- # chapter is allowed to be anything (or None)
- if item.chapter is not None and item.chapter > 175:
- errors.append(f"Invalid chapter {item.chapter} for book {item.book}.For OT/NT, chapter cannot be greater than 175.")
- return errors
-
- # Check against book_code or book_name (case-insensitive)
- book_obj = (
- db.query(db_models.BookLookup)
- .filter(
- (db_models.BookLookup.book_code.ilike(book_val)) |
- (db_models.BookLookup.book_name.ilike(book_val))
- )
- .first()
- )
-
- if not book_obj:
- errors.append(
- f"Invalid book '{item.book}'. Must be OT, NT, or a valid Bible book "
- f"(book_code or book_name)."
- )
- return errors
-
- # --- 3) Chapter validation ---
- if item.chapter is not None:
- try:
- chapter_int = int(item.chapter)
- except ValueError:
- errors.append("chapter must be a number")
- return errors
-
- if chapter_int < 0:
- errors.append("chapter must be ≥ 0")
- elif chapter_int > book_obj.chapter_count:
- errors.append(
- f"chapter {chapter_int} exceeds max chapters "
- f"({book_obj.chapter_count}) for '{book_obj.book_name}'."
- )
-
- return errors
-def test_commentary_images(db, resource_id: int):
- """
- Test if all commentary images exist.
- """
-
- # 1. Fetch resource
- res = db.query(db_models.Resource).filter_by(resource_id=resource_id).first()
- if not res:
- raise NotAvailableException(detail="Resource not found")
-
- base_url = extract_base_url(res)
- if not base_url:
- raise UnprocessableException(detail="Base URL missing in resource metadata")
-
- # Helper: extract all image filenames
- def collect_filenames():
- filenames = set()
- for row in (
- db.query(db_models.Commentary)
- .filter(db_models.Commentary.resource_id == resource_id)
- .all()
- ):
- soup = BeautifulSoup(row.text, "html.parser")
- for img in soup.find_all("img"):
- src = (img.get("src") or "").strip()
- if not src:
- continue
-
- # Always extract the filename only
- fname = src.rstrip("/").split("/")[-1]
-
- filenames.add((row.book_id, row.chapter, row.verse, fname))
- return filenames
-
-
- # Helper: remote check
- def remote_exists(url: str) -> bool:
- try:
- response = requests.head(url, timeout=5)
- return response.status_code == 200
- except requests.RequestException:
- return False
-
- all_filenames = collect_filenames()
-
- report = []
- for book_id, chapter, verse, fname in all_filenames:
- url = f"{base_url}/{fname}"
- exists = remote_exists(url)
- report.append(
- {
- "bookId": book_id,
- "chapter": chapter,
- "verse": verse,
- "file_name": fname,
- "present": exists,
- }
- )
-
- images_present = sum(1 for item in report if item["present"])
-
- return {
- "success": True,
- "resource_id": resource_id,
- "base_url": base_url,
- "total_images": len(all_filenames),
- "images_present": images_present,
- "images": report,
- }
-
-def validate_commentary_book_and_chapter(db: Session, book_id: int, chapter: int):
- """
- Validates that:
- - book_id exists in BookLookup table
- - chapter does NOT exceed chapter_count for that book
- """
-
- # 1. Check book exists
- book = (
- db.query(db_models.BookLookup)
- .filter(db_models.BookLookup.book_id == book_id)
- .first()
- )
-
- if not book:
- raise NotAvailableException(
- detail=f"Invalid book_id {book_id}. Book does not exist in BookLookup."
- )
-
- if chapter < 0:
- raise BadRequestException(
- detail=f"Chapter must be >= 0 for book_id {book_id}."
- )
-
- if chapter > book.chapter_count:
- raise BadRequestException(
- detail=(
- f"Invalid chapter {chapter} for book_id {book_id}. "
- f"Max allowed chapter is {book.chapter_count}."
- )
- )
-
-def validate_audio_bible_books(db, books: dict):
- """
- Validate Audio Bible books:
- - book_code must exist in BookLookup (case-insensitive)
- - chapter count must be <= chapter_count in BookLookup
- """
-
- errors = []
-
- for book_code, chapter_count in books.items():
- bc = book_code.strip().lower()
-
- # --- 1. Check if book code exists in BookLookup ---
- book_obj = (
- db.query(db_models.BookLookup)
- .filter(db_models.BookLookup.book_code.ilike(bc))
- .first()
- )
-
- if not book_obj:
- errors.append(
- f"Invalid book code '{book_code}'. Must match book_code in BookLookup table."
- )
- continue
-
- # --- 2. Validate chapter_count is integer ---
- if not isinstance(chapter_count, int) or chapter_count <= 0:
- errors.append(
- f"Chapter count for '{book_code}' must be a positive integer."
- )
- continue
-
- # --- 3. Validate chapter_count does not exceed Bible max ---
- if chapter_count > book_obj.chapter_count:
- errors.append(
- f"Chapter count {chapter_count} exceeds max chapters "
- f"({book_obj.chapter_count}) for '{book_obj.book_code}'."
- )
-
- return errors
-def check_audio_bible_remote(db, resource_id: int):
- """Checks remote DigitalOcean Spaces for missing audio files for a given Audio Bible."""
-
- ab = db.query(db_models.AudioBible).filter_by(resource_id=resource_id).first()
- if not ab:
- return {"error": f"Audio Bible not found for resource_id {resource_id}"}
-
- base = ab.base_url.rstrip("/")
- books = ab.books
- fmt = ab.format
-
- results = []
- books_found = len(books)
- full_books_present = 0
- all_missing_files = {}
-
- for book_code, total_chapters in books.items():
- missing = []
- present = 0
-
- for chap in range(1, total_chapters + 1):
- url = f"{base}/{book_code}/{chap}.{fmt}"
-
- # --- Step 1: First GET attempt ---
- file_exists = False
- try:
- r = requests.get(url, timeout=10, stream=True)
- if r.status_code == 200:
- file_exists = True
- except:
- pass
-
- # --- Step 2: Retry GET once if failed ---
- if not file_exists:
- try:
- time.sleep(0.15) # 150 ms CDN warm-up
- r = requests.get(url, timeout=10, stream=True)
- if r.status_code == 200:
- file_exists = True
- except:
- pass
-
- # --- Step 3: Fallback to HEAD request ---
- if not file_exists:
- try:
- h = requests.head(url, timeout=10, allow_redirects=True)
- if h.status_code == 200:
- file_exists = True
- except:
- pass
-
- # --- Final Result ---
- if file_exists:
- present += 1
- else:
- missing.append(chap)
-
- if len(missing) == 0:
- full_books_present += 1
-
- results.append({
- "book": book_code,
- "total_chapters": total_chapters,
- "present": present,
- "missing_chapters": missing
- })
-
- if missing:
- all_missing_files[book_code] = missing
-
- # Save results
- ab.files_missing = all_missing_files
- ab.test_date = datetime.now(timezone.utc)
- db.commit()
- return {
- "success": True,
- "resource_id": resource_id,
- "books_found": books_found,
- "full_books_present": full_books_present,
- "base_url": base,
- "test_date": ab.test_date.isoformat(),
- "audio_files": results
- }
-def _is_public_url(url: str) -> bool:
- try:
- r = requests.get(url, timeout=8, allow_redirects=True)
-
- # If non-YouTube URL → fallback logic
- if "youtube.com" not in r.url and "youtu.be" not in url:
- return r.status_code < 400
-
- # ---- YouTube-specific validation ----
-
- html = r.text.lower()
-
- # YouTube invalid-video markers
- if "video unavailable" in html or "this video is unavailable" in html:
- return False
-
- # If YouTube returns a watch page but without player → invalid
- if "player-unavailable" in html:
- return False
-
- # If YouTube returns 410, 404, 429 etc.
- if r.status_code >= 400:
- return False
-
- return True
-
- except requests.RequestException:
- return False
-
-
-def test_videos_for_resource(db: Session, resource_id: int):
- """Test videos for resource"""
- # Validate resource
- res = db.query(db_models.Resource).filter_by(resource_id=resource_id).first()
- if not res:
- raise NotAvailableException(detail="Resource not found")
- if (res.content_type or "").lower() != "video":
- raise TypeException(
- detail="Resource is not of type 'video'"
- )
-
- videos = db.query(db_models.Video).filter_by(resource_id=resource_id).all()
-
- out_items = []
- public_count = 0
-
- for vid in videos:
- is_public = _is_public_url(vid.url)
-
- if is_public:
- public_count += 1
-
- out_items.append({
- "videoId": vid.video_id,
- "book": vid.book,
- "chapter": vid.chapter,
- "url": vid.url,
- "public": is_public
- })
-
- return {
- "success": True,
- "resource_id": resource_id,
- "videos_found": len(videos),
- "videos_public": public_count,
- "videos": out_items
- }
-def test_isl_bible_videos_for_resource(db: Session, resource_id: int):
- """Test isl bible videos for resource"""
- # Validate resource
- res = db.query(db_models.Resource).filter_by(resource_id=resource_id).first()
- if not res:
- raise NotAvailableException(detail="Resource not found")
- if (res.content_type or "").lower() != "isl_bible":
- raise TypeException(
- detail="Resource is not of type 'isl_bible'"
- )
-
- isl_videos = db.query(db_models.IslVideo).filter_by(resource_id=resource_id).all()
-
- out_items = []
- public_count = 0
-
- for vid in isl_videos:
- is_public = _is_public_url(vid.url)
-
- if is_public:
- public_count += 1
-
- out_items.append({
- "islvideoId": vid.id,
- "book": vid.book_id,
- "chapter": vid.chapter,
- "url": vid.url,
- "public": is_public
- })
-
- return {
- "success": True,
- "resource_id": resource_id,
- "isl_videos_found": len(isl_videos),
- "isl_videos_public": public_count,
- "isl_videos": out_items
- }
-async def validate_usfm_file(file: UploadFile) -> Dict[str, Any]:
- """
- Validates USFM file structure and returns validation result.
- Returns dict with 'valid' (bool) and optional metadata keys.
- """
- try:
- # 1. VALIDATE FILE EXTENSION
- if not file.filename:
- raise UnprocessableException(detail="No filename provided")
-
- filename_lower = file.filename.lower()
- if not filename_lower.endswith((".usfm", ".sfm")):
- raise UnprocessableException(
- detail=(
- f"Invalid file type. Expected .usfm or .sfm file, "
- f"got '{file.filename}'. Please upload a valid USFM file."
- )
- )
-
- # 2. READ FILE CONTENT (ASYNC SAFE)
- content = await file.read()
-
- # 3. CHECK FILE SIZE
- max_size = 10 * 1024 * 1024 # 10 MB
- if len(content) > max_size:
- await file.seek(0)
- raise UnprocessableException(
- detail=(
- f"File too large ({len(content)} bytes). "
- f"Maximum allowed: {max_size} bytes"
- )
- )
-
- # 4. RESET FILE POINTER
- await file.seek(0)
-
- # 5. DECODE CONTENT
- try:
- usfm_content = content.decode("utf-8")
- except UnicodeDecodeError:
- raise UnprocessableException(
- detail=(
- "File encoding error. USFM files must be UTF-8 encoded "
- "plain text."
- )
- )
-
- # 6. EMPTY FILE CHECK
- if not usfm_content.strip():
- raise UnprocessableException(
- detail="USFM file is empty or contains only whitespace"
- )
-
- # 7. BINARY FILE CHECK
- if "\x00" in usfm_content:
- raise UnprocessableException(
- detail=(
- "File appears to be binary, not text. "
- "USFM files must be plain text files."
- )
- )
-
- # 8. REQUIRED \id MARKER
- if "\\id" not in usfm_content:
- raise UnprocessableException(
- detail=(
- "Not a valid USFM file. Missing required \\id marker. "
- "Please ensure this is a properly formatted USFM file."
- )
- )
-
- # 9. PARSE WITH USFM-GRAMMAR (THREADPOOL SAFE)
- try:
- parser = USFMParser(usfm_content)
- usj_data = await run_in_threadpool(parser.to_usj)
-
- # Validate USJ structure
- if not isinstance(usj_data, dict) or "content" not in usj_data:
- raise UnprocessableException(
- detail="Invalid USFM structure: Cannot parse to valid USJ format"
- )
-
- # Extract book code
- book_code = None
- for item in usj_data.get("content", []):
- if item.get("type") == "book" and item.get("marker") == "id":
- book_code = item.get("code")
- break
-
- if not book_code:
- raise UnprocessableException(
- detail="USFM file must contain a valid book code in \\id marker"
- )
-
- # Count chapters
- chapter_count = sum(
- 1 for item in usj_data.get("content", [])
- if item.get("type") == "chapter"
- )
-
- if chapter_count == 0:
- raise UnprocessableException(
- detail="USFM file must contain at least one chapter (\\c marker)"
- )
-
- return {
- "valid": True,
- "book_code": book_code,
- "chapter_count": chapter_count,
- }
-
- except HTTPException:
- raise
- except Exception as parse_error:
- raise UnprocessableException(
- detail=(
- f"USFM parsing error: {str(parse_error)}. "
- "Please ensure this is a valid USFM file."
- )
- )
-
- except HTTPException:
- raise
- except Exception as exc:
- raise UnprocessableException(
- detail=f"File validation error: {str(exc)}"
- )
-
-def _resolve_book_id(db, book_code: str):
- """Return BookLookup row (object) for given book_code (case-insensitive) or raise 422."""
- if not book_code:
- raise UnprocessableException(
- detail="book code is required")
- book = (
- db.query(db_models.BookLookup)
- .filter(db_models.BookLookup.book_code.ilike(book_code))
- .first()
- )
- if not book:
- raise UnprocessableException(
- detail=f"Invalid book code '{book_code}'")
- return book
-
-def create_isl_videos(db, payload: schema.IslVideoCreateRequest):
- resource = (
- db.query(db_models.Resource)
- .filter(db_models.Resource.resource_id == payload.resourceId)
- .first()
- )
- if not resource:
- raise NotAvailableException(detail=f"Resource {payload.resourceId} not found")
- # Resource content_type must be 'isl_bible'
- if resource.content_type.lower() != "isl_bible":
- raise BadRequestException(
- detail=f"Resource {payload.resourceId} is not of type 'isl_bible' (found '{resource.content_type}')"
- )
- created: List[schema.IslVideoResponseItem] = []
-
- for item in payload.videos:
- # resolve book
- book_obj = _resolve_book_id(db, item.book)
- # chapter validation: allow 0 .. chapter_count
- if item.chapter < 0 or item.chapter > book_obj.chapter_count:
- raise UnprocessableException(
- detail=f"Invalid chapter {item.chapter} for book '{item.book}'. Allowed 0 to {book_obj.chapter_count}")
-
- # check duplicate in DB
- dup = (
- db.query(db_models.IslVideo)
- .filter_by(resource_id=payload.resourceId, book_id=book_obj.book_id, chapter=item.chapter)
- .first()
- )
- if dup:
- raise AlreadyExistsException(
- detail=f"Duplicate entry for resource_id={payload.resourceId}, book={item.book}, chapter={item.chapter}")
-
- row = db_models.IslVideo(
- resource_id=payload.resourceId,
- book_id=book_obj.book_id,
- chapter=item.chapter,
- url=item.url,
- title=item.title,
- description=item.description,
- )
- db.add(row)
- db.flush()
-
- created.append(schema.IslVideoResponseItem(
- video_id=row.id,
- book=book_obj.book_code,
- chapter=row.chapter,
- url=row.url,
- title=row.title,
- description=row.description
- ))
-
- try:
- db.commit()
- except SQLAlchemyError as exc:
- db.rollback()
- raise GenericException(detail=str(exc))
- return {"resource_id": payload.resourceId, "videos": created}
-
-
-def update_isl_videos(db, payload: schema.IslVideoUpdateRequest):
- resource = (
- db.query(db_models.Resource)
- .filter(db_models.Resource.resource_id == payload.resourceId)
- .first()
- )
- if not resource:
- raise NotAvailableException(detail=f"Resource {payload.resourceId} not found")
- # Resource content_type must be 'isl_bible'
- if resource.content_type.lower() != "isl_bible":
- raise BadRequestException(
- detail=f"Resource {payload.resourceId} is not of type 'isl_bible' (found '{resource.content_type}')"
- )
- updated: List[schema.IslVideoResponseItem] = []
-
- for item in payload.videos:
- row = db.query(db_models.IslVideo).filter_by(id=item.id).first()
- if not row:
- raise NotAvailableException(detail="ISL Video id {item.id} not found")
- book_obj = _resolve_book_id(db, item.book)
-
- # chapter validation: allow 0 .. chapter_count
- if item.chapter < 0 or item.chapter > book_obj.chapter_count:
- raise UnprocessableException(detail=f"Invalid chapter {item.chapter} for book '{item.book}'. Allowed 0 to {book_obj.chapter_count}")
-
- # uniqueness check against other rows
- conflict = (
- db.query(db_models.IslVideo)
- .filter(
- db_models.IslVideo.resource_id == payload.resourceId,
- db_models.IslVideo.book_id == book_obj.book_id,
- db_models.IslVideo.chapter == item.chapter,
- db_models.IslVideo.id != row.id
- )
- .first()
- )
- if conflict:
- raise AlreadyExistsException(detail=f"Update would violate uniqueness for resource_id={payload.resourceId}, book={item.book}, chapter={item.chapter}")
-
- # apply updates
- row.resource_id = payload.resourceId
- row.book_id = book_obj.book_id
- row.chapter = item.chapter
- row.url = item.url
- row.title = item.title
- row.description = item.description
-
- updated.append(schema.IslVideoResponseItem(
- video_id=row.id,
- book=book_obj.book_code,
- chapter=row.chapter,
- url=row.url,
- title=row.title,
- description=row.description
- ))
-
- try:
- db.commit()
- except SQLAlchemyError as exc:
- db.rollback()
- raise GenericException(detail=str(exc))
- return {"resource_id": payload.resourceId, "videos": updated}
-
-
-def get_isl_videos(db: Session, resource_id: int, book_code: Optional[str] = None, chapter: Optional[int] = None):
- """
- Return grouped videos as:
- { "books": { "gen": [ {video_id, chapter, title, description, url}, ... ], ... } }
- """
- # Validate resource exists (optional)
- # Fetch rows, join with BookLookup to get book_code
- q = db.query(db_models.IslVideo, db_models.BookLookup).join(
- db_models.BookLookup,
- db_models.IslVideo.book_id == db_models.BookLookup.book_id,
- ).filter(db_models.IslVideo.resource_id == resource_id)
-
- if book_code:
- # normalize and validate input book_code
- bval = book_code.strip().lower()
- bl = _resolve_book_id(db, bval)
- # if book matched BookLookup, filter by book_id
- if bl:
- q = q.filter(db_models.IslVideo.book_id == bl.book_id)
- if chapter is not None:
- # chapter int validation happens elsewhere; here just filter
- q = q.filter(db_models.IslVideo.chapter == chapter)
-
- rows = q.order_by(db_models.BookLookup.book_code, db_models.IslVideo.chapter).all()
- if not rows:
- raise NotAvailableException(
- detail="No ISL videos found")
- result = {}
- for row, bl in rows:
- code = bl.book_code
- entry = {
- "video_id": row.id,
- "chapter": row.chapter,
- "title": row.title,
- "description": row.description,
- "url": row.url,
- }
- result.setdefault(code, []).append(entry)
-
- return {"books": result}
-
-
-def delete_isl_videos(db: Session, resource_id: int, ids: List[int]) -> dict:
- resource = (
- db.query(db_models.Resource)
- .filter(db_models.Resource.resource_id == resource_id)
- .first()
- )
- if not resource:
- raise NotAvailableException(detail=f"Resource {resource_id} not found")
- # Resource content_type must be 'isl_bible'
- if resource.content_type.lower() != "isl_bible":
- raise BadRequestException(
- detail=f"Resource {resource_id} is not of type 'isl_bible' (found '{resource.content_type}')"
- )
- if not ids:
- return {
- "deleted_count": 0,
- "deleted_ids": [],
- "invalid_ids": []
- }
-
- # Get rows that belong to this resource_id
- rows = db.query(db_models.IslVideo).filter(
- db_models.IslVideo.id.in_(ids),
- db_models.IslVideo.resource_id == resource_id
- ).all()
-
- existing_ids = [r.id for r in rows]
-
- # IDs provided but not found or not belonging to this resource
- invalid_ids = list(set(ids) - set(existing_ids))
-
- # Delete the valid ones
- deleted_count = (
- db.query(db_models.IslVideo)
- .filter(db_models.IslVideo.id.in_(existing_ids))
- .delete(synchronize_session=False)
- )
-
- db.commit()
-
- return {
- "deleted_count": deleted_count,
- "deleted_ids": existing_ids,
- "invalid_ids": invalid_ids
- }
diff --git a/backend/app/crud/content_bible.py b/backend/app/crud/content_bible.py
index 8345fec7..82112890 100644
--- a/backend/app/crud/content_bible.py
+++ b/backend/app/crud/content_bible.py
@@ -1,7 +1,8 @@
"""CRUD operations for the Bible."""
+import re
from typing import Dict, List,Any
from sqlalchemy.orm import Session
-from sqlalchemy import func
+from sqlalchemy import func, text
from fastapi import UploadFile
import db_models
import schema
@@ -23,7 +24,15 @@
_compute_next_verse,
)
+from dependencies import logger
+import time
+def _ms(start: float) -> float:
+ return (time.perf_counter() - start) * 1000
+
+def tprint(step: str, start: float, **kw):
+ meta = " ".join([f"{k}={v}" for k, v in kw.items()]) if kw else ""
+ print(f"[{_ms(start):9.2f} ms] {step} {meta}")
def validate_chapter_count(db_session: Session,
book_code: str, chapter_count: int):
"""Validate chapter count against DB table"""
@@ -44,62 +53,136 @@ def validate_chapter_count(db_session: Session,
)
)
-def upload_bible_book(
- db_session: Session,
- resource_id: int,
- usfm_file: UploadFile,
- actor_user_id: int
-) -> Dict[str, str]:
- """Upload and process a new bible book"""
-
- # Check if resource exists and validate content type
- resource = _get_resource(db_session, resource_id)
+# def upload_bible_book(
+# db_session: Session,
+# resource_id: int,
+# usfm_file: UploadFile,
+# actor_user_id: int,
+# pre_parsed_usj_data: Dict[str, Any] = None,
+# usfm_content: str = None
+# ) -> Dict[str, str]:
+# """Upload and process a new bible book"""
+
+# # Check if resource exists and validate content type
+# resource = _get_resource(db_session, resource_id)
- # Resource content_type must be 'bible'
- if resource.content_type.lower() != "bible":
- raise BadRequestException(
- detail=(
- f"Resource {resource_id} is not of type 'bible' "
- f"(found '{resource.content_type}')"
- )
- )
+# if resource.content_type.lower() != "bible":
+# raise BadRequestException(
+# detail=(
+# f"Resource {resource_id} is not of type 'bible' "
+# f"(found '{resource.content_type}')"
+# )
+# )
- usfm_content = _read_usfm_file(usfm_file)
- book_code = extract_book_code_from_usfm(usfm_content)
- book = _lookup_book_or_404(db_session, book_code)
+# # Read file if not already provided
+# if usfm_content is None:
+# usfm_content = _read_usfm_file(usfm_file)
+
+# # Extract book code
+# book_code = extract_book_code_from_usfm(usfm_content)
+# book = _lookup_book_or_404(db_session, book_code)
- _check_book_not_exists(db_session, resource_id, book.book_id)
+# _check_book_not_exists(db_session, resource_id, book.book_id)
- usj_data = _parse_usfm_to_usj(usfm_content)
+# # Parse USFM if not already parsed
+# if pre_parsed_usj_data is not None:
+# usj_data = pre_parsed_usj_data
+# else:
+# usj_data = _parse_usfm_to_usj(usfm_content)
- content_items = usj_data.get("content", [])
- chapter_count = _count_chapters(content_items)
- validate_chapter_count(db_session, book_code, chapter_count)
+# content_items = usj_data.get("content", [])
+# chapter_count = _count_chapters(content_items)
+# validate_chapter_count(db_session, book_code, chapter_count)
- entry_data = BibleEntrySchema(
- resource_id=resource_id,
- book_id=book.book_id,
- usfm_content=usfm_content,
- usj_data=usj_data,
- chapter_count=chapter_count
- )
+# entry_data = BibleEntrySchema(
+# resource_id=resource_id,
+# book_id=book.book_id,
+# usfm_content=usfm_content,
+# usj_data=usj_data,
+# chapter_count=chapter_count
+# )
- bible_record = _create_bible_entry(db_session, entry_data)
+# bible_record = _create_bible_entry(db_session, entry_data)
- _save_clean_verses(
- db_session=db_session,
+# _save_clean_verses(
+# db_session=db_session,
+# resource_id=resource_id,
+# book_id=book.book_id,
+# usj_data=usj_data
+# )
+
+# touch_resource(db_session, resource_id=bible_record.resource_id, actor_user_id=actor_user_id)
+# db_session.commit()
+
+# return {
+# "message": "Bible book uploaded successfully",
+# "bible_book_id": bible_record.bible_book_id
+# }
+
+
+def upload_bible_book(
+ db_session: Session,
+ resource_id: int,
+ actor_user_id: int,
+ usj_data: Dict[str, Any],
+ usfm_content: str,
+ book_id: int,
+ book_code: str,
+ chapter_count: int,
+) -> Dict[str, str]:
+ t0 = time.perf_counter()
+ tprint("CRUD START upload_bible_book", t0, resource_id=resource_id, book_code=book_code)
+
+ # Insert Bible table
+ s1 = time.perf_counter()
+ bible_record = db_models.Bible(
resource_id=resource_id,
- book_id=book.book_id,
- usj_data=usj_data
+ book_id=book_id,
+ usfm=usfm_content,
+ json=usj_data,
+ chapters=chapter_count,
)
+ db_session.add(bible_record)
+ db_session.flush()
+ tprint("insert bible DONE", t0, step_ms=_ms(s1), bible_book_id=bible_record.bible_book_id)
- touch_resource(db_session, resource_id=bible_record.resource_id, actor_user_id=actor_user_id)
+ # Build clean verses list
+ s2 = time.perf_counter()
+ verses = parse_usfm_to_clean_verses(usj_data)
+ tprint("parse_usfm_to_clean_verses DONE", t0, step_ms=_ms(s2), verses=len(verses))
+
+ s3 = time.perf_counter()
+ rows = [
+ {
+ "resource_id": resource_id,
+ "book_id": book_id,
+ "chapter": v["chapter"],
+ "verse": v["verse"],
+ "text": v["text"],
+ }
+ for v in verses
+ ]
+ tprint("build rows DONE", t0, step_ms=_ms(s3), rows=len(rows))
+
+ # SUPER FAST insert
+ s4 = time.perf_counter()
+ if rows:
+ db_session.bulk_insert_mappings(db_models.CleanBible, rows)
+ tprint("bulk_insert clean_bible DONE", t0, step_ms=_ms(s4))
+
+ # Update resource audit
+ s5 = time.perf_counter()
+ touch_resource(db_session, resource_id=resource_id, actor_user_id=actor_user_id)
+ tprint("touch_resource DONE", t0, step_ms=_ms(s5))
+
+ # Commit
+ s6 = time.perf_counter()
db_session.commit()
+ tprint("commit DONE", t0, step_ms=_ms(s6))
+
+ tprint("CRUD END upload_bible_book", t0, total_ms=_ms(t0))
+ return {"message": "Bible book uploaded successfully", "bible_book_id": bible_record.bible_book_id}
- return {
- "message": "Bible book uploaded successfully",
- "bible_book_id": bible_record.bible_book_id
- }
def _get_resource(db_session: Session, resource_id: int):
resource = db_session.query(db_models.Resource).filter_by(resource_id=resource_id).first()
if not resource:
@@ -159,44 +242,130 @@ def _save_clean_verses(
)
+# def update_bible_book(
+# db_session: Session,
+# bible_book_id: int,
+# usfm_file: UploadFile,
+# actor_user_id: int,
+# pre_parsed_usj_data: Dict[str, Any] = None,
+# usfm_content: str = None
+# ) -> Dict[str, str]:
+# """Update an existing bible book"""
+
+# bible_record = _get_bible_record_or_404(db_session, bible_book_id)
+
+# # Read file if not already provided
+# if usfm_content is None:
+# usfm_content = _read_usfm_file(usfm_file)
+
+# book_code = extract_book_code_from_usfm(usfm_content)
+# _validate_book_code_matches(db_session, bible_record.book_id, book_code)
+
+# # Parse USFM if not already parsed
+# if pre_parsed_usj_data is not None:
+# usj_data = pre_parsed_usj_data
+# else:
+# usj_data = _parse_usfm_to_usj(usfm_content)
+
+# content_items = usj_data.get("content", [])
+# chapter_count = _count_chapters(content_items)
+# validate_chapter_count(db_session, book_code, chapter_count)
+
+# _update_bible_entry(
+# bible_record=bible_record,
+# usfm_content=usfm_content,
+# usj_data=usj_data,
+# chapter_count=chapter_count,
+# )
+
+# _replace_clean_verses(
+# db_session=db_session,
+# resource_id=bible_record.resource_id,
+# book_id=bible_record.book_id,
+# usj_data=usj_data
+# )
+
+# touch_resource(db_session, resource_id=bible_record.resource_id, actor_user_id=actor_user_id)
+# db_session.commit()
+
+# return {"message": "Bible book updated successfully"}
+
def update_bible_book(
db_session: Session,
bible_book_id: int,
- usfm_file: UploadFile,
- actor_user_id: int
+ actor_user_id: int,
+ usj_data: Dict[str, Any],
+ usfm_content: str,
+ book_id: int,
+ book_code: str,
+ chapter_count: int,
) -> Dict[str, str]:
- """Update an existing bible book"""
+ t0 = time.perf_counter()
+ tprint("CRUD START update_bible_book", t0, bible_book_id=bible_book_id, book_code=book_code)
+ # 1) get existing bible row
+ s1 = time.perf_counter()
bible_record = _get_bible_record_or_404(db_session, bible_book_id)
- usfm_content = _read_usfm_file(usfm_file)
+ tprint("get bible_record DONE", t0, step_ms=_ms(s1), resource_id=bible_record.resource_id)
- book_code = extract_book_code_from_usfm(usfm_content)
- _validate_book_code_matches(db_session, bible_record.book_id, book_code)
+ # 2) safety: uploaded book must match existing book_id
+ if bible_record.book_id != book_id:
+ raise BadRequestException(
+ detail=f"Book mismatch: existing book_id={bible_record.book_id} vs uploaded book_id={book_id} ({book_code})"
+ )
- usj_data = _parse_usfm_to_usj(usfm_content)
+ # 3) update bible table
+ s2 = time.perf_counter()
+ bible_record.usfm = usfm_content
+ bible_record.json = usj_data
+ bible_record.chapters = chapter_count
+ db_session.add(bible_record)
+ tprint("update bible row DONE", t0, step_ms=_ms(s2))
- content_items = usj_data.get("content", [])
- chapter_count = _count_chapters(content_items)
- validate_chapter_count(db_session, book_code, chapter_count)
+ # 4) delete clean_bible rows (fast)
+ s3 = time.perf_counter()
+ db_session.query(db_models.CleanBible).filter_by(
+ resource_id=bible_record.resource_id,
+ book_id=book_id
+ ).delete(synchronize_session=False)
+ tprint("delete clean_bible DONE", t0, step_ms=_ms(s3))
- _update_bible_entry(
- bible_record=bible_record,
- usfm_content=usfm_content,
- usj_data=usj_data,
- chapter_count=chapter_count,
- )
+ # 5) rebuild verses + bulk insert
+ s4 = time.perf_counter()
+ verses = parse_usfm_to_clean_verses(usj_data)
+ tprint("parse_usfm_to_clean_verses DONE", t0, step_ms=_ms(s4), verses=len(verses))
+
+ s5 = time.perf_counter()
+ rows = [
+ {
+ "resource_id": bible_record.resource_id,
+ "book_id": book_id,
+ "chapter": v["chapter"],
+ "verse": v["verse"],
+ "text": v["text"],
+ }
+ for v in verses
+ ]
+ tprint("build rows DONE", t0, step_ms=_ms(s5), rows=len(rows))
- _replace_clean_verses(
- db_session=db_session,
- resource_id=bible_record.resource_id,
- book_id=bible_record.book_id,
- usj_data=usj_data
- )
+ s6 = time.perf_counter()
+ if rows:
+ db_session.bulk_insert_mappings(db_models.CleanBible, rows)
+ tprint("bulk_insert clean_bible DONE", t0, step_ms=_ms(s6))
+ # 6) touch + commit
+ s7 = time.perf_counter()
touch_resource(db_session, resource_id=bible_record.resource_id, actor_user_id=actor_user_id)
+ tprint("touch_resource DONE", t0, step_ms=_ms(s7))
+
+ s8 = time.perf_counter()
db_session.commit()
+ tprint("commit DONE", t0, step_ms=_ms(s8))
+
+ tprint("CRUD END update_bible_book", t0, total_ms=_ms(t0))
+ return {"message": "Bible book updated successfully", "bible_book_id": bible_book_id}
+
- return {"message": "Bible book updated successfully"}
def _get_bible_record_or_404(db_session: Session, bible_book_id: int):
record = db_session.query(db_models.Bible).filter_by(bible_book_id=bible_book_id).first()
if not record:
@@ -321,31 +490,37 @@ def delete_bible_books(
def get_bible_books(db_session: Session, resource_id: int) -> schema.BibleBooksListResponse:
"""Get list of books for a bible resource"""
- # Find resource
resource = db_session.query(db_models.Resource).filter_by(resource_id=resource_id).first()
if not resource:
raise NotAvailableException(detail=f"Resource {resource_id} not found")
- books = db_session.query(db_models.Bible, db_models.BookLookup).join(
- db_models.BookLookup, db_models.Bible.book_id == db_models.BookLookup.book_id
- ).filter(db_models.Bible.resource_id == resource_id).all()
-
- book_responses = []
- for bible, book_lookup in books:
- book_responses.append(schema.BibleBookResponse(
- bible_book_id=bible.bible_book_id,
- book_code=book_lookup.book_code,
- book_id=book_lookup.book_id,
- short=book_lookup.book_code, # You may want to add these fields to BookLookup
- long=book_lookup.book_name,
- abbr=book_lookup.book_code[:3]
- ))
-
- return schema.BibleBooksListResponse(
- resource_id=resource_id,
- books=book_responses
+ rows = (
+ db_session.query(
+ db_models.Bible.bible_book_id,
+ db_models.Bible.book_id,
+ db_models.Bible.chapters,
+ db_models.BookLookup.book_code,
+ db_models.BookLookup.book_name,
+ )
+ .join(db_models.BookLookup, db_models.Bible.book_id == db_models.BookLookup.book_id)
+ .filter(db_models.Bible.resource_id == resource_id)
+ .all()
)
+ book_responses = [
+ schema.BibleBookResponse(
+ bible_book_id=r.bible_book_id,
+ book_code=r.book_code,
+ book_id=r.book_id,
+ short=r.book_code,
+ long=r.book_name,
+ abbr=r.book_code[:3],
+ )
+ for r in rows
+ ]
+
+ return schema.BibleBooksListResponse(resource_id=resource_id, books=book_responses)
+
def get_full_bible_content(
db_session: Session,
resource_id: int,
@@ -767,3 +942,277 @@ def _find_book_index(available_books, book_id):
if book.book_id == book_id:
return i
return None
+
+
+# --Book Name CRUD--
+def create_booknames(
+ db: Session,
+ payload: list[schema.BookNameBase],
+ _actor_user_id: int
+):
+ """Create book names"""
+ created_records = []
+
+ for item in payload:
+ if not item.bookCode or not item.bookCode.strip():
+ raise BadRequestException(detail="bookCode field should not be empty")
+ if not item.languageCode or not item.languageCode.strip():
+ raise BadRequestException(detail="languageCode field should not be empty")
+
+ language = db.query(db_models.Language).filter(
+ db_models.Language.language_code == item.languageCode
+ ).first()
+ if not language:
+ raise BadRequestException(detail=f"Invalid languageCode: {item.languageCode}")
+
+ book = db.query(db_models.BookLookup).filter(
+ db_models.BookLookup.book_code == item.bookCode
+ ).first()
+ if not book:
+ raise BadRequestException(detail=f"Invalid bookCode: {item.bookCode}")
+
+ # Duplicate check
+ existing = db.query(db_models.BookName).filter(
+ db_models.BookName.book_id == book.book_id,
+ db_models.BookName.language_id == language.language_id
+ ).first()
+
+ if existing:
+ raise AlreadyExistsException(
+ detail=f"A BookName for bookCode '{item.bookCode}' and languageCode '{item.languageCode}' already exists"
+ )
+
+ book_name = db_models.BookName(
+ abbr=item.abbr,
+ short=item.short,
+ long=item.long,
+ book_id=book.book_id,
+ language_id=language.language_id
+ )
+ db.add(book_name)
+ created_records.append(book_name)
+
+ db.commit()
+
+ for record in created_records:
+ db.refresh(record)
+
+ return {
+ "message": "Book names created successfully",
+ "data": [
+ {
+ "id": r.id,
+ "abbr": r.abbr,
+ "short": r.short,
+ "long": r.long,
+ "book_id": r.book_id,
+ "language_id": r.language_id,
+ }
+ for r in created_records
+ ]
+ }
+
+def update_bookname(
+ db: Session,
+ payload: schema.BookNameUpdate,
+ _actor_user_id: int
+):
+ """Update book name by primary key ID"""
+ # Fetch the record by primary key
+ book_name = db.query(db_models.BookName).filter(
+ db_models.BookName.id == payload.id
+ ).first()
+
+ if not book_name:
+ raise NotAvailableException(detail=f"BookName with id {payload.id} not found")
+
+ # Specific field validation
+ if not payload.bookCode or not payload.bookCode.strip():
+ raise BadRequestException(detail="bookCode field should not be empty")
+ if not payload.languageCode or not payload.languageCode.strip():
+ raise BadRequestException(detail="languageCode field should not be empty")
+
+ language = db.query(db_models.Language).filter(
+ db_models.Language.language_code == payload.languageCode
+ ).first()
+
+ book = db.query(db_models.BookLookup).filter(
+ db_models.BookLookup.book_code == payload.bookCode
+ ).first()
+
+ if not language:
+ raise BadRequestException(detail=f"Invalid languageCode: {payload.languageCode}")
+ if not book:
+ raise BadRequestException(detail=f"Invalid bookCode: {payload.bookCode}")
+
+ duplicate = db.query(db_models.BookName).filter(
+ db_models.BookName.book_id == book.book_id,
+ db_models.BookName.language_id == language.language_id,
+ db_models.BookName.id != book_name.id
+ ).first()
+
+ if duplicate:
+ raise AlreadyExistsException(
+ detail=f"A BookName for bookCode '{payload.bookCode}' and languageCode '{payload.languageCode}' already exists"
+ )
+
+ book_name.abbr = payload.abbr
+ book_name.short = payload.short
+ book_name.long = payload.long
+ book_name.book_id = book.book_id
+ book_name.language_id = language.language_id
+
+ db.commit()
+ db.refresh(book_name)
+
+ return {
+ "message": "Book name updated successfully",
+ "data": {
+ "id": book_name.id,
+ "abbr": book_name.abbr,
+ "short": book_name.short,
+ "long": book_name.long,
+ "book_id": book_name.book_id,
+ "language_id": book_name.language_id,
+ }
+ }
+
+def delete_booknames(
+ db: Session,
+ ids: list[int],
+ _actor_user_id: int
+):
+ """Delete book names by IDs"""
+ if not ids:
+ raise BadRequestException(
+ detail="IDs list cannot be empty"
+ )
+
+ deleted_ids = []
+ invalid_ids = []
+
+ for book_id in ids:
+ book = db.query(db_models.BookName).filter(
+ db_models.BookName.id == book_id
+ ).first()
+
+ if book:
+ db.delete(book)
+ deleted_ids.append(book_id)
+ else:
+ invalid_ids.append(book_id)
+
+ db.commit()
+
+ return {
+ "deleted_count": len(deleted_ids),
+ "deleted_ids": deleted_ids,
+ "invalid_ids": invalid_ids,
+ }
+def get_booknames(
+ db: Session,
+ language_code: str | None,
+):
+ """Get book names"""
+ query = (
+ db.query(db_models.BookName, db_models.BookLookup, db_models.Language)
+ .join(db_models.BookLookup, db_models.BookLookup.book_id == db_models.BookName.book_id)
+ .join(db_models.Language, db_models.Language.language_id == db_models.BookName.language_id)
+ )
+
+ if language_code:
+ query = query.filter(db_models.Language.language_code == language_code)
+
+ rows = query.all()
+
+ languages: dict = {}
+ for book_name, book_lookup, lang in rows:
+ if lang.language_id not in languages:
+ languages[lang.language_id] = {
+ "language": {
+ "id": lang.language_id,
+ "code": lang.language_code,
+ "name": lang.language_name,
+ },
+ "bookNames": [],
+ }
+ languages[lang.language_id]["bookNames"].append({
+ "id": book_name.id,
+ "abbr": book_name.abbr,
+ "short": book_name.short,
+ "long": book_name.long,
+ "book_id": book_name.book_id,
+ "language_id": book_name.language_id,
+ })
+
+ return {
+ "data": list(languages.values())
+ }
+
+def search_bible(
+ db_session: Session,
+ resource_id: int,
+ keyword: str
+):
+ """Bible Keyword Search API"""
+ start = time.perf_counter()
+
+ # --- Validation ---
+ resource = db_session.query(db_models.Resource).filter_by(
+ resource_id=resource_id
+ ).first()
+
+ if not resource:
+ raise NotAvailableException(detail=f"Resource {resource_id} not found")
+
+ if resource.content_type.lower() != "bible":
+ raise BadRequestException(
+ detail=f"Resource {resource_id} is not of type 'bible'"
+ )
+
+ keyword = keyword.strip()
+ if not keyword:
+ raise BadRequestException(detail="Keyword must not be empty")
+
+
+ # --- SEARCH (Full-text search preferred) ---
+ keyword = keyword.strip()
+ if any(ord(c) > 127 for c in keyword):
+ ts_query = func.plainto_tsquery("simple", keyword)
+ else:
+ safe_keyword = keyword.lower()
+ tokens = re.findall(r"[a-z0-9]+", safe_keyword)
+ if not tokens:
+ raise BadRequestException(detail="Keyword invalid")
+ query_string = " & ".join(f"{t}:*" for t in tokens)
+ ts_query = func.to_tsquery("simple", query_string)
+
+ rows = (
+ db_session.query(
+ db_models.BookLookup.book_code,
+ db_models.CleanBible.chapter,
+ db_models.CleanBible.verse,
+ db_models.CleanBible.text
+ )
+ .join(
+ db_models.BookLookup,
+ db_models.CleanBible.book_id == db_models.BookLookup.book_id
+ )
+ .filter(db_models.CleanBible.resource_id == resource_id)
+ .filter(
+ func.to_tsvector("simple", db_models.CleanBible.text).op("@@")(ts_query)
+ )
+ .all()
+ )
+
+ result = [
+ {
+ "bookCode": r.book_code,
+ "chapter": r.chapter,
+ "verse": r.verse,
+ "text": r.text
+ }
+ for r in rows
+ ]
+ tprint("search_bible_keyword DONE", start, results=len(result))
+ return result
diff --git a/backend/app/crud/content_crud.py b/backend/app/crud/content_crud.py
index 9bcc581b..bcc79478 100644
--- a/backend/app/crud/content_crud.py
+++ b/backend/app/crud/content_crud.py
@@ -276,7 +276,7 @@ def get_dictionary_words(db_: Session, resource_id: int, skip: int = 0, limit: i
def get_dictionary_index(db_: Session, resource_id: int):
- '''Fetches dictionary index grouped by first letter of keywords.'''
+ '''Fetches dictionary index grouped by first letter of word forms.'''
# Verify resource exists
resource_db_content = db_.query(db_models.Resource).filter(
@@ -293,37 +293,48 @@ def get_dictionary_index(db_: Session, resource_id: int):
)
)
- model_cls = db_models.Dictionary
- words = db_.query(model_cls.word_id, model_cls.keyword).filter(
- model_cls.resource_id == resource_id
- ).order_by(model_cls.keyword).all()
+ # Fetch all words for this resource — no join needed
+ words = (
+ db_.query(db_models.Dictionary.word_id, db_models.Dictionary.word_forms)
+ .filter(db_models.Dictionary.resource_id == resource_id)
+ .all()
+ )
- # Group by first letter
- index_dict = {}
+ # Expand each comma-separated word_forms string into individual entries
+ entries = []
for word in words:
- if word.keyword:
- first_letter = word.keyword[0].upper()
- if first_letter not in index_dict:
- index_dict[first_letter] = []
- index_dict[first_letter].append({
- 'wordId': word.word_id,
- 'word': word.keyword
- })
+ if not word.word_forms:
+ continue
+ for form in word.word_forms.split(","):
+ form = form.strip()
+ if form:
+ entries.append((word.word_id, form))
- # Convert to list format
- index_list = []
- for letter in sorted(index_dict.keys()):
- index_list.append({
- 'letter': letter,
- 'words': index_dict[letter]
+ # Sort all entries by word form before grouping
+ entries.sort(key=lambda x: x[1].lower())
+
+ # Group by first letter of the word form
+ index_dict = {}
+ for word_id, form in entries:
+ first_letter = form[0].upper()
+ if first_letter not in index_dict:
+ index_dict[first_letter] = []
+ index_dict[first_letter].append({
+ 'wordId': word_id,
+ 'word': form
})
+ # Convert to sorted list format
+ index_list = [
+ {'letter': letter, 'words': index_dict[letter]}
+ for letter in sorted(index_dict.keys())
+ ]
+
return {
'index': index_list,
'resource_content': resource_db_content
}
-
def get_dictionary_word_by_id(db_: Session, resource_id: int, word_id: int):
'''Fetches a specific dictionary word by wordId and resource_id.'''
diff --git a/backend/app/crud/content_songs.py b/backend/app/crud/content_songs.py
new file mode 100644
index 00000000..dbae76b3
--- /dev/null
+++ b/backend/app/crud/content_songs.py
@@ -0,0 +1,225 @@
+"""CRUD operations for the Songs."""
+from typing import List, Optional, Tuple
+
+from sqlalchemy.orm import Session
+
+import db_models
+import schema
+from custom_exceptions import (
+ NotAvailableException,
+ BadRequestException,
+ AlreadyExistsException,
+)
+from crud.utils import touch_resource
+
+def _check_duplicate_song(
+ db: Session,
+ resource_id: int,
+ name: str,
+ url: str,
+ exclude_id: int = -1,
+) -> None:
+ """Raise AlreadyExistsException if name or url already exists in the resource."""
+ # Check name uniqueness
+ name_duplicate = (
+ db.query(db_models.Song)
+ .filter(
+ db_models.Song.resource_id == resource_id,
+ db_models.Song.name == name,
+ db_models.Song.id != exclude_id,
+ )
+ .first()
+ )
+ if name_duplicate:
+ raise AlreadyExistsException(
+ detail=(
+ f"Song with name '{name}' already exists "
+ f"(id={name_duplicate.id}) in resource {resource_id}"
+ )
+ )
+
+ # Check url uniqueness (only if url is provided)
+ if url is not None:
+ url_duplicate = (
+ db.query(db_models.Song)
+ .filter(
+ db_models.Song.resource_id == resource_id,
+ db_models.Song.url == url,
+ db_models.Song.id != exclude_id,
+ )
+ .first()
+ )
+ if url_duplicate:
+ raise AlreadyExistsException(
+ detail=(
+ f"Song with url '{url}' already exists "
+ f"(id={url_duplicate.id}) in resource {resource_id}"
+ )
+ )
+
+
+def _apply_song_update(
+ db: Session,
+ song: db_models.Song,
+ item: schema.SongUpdate,
+ resource_id: int,
+) -> None:
+ """Apply partial field updates to a single song instance."""
+ new_name = (item.name if item.name is not None else song.name).strip()
+ raw_url = item.url if item.url is not None else song.url
+ new_url = raw_url.strip() if raw_url is not None else None
+
+ _check_duplicate_song(db, resource_id, new_name, new_url, song.id)
+
+ if item.name is not None:
+ song.name = new_name
+ if item.url is not None:
+ song.url = new_url
+ if item.lyrics is not None:
+ song.lyrics = item.lyrics
+
+def get_songs_by_resource(
+ db: Session,
+ resource_id: int,
+) -> List[db_models.Song]:
+ """Get all songs for a resource."""
+ return (
+ db.query(db_models.Song)
+ .filter(db_models.Song.resource_id == resource_id)
+ .order_by(db_models.Song.id)
+ .all()
+ )
+
+
+def get_song_by_id(
+ db: Session,
+ song_id: int,
+) -> Optional[db_models.Song]:
+ """Get a song by id."""
+ return db.query(db_models.Song).filter(db_models.Song.id == song_id).first()
+
+
+def create_songs(
+ db: Session,
+ resource_id: int,
+ songs_in: List[schema.SongCreate],
+ actor_user_id: Optional[int],
+) -> List[int]:
+ """Create new song entries, storing lyrics text directly as provided."""
+ resource = (
+ db.query(db_models.Resource)
+ .filter_by(resource_id=resource_id)
+ .first()
+ )
+ if not resource:
+ raise NotAvailableException(detail=f"Resource {resource_id} not found")
+
+ if resource.content_type.lower() != "song":
+ raise BadRequestException(
+ f"Resource {resource_id} is not of type 'song' "
+ f"(found '{resource.content_type}')"
+ )
+
+ try:
+ new_songs = []
+
+ for song_data in songs_in:
+ _check_duplicate_song(db, resource_id, song_data.name, song_data.url)
+
+ song = db_models.Song(
+ resource_id=resource_id,
+ name=song_data.name,
+ url=song_data.url,
+ lyrics=song_data.lyrics,
+ )
+ db.add(song)
+ new_songs.append(song)
+
+ db.flush()
+ ids = [s.id for s in new_songs]
+
+ touch_resource(db, resource_id, actor_user_id)
+ db.commit()
+
+ return ids
+
+ except Exception:
+ db.rollback()
+ raise
+
+
+def update_songs(
+ db: Session,
+ resource_id: int,
+ songs_in: List[schema.SongUpdate],
+ actor_user_id: Optional[int],
+) -> Tuple[List[int], List[int]]:
+ """Update existing song entries (partial update — only supplied fields change)."""
+ resource = (
+ db.query(db_models.Resource)
+ .filter_by(resource_id=resource_id)
+ .first()
+ )
+ if not resource:
+ raise NotAvailableException(detail=f"Resource {resource_id} not found")
+
+ if resource.content_type.lower() != "song":
+ raise BadRequestException(
+ f"Resource {resource_id} is not of type 'song' "
+ f"(found '{resource.content_type}')"
+ )
+
+ requested_ids = [s.id for s in songs_in]
+ existing_map = {
+ s.id: s
+ for s in db.query(db_models.Song).filter(
+ db_models.Song.resource_id == resource_id,
+ db_models.Song.id.in_(requested_ids),
+ ).all()
+ }
+
+ not_found_ids = [sid for sid in requested_ids if sid not in existing_map]
+ updated_ids = []
+
+ for item in songs_in:
+ if item.id not in existing_map:
+ continue
+ _apply_song_update(db, existing_map[item.id], item, resource_id)
+ updated_ids.append(item.id)
+
+ if updated_ids:
+ touch_resource(db, resource_id, actor_user_id)
+ db.commit()
+
+ return updated_ids, not_found_ids
+
+
+def delete_songs(
+ db: Session,
+ resource_id: int,
+ song_ids: List[int],
+ actor_user_id: Optional[int],
+) -> Tuple[List[int], List[int]]:
+ """Delete song entries by id."""
+ existing = (
+ db.query(db_models.Song)
+ .filter(
+ db_models.Song.resource_id == resource_id,
+ db_models.Song.id.in_(song_ids),
+ )
+ .all()
+ )
+
+ existing_map = {s.id: s for s in existing}
+ not_found_ids = [sid for sid in song_ids if sid not in existing_map]
+ deleted_ids = []
+
+ for song in existing:
+ db.delete(song)
+ deleted_ids.append(song.id)
+
+ if deleted_ids:
+ touch_resource(db, resource_id, actor_user_id)
+ db.commit()
+
+ return deleted_ids, not_found_ids
diff --git a/backend/app/crud/isl_verse_markers_crud.py b/backend/app/crud/isl_verse_markers_crud.py
new file mode 100644
index 00000000..c0521a90
--- /dev/null
+++ b/backend/app/crud/isl_verse_markers_crud.py
@@ -0,0 +1,328 @@
+"""CRUD operations for the ISL verse markers API."""
+import json
+from pathlib import Path
+from typing import List, Dict, Any
+from sqlalchemy.orm import Session
+from sqlalchemy.exc import SQLAlchemyError
+import db_models
+from custom_exceptions import NotAvailableException, AlreadyExistsException,UnprocessableException
+from dependencies import logger
+
+
+VERSIFICATION_PATH = (Path("data/versification.json").resolve())
+
+with open(VERSIFICATION_PATH, "r", encoding="utf-8") as file:
+ VERSIFICATION = json.load(file)
+
+def _ensure_verse_zero(markers):
+ """
+ Ensure intro verse marker exists unless
+ first verse already starts at 00:00:00:00
+ """
+
+ if not markers:
+ return markers
+
+ has_verse_zero = any(
+ str(marker["verse"]) == "0"
+ for marker in markers
+ )
+
+ first_marker_starts_at_zero = (
+ markers[0]["time"] == "00:00:00:00"
+ )
+
+ if not has_verse_zero and not first_marker_starts_at_zero:
+ markers.insert(
+ 0,
+ {
+ "verse": 0,
+ "time": "00:00:00:00"
+ }
+ )
+
+ return markers
+
+
+def update_verse_markers(
+ db_session: Session,
+ isl_bible_id: int,
+ markers: List[Dict[str, Any]],
+):
+ """Updates verse markers for the given ISL Bible ID."""
+ logger.info("Updating ISL verse markers")
+
+ isl_bible_rec = db_session.query(db_models.IslVideo).filter_by(
+ id=isl_bible_id
+ ).first()
+
+ if not isl_bible_rec:
+ logger.error("ISL Bible %s not found",isl_bible_id)
+ raise NotAvailableException(
+ detail=f"ISL Bible {isl_bible_id} not found"
+ )
+
+ record = db_session.query(db_models.IslVerseMarkers).filter_by(
+ isl_video_id=isl_bible_id
+ ).first()
+
+ if not record:
+ logger.error("Verse markers not found for ISL Bible %s", isl_bible_id)
+ raise NotAvailableException(
+ detail=f"Verse markers not found for ISL Bible {isl_bible_id}"
+ )
+
+ markers = _ensure_verse_zero(markers)
+ record.verse_markers_json = markers
+
+ db_session.commit()
+ db_session.refresh(record)
+
+ return record
+
+def get_verse_markers(
+ db_session: Session,
+ isl_bible_id: int,
+):
+ """Retrieves verse markers for the given islbible id"""
+ record = db_session.query(db_models.IslVerseMarkers).filter_by(
+ isl_video_id=isl_bible_id
+ ).first()
+
+ if not record:
+ logger.error("Verse markers not found for ISL Bible %s",isl_bible_id)
+ raise NotAvailableException(
+ detail=f"Verse markers not found for ISL Bible {isl_bible_id}"
+ )
+
+ return record
+def _build_bulk_delete_response(deleted_ids, errors):
+ """Build consistent bulk delete response structure."""
+ return {
+ "data": {"deletedCount": len(deleted_ids),
+ "deletedIds": deleted_ids,
+ "errors": errors if errors else None,
+ },
+ "all_failed": len(deleted_ids) == 0 and len(errors) > 0,
+ "has_errors": len(errors) > 0,
+ }
+
+def delete_verse_markers_bulk(
+ db_session: Session,
+ isl_bible_ids: List[int],
+):
+ """Deletes verse markers for the given ISL Bible IDs."""
+ logger.info("Deleting ISL verse markers")
+ deleted_ids = []
+ errors = []
+
+ for isl_id in isl_bible_ids:
+ try:
+ record = db_session.query(db_models.IslVerseMarkers).filter_by(
+ isl_video_id=isl_id
+ ).first()
+
+ if not record:
+ logger.error("Verse markers not found for ISL Bible %s",isl_id)
+ errors.append(f"Verse markers not found for ISL Bible {isl_id}")
+ continue
+
+ db_session.delete(record)
+ deleted_ids.append(isl_id)
+
+ except SQLAlchemyError as exc:
+ logger.error("Error deleting ISL Bible %s: %s", isl_id, exc)
+ errors.append(f"Error deleting ISL Bible {isl_id}: {exc}")
+
+ db_session.commit()
+
+ return _build_bulk_delete_response(deleted_ids, errors)
+
+
+
+def get_all_verse_markers(db_session: Session):
+ """Get all verse markers without isl bible id"""
+ return db_session.query(db_models.IslVerseMarkers).all()
+
+def _timestamp_to_frames(timestamp: str) -> int:
+ hours, minutes, seconds, frames = map(int, timestamp.split(":"))
+ return (((hours * 60) + minutes) * 60 + seconds) * 100 + frames
+
+def _validate_marker_verses(db_session: Session,isl_video, markers):
+ """
+ Validate verses using versification json.
+ """
+
+ chapter = isl_video.chapter
+
+ # Allow intro chapter
+ if chapter == 0:
+ return
+
+ book = (db_session.query(db_models.BookLookup)
+ .filter_by(book_id=isl_video.book_id)
+ .first())
+
+ if not book:
+ raise UnprocessableException(
+ detail="Book lookup not found"
+ )
+
+ book_code = book.book_code.upper()
+
+ if not book_code:
+ raise UnprocessableException(
+ detail="Unable to determine book code"
+ )
+
+ max_verses_data = (
+ VERSIFICATION["maxVerses"]
+ .get(book_code.upper())
+ )
+
+ if not max_verses_data:
+ raise UnprocessableException(
+ detail=f"No versification data for {book_code}"
+ )
+
+ if chapter > len(max_verses_data):
+ raise UnprocessableException(
+ detail=f"Invalid chapter {chapter}"
+ )
+
+ max_verse = int(max_verses_data[chapter - 1])
+
+ for marker in markers:
+ verse = marker["verse"]
+
+ # allow intro marker
+ if verse == 0:
+ continue
+
+ if isinstance(verse, str) and "_" in verse:
+
+ start, end = map(int, verse.split("_"))
+
+ if start > max_verse or end > max_verse:
+ raise UnprocessableException(
+ detail=(
+ f"Invalid verse range "
+ f"{verse} for chapter {chapter}"
+ )
+ )
+
+ else:
+
+ if int(verse) > max_verse:
+ raise UnprocessableException(
+ detail=(
+ f"Invalid verse {verse} "
+ f"for chapter {chapter}"
+ )
+ )
+
+def _validate_timestamp_order(markers):
+ previous = -1
+
+ for marker in markers:
+ current = _timestamp_to_frames(marker["time"])
+
+ if current <= previous:
+ raise UnprocessableException(
+ detail="timestamps must be in increasing order"
+ )
+
+ previous = current
+
+def add_verse_markers_bulk(
+ db_session: Session,
+ payload: Dict[int, List[Dict[str, Any]]],
+):
+ """
+ Bulk create verse markers.
+
+ Payload format:
+ {
+ 1: [
+ {
+ "verse": 0,
+ "time": "00:00:00:00"
+ }
+ ],
+ 2: [
+ {
+ "verse": "12_13",
+ "time": "00:01:20:10"
+ }
+ ]
+ }
+ """
+
+ logger.info("Adding bulk ISL verse markers")
+
+ created_records = []
+
+ for isl_bible_id, markers in payload.items():
+ markers = [
+ m.model_dump() if hasattr(m, "model_dump") else m
+ for m in markers]
+ # Validate ISL video exists
+ isl_video = (
+ db_session.query(db_models.IslVideo)
+ .filter_by(id=isl_bible_id)
+ .first()
+ )
+
+ if not isl_video:
+ logger.error(
+ "ISL Video %s not found",
+ isl_bible_id
+ )
+ raise NotAvailableException(
+ detail=f"ISL Video {isl_bible_id} not found"
+ )
+
+ # Check existing verse markers
+ existing = (
+ db_session.query(db_models.IslVerseMarkers)
+ .filter_by(isl_video_id=isl_bible_id)
+ .first()
+ )
+
+ if existing:
+ logger.error(
+ "Verse markers already exist for ISL Video %s",
+ isl_bible_id
+ )
+ raise AlreadyExistsException(
+ detail=(
+ f"Verse markers already exist "
+ f"for ISL Video {isl_bible_id}"
+ )
+ )
+
+ # Ensure verse 0 exists
+ markers = _ensure_verse_zero(markers)
+
+ # Validate timestamp order
+ _validate_timestamp_order(markers)
+
+ # Validate verses against clean_bible
+ # chapter 0 allowed
+ _validate_marker_verses(db_session,isl_video,markers)
+
+ record = db_models.IslVerseMarkers(
+ isl_video_id=isl_bible_id,
+ verse_markers_json=markers
+ )
+
+ db_session.add(record)
+
+ created_records.append({
+ "id": isl_bible_id,
+ "markers": markers
+ })
+
+ db_session.commit()
+
+ return created_records
diff --git a/backend/app/crud/remote_filecheck_crud.py b/backend/app/crud/remote_filecheck_crud.py
index 1b1f5734..b047f5d8 100644
--- a/backend/app/crud/remote_filecheck_crud.py
+++ b/backend/app/crud/remote_filecheck_crud.py
@@ -1,6 +1,7 @@
"""
This module contains the functions for remote file check crud operations.
"""
+import re
import asyncio
from typing import Dict, Any
import time
@@ -9,6 +10,7 @@
from sqlalchemy.orm import Session
import requests
import db_models
+from dependencies import logger
from usfm_grammar import USFMParser
from fastapi import HTTPException,UploadFile
from fastapi.concurrency import run_in_threadpool
@@ -16,11 +18,45 @@
BadRequestException,
NotAvailableException,
UnprocessableException,
- TypeException
+ TypeException,
+ AlreadyExistsException,
)
+from sqlalchemy import func
from crud import utils
+_BOOK_ID_RE = re.compile(r"\\id\s+([A-Za-z0-9]{3})")
+import time
+from sqlalchemy import func
+from fastapi.concurrency import run_in_threadpool
+
+def _ms(start: float) -> float:
+ return (time.perf_counter() - start) * 1000
+
+def tprint(step: str, start: float, **kw):
+ meta = " ".join([f"{k}={v}" for k, v in kw.items()]) if kw else ""
+ print(f"[{_ms(start):9.2f} ms] {step} {meta}")
+ logger.info(f"[{_ms(start):9.2f} ms] {step} {meta}")
+
+def _fast_book_code(usfm_content: str) -> str:
+ # Minimal fast method: find "\id" line
+ # Example line: \id GEN ...
+ for line in usfm_content.splitlines():
+ line = line.strip()
+ if line.startswith("\\id "):
+ parts = line.split()
+ if len(parts) >= 2 and parts[1].strip():
+ return parts[1].strip()
+ raise UnprocessableException(detail="Missing or invalid \\id book code")
+
+
+def _extract_book_code_from_usj(usj_data: Dict[str, Any]) -> str:
+ for item in usj_data.get("content", []):
+ if item.get("type") == "book" and item.get("marker") == "id":
+ code = item.get("code")
+ if code:
+ return code
+ raise UnprocessableException(detail="USJ missing book code")
def test_commentary_images(db, resource_id: int):
"""
Test if all commentary images exist.
@@ -373,128 +409,312 @@ async def check_one(vid):
"isl_videos": out_items
}
-async def validate_usfm_file(file: UploadFile) -> Dict[str, Any]:
- """
- Validates USFM file structure and returns validation result.
- Returns dict with 'valid' (bool) and optional metadata keys.
- """
+# async def validate_usfm_file_internal(file: UploadFile) -> Dict[str, Any]:
+# """
+# INTERNAL VERSION: Validates USFM file and returns full parsed data.
+# Used by upload_bible_book() and update_bible_book() to avoid re-parsing.
+
+# Returns: {
+# 'valid': True,
+# 'book_code': 'PSA',
+# 'chapter_count': 150,
+# 'usj_data': {...}, ← Complex nested object
+# 'usfm_content': '\\id PSA...' ← Raw USFM string
+# }
+# """
+# try:
+# # 1. VALIDATE FILE EXTENSION
+# if not file.filename:
+# raise UnprocessableException(detail="No filename provided")
+
+# filename_lower = file.filename.lower()
+# if not filename_lower.endswith((".usfm", ".sfm")):
+# raise UnprocessableException(
+# detail=(
+# f"Invalid file type. Expected .usfm or .sfm file, "
+# f"got '{file.filename}'. Please upload a valid USFM file."
+# )
+# )
+
+# # 2. READ FILE CONTENT (ASYNC SAFE)
+# content = await file.read()
+
+# # 3. CHECK FILE SIZE
+# max_size = 10 * 1024 * 1024 # 10 MB
+# if len(content) > max_size:
+# await file.seek(0)
+# raise UnprocessableException(
+# detail=(
+# f"File too large ({len(content)} bytes). "
+# f"Maximum allowed: {max_size} bytes"
+# )
+# )
+
+# # 4. RESET FILE POINTER
+# await file.seek(0)
+
+# # 5. DECODE CONTENT
+# try:
+# usfm_content = content.decode("utf-8")
+# except UnicodeDecodeError as exc:
+# raise UnprocessableException(
+# detail=(
+# "File encoding error. USFM files must be UTF-8 encoded "
+# "plain text."
+# )
+# ) from exc
+
+# # 6. EMPTY FILE CHECK
+# if not usfm_content.strip():
+# raise UnprocessableException(
+# detail="USFM file is empty or contains only whitespace"
+# )
+
+# # 7. BINARY FILE CHECK
+# if "\x00" in usfm_content:
+# raise UnprocessableException(
+# detail=(
+# "File appears to be binary, not text. "
+# "USFM files must be plain text files."
+# )
+# )
+
+# # 8. REQUIRED \id MARKER
+# if "\\id" not in usfm_content:
+# raise UnprocessableException(
+# detail=(
+# "Not a valid USFM file. Missing required \\id marker. "
+# "Please ensure this is a properly formatted USFM file."
+# )
+# )
+
+# # 9. PARSE WITH USFM-GRAMMAR (THREADPOOL SAFE)
+# try:
+# parser = USFMParser(usfm_content)
+# usj_data = await run_in_threadpool(parser.to_usj)
+
+# # Validate USJ structure
+# if not isinstance(usj_data, dict) or "content" not in usj_data:
+# raise UnprocessableException(
+# detail="Invalid USFM structure: Cannot parse to valid USJ format"
+# )
+
+# # Extract book code
+# book_code = None
+# for item in usj_data.get("content", []):
+# if item.get("type") == "book" and item.get("marker") == "id":
+# book_code = item.get("code")
+# break
+
+# if not book_code:
+# raise UnprocessableException(
+# detail="USFM file must contain a valid book code in \\id marker"
+# )
+
+# # Count chapters
+# chapter_count = sum(
+# 1 for item in usj_data.get("content", [])
+# if item.get("type") == "chapter"
+# )
+
+# if chapter_count == 0:
+# raise UnprocessableException(
+# detail="USFM file must contain at least one chapter (\\c marker)"
+# )
+
+# return {
+# "valid": True,
+# "book_code": book_code,
+# "chapter_count": chapter_count,
+# "usj_data": usj_data,
+# "usfm_content": usfm_content,
+# }
+
+# except HTTPException:
+# raise
+# except Exception as exc:
+# raise UnprocessableException(
+# detail=f"File validation error: {str(exc)}"
+# ) from exc
+
+# except HTTPException:
+# raise
+# except Exception as exc:
+# raise UnprocessableException(
+# detail=f"File validation error: {str(exc)}"
+# ) from exc
+
+
+# except HTTPException:
+# raise
+# except Exception as exc:
+# raise UnprocessableException(
+# detail=f"File validation error: {str(exc)}"
+# ) from exc
+
+
+
+
+async def validate_usfm_file_internal(
+ db_session: Session,
+ file: UploadFile,
+ mode: str = "upload", # "upload" | "update"
+ resource_id: int | None = None,
+ bible_book_id: int | None = None,
+) -> Dict[str, Any]:
+ t0 = time.perf_counter()
+ tprint("validate_usfm_file_internal START", t0, mode=mode, filename=file.filename)
+
+ # 1) resource checks FIRST (NO parse)
+ if mode == "upload":
+ if resource_id is None:
+ raise BadRequestException(detail="resource_id required for upload validation")
+
+ resource = db_session.query(db_models.Resource).filter_by(resource_id=resource_id).first()
+ if not resource:
+ raise NotAvailableException(detail=f"Resource {resource_id} not found")
+ if resource.content_type.lower() != "bible":
+ raise BadRequestException(detail=f"Resource {resource_id} is not bible")
+
+ existing_bible = None
+
+ elif mode == "update":
+ if bible_book_id is None:
+ raise BadRequestException(detail="bible_book_id required for update validation")
+
+ existing_bible = db_session.query(db_models.Bible).filter_by(bible_book_id=bible_book_id).first()
+ if not existing_bible:
+ raise NotAvailableException(detail=f"Bible book {bible_book_id} not found")
+
+ resource_id = existing_bible.resource_id
+ resource = db_session.query(db_models.Resource).filter_by(resource_id=resource_id).first()
+ if not resource:
+ raise NotAvailableException(detail=f"Resource {resource_id} not found")
+ if resource.content_type.lower() != "bible":
+ raise BadRequestException(detail=f"Resource {resource_id} is not bible")
+ else:
+ raise BadRequestException(detail="mode must be 'upload' or 'update'")
+
+ tprint("resource checks DONE", t0, resource_id=resource_id)
+
+ # 2) filename checks
+ if not file.filename:
+ raise UnprocessableException(detail="No filename provided")
+ if not file.filename.lower().endswith((".usfm", ".sfm")):
+ raise UnprocessableException(detail="Invalid file type. Upload .usfm/.sfm")
+ tprint("filename checks DONE", t0)
+
+ # 3) read once
+ content = await file.read()
+ await file.seek(0)
+ if not content:
+ raise UnprocessableException(detail="USFM file is empty")
+ if len(content) > 10 * 1024 * 1024:
+ raise UnprocessableException(detail="File too large (max 10MB)")
+
try:
- # 1. VALIDATE FILE EXTENSION
- if not file.filename:
- raise UnprocessableException(detail="No filename provided")
-
- filename_lower = file.filename.lower()
- if not filename_lower.endswith((".usfm", ".sfm")):
- raise UnprocessableException(
- detail=(
- f"Invalid file type. Expected .usfm or .sfm file, "
- f"got '{file.filename}'. Please upload a valid USFM file."
- )
- )
+ usfm_content = content.decode("utf-8")
+ except UnicodeDecodeError:
+ raise UnprocessableException(detail="USFM must be UTF-8 text")
- # 2. READ FILE CONTENT (ASYNC SAFE)
- content = await file.read()
-
- # 3. CHECK FILE SIZE
- max_size = 10 * 1024 * 1024 # 10 MB
- if len(content) > max_size:
- await file.seek(0)
- raise UnprocessableException(
- detail=(
- f"File too large ({len(content)} bytes). "
- f"Maximum allowed: {max_size} bytes"
- )
- )
+ if "\\id" not in usfm_content:
+ raise UnprocessableException(detail="Missing \\id marker")
- # 4. RESET FILE POINTER
- await file.seek(0)
+ tprint("read+decode DONE", t0, bytes=len(content))
- # 5. DECODE CONTENT
- try:
- usfm_content = content.decode("utf-8")
- except UnicodeDecodeError as exc:
- raise UnprocessableException(
- detail=(
- "File encoding error. USFM files must be UTF-8 encoded "
- "plain text."
- )
- ) from exc
-
- # 6. EMPTY FILE CHECK
- if not usfm_content.strip():
- raise UnprocessableException(
- detail="USFM file is empty or contains only whitespace"
- )
+ # 4) fast book_code from text (NO parse)
+ book_code = _fast_book_code(usfm_content)
+ tprint("fast book_code DONE", t0, book_code=book_code)
- # 7. BINARY FILE CHECK
- if "\x00" in usfm_content:
- raise UnprocessableException(
- detail=(
- "File appears to be binary, not text. "
- "USFM files must be plain text files."
- )
+ # 5) lookup book
+ book = db_session.query(db_models.BookLookup).filter(
+ func.lower(db_models.BookLookup.book_code) == book_code.lower()
+ ).first()
+ if not book:
+ raise NotAvailableException(detail=f"Book {book_code} not found in lookup")
+
+ # 6) exists checks differ by mode
+ if mode == "upload":
+ # prevent duplicate book for this resource
+ exists = db_session.query(db_models.Bible).filter_by(resource_id=resource_id, book_id=book.book_id).first()
+ if exists:
+ raise AlreadyExistsException(detail=f"Book {book_code} already exists for resource {resource_id}")
+
+ else: # update
+ # must match the same book_id already stored
+ if existing_bible.book_id != book.book_id:
+ raise BadRequestException(
+ detail=f"Book mismatch: existing book_id={existing_bible.book_id} but uploaded={book.book_id} ({book_code})"
)
- # 8. REQUIRED \id MARKER
- if "\\id" not in usfm_content:
- raise UnprocessableException(
- detail=(
- "Not a valid USFM file. Missing required \\id marker. "
- "Please ensure this is a properly formatted USFM file."
- )
- )
+ tprint("lookup + checks DONE", t0, book_id=book.book_id)
- # 9. PARSE WITH USFM-GRAMMAR (THREADPOOL SAFE)
- try:
- parser = USFMParser(usfm_content)
- usj_data = await run_in_threadpool(parser.to_usj)
-
- # Validate USJ structure
- if not isinstance(usj_data, dict) or "content" not in usj_data:
- raise UnprocessableException(
- detail="Invalid USFM structure: Cannot parse to valid USJ format"
- )
-
- # Extract book code
- book_code = None
- for item in usj_data.get("content", []):
- if item.get("type") == "book" and item.get("marker") == "id":
- book_code = item.get("code")
- break
-
- if not book_code:
- raise UnprocessableException(
- detail="USFM file must contain a valid book code in \\id marker"
- )
-
- # Count chapters
- chapter_count = sum(
- 1 for item in usj_data.get("content", [])
- if item.get("type") == "chapter"
- )
+ # 7) parse ONCE (only here)
+ parser = USFMParser(usfm_content)
+ try:
+ usj_data = await run_in_threadpool(lambda: parser.to_usj(ignore_errors=True))
+ except Exception as exc:
+ raise UnprocessableException(detail=f"USFM parsing error: {exc}") from exc
- if chapter_count == 0:
- raise UnprocessableException(
- detail="USFM file must contain at least one chapter (\\c marker)"
- )
+ tprint("parse to_usj DONE", t0)
- return {
- "valid": True,
- "book_code": book_code,
- "chapter_count": chapter_count,
- }
+ if not isinstance(usj_data, dict) or "content" not in usj_data:
+ raise UnprocessableException(detail="Invalid USFM structure")
- except HTTPException:
- raise
- except Exception as exc:
- raise UnprocessableException(
- detail=f"File validation error: {str(exc)}"
- ) from exc
+ # 8) chapter count
+ chapter_count = sum(1 for item in usj_data.get("content", []) if item.get("type") == "chapter")
+ if chapter_count == 0:
+ raise UnprocessableException(detail="USFM must contain chapters (\\c)")
+ tprint("chapter_count DONE", t0, chapter_count=chapter_count)
- except HTTPException:
- raise
- except Exception as exc:
- raise UnprocessableException(
- detail=f"File validation error: {str(exc)}"
- ) from exc
+ # Optional: verify parsed book code matches fast extracted
+ parsed_code = _extract_book_code_from_usj(usj_data)
+ if parsed_code and parsed_code.lower() != book_code.lower():
+ raise UnprocessableException(detail=f"Book code mismatch: {book_code} != {parsed_code}")
+
+ tprint("validate_usfm_file_internal END", t0)
+
+ return {
+ "valid": True,
+ "resource_id": resource_id,
+ "book_code": book_code,
+ "book_id": book.book_id,
+ "chapter_count": chapter_count,
+ "usj_data": usj_data,
+ "usfm_content": usfm_content,
+ }
+
+async def validate_usfm_file_api(file: UploadFile) -> Dict[str, Any]:
+ """
+ API VERSION: Validates USFM file and returns only JSON-serializable data.
+ Used by the /bible/usfm/validate endpoint.
+
+ Returns: {
+ 'valid': True,
+ 'book_code': 'PSA',
+ 'chapter_count': 150,
+ 'message': 'USFM file is valid'
+ }
+ """
+ try:
+ result = await validate_usfm_file_internal(file)
+
+ return {
+ "valid": result["valid"],
+ "book_code": result["book_code"],
+ "chapter_count": result["chapter_count"],
+ }
+
+ except UnprocessableException as e:
+ return {
+ "valid": False,
+ "error": e.detail,
+ "message": "USFM file validation failed"
+ }
+ except Exception as e:
+ return {
+ "valid": False,
+ "error": str(e),
+ "message": "USFM file validation failed"
+ }
\ No newline at end of file
diff --git a/backend/app/crud/structural_crud.py b/backend/app/crud/structural_crud.py
index f57c2cc9..20e75d7c 100644
--- a/backend/app/crud/structural_crud.py
+++ b/backend/app/crud/structural_crud.py
@@ -174,8 +174,18 @@ def get_languages_with_pagination(
offset = page * page_size
languages = query.order_by(
db_models.Language.language_id.asc()).offset(offset).limit(page_size).all()
+ languages_with_details = [
+ {
+ "id": lang.language_id,
+ "code": lang.language_code,
+ "name": lang.language_name,
+ "local_script_name": lang.local_script_name,
+ "script_direction": lang.script_direction,
+ }
+ for lang in languages
+ ]
+ return languages_with_details, total_items
- return languages, total_items
def get_language(db_session: Session, language_id: int):
"""Retrieve a single language by ID."""
return db_session.query(db_models.Language).filter(
@@ -208,6 +218,8 @@ def create_language(db_session: Session, lang: schema.LanguageCreate):
db_obj = db_models.Language(
language_code=lang.language_code,
language_name=lang.language_name,
+ local_script_name=lang.local_script_name, # New field
+ script_direction=lang.script_direction, # New field
meta_data=lang.metadata
)
db_session.add(db_obj)
@@ -244,10 +256,14 @@ def update_language(db_session: Session, language_id: int, lang: schema.Language
# Update fields
db_obj.language_code = lang.language_code
db_obj.language_name = lang.language_name
+ db_obj.local_script_name = lang.local_script_name
+ db_obj.script_direction = lang.script_direction
db_obj.meta_data = lang.metadata
db_session.commit()
db_session.refresh(db_obj)
return db_obj
+
+
def delete_languages_bulk(db_session: Session, language_ids: List[int]):
"""Delete multiple languages by ID."""
deleted_ids = []
@@ -522,7 +538,9 @@ def create_resource(
language=schema.LanguageBrief(
id=language.language_id,
code=language.language_code,
- name=language.language_name
+ name=language.language_name,
+ local_script_name=language.local_script_name,
+ script_direction=language.script_direction,
),
content=schema.ContentRef(contentType=schema.ContentTypeEnum(db_obj.content_type)),
license=schema.LicenseRef(id=license_.license_id, name=license_.license_name),
diff --git a/backend/app/crud/utils.py b/backend/app/crud/utils.py
index 7c14ce65..0632f21c 100644
--- a/backend/app/crud/utils.py
+++ b/backend/app/crud/utils.py
@@ -48,12 +48,15 @@ def _group_resources(rows: List[tuple]) -> List[schema.LanguageGroupOut]:
for resource, lang, version, lic in rows:
lid = lang.language_id
+ # FIX 1: include local_script_name and script_direction
if lid not in groups:
groups[lid] = {
"language": schema.LanguageBrief(
id=lid,
code=lang.language_code,
name=lang.language_name,
+ local_script_name=lang.local_script_name,
+ script_direction=lang.script_direction,
),
"versions": []
}
@@ -85,11 +88,16 @@ def _group_resources(rows: List[tuple]) -> List[schema.LanguageGroupOut]:
id=lic.license_id,
name=lic.license_name
),
+
+ # FIX 2: include fields here also
language=schema.LanguageBrief(
id=lang.language_id,
code=lang.language_code,
- name=lang.language_name
+ name=lang.language_name,
+ local_script_name=lang.local_script_name,
+ script_direction=lang.script_direction,
),
+
metadata=json.loads(resource.meta_data) if resource.meta_data else None,
published=bool(resource.published),
createdBy=resource.created_by,
@@ -240,21 +248,107 @@ def parse_verse_number(verse_str: str) -> List[int]:
raise ValueError(f"Could not parse verse number: {verse_str}") from e
+# def parse_usfm_to_clean_verses(usj_data: Dict[str, Any]) -> List[Dict[str, Any]]:
+# """Parse USJ data to extract clean verse-by-verse content, handling verse ranges"""
+# verses = []
+# chapter = None
+
+# for item in usj_data.get("content", []):
+# item_type = item.get("type")
+
+# if item_type == "chapter":
+# chapter = item.get("number")
+# continue
+
+# if item_type == "para":
+# _process_paragraph(item.get("content", []), chapter, verses)
+
+# return verses
+
+def _flatten_usj_text(node: Any) -> str:
+ """
+ Convert USJ content nodes into plain text.
+ Handles strings, dict nodes, and list nodes.
+ """
+ if node is None:
+ return ""
+ if isinstance(node, str):
+ return node.strip()
+ if isinstance(node, list):
+ return " ".join(filter(None, (_flatten_usj_text(x) for x in node))).strip()
+ if isinstance(node, dict):
+ # common keys that hold nested text/content
+ for k in ("text", "content", "children"):
+ if k in node:
+ return _flatten_usj_text(node[k])
+ return ""
+ return ""
+
def parse_usfm_to_clean_verses(usj_data: Dict[str, Any]) -> List[Dict[str, Any]]:
- """Parse USJ data to extract clean verse-by-verse content, handling verse ranges"""
- verses = []
- chapter = None
+ """
+ Robust USJ -> clean verses.
+ Produces one row per verse marker encountered, collecting all text until next verse marker.
+ """
+ verses: List[Dict[str, Any]] = []
+ chapter: Optional[int] = None
+ current_verse: Optional[int] = None
+ buffer: List[str] = []
+
+ def flush():
+ nonlocal buffer, current_verse, chapter
+ if chapter is None or current_verse is None:
+ buffer = []
+ return
+ text = " ".join(t for t in buffer if t).strip()
+ if text:
+ verses.append({"chapter": int(chapter), "verse": int(current_verse), "text": text})
+ buffer = []
+
+ content = usj_data.get("content", [])
+ # USJ usually has chapter/para/verse nodes. We walk all nodes recursively.
+ stack = list(reversed(content))
+
+ while stack:
+ item = stack.pop()
+
+ if isinstance(item, dict):
+ t = item.get("type")
+
+ if t == "chapter":
+ # new chapter -> flush previous verse
+ flush()
+ num = item.get("number")
+ chapter = int(num) if str(num).isdigit() else None
+ current_verse = None
+ continue
- for item in usj_data.get("content", []):
- item_type = item.get("type")
+ if t == "verse":
+ # new verse -> flush previous verse buffer
+ flush()
+ num = item.get("number")
+ # handle "23-24" etc: take first verse as anchor
+ if num is not None:
+ num_str = str(num).split("-")[0].strip()
+ current_verse = int(num_str) if num_str.isdigit() else None
+ continue
- if item_type == "chapter":
- chapter = item.get("number")
- continue
+ # If this node has nested content, traverse it
+ if "content" in item and isinstance(item["content"], list):
+ stack.extend(reversed(item["content"]))
+ continue
- if item_type == "para":
- _process_paragraph(item.get("content", []), chapter, verses)
+ # otherwise try flatten
+ txt = _flatten_usj_text(item)
+ if txt:
+ buffer.append(txt)
+ elif isinstance(item, list):
+ stack.extend(reversed(item))
+ elif isinstance(item, str):
+ if item.strip():
+ buffer.append(item.strip())
+
+ flush()
return verses
def _process_paragraph(para_content: List[Any], chapter: int, verses: List[Dict[str, Any]]) -> None:
@@ -741,76 +835,51 @@ def validate_and_process_entry(entry, index, db, counts):
created += 1
return (created, updated, skipped)
-def validate_html(html_text: str):
+def validate_html(ref: str, html_text: str):
"""
- Validates commentary HTML with strict rules:
+ Validates that HTML is well-formed:
- No unclosed tags
- - No broken tags like
- - No missing closing , , etc.
- - Only allowed tags are permitted
+ - No mismatched closing tags
+ - Parseable by html5lib
"""
if not html_text or not html_text.strip():
return
- if "<" not in html_text and ">" not in html_text:
- raise UnprocessableException(
- detail="no html tags found"
- )
-
- allowed_tags = {"p", "strong", "img", "br", "sup", "em", "b", "i", "u"}
- void_tags = {"br", "img", "hr", "meta", "link", "input"}
+ void_tags = {"br", "img", "hr", "meta", "link", "input", "area", "base",
+ "col", "embed", "param", "source", "track", "wbr"}
tag_pattern = re.compile(r"?([a-zA-Z0-9]+)[^>]*>")
- # ---- Helper function for stack-based tag validation ----
- def _check_tag_stack(content: str):
- stack = []
- for match in tag_pattern.finditer(content):
- tag = match.group(1).lower()
- full_tag = match.group(0)
-
- if tag in void_tags:
- continue
-
- if full_tag.startswith(""):
- if not stack or stack[-1] != tag:
- raise UnprocessableException(
- detail=f"Closing tag {tag}> is mismatched or missing opener"
- )
- stack.pop()
- else:
- stack.append(tag)
-
- if stack:
- raise UnprocessableException(
- detail=f"Unclosed tag(s): {stack}"
- )
+ # ---- Parse with html5lib ----
+ try:
+ BeautifulSoup(html_text, "html5lib")
+ except Exception as e:
+ raise UnprocessableException(
+ detail=f"Invalid HTML for reference {ref}: {str(e)}"
+ ) from e
- # ---- Validate each tag individually ----
+ # ---- Stack-based check for unclosed/mismatched tags ----
+ stack = []
for match in tag_pattern.finditer(html_text):
tag = match.group(1).lower()
full_tag = match.group(0)
- if tag not in allowed_tags:
- raise UnprocessableException(
- detail=f"Invalid HTML tag '{full_tag}'"
- )
+ if tag in void_tags:
+ continue
- if full_tag.startswith(f"<{tag}") and not re.match(rf"?{tag}\b", full_tag):
- raise UnprocessableException(
- detail=f"Malformed HTML tag '{full_tag}'"
- )
+ if full_tag.startswith(""):
+ if not stack or stack[-1] != tag:
+ raise UnprocessableException(
+ detail=f"Invalid HTML for reference {ref}: Closing tag {tag}> is mismatched or missing opener"
+ )
+ stack.pop()
+ else:
+ stack.append(tag)
- # ---- Parse with html5lib ----
- try:
- BeautifulSoup(html_text, "html5lib")
- except Exception as e:
+ if stack:
raise UnprocessableException(
- detail=f"Invalid HTML: {str(e)}"
- ) from e
-
- # ---- Run stack-based tag check ----
- _check_tag_stack(html_text)
+ detail=f"Invalid HTML for reference {ref}: Unclosed tag(s): {stack}"
+ )
# Convert GitHub URLs to raw URLs
def convert_to_raw_url(url: str) -> str:
diff --git a/backend/app/data/booknames.csv b/backend/app/data/booknames.csv
new file mode 100644
index 00000000..e60024b1
--- /dev/null
+++ b/backend/app/data/booknames.csv
@@ -0,0 +1,1304 @@
+abbr,short,long,bookCode,language
+2ki,2 Kings,2 Kings,2ki,eng
+আদি,আদিপুস্তক,আদি পুস্তক,gen,asm
+যাত্ৰা,যাত্রাপুস্তক,যাত্ৰা পুস্তক,exo,asm
+লেবী,লেবীয়া পুস্তক,লেবীয়া পুস্তক,lev,asm
+গন,গননা পুস্তক,গননা পুস্তক,num,asm
+দ্বি.বি.,দ্বিতীয় বিৱৰণ,দ্বিতীয় বিৱৰণ,deu,asm
+যিহো,যিহোচূৱা,যিহোচূৱা,jos,asm
+বিচাৰ,বিচাৰকর্তাবিলাক,বিচাৰকর্তাবিলাক,jdg,asm
+ৰূথ,ৰূথ,ৰূথ,rut,asm
+1চমূ,1 চমূৱেল,1 চমূৱেল,1sa,asm
+2চমূ,2 চমূৱেল,2 চমূৱেল,2sa,asm
+1ৰাজা,1 ৰাজাৱলী,1 ৰাজাৱলী,1ki,asm
+2ৰাজা,2 ৰাজাৱলি,2 ৰাজাৱলি,2ki,asm
+1 বংশা,1 বংশাৱলি,1 বংশাৱলি,1ch,asm
+2বংশা,2 বংশাৱলি,2 বংশাৱলি,2ch,asm
+ইজ্ৰা,ইজ্ৰা,ইজ্ৰা,ezr,asm
+নহি,নহিমিয়া,নহিমিয়া,neh,asm
+ইষ্ট,ইষ্টেৰ,ইষ্টেৰ,est,asm
+ইয়ো,ইয়োব,ইয়োব,job,asm
+গীত,গীতমালা,গীতমালা,psa,asm
+হিতো,হিতোপদেশ,হিতোপদেশ,pro,asm
+উপ,উপদেশক,উপদেশক,ecc,asm
+পৰম,পৰমগীত,পৰমগীত,sng,asm
+যিচ,যিচয়া,যিচয়া,isa,asm
+যিৰি,যিৰিমিয়া,যিৰিমিয়া,jer,asm
+বিলা,বিলাপ,বিলাপ,lam,asm
+যিহি,যিহিষ্কেল,যিহিষ্কেল,ezk,asm
+দানি,দানিয়েল,দানিয়েল,dan,asm
+হোচ,হোচেয়া,হোচেয়া,hos,asm
+যোৱে,যোৱেল,যোৱেল,jol,asm
+আমো,আমোচ,আমোচ,amo,asm
+ওব,ওবদিয়া,ওবদিয়া,oba,asm
+যোনা,যোনা,যোনা,jon,asm
+মীখা,মীখা,মীখা,mic,asm
+নহূ,নহূম,নহূম,nam,asm
+হব,হবক্কুক,হবক্কুক,hab,asm
+চফ,চফনিয়া,চফনিয়া,zep,asm
+হ্গ্গ,হ্গ্গয়,হ্গ্গয়,hag,asm
+জখ,জখৰিয়া,জখৰিয়া,zec,asm
+মলা,মলাখী,মলাখী,mal,asm
+মথি,মথি,মথিয়ে লিখা শুভবাৰ্তা,mat,asm
+মাৰ্ক,মাৰ্ক,মাৰ্কে লিখা শুভবাৰ্তা,mrk,asm
+লূক,লূক,লূকে লিখা শুভবার্তা,luk,asm
+যোহন,যোহন,যোহনে লিখা শুভবাৰ্তা,jhn,asm
+পাঁচনি,পাঁচনি,পাঁচনি সকলৰ কৰ্ম,act,asm
+ৰোমীয়া,ৰোমীয়া,ৰোমীয়া সকলৰ প্ৰতি পত্ৰ,rom,asm
+1কৰি,1 কৰিন্থীয়া,1 কৰিন্থীয়া,1co,asm
+2কৰি,2 কৰিন্থীয়া,2 কৰিন্থীয়া,2co,asm
+গালা,গালাতীয়া,গালাতীয়া পত্র,gal,asm
+ইফি,ইফিচীয়া,ইফিচীয়া পত্ৰ,eph,asm
+ফিল,ফিলিপীয়া,ফিলিপীয়া পত্ৰ,php,asm
+কল,কলচীয়া,কলচীয়া পত্ৰ,col,asm
+1থিচ,1 থিচলনীকীয়া,1 থিচলনীকীয়া,1th,asm
+2থিচ,2 থিচলনীকীয়া,2 থিচলনীকীয়া,2th,asm
+1তীম,1 তীমথিয়,1 তীমথিয়,1ti,asm
+2তীম,2 তীমথিয়,2 তীমথিয়,2ti,asm
+তীত,তীত,তীতৰ প্ৰতি পত্ৰ,tit,asm
+ফিলী,ফিলীমন,ফিলীমনৰ প্ৰতি পত্ৰ,phm,asm
+ইব্ৰী,ইব্ৰী,ইব্ৰীসকলৰ প্ৰতি পত্ৰ,heb,asm
+যাকো,যাকোব,যাকোবৰ পত্ৰ,jas,asm
+1পিত,1 পিতৰ,1 পিতৰ,1pe,asm
+2পিত,2 পিতৰ,2 পিতৰ,2pe,asm
+1যো,1 যোহন,1 যোহন,1jn,asm
+2যো,2 যোহন,2 যোহন,2jn,asm
+3যো,3 যোহন,3 যোহন,3jn,asm
+যিহূদা,যিহূদা,যিহূদাৰ পত্ৰ,jud,asm
+প্ৰকা,প্ৰকাশিত বাক্য,যোহনৰ প্ৰতি প্ৰকাশিত বাক্য,rev,asm
+আদি,আদি,আদি পুস্তক,gen,ben
+যাত্ৰা,যাত্ৰা,যাত্ৰা পুস্তক,exo,ben
+লেবী,লেবীয়া,লেবীয়া পুস্তক,lev,ben
+গন,গননা,গননা পুস্তক,num,ben
+1 રાજા.,1 રાજાઓ,1 રાજાઓ,1ki,guj
+দ্বি.বি.,দ্বিতীয় বিৱৰণ,দ্বিতীয় বিৱৰণ,deu,ben
+যিহো,যিহোচূৱা,যিহোচূৱা,jos,ben
+বিচাৰ,বিচাৰকর্তাবিলাক,বিচাৰকর্তাবিলাক,jdg,ben
+ৰূথ,ৰূথ,ৰূথ,rut,ben
+1চমূ,1 চমূৱেল,1 চমূৱেল,1sa,ben
+2চমূ,2 চমূৱেল,2 চমূৱেল,2sa,ben
+1ৰাজা,1 ৰাজাৱলী,1 ৰাজাৱলী,1ki,ben
+2ৰাজা,2 ৰাজাৱলি,2 ৰাজাৱলি,2ki,ben
+1 বংশা,1 বংশাৱলি,1 বংশাৱলি,1ch,ben
+2বংশা,2 বংশাৱলি,2 বংশাৱলি,2ch,ben
+ইজ্ৰা,ইজ্ৰা,ইজ্ৰা,ezr,ben
+নহি,নহিমিয়া,নহিমিয়া,neh,ben
+ইষ্ট,ইষ্টেৰ,ইষ্টেৰ,est,ben
+ইয়ো,ইয়োব,ইয়োব,job,ben
+গীত,গীতমালা,গীতমালা,psa,ben
+হিতো,হিতোপদেশ,হিতোপদেশ,pro,ben
+উপ,উপদেশক,উপদেশক,ecc,ben
+পৰম,পৰমগীত,পৰমগীত,sng,ben
+যিচ,যিচয়া,যিচয়া,isa,ben
+যিৰি,যিৰিমিয়া,যিৰিমিয়া,jer,ben
+বিলা,বিলাপ,বিলাপ,lam,ben
+যিহি,যিহিষ্কেল,যিহিষ্কেল,ezk,ben
+দানি,দানিয়েল,দানিয়েল,dan,ben
+হোচ,হোচেয়া,হোচেয়া,hos,ben
+যোৱে,যোৱেল,যোৱেল,jol,ben
+আমো,আমোচ,আমোচ,amo,ben
+ওব,ওবদিয়া,ওবদিয়া,oba,ben
+যোনা,যোনা,যোনা,jon,ben
+মীখা,মীখা,মীখা,mic,ben
+নহূ,নহূম,নহূম,nam,ben
+হব,হবক্কুক,হবক্কুক,hab,ben
+চফ,চফনিয়া,চফনিয়া,zep,ben
+হ্গ্গ,হ্গ্গয়,হ্গ্গয়,hag,ben
+জখ,জখৰিয়া,জখৰিয়া,zec,ben
+মলা,মলাখী,মলাখী,mal,ben
+মথি,মথি,মথিয়ে লিখা শুভবাৰ্তা,mat,ben
+মার্ক,মার্ক,মাৰ্কে লিখা শুভবাৰ্তা,mrk,ben
+লূক,লূক,লূকে লিখা শুভবার্তা,luk,ben
+যোহন,যোহন,যোহনে লিখা শুভবাৰ্তা,jhn,ben
+পাঁচনি,पশিষ্যচরিত,পাঁচনি সকলৰ কৰ্ম,act,ben
+ৰোমীয়া,ৰোমীয়া,ৰোমীয়া সকলৰ প্ৰতি পত্ৰ,rom,ben
+1কৰি,1 কৰিন্থীয়া,1 কৰিন্থীয়া,1co,ben
+2কৰি,2 কৰিন্থীয়া,2 কৰিন্থীয়া,2co,ben
+গালা,গালাতীয়া,গালাতীয়া পত্র,gal,ben
+ইফি,ইফিচীয়া,ইফিচীয়া পত্ৰ,eph,ben
+ফিল,ফিলিপীয়া,ফিলিপীয়া পত্ৰ,php,ben
+কল,কলচীয়া,কলচীয়া পত্ৰ,col,ben
+1থিচ,1 থিচলনীকীয়া,1 থিচলনীকীয়া,1th,ben
+2থিচ,2 থিচলনীকীয়া,2 থিচলনীকীয়া,2th,ben
+1তীম,1 তীমথিয়,1 তীমথিয়,1ti,ben
+2তীম,2 তীমথিয়,2 তীমথিয়,2ti,ben
+তীত,তীত,তীতৰ প্ৰতি পত্ৰ,tit,ben
+ফিলী,ফিলীমন,ফিলীমনৰ প্ৰতি পত্ৰ,phm,ben
+ইব্ৰী,হিব্রুদের,ইব্ৰীসকলৰ প্ৰতি পত্ৰ,heb,ben
+যাকো,যাকোবের,যাকোবৰ পত্ৰ,jas,ben
+1পিত,1 পিতরের,1 পিতৰ,1pe,ben
+2পিত,2 পিতরের,2 পিতৰ,2pe,ben
+1যো,1 যোহনের,1 যোহন,1jn,ben
+2যো,2 যোহনের,2 যোহন,2jn,ben
+3যো,3 যোহনের,3 যোহন,3jn,ben
+যিহূদা,যুদের,যিহূদাৰ পত্ৰ,jud,ben
+প্ৰকা,पপ্রত্যাদেশ,যোহনৰ প্ৰতি প্ৰকাশিত বাক্য,rev,ben
+ઉત.,ઉત્પત્તિ,ઉત્પત્તિ,gen,guj
+નિર્ગ.,નિર્ગમન,નિર્ગમન,exo,guj
+લેવ.,લેવીય,લેવીય,lev,guj
+ગણ.,ગણના,ગણના,num,guj
+પુન.,પુનર્નિયમ,પુનર્નિયમ,deu,guj
+યહો.,યહોશુઆ,યહોશુઆ,jos,guj
+ન્યાય.,ન્યાયાધીશો,ન્યાયાધીશો,jdg,guj
+રૂથ.,રૂથ,રૂથ,rut,guj
+1 શમુ.,1 શમુએલ,1 શમુએલ,1sa,guj
+2 શમુ.,2 શમુએલ,2 શમુએલ,2sa,guj
+2 રાજા.,2 રાજાઓ,2 રાજાઓ,2ki,guj
+1 કાળ.,1 કાળવૃતાંત,1 કાળવૃતાંત,1ch,guj
+2 કાળ.,2 કાળવૃતાંત,2 કાળવૃતાંત,2ch,guj
+એઝ.,એઝરા,એઝરા,ezr,guj
+નહે.,નહેમ્યા,નહેમ્યા,neh,guj
+એસ્.,એસ્તેર,એસ્તેર,est,guj
+અયૂ.,અયૂબ,અયૂબ,job,guj
+ગી.શા.,ગીતશાસ્ત્ર,ગીતશાસ્ત્ર,psa,guj
+નીતિ.,નીતિવચનો,નીતિવચનો,pro,guj
+સભા.,સભાશિક્ષક,સભાશિક્ષક,ecc,guj
+ગીત.,ગીતોનું ગીત,ગીતોનું ગીત,sng,guj
+યશા.,યશાયા,યશાયા,isa,guj
+યર્મિ.,યર્મિયા,યર્મિયા,jer,guj
+વિલા.,યર્મિયાનો વિલાપ,યર્મિયાનો વિલાપ,lam,guj
+હઝ.,હઝકિયેલ,હઝકિયેલ,ezk,guj
+દાન.,દાનિયેલ,દાનિયેલ,dan,guj
+હોશ.,હોશિયા,હોશિયા,hos,guj
+યોએ.,યોએલ,યોએલ,jol,guj
+આમ.,આમોસ,આમોસ,amo,guj
+ઓબ.,ઓબાદ્યા,ઓબાદ્યા,oba,guj
+યૂન.,યૂના,યૂના,jon,guj
+મીખ.,મીખાહ,મીખાહ,mic,guj
+નાહ.,નાહૂમ,નાહૂમ,nam,guj
+હબ.,હબાક્કુક,હબાક્કુક,hab,guj
+સફ.,સફાન્યા,સફાન્યા,zep,guj
+હાગ.,હાગ્ગાય,હાગ્ગાય,hag,guj
+ઝખ.,ઝખાર્યા,ઝખાર્યા,zec,guj
+માલ.,માલાખી,માલાખી,mal,guj
+માથ.,માથ્થી,માથ્થીની લખેલી સુવાર્તા,mat,guj
+માર્ક,માર્ક,માર્કની લખેલી સુવાર્તા,mrk,guj
+લૂક,લૂક,લૂકની લખેલી સુવાર્તા,luk,guj
+યોહ.,યોહાન,યોહાનની લખેલી સુવાર્તા,jhn,guj
+પ્રે.કૃ.,પ્રેરિતોનાં ક્રત્યો,પ્રેરીતોનાં કૃત્યો,act,guj
+રોમ.,રોમનોને,રોમનોને પાઉલ પ્રેરીતનો પત્ર,rom,guj
+1 કરિં.,1 કરિંથીઓને,કરિંથીઓને પાઉલ પ્રેરીતનો પહેલો પત્ર,1co,guj
+2 કરિં.,2 કરિંથીઓને,કરિંથીઓને પાઉલ પ્રેરીતનો બીજો પત્ર,2co,guj
+ગલ.,ગલાતીઓને,ગલાતીઓને પાઉલ પ્રેરીતનો પત્ર,gal,guj
+એફે.,એફેસીઓને,એફેસીઓને પાઉલ પ્રેરીતનો પત્ર,eph,guj
+ફિલિ.,ફિલિપ્પીઓને,ફિલિપ્પીઓને પાઉલ પ્રેરીતનો પત્ર,php,guj
+કલો.,ક્લોસ્સીઓને,ક્લોસ્સીઓને પાઉલ પ્રેરીતનો પત્ર,col,guj
+ಮಾರ್ಕ,ಮಾರ್ಕನು,ಮಾರ್ಕನು,mrk,kan
+1 થેસ.,1 થેસ્સલોનિકીઓને,થેસ્સલોનિકીઓને પાઉલ પ્રેરીતનો પહેલો પત્ર,1th,guj
+2 થેસ.,2 થેસ્સલોનિકીઓને,થેસ્સલોનિકીઓને પાઉલ પ્રેરીતનો બીજો પત્ર,2th,guj
+1 તિમ.,1 તિમોથીને,તિમોથીને પાઉલ પ્રેરીતનો પહેલો પત્ર,1ti,guj
+2 તિમ.,2 તિમોથીને,તિમોથીને પાઉલ પ્રેરીતનો બીજો પત્ર,2ti,guj
+તિત.,તિતસને,તિતસને પાઉલ પ્રેરીતનો પત્ર,tit,guj
+ફિલે.,ફિલેમોનને,ફિલેમોનને પાઉલ પ્રેરીતનો પત્ર,phm,guj
+હિબ.,હિબ્રૂઓને,હિબ્રૂઓને પત્ર,heb,guj
+યાકૂ.,યાકૂબનો,યાકૂબનો પત્ર,jas,guj
+1 પિત.,1 પિતરનો,પિતરનો પહેલો પત્ર,1pe,guj
+2 પિત.,2 પિતરનો,પિતરનો બીજો પત્ર,2pe,guj
+1 યોહ.,1 યોહાનનો,યોહાનનો પહેલો પત્ર,1jn,guj
+2 યોહ.,2 યોહાનનો,યોહાનનો બીજો પત્ર,2jn,guj
+3 યોહ.,3 યોહાનનો,યોહાનનો ત્રીજો પત્ર,3jn,guj
+યહૂ.,યહૂદાનો,યહૂદાનો પત્ર,jud,guj
+પ્રક.,પ્રકટીકરણ,પ્રકટીકરણ,rev,guj
+उत्प.,उत्पत्ति,उत्पत्ति,gen,hin
+निर्ग.,निर्गमन,निर्गमन,exo,hin
+लैव्य.,लैव्यव्यवस्था,लैव्यव्यवस्था,lev,hin
+गिन.,गिनती,गिनती,num,hin
+व्यव.,व्यवस्थाविवरण,व्यवस्थाविवरण,deu,hin
+यहो.,यहोशू,यहोशू,jos,hin
+न्याय.,न्यायियों,न्यायियों,jdg,hin
+रूत,रूत,रूत,rut,hin
+1 शमू.,1 शमूएल,शमूएल की पहली पुस्तक,1sa,hin
+2 शमू.,2 शमूएल,शमूएल की दूसरी पुस्तक,2sa,hin
+1 राजा.,1 राजाओं,राजाओं की पहली पुस्तक,1ki,hin
+2 राजा.,2 राजाओं,राजाओं की दूसरी पुस्तक,2ki,hin
+1 इति.,1 इतिहास,इतिहास की पहली पुस्तक,1ch,hin
+2 इति.,2 इतिहास,इतिहास की दूसरी पुस्तक,2ch,hin
+एज्रा,एज्रा,एज्रा,ezr,hin
+नहे.,नहेम्याह,नहेम्याह,neh,hin
+एस्ते.,एस्तेर,एस्तेर,est,hin
+अय्यू.,अय्यूब,अय्यूब,job,hin
+भज.,भजन संहिता,भजन संहिता,psa,hin
+नीति.,नीतिवचन,नीतिवचन,pro,hin
+सभो.,सभोपदेशक,सभोपदेशक,ecc,hin
+श्रेष्ठ.,श्रेष्ठगीत,श्रेष्ठगीत,sng,hin
+यशा.,यशायाह,यशायाह,isa,hin
+यिर्म.,यिर्मयाह,यिर्मयाह,jer,hin
+विला.,विलापगीत,विलापगीत,lam,hin
+यहे.,यहेजकेल,यहेजकेल,ezk,hin
+दानि.,दानिय्येल,दानिय्येल,dan,hin
+होशे,होशे,होशे,hos,hin
+योए.,योएल,योएल,jol,hin
+आमो.,आमोस,आमोस,amo,hin
+ओब.,ओबद्याह,ओबद्याह,oba,hin
+योना,योना,योना,jon,hin
+मीका,मीका,मीका,mic,hin
+नहू.,नहूम,नहूम,nam,hin
+हब.,हबक्कूक,हबक्कूक,hab,hin
+सप.,सपन्याह,सपन्याह,zep,hin
+हाग्गै,हाग्गै,हाग्गै,hag,hin
+जक.,जकर्याह,जकर्याह,zec,hin
+मला.,मलाकी,मलाकी,mal,hin
+मत्ती,मत्ती,मत्ती रचित सुसमाचार,mat,hin
+मर.,मरकुस,मरकुस रचित सुसमाचार,mrk,hin
+लूका,लूका,लूका रचित सुसमाचार,luk,hin
+यूह.,यूहन्ना,यूहन्ना रचित सुसमाचार,jhn,hin
+प्रेरि.,प्रेरितों के काम,प्रेरितों के काम,act,hin
+रोम.,रोमियों,रोमियों के नाम पौलुस प्रेरित की पत्री,rom,hin
+1कुरि.,1 कुरिन्थियों,कुरिन्थियों के नाम पौलुस प्रेरित की पहली पत्री,1co,hin
+2कुरि.,2 कुरिन्थियों,कुरिन्थियों के नाम पौलुस प्रेरित की दूसरी पत्री,2co,hin
+गला.,गलातियों,गलातियों के नाम पौलुस प्रेरित की पत्री,gal,hin
+इफि.,इफिसियों,इफिसियों के नाम पौलुस प्रेरित की पत्री,eph,hin
+फिलि.,फिलिप्पियों,फिलिप्पियों के नाम पौलुस प्रेरित की पत्री,php,hin
+कुलु.,कुलुस्सियों,कुलुस्सियों के नाम पौलुस प्रेरित की पत्री,col,hin
+1थिस्स.,1 थिस्सलुनीकियों,थिस्सलुनीकियों के नाम पौलुस प्रेरित की पहली पत्री,1th,hin
+2 थिस्स.,2 थिस्सलुनीकियों,थिस्सलुनीकियों के नाम पौलुस प्रेरित की दूसरी पत्री,2th,hin
+1 तीमु.,1 तीमुथियुस,तीमुथियुस के नाम प्रेरित पौलुस की पहली पत्री,1ti,hin
+2 तीमु.,2 तीमुथियुस,तीमुथियुस के नाम प्रेरित पौलुस की दूसरी पत्री,2ti,hin
+तीतु.,तीतुस,तीतुस के नाम प्रेरित पौलुस की पत्री,tit,hin
+फिले.,फिलेमोन,फिलेमोन के नाम प्रेरित पौलुस की पत्री,phm,hin
+इब्रा.,इब्रानियों,इब्रानियों के नाम पत्री,heb,hin
+याकू.,याकूब,याकूब की पत्री,jas,hin
+1 पत.,1 पतरस,पतरस की पहली पत्री,1pe,hin
+2 पत.,2 पतरस,पतरस की दूसरी पत्री,2pe,hin
+1 यूह.,1 यूहन्ना,यूहन्ना की पहली पत्री,1jn,hin
+2 यूह.,2 यूहन्ना,यूहन्ना की दूसरी पत्री,2jn,hin
+3 यूह.,3 यूहन्ना,यूहन्ना की तीसरी पत्री,3jn,hin
+यहू.,यहूदा,यहूदा की पत्री,jud,hin
+प्रका.,प्रकाशितवाक्य,यूहन्ना का प्रकाशितवाक्य,rev,hin
+ಆದಿ,ಆದಿಕಾಂಡ,ಆದಿಕಾಂಡ,gen,kan
+ವಿಮೋ,ವಿಮೋಚನಕಾಂಡ,ವಿಮೋಚನಕಾಂಡ,exo,kan
+ಯಾಜ,ಯಾಜಕಕಾಂಡ,ಯಾಜಕಕಾಂಡ,lev,kan
+ಅರಣ್ಯ,ಅರಣ್ಯಕಾಂಡ,ಅರಣ್ಯಕಾಂಡ,num,kan
+ಧರ್ಮೋ,ಧರ್ಮೋಪದೇಶಕಾಂಡ,ಧರ್ಮೋಪದೇಶಕಾಂಡ,deu,kan
+ಯೆಹೋ,ಯೆಹೋಶುವನು,ಯೆಹೋಶುವನು,jos,kan
+ನ್ಯಾಯ,ನ್ಯಾಯಸ್ಥಾಪಕರು,ನ್ಯಾಯಸ್ಥಾಪಕರು,jdg,kan
+ರೂತ,ರೂತಳು,ರೂತಳು,rut,kan
+1ಸಮು,1 ಸಮುವೇಲನು,1 ಸಮುವೇಲನು,1sa,kan
+2ಸಮು,2 ಸಮುವೇಲನು,2 ಸಮುವೇಲನು,2sa,kan
+1ಅರಸು,1 ಅರಸುಗಳು,1 ಅರಸುಗಳು,1ki,kan
+2ಅರಸು,2 ಅರಸುಗಳು,2 ಅರಸುಗಳು,2ki,kan
+1ಪೂರ್ವ,1 ಪೂರ್ವಕಾಲವೃತ್ತಾಂತ,1 ಪೂರ್ವಕಾಲವೃತ್ತಾಂತ,1ch,kan
+2ಪೂರ್ವ,2 ಪೂರ್ವಕಾಲವೃತ್ತಾಂತ,2 ಪೂರ್ವಕಾಲವೃತ್ತಾಂತ,2ch,kan
+ಎಜ್ರ,ಎಜ್ರನು,ಎಜ್ರನು,ezr,kan
+ನೆಹೆ,ನೆಹೆಮೀಯನು,ನೆಹೆಮೀಯನು,neh,kan
+ಎಸ್ತೇ,ಎಸ್ತೇರಳು,ಎಸ್ತೇರಳು,est,kan
+ಯೋಬ,ಯೋಬನು,ಯೋಬನು,job,kan
+ಕೀರ್ತ,ಕೀರ್ತನೆಗಳು,ಕೀರ್ತನೆಗಳು,psa,kan
+ಜ್ಞಾನೋ,ಜ್ಞಾನೋಕ್ತಿಗಳು,ಜ್ಞಾನೋಕ್ತಿಗಳು,pro,kan
+ಪ್ರಸ,ಪ್ರಸಂಗಿ,ಪ್ರಸಂಗಿ,ecc,kan
+ಪ. ಗೀ.,ಪರಮಗೀತೆ,ಪರಮಗೀತೆ,sng,kan
+ಯೆಶಾ,ಯೆಶಾಯನು,ಯೆಶಾಯನು,isa,kan
+ಯೆರೆ,ಯೆರೆಮೀಯನು,ಯೆರೆಮೀಯನು,jer,kan
+ಪ್ರಲಾ,ಪ್ರಲಾಪಗಳು,ಪ್ರಲಾಪಗಳು,lam,kan
+ಯೆಹೆ,ಯೆಹೆಜ್ಕೇಲನು,ಯೆಹೆಜ್ಕೇಲನು,ezk,kan
+ದಾನಿ,ದಾನಿಯೇಲನು,ದಾನಿಯೇಲನು,dan,kan
+ಹೋಶೇ,ಹೋಶೇಯನು,ಹೋಶೇಯನು,hos,kan
+ಯೋವೇ,ಯೋವೇಲನು,ಯೋವೇಲನು,jol,kan
+ಆಮೋ,ಆಮೋಸನು,ಆಮೋಸನು,amo,kan
+ಓಬ,ಓಬದ್ಯನು,ಓಬದ್ಯನು,oba,kan
+ಯೋನ,ಯೋನನು,ಯೋನನು,jon,kan
+ಮೀಕ,ಮೀಕನು,ಮೀಕನು,mic,kan
+ನಹೂ,ನಹೂಮನು,ನಹೂಮನು,nam,kan
+ಹಬ,ಹಬಕ್ಕೂಕನು,ಹಬಕ್ಕೂಕನು,hab,kan
+ಚೆಫ,ಚೆಫನ್ಯನು,ಚೆಫನ್ಯನು,zep,kan
+ಹಗ್ಗಾ,ಹಗ್ಗಾಯನು,ಹಗ್ಗಾಯನು,hag,kan
+ಜೆಕ,ಜೆಕರ್ಯನು,ಜೆಕರ್ಯನು,zec,kan
+ಮಲಾ,ಮಲಾಕಿಯನು,ಮಲಾಕಿಯನು,mal,kan
+ಮತ್ತಾ,ಮತ್ತಾಯನು,ಮತ್ತಾಯನು,mat,kan
+ಲೂಕ,ಲೂಕನು,ಲೂಕನು,luk,kan
+ಯೋಹಾ,ಯೋಹಾನನು,ಯೋಹಾನನು,jhn,kan
+ಅ. ಕೃ.,ಅಪೊಸ್ತಲರ ಕೃತ್ಯಗಳು,ಅಪೊಸ್ತಲರ ಕೃತ್ಯಗಳು,act,kan
+ರೋಮಾ,ರೋಮಾಪುರದವರಿಗೆ,ರೋಮಾಪುರದವರಿಗೆ,rom,kan
+1ಕೊರಿ,1 ಕೊರಿಂಥದವರಿಗೆ,1 ಕೊರಿಂಥದವರಿಗೆ,1co,kan
+2ಕೊರಿ,2 ಕೊರಿಂಥದವರಿಗೆ,2 ಕೊರಿಂಥದವರಿಗೆ,2co,kan
+ಗಲಾ,ಗಲಾತ್ಯದವರಿಗೆ,ಗಲಾತ್ಯದವರಿಗೆ,gal,kan
+ಎಫೆ,ಎಫೆಸದವರಿಗೆ,ಎಫೆಸದವರಿಗೆ,eph,kan
+ಫಿಲಿಪ್ಪಿ,ಫಿಲಿಪ್ಪಿಯವರಿಗೆ,ಫಿಲಿಪ್ಪಿಯವರಿಗೆ,php,kan
+ಕೊಲೊ,ಕೊಲೊಸ್ಸೆಯವರಿಗೆ,ಕೊಲೊಸ್ಸೆಯವರಿಗೆ,col,kan
+1ಥೆಸ,1 ಥೆಸಲೋನಿಕದವರಿಗೆ,1 ಥೆಸಲೋನಿಕದವರಿಗೆ,1th,kan
+2ಥೆಸ,2 ಥೆಸಲೋನಿಕದವರಿಗೆ,2 ಥೆಸಲೋನಿಕದವರಿಗೆ,2th,kan
+1ತಿಮೊ,1 ತಿಮೊಥೆಯನಿಗೆ,1 ತಿಮೊಥೆಯನಿಗೆ,1ti,kan
+2ತಿಮೊ,2 ತಿಮೊಥೆಯನಿಗೆ,2 ತಿಮೊಥೆಯನಿಗೆ,2ti,kan
+ತೀತ,ತೀತನಿಗೆ,ತೀತನಿಗೆ,tit,kan
+ಫಿಲೆ,ಫಿಲೆಮೋನನಿಗೆ,ಫಿಲೆಮೋನನಿಗೆ,phm,kan
+ಇಬ್ರಿ,ಇಬ್ರಿಯರಿಗೆ,ಇಬ್ರಿಯರಿಗೆ,heb,kan
+ಯಾಕೋ,ಯಾಕೋಬನು,ಯಾಕೋಬನು,jas,kan
+1ಪೇತ್ರ,1 ಪೇತ್ರನು,1 ಪೇತ್ರನು,1pe,kan
+2ಪೇತ್ರ,2 ಪೇತ್ರನು,2 ಪೇತ್ರನು,2pe,kan
+1ಯೋಹಾ,1 ಯೋಹಾನನು,1 ಯೋಹಾನನು,1jn,kan
+2ಯೋಹಾ,2 ಯೋಹಾನನು,2 ಯೋಹಾನನು,2jn,kan
+3ಯೋಹಾ,3 ಯೋಹಾನನು,3 ಯೋಹಾನನು,3jn,kan
+ಯೂದ,ಯೂದನು,ಯೂದನು,jud,kan
+ಪ್ರಕ,ಪ್ರಕಟಣೆ,ಪ್ರಕಟಣೆ,rev,kan
+ഉല്പ.,ഉല്പത്തി,ഉല്പത്തിപുസ്തകം,gen,mal
+പുറ.,പുറപ്പാടു്,പുറപ്പാടുപുസ്തകം,exo,mal
+ലേവ്യ.,ലേവ്യപുസ്തകം,ലേവ്യപുസ്തകം,lev,mal
+സംഖ്യാ.,സംഖ്യാപുസ്തകം,സംഖ്യാപുസ്തകം,num,mal
+ആവർത്തനം.,ആവർത്തനം.,ആവർത്തനപുസ്തകം,deu,mal
+യോശുവ,യോശുവ,യോശുവ,jos,mal
+ന്യായാ.,ന്യായാധിപന്മാർ,ന്യായാധിപന്മാർ,jdg,mal
+രൂത്ത്,രൂത്ത്,രൂത്ത്,rut,mal
+1 ശമു.,1 ശമൂവേൽ,ശമൂവേൽ: ഒന്നാം പുസ്തകം,1sa,mal
+2 ശമു.,2 ശമൂവേൽ,ശമൂവേൽ: രണ്ടാം പുസ്തകം,2sa,mal
+1 രാജാ.,1 രാജാക്കന്മാർ,രാജാക്കന്മാർ: ഒന്നാം പുസ്തകം,1ki,mal
+2 രാജാ.,2 രാജാക്കന്മാർ,രാജാക്കന്മാർ: രണ്ടാം പുസ്തകം,2ki,mal
+1 ദിന.,1 ദിനവൃത്താന്തം,ദിനവൃത്താന്തം: ഒന്നാം പുസ്തകം,1ch,mal
+2 ദിന.,2 ദിനവൃത്താന്തം,ദിനവൃത്താന്തം: രണ്ടാം പുസ്തകം,2ch,mal
+എസ്രാ,എസ്രാ,എസ്രാ,ezr,mal
+നെഹെ.,നെഹെമ്യാവു,നെഹെമ്യാവു,neh,mal
+എസ്ഥേ.,എസ്ഥേർ,എസ്ഥേർ,est,mal
+ഇയ്യോ.,ഇയ്യോബ്,ഇയ്യോബ്,job,mal
+സങ്കീ.,സങ്കീൎത്തനങ്ങൾ,സങ്കീൎത്തനങ്ങൾ,psa,mal
+സദൃ.,സദൃശവാക്യങ്ങൾ,സദൃശവാക്യങ്ങൾ,pro,mal
+സഭാ.,സഭാപ്രസംഗി,സഭാപ്രസംഗി,ecc,mal
+ഉത്ത.,ഉത്തമഗീതം,ഉത്തമഗീതം,sng,mal
+യെശ.,യെശയ്യാ,യെശയ്യാപ്രവാചകന്റെ പുസ്തകം,isa,mal
+യിരെ.,യിരേമ്യാവു,യിരെമ്യാപ്രവാചകന്റെ പുസ്തകം,jer,mal
+വിലാ.,വിലാപങ്ങൾ,വിലാപങ്ങൾ,lam,mal
+യെഹെ.,യെഹെസ്കേൽ,യെഹെസ്കേൽ,ezk,mal
+ദാനീ.,ദാനീയേൽ,ദാനീയേലിന്റെ പുസ്തകം,dan,mal
+ഹോശേ.,ഹോശേയ,ഹോശേയ,hos,mal
+യോവേ.,യോവേൽ,യോവേൽ,jol,mal
+ആമോ.,ആമോസ്,ആമോസ്,amo,mal
+ഓബ.,ഓബദ്യാവു,ഓബദ്യാവു,oba,mal
+യോനാ,യോനാ,യോനാ,jon,mal
+മീഖാ,മീഖാ,മീഖാ,mic,mal
+നഹൂം,നഹൂം,നഹൂം,nam,mal
+ഹബ.,ഹബക്കൂൿ,ഹബക്കൂൿ,hab,mal
+സെഫ.,സെഫന്യാവു,സെഫന്യാവു,zep,mal
+ഹഗ്ഗാ.,ഹഗ്ഗായി,ഹഗ്ഗായി,hag,mal
+സെഖ.,സെഖര്യാവു,സെഖര്യാവു,zec,mal
+മലാ.,മലാഖി,മലാഖി,mal,mal
+മത്താ.,മത്തായി,മത്തായി എഴുതിയ സുവിശേഷം,mat,mal
+മർക്കൊ.,മർക്കൊസ്,മർക്കൊസ് എഴുതിയ സുവിശേഷം,mrk,mal
+ലൂക്കൊ.,ലൂക്കൊസ്,ലൂക്കൊസ് എഴുതിയ സുവിശേഷം,luk,mal
+യോഹ.,യോഹന്നാൻ,യോഹന്നാൻ എഴുതിയ സുവിശേഷം,jhn,mal
+പ്രവൃത്തികൾ.,പ്രവൃത്തികൾ.,അപ്പൊസ്തലന്മാരുടെ പ്രവൃത്തികൾ,act,mal
+റോമർ,റോമർ,അപ്പൊസ്തലനായ പൗലൊസ് റോമർക്ക് എഴുതിയ ലേഖനം,rom,mal
+1 കൊരി.,1 കൊരിന്ത്യർ,അപ്പൊസ്തലനായ പൗലൊസ് കൊരിന്ത്യർക്ക് എഴുതിയ ഒന്നാം ലേഖനം,1co,mal
+2 കൊരി.,2 കൊരിന്ത്യർ,അപ്പൊസ്തലനായ പൗലൊസ് കൊരിന്ത്യർക്ക് എഴുതിയ രണ്ടാം ലേഖനം,2co,mal
+ഗലാ.,ഗലാത്യർ,അപ്പൊസ്തലനായ പൗലൊസ് ഗലാത്യർക്ക് എഴുതിയ ലേഖനം,gal,mal
+എഫെ.,എഫെസ്യർ,അപ്പൊസ്തലനായ പൗലൊസ് എഫെസ്യർക്ക് എഴുതിയ ലേഖനം,eph,mal
+ഫിലി.,ഫിലിപ്പിയർ,അപ്പൊസ്തലനായ പൗലൊസ് ഫിലിപ്പിയർക്ക് എഴുതിയ ലേഖനം,php,mal
+കൊലൊ.,കൊലൊസ്സ്യർ,അപ്പൊസ്തലനായ പൗലൊസ് കൊലൊസ്സ്യർക്ക് എഴുതിയ ലേഖനം,col,mal
+1 തെസ്സ.,1 തെസ്സലൊനീക്യർ,അപ്പൊസ്തലനായ പൗലൊസ് തെസ്സലൊനീക്യർക്ക് എഴുതിയ ഒന്നാം ലേഖനം,1th,mal
+इफि.,इफिसकरांस,इफिसकरांस पत्र,eph,mar
+फिलि.,फिलिप्पैकरांस,फिलिप्पैकरांस पत्र,php,mar
+2 തെസ്സ.,2 തെസ്സലൊനീക്യർ,അപ്പൊസ്തലനായ പൗലൊസ് തെസ്സലൊനീക്യർക്ക് എഴുതിയ രണ്ടാം ലേഖനം,2th,mal
+1 തിമൊ.,1 തിമൊഥെയൊസ്,അപ്പൊസ്തലനായ പൗലൊസ് തിമൊഥെയൊസിന് എഴുതിയ ഒന്നാം ലേഖനം,1ti,mal
+2 തിമൊ.,2 തിമൊഥെയൊസ്,അപ്പൊസ്തലനായ പൗലൊസ് തിമൊഥെയൊസിന് എഴുതിയ രണ്ടാം ലേഖനം,2ti,mal
+തീത്തൊ.,തീത്തൊസ്,അപ്പൊസ്തലനായ പൗലൊസ് തീത്തൊസിന് എഴുതിയ ലേഖനം,tit,mal
+ഫിലേ.,ഫിലേമോൻ,അപ്പൊസ്തലനായ പൗലൊസ് ഫിലേമോന് എഴുതിയ ലേഖനം,phm,mal
+എബ്രാ.,എബ്രായർ,എബ്രായർക്ക് എഴുതിയ ലേഖനം,heb,mal
+യാക്കോ.,യാക്കോബ്,യാക്കോബ് എഴുതിയ ലേഖനം,jas,mal
+1 പത്രൊ.,1 പത്രൊസ്,പത്രൊസ് എഴുതിയ ഒന്നാം ലേഖനം,1pe,mal
+2 പത്രൊ.,2 പത്രൊസ്,പത്രൊസ് എഴുതിയ രണ്ടാം ലേഖനം,2pe,mal
+1 യോഹ.,1 യോഹന്നാൻ,യോഹന്നാൻ എഴുതിയ ഒന്നാം ലേഖനം,1jn,mal
+2 യോഹ.,2 യോഹന്നാൻ,യോഹന്നാൻ എഴുതിയ രണ്ടാം ലേഖനം,2jn,mal
+3 യോഹ.,3 യോഹന്നാൻ,യോഹന്നാൻ എഴുതിയ മൂന്നാം ലേഖനം,3jn,mal
+യൂദാ,യൂദാ,യൂദാ എഴുതിയ ലേഖനം,jud,mal
+വെളി.,വെളിപ്പാട്,യോഹന്നാന് ഉണ്ടായ വെളിപ്പാട്,rev,mal
+उत्प.,उत्पत्ति,उत्पत्ति,gen,mar
+निर्ग.,निर्गम,निर्गम,exo,mar
+लेवी.,लेवीय,लेवीय,lev,mar
+गण.,गणना,गणना,num,mar
+अनु.,अनुवाद,अनुवाद,deu,mar
+यहो.,यहोशवा,यहोशवा,jos,mar
+शास्ते,शास्ते,शास्ते,jdg,mar
+रूथ,रूथ,रूथ,rut,mar
+1 शमु.,1 शमुवेल,1 शमुवेल,1sa,mar
+2 शमु.,2 शमुवेल,2 शमुवेल,2sa,mar
+1 राजे,1 राजे,1 राजे,1ki,mar
+2 राजे,2 राजे,2 राजे,2ki,mar
+1 इति.,1 इतिहास,1 इतिहास,1ch,mar
+2 इति.,2 इतिहास,2 इतिहास,2ch,mar
+एज्रा,एज्रा,एज्रा,ezr,mar
+नहे.,नहेम्या,नहेम्या,neh,mar
+एस्ते.,एस्तेर,एस्तेर,est,mar
+ईयो.,ईयोब,ईयोब,job,mar
+स्तोत्र.,स्तोत्रसंहिता,स्तोत्रसंहिता,psa,mar
+नीति.,नीतिसूत्रे,नीतिसूत्रे,pro,mar
+उप.,उपदेशक,उपदेशक,ecc,mar
+गीत.,गीतरत्न,गीतरत्न,sng,mar
+यश.,यशया,यशया,isa,mar
+यिर्म.,यिर्मया,यिर्मया,jer,mar
+विला.,विलापगीत,विलापगीत,lam,mar
+यहे.,यहेज्केल,यहेज्केल,ezk,mar
+दानि.,दानिएल,दानिएल,dan,mar
+होशे.,होशेय,होशेय,hos,mar
+योए.,योएल,योएल,jol,mar
+आमो.,आमोस,आमोस,amo,mar
+ओब.,ओबद्या,ओबद्या,oba,mar
+योना,योना,योना,jon,mar
+मीखा,मीखा,मीखा,mic,mar
+नहू.,नहूम,नहूम,nam,mar
+हब.,हबक्कूक,हबक्कूक,hab,mar
+सफ.,सफन्या,सफन्या,zep,mar
+हाग्ग.,हाग्गय,हाग्गय,hag,mar
+जख.,जखऱ्या,जखऱ्या,zec,mar
+मला.,मलाखी,मलाखी,mal,mar
+मत्त.,मत्तय,मत्तयकृत शुभवर्तमान,mat,mar
+मार्क,मार्क,मार्ककृत शुभवर्तमान,mrk,mar
+लूक,लूक,लूककृत शुभवर्तमान,luk,mar
+योहा.,योहान,योहानकृत शुभवर्तमान,jhn,mar
+प्रेषि.,प्रेषितांचीं कृत्यें,प्रेषितांची कृत्ये,act,mar
+रोम.,रोमकरांस,रोमकरांस पत्र,rom,mar
+1 करिं.,1 करिंथकरांस,करिंथकरांस पहिले पत्र,1co,mar
+2 करिं.,2 करिंथकरांस,करिंथकरांस दुसरे पत्र,2co,mar
+गल.,गलतीकरांस,गलतीकरांस पत्र,gal,mar
+कल.,कलस्सैकरांस,कलस्सैकरांस पत्र,col,mar
+1 थेस्स.,1 थेस्सलनीकाकरांस,थेस्सलनीकाकरांस पहिले पत्र,1th,mar
+2 थेस्स.,2 थेस्सलनीकाकरांस,थेस्सलनीकाकरांस दुसरे पत्र,2th,mar
+1 तीम.,1 तीमथ्याला,तीमथ्याला पहिले पत्र,1ti,mar
+2 तीम.,2 तीमथ्याला,तीमथ्याला दुसरे पत्र,2ti,mar
+तीत.,तीताला,तीताला पत्र,tit,mar
+फिले.,फिलेमोना,फिलेमानाला पत्र,phm,mar
+इब्री.,इब्री लोकांस,इब्री लोकांस पत्र,heb,mar
+याको.,याकोब,याकोबाचे पत्र,jas,mar
+1 पेत्र.,1 पेत्र,पेत्राचे पहिले पत्र,1pe,mar
+2 पेत्र.,2 पेत्र,पेत्राचे दुसरे पत्र,2pe,mar
+1 योहा.,1 योहान,योहानाचे पहिले पत्र,1jn,mar
+2 योहा.,2 योहान,योहानाचे दुसरे पत्र,2jn,mar
+3 योहा.,3 योहान,योहानाचे तिसरे पत्र,3jn,mar
+यहू.,यहूदा,यहूदाचे पत्र,jud,mar
+प्रक.,प्रकटीकरण,प्रकटीकरण,rev,mar
+ଆଦି.,ଆଦି ପୁସ୍ତକ,ଆଦି ପୁସ୍ତକ,gen,ory
+ଯାତ୍ରା.,ଯାତ୍ରା ପୁସ୍ତକ,ଯାତ୍ରା ପୁସ୍ତକ,exo,ory
+ଲେବୀ.,ଲେବୀୟ ପୁସ୍ତକ,ଲେବୀୟ ପୁସ୍ତକ,lev,ory
+ଗଣ.,ଗଣନା ପୁସ୍ତକ,ଗଣନା ପୁସ୍ତକ,num,ory
+ଦ୍ବି.ବି.,ଦ୍ୱିତୀୟ ବିବରଣ,ଦ୍ୱିତୀୟ ବିବରଣ,deu,ory
+ଯିହୋ.,ଯିହୋଶୂୟ,ଯିହୋଶୂୟ ପୁସ୍ତକ,jos,ory
+ବିଚାର.,ବିଚାରକର୍ତ୍ତା ପୁସ୍ତକ,ବିଚାରକର୍ତ୍ତାମାନଙ୍କ ବିବରଣ,jdg,ory
+ରୂତ.,ରୂତର ପୁସ୍ତକ,ରୂତର ବିବରଣ,rut,ory
+ପ୍ର.ଶା.,1 ଶାମୁୟେଲ,ପ୍ରଥମ ଶାମୁୟେଲ,1sa,ory
+ଦ୍ବି.ଶା.,2 ଶାମୁୟେଲ,ଦ୍ୱିତୀୟ ଶାମୁୟେଲ,2sa,ory
+ପ୍ର.ରା.,1 ରାଜାବଳୀ,ପ୍ରଥମ ରାଜାବଳୀ,1ki,ory
+ଦ୍ବି.ରା.,2 ରାଜାବଳୀ,ଦ୍ୱିତୀୟ ରାଜାବଳୀ,2ki,ory
+ପ୍ର.ବଂ.,1 ବଂଶାବଳୀ,ପ୍ରଥମ ବଂଶାବଳୀ,1ch,ory
+ଦ୍ବି.ବଂ.,2 ବଂଶାବଳୀ,ଦ୍ବିତୀୟ ବଂଶାବଳୀ,2ch,ory
+ଏଜ୍ରା.,ଏଜ୍ରା,ଏଜ୍ରା ପୁସ୍ତକ,ezr,ory
+ନିହି.,ନିହିମୀୟା,ନିହିମୀୟାଙ୍କ ପୁସ୍ତକ,neh,ory
+ଏଷ୍ଟ.,ଏଷ୍ଟର,ଏଷ୍ଟର ବିବରଣ,est,ory
+ଆୟୁବ.,ଆୟୁବ,ଆୟୁବ ପୁସ୍ତକ,job,ory
+ଗୀତ.,ଗୀତସଂହିତା,ଗୀତସଂହିତା,psa,ory
+ହିତୋ.,ହିତୋପଦେଶ,ହିତୋପଦେଶ,pro,ory
+ଉପ.,ଉପଦେଶକ,ଉପଦେଶକ,ecc,ory
+ପ.ଗୀ.,ପରମ ଗୀତ,ପରମ ଗୀତ,sng,ory
+ଯିଶା.,ଯିଶାଇୟ,ଯିଶାଇୟ ଭବିଷ୍ୟଦ୍ବକ୍ତାଙ୍କ ପୁସ୍ତକ,isa,ory
+ଯିରି.,ଯିରିମୀୟ,ଯିରିମୀୟ ଭବିଷ୍ୟଦ୍ବକ୍ତାଙ୍କ ପୁସ୍ତକ,jer,ory
+ବିଳା.,ବିଳାପ,ଯିରିମୀୟଙ୍କ ବିଳାପ,lam,ory
+ଯିହି.,ଯିହିଜିକଲ,ଯିହିଜିକଲ ଭବିଷ୍ୟଦ୍ବକ୍ତାଙ୍କ ପୁସ୍ତକ,ezk,ory
+ଦାନି.,ଦାନିୟେଲ,ଦାନିୟେଲଙ୍କ ପୁସ୍ତକ,dan,ory
+ହୋଶେ.,ହୋଶେୟ,ହୋଶେୟ ଭବିଷ୍ୟଦ୍ବକ୍ତାଙ୍କ ପୁସ୍ତକ,hos,ory
+ଯୋୟେ.,ଯୋୟେଲ,ଯୋୟେଲ ଭବିଷ୍ୟଦ୍ବକ୍ତାଙ୍କ ପୁସ୍ତକ,jol,ory
+ଆମୋ.,ଆମୋଷ,ଆମୋଷ ଭବିଷ୍ୟଦ୍ବକ୍ତାଙ୍କ ପୁସ୍ତକ,amo,ory
+ଓବ.,ଓବଦୀୟ,ଓବଦୀୟ ଭବିଷ୍ୟଦ୍ବକ୍ତାଙ୍କ ପୁସ୍ତକ,oba,ory
+ଯୂନ.,ଯୂନସ,ଯୂନସ ଭବିଷ୍ୟଦ୍ବକ୍ତାଙ୍କ ପୁସ୍ତକ,jon,ory
+ମୀଖା.,ମୀଖା,ମୀଖା ଭବିଷ୍ୟଦ୍ବକ୍ତାଙ୍କ ପୁସ୍ତକ,mic,ory
+ନାହୂ.,ନାହୂମ,ନାହୂମ ଭବିଷ୍ୟଦ୍ବକ୍ତାଙ୍କ ପୁସ୍ତକ,nam,ory
+ହବ.,ହବକ୍କୂକ,ହବକ୍କୂକ ଭବିଷ୍ୟଦ୍ବକ୍ତାଙ୍କ ପୁସ୍ତକ,hab,ory
+ସିଫ.,ସିଫନୀୟ,ସିଫନୀୟ ଭବିଷ୍ୟଦ୍ବକ୍ତାଙ୍କ ପୁସ୍ତକ,zep,ory
+ହାଗ.,ହାଗୟ,ହାଗୟ ଭବିଷ୍ୟଦ୍ବକ୍ତାଙ୍କ ପୁସ୍ତକ,hag,ory
+ଯିଖ.,ଯିଖରୀୟ,ଯିଖରୀୟ ଭବିଷ୍ୟବକ୍ତାଙ୍କ ପୁସ୍ତକ,zec,ory
+ମଲାଖି.,ମଲାଖି,ମଲାଖି ଭବିଷ୍ୟବକ୍ତାଙ୍କ ପୁସ୍ତକ,mal,ory
+ମାଥି.,ମାଥିଉ,ମାଥିଉ ଲିଖିତ ସୁସମାଚାର,mat,ory
+tit,Titus,Titus,tit,eng
+ମାର୍କ,ମାର୍କ,ମାର୍କ ଲିଖିତ ସୁସମାଚାର,mrk,ory
+ଲୂକ,ଲୂକ,ଲୂକ ଲିଖିତ ସୁସମାଚାର,luk,ory
+ଯୋହ.,ଯୋହନ,ଯୋହନ ଲିଖିତ ସୁସମାଚାର,jhn,ory
+ପ୍ରେରି.,ପ୍ରେରିତ,ପ୍ରେରିତମାନଙ୍କ କାର୍ଯ୍ୟର ବିବରଣ,act,ory
+ରୋମୀ.,ରୋମୀୟ,ରୋମୀୟ ମଣ୍ଡଳୀ ନିକଟକୁ ପ୍ରେରିତ ପାଉଲଙ୍କ ପତ୍ର,rom,ory
+ପ୍ର.କରି.,1 କରିନ୍ଥୀୟ,କରିନ୍ଥୀୟ ମଣ୍ଡଳୀ ନିକଟକୁ ପାଉଲଙ୍କ ପ୍ରଥମ ପତ୍ର,1co,ory
+ଦ୍ୱି.କରି.,2 କରିନ୍ଥୀୟ,କରିନ୍ଥୀୟ ମଣ୍ଡଳୀ ନିକଟକୁ ପାଉଲଙ୍କ ଦ୍ୱିତୀୟ ପତ୍ର,2co,ory
+ଗାଲା.,ଗାଲାତୀୟ,ଗାଲାତୀୟ ମଣ୍ଡଳୀ ନିକଟକୁ ପ୍ରେରିତ ପାଉଲଙ୍କ ପତ୍ର,gal,ory
+ଏଫି.,ଏଫିସୀୟ,ଏଫିସୀୟ ମଣ୍ଡଳୀ ନିକଟକୁ ପ୍ରେରିତ ପାଉଲଙ୍କ ପତ୍ର,eph,ory
+ଫିଲିପ୍ପୀ.,ଫିଲିପ୍ପୀୟ,ଫିଲିପ୍ପୀୟ ମଣ୍ଡଳୀ ନିକଟକୁ ପ୍ରେରିତ ପାଉଲଙ୍କ ପତ୍ର,php,ory
+କଲ.,କଲସୀୟ,କଲସୀୟ ମଣ୍ଡଳୀ ନିକଟକୁ ପ୍ରେରିତ ପାଉଲଙ୍କ ପତ୍ର,col,ory
+ପ୍ର.ଥେସ.,1 ଥେସଲନୀକୀୟ,ଥେସଲନୀକୀୟ ମଣ୍ଡଳୀ ନିକଟକୁ ପ୍ରେରିତ ପାଉଲଙ୍କ ପ୍ରଥମ ପତ୍ର,1th,ory
+ଦ୍ୱି.ଥେସ.,2 ଥେସଲନୀକୀୟ,ଥେସଲନୀକୀୟ ମଣ୍ଡଳୀ ନିକଟକୁ ପ୍ରେରିତ ପାଉଲଙ୍କ ଦ୍ୱିତୀୟ ପତ୍ର,2th,ory
+ପ୍ର.ତୀମ.,1 ତୀମଥି,ତୀମଥି ନିକଟକୁ ପ୍ରେରିତ ପାଉଲଙ୍କ ପ୍ରଥମ ପତ୍ର,1ti,ory
+ଦ୍ୱି.ତୀମ.,2 ତୀମଥି,ତୀମଥି ନିକଟକୁ ପ୍ରେରିତ ପାଉଲଙ୍କ ଦ୍ୱିତୀୟ ପତ୍ର,2ti,ory
+ତୀତ.,ତୀତସ,ତୀତସଙ୍କ ନିକଟକୁ ପ୍ରେରିତ ପାଉଲଙ୍କ ପତ୍ର,tit,ory
+ଫିଲୀ.,ଫିଲୀମୋନ,ଫିଲୀମୋନଙ୍କ ନିକଟକୁ ପ୍ରେରିତ ପାଉଲଙ୍କ ପତ୍ର,phm,ory
+ଏବ୍ରୀ,ଏବ୍ରୀ,ଏବ୍ରୀମାନଙ୍କ ନିକଟକୁ ପତ୍ର,heb,ory
+ଯାକୁ.,ଯାକୁବ,ଯାକୁବଙ୍କ ପତ୍ର,jas,ory
+ପ୍ର. ପିତ.,1 ପିତର,ପିତରଙ୍କ ପ୍ରଥମ ପତ୍ର,1pe,ory
+ଦ୍ୱି. ପିତ.,2 ପିତର,ପିତରଙ୍କ ଦ୍ୱିତୀୟ ପତ୍ର,2pe,ory
+पैदा,पैदाइश,पैदाइश,gen,urd
+ପ୍ର. ଯୋହ.,1 ଯୋହନ,ଯୋହନଙ୍କ ପ୍ରଥମ ପତ୍ର,1jn,ory
+ଦ୍ୱି. ଯୋହ.,2 ଯୋହନ,ଯୋହନଙ୍କ ଦ୍ୱିତୀୟ ପତ୍ର,2jn,ory
+ତୃ. ଯୋହ,3 ଯୋହନ,ଯୋହନଙ୍କ ତୃତୀୟ ପତ୍ର,3jn,ory
+ଯିହୂ.,ଯିହୂଦା,ଯିହୂଦାଙ୍କ ପତ୍ର,jud,ory
+ପ୍ରକା.,ପ୍ରକାଶିତ,ଯୋହନଙ୍କ ପ୍ରତି ପ୍ରକାଶିତ ବାକ୍ୟ,rev,ory
+ਉਤ,ਪੈਦਾਇਸ਼,ਉਤਪਤ,gen,pan
+ਕੂਚ,ਖ਼ਰੋਜ,ਕੂਚ,exo,pan
+ਲੇਵੀਆਂ,ਅਹਬਾਰ,ਲੇਵੀਆਂ ਦੀ ਪੋਥੀ,lev,pan
+ਗਿਣਤੀ,ਗਿਣਤੀ,ਗਿਣਤੀ,num,pan
+ਬਿਵਸਥਾ,ਅਸਤਸਨਾ,ਬਿਵਸਥਾ ਸਾਰ,deu,pan
+ਯਹੋਸ਼ੁ,ਯਸ਼ਵਾ,ਯਹੋਸ਼ੁਆ,jos,pan
+ਨਿਆਂਈ,ਕਜ਼ਾૃ,ਨਿਆਂਈਆਂ ਦੀ ਪੋਥੀ,jdg,pan
+ਰੂਥ,ਰੁੱਤ,ਰੂਥ,rut,pan
+1 ਸਮੂ,1 ਸਮੂਏਲ,1 ਸਮੂਏਲ,1sa,pan
+2 ਸਮੂ,2 ਸਮੂਏਲ,2 ਸਮੂਏਲ,2sa,pan
+1 ਰਾਜਾ,1 ਰਾਜਿਆਂ,1 ਰਾਜਿਆਂ,1ki,pan
+2 ਰਾਜਾ,2 ਰਾਜਿਆਂ,2 ਰਾਜਿਆਂ,2ki,pan
+1 ਇਤ,1 ਇਤਿਹਾਸ,1 ਇਤਿਹਾਸ,1ch,pan
+2 ਇਤ,2 ਇਤਿਹਾਸ,2 ਇਤਿਹਾਸ,2ch,pan
+ਅਜ਼ਰਾ,ਅਜ਼ਰਾ,ਅਜ਼ਰਾ,ezr,pan
+ਨਹਮਯਾਹ,ਨਹਮਯਾਹ,ਨਹਮਯਾਹ,neh,pan
+ਅਸਤਰ,ਅਸਤਰ,ਅਸਤਰ,est,pan
+ਅੱਯੂਬ,ਅੱਯੂਬ,ਅੱਯੂਬ,job,pan
+ਜ਼ਬੂਰ,ਜ਼ਬੂਰ,ਜ਼ਬੂਰ,psa,pan
+ਕਹਾਉਤਾਂ,ਕਹਾਉਤਾਂ,ਕਹਾਉਤਾਂ,pro,pan
+ਉਪਦੇਸ਼ਕ,ਉਪਦੇਸ਼ਕ,ਉਪਦੇਸ਼ਕ,ecc,pan
+ਸੁਲੇਮਾਨ ਦਾ ਗੀਤ,ਸੁਲੇਮਾਨ ਦਾ ਗੀਤ,ਸੁਲੇਮਾਨ ਦਾ ਗੀਤ,sng,pan
+ਯਸਾ,ਯਸਾਯਾਹ,ਯਸਾਯਾਹ,isa,pan
+ਯਿਰਮਿਯਾਹ,ਯਿਰਮਿਯਾਹ,ਯਿਰਮਿਯਾਹ,jer,pan
+ਵਿਰਲਾਪ,ਵਿਰਲਾਪ,ਯਿਰਮਿਯਾਹ ਦਾ ਵਿਰਲਾਪ,lam,pan
+ਹਿਜ਼ਕੀਏਲ,ਹਿਜ਼ਕੀਏਲ,ਹਿਜ਼ਕੀਏਲ,ezk,pan
+ਦਾਨੀਏਲ,ਦਾਨੀਏਲ,ਦਾਨੀਏਲ,dan,pan
+ਹੋਸ਼ੇ,ਹੋਸ਼ੇਆ,ਹੋਸ਼ੇਆ,hos,pan
+ਯੋਏਲ,ਯੋਏਲ,ਯੋਏਲ,jol,pan
+ਆਮੋਸ,ਆਮੋਸ,ਆਮੋਸ,amo,pan
+ਓਬਦਯਾਹ,ਓਬਦਯਾਹ,ਓਬਦਯਾਹ,oba,pan
+ਯੂਨਾ,ਯੂਨਾਹ,ਯੂਨਾਹ,jon,pan
+ਮੀਕਾ,ਮੀਕਾਹ,ਮੀਕਾਹ,mic,pan
+ਨਹੂਮ,ਨਹੂਮ,ਨਹੂਮ,nam,pan
+ਹਬੱਕੂਕ,ਹਬੱਕੂਕ,ਹਬੱਕੂਕ,hab,pan
+ਸਫ਼ਨਯਾਹ,ਸਫ਼ਨਯਾਹ,ਸਫ਼ਨਯਾਹ,zep,pan
+ਹੱਜਈ,ਹੱਜਈ,ਹੱਜਈ,hag,pan
+ਜ਼ਕਰ,ਜ਼ਕਰਯਾਹ,ਜ਼ਕਰਯਾਹ,zec,pan
+ਮਲਾਕੀ,ਮਲਾਕੀ,ਮਲਾਕੀ,mal,pan
+ਮੱਤੀ,ਮੱਤੀ,ਮੱਤੀ ਦੀ ਇੰਜੀਲ,mat,pan
+ਮਰਕੁਸ,ਮਰਕੁਸ,ਮਰਕੁਸ ਦੀ ਇੰਜੀਲ,mrk,pan
+ਲੂਕਾ,ਲੂਕਾ,ਲੂਕਾ ਦੀ ਇੰਜੀਲ,luk,pan
+ਯੂਹੰਨਾ,ਯੂਹੰਨਾ,ਯੂਹੰਨਾ ਦੀ ਇੰਜੀਲ,jhn,pan
+ਰਸੂਲਾਂ ਦੇ ਕਰਤੱਬ,ਰਸੂਲਾਂ ਦੇ ਕਰਤੱਬ,ਰਸੂਲਾਂ ਦੇ ਕਰਤੱਬ,act,pan
+ਰੋਮੀਆਂ,ਰੋਮੀਆਂ,ਰੋਮੀਆਂ ਨੂੰ,rom,pan
+1 ਕੁਰਿੰਥੀ,1 ਕੁਰਿੰਥੀਆਂ,ਕੁਰਿੰਥੀਆਂ ਨੂੰ ਪਹਿਲੀ ਪੱਤ੍ਰੀ,1co,pan
+2 ਕੁਰਿੰਥੀ,2 ਕੁਰਿੰਥੀਆਂ,ਕੁਰਿੰਥੀਆਂ ਨੂੰ ਦੂਜੀ ਪੱਤ੍ਰੀ,2co,pan
+ਗਲਾਤੀਆਂ,ਗਲਾਤੀਆਂ,ਗਲਾਤੀਆਂ ਨੂੰ,gal,pan
+ਅਫ਼ਸੀਆਂ,ਅਫ਼ਸੀਆਂ,ਅਫ਼ਸੀਆਂ ਨੂੰ,eph,pan
+ਫਿਲਿੱਪੀਆਂ,ਫਿਲਿੱਪੀਆਂ,ਫਿਲਿੱਪੀਆਂ ਨੂੰ,php,pan
+ਕੁਲੁੱਸੀਆਂ,ਕੁਲੁੱਸੀਆਂ,ਕੁਲੁੱਸੀਆਂ ਨੂੰ,col,pan
+1 ਥੱਸਲੁ,1 ਥੱਸਲੁਨੀਕੀਆਂ,ਥੱਸਲੁਨੀਕੀਆ ਨੂੰ ਪਹਿਲੀ ਪੱਤ੍ਰੀ,1th,pan
+2 ਥੱਸਲੁ,2 ਥੱਸਲੁਨੀਕੀਆਂ,ਥੱਸਲੁਨੀਕੀਆ ਨੂੰ ਦੂਜੀ ਪੱਤ੍ਰੀ,2th,pan
+1 ਤਿਮੋਥਿ,1 ਤਿਮੋਥਿਉਸ,ਤਿਮੋਥਿਉਸ ਨੂੰ ਪਹਿਲੀ ਪੱਤ੍ਰੀ,1ti,pan
+2 ਤਿਮੋਥਿ,2 ਤਿਮੋਥਿਉਸ,ਤਿਮੋਥਿਉਸ ਨੂੰ ਦੂਜੀ ਪੱਤ੍ਰੀ,2ti,pan
+ਤੀਤੁਸ,ਤੀਤੁਸ,ਤੀਤੁਸ ਨੂੰ,tit,pan
+ਫਿਲੇਮੋਨ,ਫਿਲੇਮੋਨ,ਫਿਲੇਮੋਨ ਨੂੰ,phm,pan
+ਇਬਰਾਨੀ,ਇਬਰਾਨੀ,ਇਬਰਾਨੀਆਂ ਨੂੰ,heb,pan
+ਯਾਕੂਬ,ਯਾਕੂਬ,ਯਾਕੂਬ ਦੀ ਪੱਤ੍ਰੀ,jas,pan
+1 ਪਤ,1 ਪਤਰਸ,ਪਤਰਸ ਦੀ ਪਹਿਲੀ ਪੱਤ੍ਰੀ,1pe,pan
+2 ਪਤ,2 ਪਤਰਸ,ਪਤਰਸ ਦੀ ਦੂਜੀ ਪੱਤ੍ਰੀ,2pe,pan
+1 ਯੂਹੰਨਾ,1 ਯੂਹੰਨਾ,ਯੂਹੰਨਾ ਦੀ ਪਹਿਲੀ ਪੱਤ੍ਰੀ,1jn,pan
+2 ਯੂਹੰਨਾ,2 ਯੂਹੰਨਾ,ਯੂਹੰਨਾ ਦੀ ਦੂਜੀ ਪੱਤ੍ਰੀ,2jn,pan
+3 ਯੂਹੰਨਾ,3 ਯੂਹੰਨਾ,ਯੂਹੰਨਾ ਦੀ ਤੀਸਰੀ ਪੱਤ੍ਰੀ,3jn,pan
+ਯਹੂਦਾ,ਯਹੂਦਾ,ਯਹੂਦਾਹ ਦੀ ਪੱਤ੍ਰੀ,jud,pan
+ਪਰਕਾਸ਼ ਦੀ ਪੋਥੀ,ਪਰਕਾਸ਼ ਦੀ ਪੋਥੀ,ਯੂਹੰਨਾ ਦੇ ਪਰਕਾਸ਼ ਦੀ ਪੋਥੀ,rev,pan
+ஆதி,ஆதியாகமம்,ஆதியாகமம்,gen,tam
+யாத்,யாத்திராகமம்,யாத்திராகமம்,exo,tam
+லேவி,லேவியராகமம்,லேவியராகமம்,lev,tam
+எண்,எண்ணாகமம்,எண்ணாகமம்,num,tam
+உபா,உபாகமம்,உபாகமம்,deu,tam
+யோசு,யோசுவா,யோசுவா,jos,tam
+நியா,நியாயாதிபதிகள்,நியாயாதிபதிகள்,jdg,tam
+ரூத்,ரூத்,ரூத்,rut,tam
+1சாமு,1 சாமுவேல்,1 சாமுவேல்,1sa,tam
+2சாமு,2 சாமுவேல்,2 சாமுவேல்,2sa,tam
+1இராஜா,1 இராஜாக்கள்,1 இராஜாக்கள்,1ki,tam
+2இராஜா,2 இராஜாக்கள்,2 இராஜாக்கள்,2ki,tam
+1நாளா,1 நாளாகமம்,1 நாளாகமம்,1ch,tam
+2நாளா,2 நாளாகமம்,2 நாளாகமம்,2ch,tam
+எஸ்றா,எஸ்றா,எஸ்றா,ezr,tam
+நெகே,நெகேமியா,நெகேமியா,neh,tam
+எஸ்த,எஸ்தர்,எஸ்தர்,est,tam
+யோபு,யோபு,யோபு,job,tam
+சங்,சங்கீதங்கள்,சங்கீதங்கள்,psa,tam
+நீதி,நீதிமொழிகள்,நீதிமொழிகள்,pro,tam
+பிரச,பிரசங்கி,பிரசங்கி,ecc,tam
+உன்,உன்னதப்பாட்டு,உன்னதப்பாட்டு,sng,tam
+ஏசா,ஏசாயா,ஏசாயா,isa,tam
+எரே,எரேமியா,எரேமியா,jer,tam
+புலம்,புலம்பல்,புலம்பல்,lam,tam
+எசேக்,எசேக்கியேல்,எசேக்கியேல்,ezk,tam
+தானி,தானியேல்,தானியேல்,dan,tam
+ஓசியா,ஓசியா,ஓசியா,hos,tam
+யோவே,யோவேல்,யோவேல்,jol,tam
+ஆமோ,ஆமோஸ்,ஆமோஸ்,amo,tam
+heb,Hebrews,Hebrews,heb,eng
+ஒபதி,ஒபதியா,ஒபதியா,oba,tam
+யோனா,யோனா,யோனா,jon,tam
+மீகா,மீகா,மீகா,mic,tam
+நாகூ,நாகூம்,நாகூம்,nam,tam
+ஆப,ஆபகூக்,ஆபகூக்,hab,tam
+செப்ப,செப்பனியா,செப்பனியா,zep,tam
+ஆகாய்,ஆகாய்,ஆகாய்,hag,tam
+சகரி,சகரியா,சகரியா,zec,tam
+மல்கி,மல்கியா,மல்கியா,mal,tam
+மத்,மத்தேயு,மத்தேயு,mat,tam
+மாற்,மாற்கு,மாற்கு,mrk,tam
+லூக்,லூக்கா,லூக்கா,luk,tam
+யோவா,யோவான்,யோவான்,jhn,tam
+அப்,அப்போஸ்தலருடைய நடபடிகள்,அப்போஸ்தலருடைய நடபடிகள்,act,tam
+ரோமர்,ரோமர்,ரோமர்,rom,tam
+1கொரி,1 கொரிந்தியர்,1 கொரிந்தியர்,1co,tam
+2கொரி,2 கொரிந்தியர்,2 கொரிந்தியர்,2co,tam
+கலா,கலாத்தியர்,கலாத்தியர்,gal,tam
+எபே,எபேசியர்,எபேசியர்,eph,tam
+பிலி,பிலிப்பியர்,பிலிப்பியர்,php,tam
+கொலோ,கொலோசெயர்,கொலோசெயர்,col,tam
+1தெச,1 தெசலோனிக்கேயர்,1 தெசலோனிக்கேயர்,1th,tam
+2தெச,2 தெசலோனிக்கேயர்,2 தெசலோனிக்கேயர்,2th,tam
+1தீமோ,1 தீமோத்தேயு,1 தீமோத்தேயு,1ti,tam
+2தீமோ,2 தீமோத்தேயு,2 தீமோத்தேயு,2ti,tam
+தீத்,தீத்து,தீத்து,tit,tam
+பிலே,பிலேமோன்,பிலேமோன்,phm,tam
+எபி,எபிரெயர்,எபிரெயர்,heb,tam
+யாக்,யாக்கோபு,யாக்கோபு,jas,tam
+1பேது,1 பேதுரு,1 பேதுரு,1pe,tam
+2பேது,2 பேதுரு,2 பேதுரு,2pe,tam
+1யோவா,1 யோவான்,1 யோவான்,1jn,tam
+2யோவா,2 யோவான்,2 யோவான்,2jn,tam
+3யோவா,3 யோவான்,3 யோவான்,3jn,tam
+யூதா,யூதா,யூதா,jud,tam
+வெளி,வெளிப்படுத்தின விசேஷம்,வெளிப்படுத்தின விசேஷம்,rev,tam
+ఆది,ఆదికాండం,ఆదికాండం,gen,tel
+నిర్గ,నిర్గమకాండం,నిర్గమకాండం,exo,tel
+లేవీ,లేవీకాండం,లేవీకాండం,lev,tel
+సంఖ్యా,సంఖ్యాకాండం,సంఖ్యాకాండం,num,tel
+ద్వితీ,ద్వితీయోపదేశ కాండము,ద్వితీయోపదేశ కాండము,deu,tel
+యెహో,యెహోషువ,యెహోషువ,jos,tel
+ख़ुरु,ख़ुरुज,ख़ुरुज,exo,urd
+న్యాయా,న్యాయాధిపతులు,న్యాయాధిపతులు,jdg,tel
+రూతు,రూతు,రూతు,rut,tel
+1సమూ,1 సమూయేలు,1 సమూయేలు,1sa,tel
+2సమూ,2 సమూయేలు,2 సమూయేలు,2sa,tel
+1రాజులు,1 రాజులు,1 రాజులు,1ki,tel
+2రాజులు,2 రాజులు,2 రాజులు,2ki,tel
+1దిన,1 దినవృత్తాంతాలు,1 దినవృత్తాంతాలు,1ch,tel
+2దిన,2 దినవృత్తాంతాలు,2 దినవృత్తాంతాలు,2ch,tel
+ఎజ్రా,ఎజ్రా,ఎజ్రా,ezr,tel
+నెహె,నెహెమ్యా,నెహెమ్యా,neh,tel
+ఎస్తే,ఎస్తేరు,ఎస్తేరు,est,tel
+యోబు,యోబు,యోబు,job,tel
+కీర్త,కీర్తన,కీర్తనలు,psa,tel
+సామె,సామెత,సామెతలు,pro,tel
+ప్రస,ప్రసంగి,ప్రసంగి,ecc,tel
+పరమ,పరమ,పరమగీతం,sng,tel
+యెష,యెషయా,యెషయా,isa,tel
+యిర్మీ,యిర్మీయా,యిర్మీయా,jer,tel
+విలాప,విలాపవాక్యాలు,విలాపవాక్యాలు,lam,tel
+యెహె,యెహెజ్కేలు,యెహెజ్కేలు,ezk,tel
+దాని,దానియేలు,దానియేలు,dan,tel
+హోషే,హోషేయ,హోషేయ,hos,tel
+యోవే,యోవేలు,యోవేలు,jol,tel
+ఆమో,ఆమోసు,ఆమోసు,amo,tel
+ఓబద్యా,ఓబద్యా,ఓబద్యా,oba,tel
+యోనా,యోనా,యోనా,jon,tel
+మీకా,మీకా,మీకా,mic,tel
+నహూ,నహూము,నహూము,nam,tel
+హబ,హబక్కూకు,హబక్కూకు,hab,tel
+జెఫ,జెఫన్యా,జెఫన్యా,zep,tel
+హగ్గ,హగ్గయి,హగ్గయి,hag,tel
+జెక,జెకర్యా,జెకర్యా,zec,tel
+మలా,మలాకీ,మలాకీ,mal,tel
+మత్తయి,మత్తయి,మత్తయి రాసిన సువార్త,mat,tel
+మార్కు,మార్కు,మార్కు రాసిన సువార్త,mrk,tel
+లూకా,లూకా,లూకా రాసిన సువార్త,luk,tel
+యోహా,యోహాను,యోహాను రాసిన సువార్త,jhn,tel
+అపొ.కా.,అపొస్తలుల కార్యములు,అపొస్తలుల కార్యములు,act,tel
+రోమా,రోమీయులకు,రోమీయులకు రాసిన పత్రిక,rom,tel
+1కొరింతీ,1 కొరింథీయులకు,కొరింతీయులకు రాసిన మొదటి పత్రిక,1co,tel
+2కొరింతీ,2 కొరింథీయులకు,కొరింతీయులకు రాసిన రెండవ పత్రిక,2co,tel
+గలతీ,గలతీ పత్రిక,గలతీయులకు రాసిన పత్రిక,gal,tel
+ఎఫెసీ,ఎఫెసీ పత్రిక,ఎఫెసీయులకు రాసిన పత్రిక,eph,tel
+ఫిలిప్పీ,ఫిలిప్పీ పత్రిక,ఫిలిప్పీయులకు రాసిన పత్రిక,php,tel
+కొలస్సీ,కొలస్సీ పత్రిక,కొలస్సయులకు రాసిన పత్రిక,col,tel
+1తెస్స,1 తెస్సలోనిక పత్రిక,తెస్సలోనికయులకు రాసిన మొదటి పత్రిక,1th,tel
+2తెస్స,2 తెస్సలోనిక పత్రిక,తెస్సలోనికయులకు రాసిన రెండవ పత్రిక,2th,tel
+1తిమో,1 తిమోతి పత్రిక,తిమోతికి రాసిన మొదటి పత్రిక,1ti,tel
+2తిమో,2 తిమోతి పత్రిక,తిమోతికి రాసిన రెండవ పత్రిక,2ti,tel
+తీతు,తీతు పత్రిక,తీతుకు రాసిన పత్రిక,tit,tel
+ఫిలే,ఫిలేమోను పత్రిక,ఫిలేమోనుకు రాసిన పత్రిక,phm,tel
+హెబ్రీ,హెబ్రీ పత్రిక,హెబ్రీయులకు రాసిన పత్రిక,heb,tel
+యాకో,యాకోబు పత్రిక,యాకోబు రాసిన పత్రిక,jas,tel
+1పేతు,1 పేతురు పత్రిక,పేతురు రాసిన మొదటి పత్రిక,1pe,tel
+2పేతు,2 పేతురు పత్రిక,పేతురు రాసిన రెండవ పత్రిక,2pe,tel
+1యోహా,1 యోహాను పత్రిక,యోహాను రాసిన మొదటి పత్రిక,1jn,tel
+2యోహా,2 యోహాను పత్రిక,యోహాను రాసిన రెండవ పత్రిక,2jn,tel
+3యోహా,3 యోహాను పత్రిక,యోహాను రాసిన మూడవ పత్రిక,3jn,tel
+1ch,1 Chronicles,1 Chronicles,1ch,eng
+యూదా,యూదా పత్రిక,యూదా రాసిన పత్రిక,jud,tel
+ప్రక,ప్రకటన గ్రంథం,యోహాను రాసిన ప్రకటన గ్రంథం,rev,tel
+अह,अहबार,अहबार,lev,urd
+गिन,गिनती,गिनती,num,urd
+इस्त,इस्तिस्ना,इस्तिस्ना,deu,urd
+यशो,यशोअ,यशोअ,jos,urd
+क़ुजा,क़ुजात,क़ुजात,jdg,urd
+रुत,रुत,रुत,rut,urd
+1 समु,1 समुएल,1 समुएल,1sa,urd
+2 समु,2 समुएल,2 समुएल,2sa,urd
+1 सला,1 सलातीन,1 सलातीन,1ki,urd
+2 सला,2 सलातीन,2 सलातीन,2ki,urd
+1 तवा,1 तवारीख़,1 तवारीख़,1ch,urd
+2 तवा,2 तवारीख़,2 तवारीख़,2ch,urd
+एज्रा,एज्रा,एज्रा,ezr,urd
+नहे,नहेम्याह,नहेम्याह,neh,urd
+आस्त,आस्तेर,आस्तेर,est,urd
+अय्यू,अय्यूब,अय्यूब,job,urd
+ज़बूर,ज़बूर,ज़बूर,psa,urd
+अम्सा,अम्साल,अम्साल,pro,urd
+वाइज़,वाइज़,वाइज़,ecc,urd
+गज़लुल,गज़लुल गज़लियात,गज़लुल गज़लियात,sng,urd
+यसा,यसायाह,यसायाह,isa,urd
+यर्म,यर्मयाह,यर्मयाह,jer,urd
+नोहा,नोहा,नोहा,lam,urd
+हिज़ि,हिज़िक़िएल,हिज़िक़िएल,ezk,urd
+दानि,दानिएल,दानिएल,dan,urd
+होसी,होसीअ,होसीअ,hos,urd
+यूए,यूएल,यूएल,jol,urd
+आमू,आमूस,आमूस,amo,urd
+अब्द,अब्दयाह,अब्दयाह,oba,urd
+यूना,यूनाह,यूनाह,jon,urd
+मीका,मीकाह,मीकाह,mic,urd
+नाहूम,नाहूम,नाहूम,nam,urd
+हबक़्,हबक़्क़ूक़,हबक़्क़ूक़,hab,urd
+सफ़न,सफ़नयाह,सफ़नयाह,zep,urd
+हज्जी,हज्जी,हज्जी,hag,urd
+ज़कर,ज़करयाह,ज़करयाह,zec,urd
+मला,मलाकी,मलाकी,mal,urd
+मत्त,मत्ती,मत्ती,mat,urd
+मर,मरकुस,मरकुस,mrk,urd
+लूका,लूका,लूका,luk,urd
+यूह,यूहन्ना,यूहन्ना,jhn,urd
+रसूलों,रसूलों के आ'माल,रसूलों के आ'माल,act,urd
+रोमि,रोमियों,रोमियों,rom,urd
+1 कुरि,1 कुरिन्थियों,1 कुरिन्थियों,1co,urd
+2 कुरि,2 कुरिन्थियों,2 कुरिन्थियों,2co,urd
+गला,गलातियों,गलातियों,gal,urd
+इफ़ि,इफ़िसियों,इफ़िसियों,eph,urd
+फ़िलि,फ़िलिप्पियों,फ़िलिप्पियों,php,urd
+कुलु,कुलुस्सियों,कुलुस्सियों,col,urd
+1 थिस्स,1 थिस्सलुनीकियों,1 थिस्सलुनीकियों,1th,urd
+2 थिस्स,2 थिस्सलुनीकियों,2 थिस्सलुनीकियों,2th,urd
+1 तीमु,1 तीमुथियुस,1 तीमुथियुस,1ti,urd
+2 तीमु,2 तीमुथियुस,2 तीमुथियुस,2ti,urd
+तितु,तितुस,तितुस,tit,urd
+फ़िले,फ़िलेमोन,फ़िलेमोन,phm,urd
+इब्रा,इब्रानियों,इब्रानियों,heb,urd
+या'क़ूब,या'क़ूब,या'क़ूब,jas,urd
+1 पत,1 पतरस,1 पतरस,1pe,urd
+2 पत,2 पतरस,2 पतरस,2pe,urd
+1 यूह,1 यूहन्ना,1 यूहन्ना,1jn,urd
+2 यूह,2 यूहन्ना,2 यूहन्ना,2jn,urd
+3 यूह,3 यूहन्ना,3 यूहन्ना,3jn,urd
+यहू,यहूदाह,यहूदाह,jud,urd
+मुका,मुकाश्फ़ा,मुकाश्फ़ा,rev,urd
+gen,Genesis,Genesis,gen,eng
+exo,Exodus,Exodus,exo,eng
+lev,Leviticus,Leviticus,lev,eng
+num,Numbers,Numbers,num,eng
+deu,Deuteronomy,Deuteronomy,deu,eng
+jos,Joshua,Joshua,jos,eng
+jdg,Judges,Judges,jdg,eng
+rut,Ruth,Ruth,rut,eng
+1sa,1 Samuel,1 Samuel,1sa,eng
+2sa,2 Samuel,2 Samuel,2sa,eng
+1ki,1 Kings,1 Kings,1ki,eng
+2ch,2 Chronicles,2 Chronicles,2ch,eng
+ezr,Ezra,Ezra,ezr,eng
+neh,Nehemiah,Nehemiah,neh,eng
+est,Esther,Esther,est,eng
+job,Job,Job,job,eng
+psa,Psalms,Psalms,psa,eng
+pro,Proverbs,Proverbs,pro,eng
+ecc,Ecclesiastes,Ecclesiastes,ecc,eng
+sng,Song Of Solomon,Song Of Solomon,sng,eng
+isa,Isaiah,Isaiah,isa,eng
+jer,Jeremiah,Jeremiah,jer,eng
+lam,Lamentations,Lamentations,lam,eng
+ezk,Ezekiel,Ezekiel,ezk,eng
+dan,Daniel,Daniel,dan,eng
+hos,Hosea,Hosea,hos,eng
+jol,Joel,Joel,jol,eng
+amo,Amos,Amos,amo,eng
+oba,Obadiah,Obadiah,oba,eng
+jon,Jonah,Jonah,jon,eng
+mic,Micah,Micah,mic,eng
+nam,Nahum,Nahum,nam,eng
+hab,Habakkuk,Habakkuk,hab,eng
+zep,Zephaniah,Zephaniah,zep,eng
+hag,Haggai,Haggai,hag,eng
+zec,Zechariah,Zechariah,zec,eng
+mal,Malachi,Malachi,mal,eng
+mat,Matthew,Matthew,mat,eng
+mrk,Mark,Mark,mrk,eng
+luk,Luke,Luke,luk,eng
+jhn,John,John,jhn,eng
+act,Acts,Acts,act,eng
+rom,Romans,Romans,rom,eng
+1co,1 Corinthians,1 Corinthians,1co,eng
+2co,2 Corinthians,2 Corinthians,2co,eng
+gal,Galatians,Galatians,gal,eng
+eph,Ephesians,Ephesians,eph,eng
+php,Philippians,Philippians,php,eng
+col,Colossians,Colossians,col,eng
+1th,1 Thessalonians,1 Thessalonians,1th,eng
+2th,2 Thessalonians,2 Thessalonians,2th,eng
+1ti,1 Timothy,1 Timothy,1ti,eng
+2ti,2 Timothy,2 Timothy,2ti,eng
+phm,Philemon,Philemon,phm,eng
+jas,James,James,jas,eng
+1pe,1 Peter,1 Peter,1pe,eng
+2pe,2 Peter,2 Peter,2pe,eng
+1jn,1 John,1 John,1jn,eng
+2jn,2 John,2 John,2jn,eng
+3jn,3 John,3 John,3jn,eng
+jud,Jude,Jude,jud,eng
+rev,Revelation,Revelation,rev,eng
+मत्ती,मत्ती,मत्ती के जरिये लिख्या गया सुसमाचार,mat,bgc
+मर,मरकुस,मरकुस के जरिये लिख्या गया सुसमाचार,mrk,bgc
+लूका,लूका,लूका के जरिये लिख्या गया सुसमाचार,luk,bgc
+यूह,यूहन्ना,यूहन्ना के जरिये लिख्या गया सुसमाचार,jhn,bgc
+प्रेरि,प्रेरितों के काम,प्रेरितां के काम का वर्णन,act,bgc
+रोम,रोमियों,रोम नगर के माणसां ताहीं पौलुस की चिट्ठी,rom,bgc
+1 कुरि,1 कुरिन्थियों,कुरिन्थिस नगर के माणसां ताहीं प्रेरित पौलुस की पैहली चिट्ठी,1co,bgc
+2 कुरि,2 कुरिन्थियों,कुरिन्थिस नगर के माणसां ताहीं प्रेरित पौलुस की दुसरी चिट्ठी,2co,bgc
+गला,गलातियों,गलातिया परदेस के माणसां ताहीं प्रेरित पौलुस की चिट्ठी,gal,bgc
+इफि,इफिसियों,इफिसुस नगर के माणसां ताहीं प्रेरित पौलुस की चिट्ठी,eph,bgc
+फिलि,फिलिप्पियों,फिलिप्पी नगर के माणसां ताहीं प्रेरित पौलुस की चिट्ठी,php,bgc
+कुलु,कुलुस्सियों,कुलुस्से नगर के माणसां ताहीं प्रेरित पौलुस की चिट्ठी,col,bgc
+1 थिस्स,1 थिस्सलुनीकियों,थिस्सलुनीकियों नगर के माणसां ताहीं प्रेरित पौलुस की पैहली चिट्ठी,1th,bgc
+2 थिस्स,2 थिस्सलुनीकियों,थिस्सलुनीकियों नगर के माणसां ताहीं प्रेरित पौलुस की दुसरी चिट्ठी,2th,bgc
+1 तीमुथि,1 तीमुथियुस,तीमुथियुस के नाम प्रेरित पौलुस की पैहली चिट्ठी,1ti,bgc
+2 तीमुथि,2 तीमुथियुस,तीमुथियुस के नाम प्रेरित पौलुस की दुसरी चिट्ठी,2ti,bgc
+तीतुस,तीतुस,तीतुस के नाम प्रेरित पौलुस की चिट्ठी,tit,bgc
+फिले,फिलेमोन,फिलेमोन के नाम प्रेरित पौलुस की चिट्ठी,phm,bgc
+इब्रा,इब्रानियों,इब्रानियों के नाम चिट्ठी,heb,bgc
+याकूब,याकूब,याकूब की ओड़ तै चिट्ठी,jas,bgc
+1 पत,1 पतरस,पतरस की पैहली चिट्ठी,1pe,bgc
+2 पत,2 पतरस,पतरस की दुसरी चिट्ठी,2pe,bgc
+1 यूह,1 यूहन्ना,यूहन्ना की पैहली चिट्ठी,1jn,bgc
+2 यूह,2 यूहन्ना,यूहन्ना की दुसरी चिट्ठी,2jn,bgc
+3 यूह,3 यूहन्ना,यूहन्ना की तीसरी चिट्ठी,3jn,bgc
+यहूदा,यहूदा,यहूदा की चिट्ठी,jud,bgc
+प्रका,प्रकाशित वाक्य,प्रकाशित वाक्य,rev,bgc
+मत्ती,मत्ती,मत्ती री खुशखबरी,mat,bil
+मर.,मरकुस,मरकुस री खुशखबरी,mrk,bil
+लूका,लूका,लूका री खुशखबरी,luk,bil
+यूह.,यूहन्ना,यूहन्ना री खुशखबरी,jhn,bil
+प्रेरि.,प्रेरितां,प्रेरितां रे काम्म,act,bil
+रोमी.,रोमियो,रोमियो,rom,bil
+1 कुरि.,1 कुरिन्थियों,1 कुरिन्थियों,1co,bil
+2 कुरि.,2 कुरिन्थियों,2 कुरिन्थियों,2co,bil
+गला.,गलातियां,गलातियां,gal,bil
+इफि.,इफिसियां,इफिसियां,eph,bil
+फिलि.,फिलिप्पियां,फिलिप्पियां,php,bil
+कुलु.,कुलुस्सियां,कुलुस्सियां,col,bil
+1 थिस्स.,1 थिस्सलुनीकियां,1 थिस्सलुनीकियां,1th,bil
+2 थिस्स.,2 थिस्सलुनीकियां,2 थिस्सलुनीकियां,2th,bil
+1 तिमु.,1 तिमुथियुस,1 तिमुथियुस,1ti,bil
+2 तिमु.,2 तिमुथियुस,2 तिमुथियुस,2ti,bil
+तीतु.,तीतुस,तीतुस,tit,bil
+फिले.,फिलेमोन,फिलेमोन,phm,bil
+इब्रा.,इब्रानियों,इब्रानियों,heb,bil
+याकू.,याकूब,याकूब,jas,bil
+1 पत.,1 पतरस,1 पतरस,1pe,bil
+2 पत.,2 पतरस,2 पतरस,2pe,bil
+1 यूह.,1 यूहन्ना,1 यूहन्ना,1jn,bil
+2 यूह.,2 यूहन्ना,2 यूहन्ना,2jn,bil
+3 यूह.,3 यूहन्ना,3 यूहन्ना,3jn,bil
+यहू.,यहूदा,यहूदा,jud,bil
+प्रका.,प्रकाशितवाक्य,प्रकाशितवाक्य,rev,bil
+मत्ती,मत्ती,मत्ती दा सनेया,mat,dgo
+मरकुस,मरकुस,मरकुस दा सनेया,mrk,dgo
+लूका,लूका,लूका दा सनेया,luk,dgo
+यूहन्ना,यूहन्ना,यूहन्ना दा सनेया,jhn,dgo
+प्रेरितों.,प्रेरितों के कम,प्रेरितों के कम,act,dgo
+रोमियो,रोमियो,रोमियो,rom,dgo
+1 कुरि.,1 कुरिन्थियों,1 कुरिन्थियों,1co,dgo
+2 कुरि.,2 कुरिन्थियों,2 कुरिन्थियों,2co,dgo
+गला.,गलातियों,गलातियों,gal,dgo
+इफि.,इफिसियों,इफिसियों,eph,dgo
+फिलि.,फिलिप्पियों,फिलिप्पियों,php,dgo
+कुलु.,कुलुस्सियों,कुलुस्सियों,col,dgo
+1 थिस्स.,1 थिस्सलुनीकियों,1 थिस्सलुनीकियों,1th,dgo
+2 थिस्स.,2 थिस्सलुनीकियों,2 थिस्सलुनीकियों,2th,dgo
+1 तीमु.,1 तीमुथियुस,1 तीमुथियुस,1ti,dgo
+2 तीमु.,2 तीमुथियुस,2 तीमुथियुस,2ti,dgo
+तीतुस,तीतुस,तीतुस,tit,dgo
+फिलेमोन,फिलेमोन,फिलेमोन,phm,dgo
+इब्रानियों,इब्रानियों,इब्रानियों,heb,dgo
+याकूब,याकूब,याकूब,jas,dgo
+1 पतरस,1 पतरस,1 पतरस,1pe,dgo
+2 पतरस,2 पतरस,2 पतरस,2pe,dgo
+1 यूहन्ना,1 यूहन्ना,1 यूहन्ना,1jn,dgo
+2 यूहन्ना,2 यूहन्ना,2 यूहन्ना,2jn,dgo
+3 यूहन्ना,3 यूहन्ना,3 यूहन्ना,3jn,dgo
+यहूदा,यहूदा,यहूदा,jud,dgo
+प्रकाशित.,प्रकाशितवाक्य,प्रकाशितवाक्य,rev,dgo
+Mat,Matthew,Matthew Pora Likha Susamachar,mat,nag
+Mrk,Mark,Mark Pora Likha Susamachar,mrk,nag
+Luk,Luke,Luke Pora Likha Susamachar,luk,nag
+Jhn,John,John Pora Likha Susamachar,jhn,nag
+Act,Acts,Pasoni Khan Laga Kormo Khan,act,nag
+Rom,Romans,Rome Te Thaka Khan Logot Paul Pora Likha Chithi,rom,nag
+1Co,Prothom Corinthians,Corinth Te Thaka Khan Logot Paul Pora Likha Prothom Chithi,1co,nag
+2Co,Duitio Corinthians,Corinth Te Thaka Khan Logot Paul Pora Likha Duitio Chithi,2co,nag
+Gal,Galatians,Galatia Te Thaka Khan Logot Paul Pora Likha Chithi,gal,nag
+Eph,Ephesians,Ephesus Te Thaka Khan Logot Paul Pora Likha Chithi,eph,nag
+Php,Philippians,Philippi Te Thaka Khan Logot Paul Pora Likha Chithi,php,nag
+Col,Colossians,Colossae Te Thaka Khan Logot Paul Pora Likha Chithi,col,nag
+1Th,Prothom Thessalonians,Thessalonica Te Thaka Khan Logot Paul Laga Prothom Chithi,1th,nag
+2Th,Duitio Thessalonians,Thessalonica Te Thaka Khan Logot Paul Laga Duitio Chithi,2th,nag
+1Ti,Prothom Timothy,Timothy Ke Paul Pora Likha Prothom Chithi,1ti,nag
+2Ti,Duitio Timothy,Timothy Ke Paul Pora Likha Duitio Chithi,2ti,nag
+Tit,Titus,Titus Ke Paul Pora Likha Chithi,tit,nag
+Phm,Philemon,Philemon Ke Paul Pora Likha Chithi,phm,nag
+Heb,Hebrews,Ibrani Khan Ke Likha Chithi,heb,nag
+Jas,James,James Pora Likha Chithi,jas,nag
+1Pe,Prothom Peter,Peter Pora Likha Prothom Chithi,1pe,nag
+2Pe,Duitio Peter,Peter Pora Likha Duitio Chithi,2pe,nag
+1Jn,Prothom John,John Pora Likha Prothom Chithi,1jn,nag
+2Jn,Duitio John,John Pora Likha Duitio Chithi,2jn,nag
+3Jn,Tritio John,John Pora Likha Tritio Chithi,3jn,nag
+Jud,Jude,Jude Pora Likha Chithi,jud,nag
+Rev,Revelation,John Logot Prokahit Kora Kotha,rev,nag
+उत्पत्ति,उत्पत्ति,उत्पत्तिको पुस्तक,gen,nep
+प्रस्थान,प्रस्थान,प्रस्थानको पुस्तक,exo,nep
+लेवीहरू,लेवीहरू,लेवीहरूको पुस्तक,lev,nep
+गन्ती,गन्ती,गन्तीको पुस्तक,num,nep
+व्यवस्था,व्यवस्था,व्यवस्थाको पुस्तक,deu,nep
+यहोशू,यहोशू,यहोशूको पुस्तक,jos,nep
+न्यायकर्ताहरू,न्यायकर्ताहरू,न्यायकर्ताहरूको पुस्तक,jdg,nep
+रूथ,रूथ,रूथको पुस्तक,rut,nep
+१ शमूएल,१ शमूएल,१ शमूएलको पुस्तक,1sa,nep
+२ शमूएल,२ शमूएल,२ शमूएलको पुस्तक,2sa,nep
+१ राजाहरू,१ राजाहरू,१ राजाहरूको पुस्तक,1ki,nep
+२ राजाहरू,२ राजाहरू,२ राजाहरूको पुस्तक,2ki,nep
+१ इतिहास,१ इतिहास,१ इतिहासको पुस्तक,1ch,nep
+२ इतिहास,२ इतिहास,२ इतिहासको पुस्तक,2ch,nep
+एज्रा,एज्रा,एज्राको पुस्तक,ezr,nep
+नहेम्याह,नहेम्याह,नहेम्याहको पुस्तक,neh,nep
+एस्तर,एस्तर,एस्तरको पुस्तक,est,nep
+अय्यूब,अय्यूब,अय्यूबको पुस्तक,job,nep
+भजनसंग्रह,भजनसंग्रह,भजनसंग्रहको पुस्तक,psa,nep
+हितोपदेश,हितोपदेश,हितोपदेशको पुस्तक,pro,nep
+उपदेशक,उपदेशक,उपदेशकको पुस्तक,ecc,nep
+श्रेष्ठगीत,श्रेष्ठगीत,श्रेष्ठगीतको पुस्तक,sng,nep
+यशैया,यशैया,यशैयाको पुस्तक,isa,nep
+यर्मिया,यर्मिया,यर्मियाको पुस्तक,jer,nep
+विलाप,विलाप,विलापको पुस्तक,lam,nep
+इजकिएल,इजकिएल,इजकिएलको पुस्तक,ezk,nep
+दानिएल,दानिएल,दानिएलको पुस्तक,dan,nep
+होशे,होशे,होशेको पुस्तक,hos,nep
+योएल,योएल,योएलको पुस्तक,jol,nep
+आमोस,आमोस,आमोसको पुस्तक,amo,nep
+ओबदिया,ओबदिया,ओबदियाको पुस्तक,oba,nep
+योना,योना,योनाको पुस्तक,jon,nep
+मिका,मिका,मिकाको पुस्तक,mic,nep
+नहूम,नहूम,नहूमको पुस्तक,nam,nep
+हबकूक,हबकूक,हबकूकको पुस्तक,hab,nep
+सपन्याह,सपन्याह,सपन्याहको पुस्तक,zep,nep
+हाग्गै,हाग्गै,हाग्गैको पुस्तक,hag,nep
+जकरिया,जकरिया,जकरियाको पुस्तक,zec,nep
+मलाकी,मलाकी,मलाकीको पुस्तक,mal,nep
+मत्ती,मत्ती,मत्तीले लेखेको सुसमाचार,mat,nep
+मर्कूस,मर्कूस,मर्कूसले लेखेको सुसमाचार,mrk,nep
+लुका,लुका,लुकाले लेखेको सुसमाचार,luk,nep
+यूहन्ना,यूहन्ना,यूहन्नाले लेखेको सुसमाचार,jhn,nep
+प्रेरित,प्रेरित,प्रेरितहरूका काम,act,nep
+रोमी,रोमी,रोमीहरूलाई पावलको पत्र,rom,nep
+१ कोरिन्थी,१ कोरिन्थी,कोरिन्थीहरूलाई पावलको पहिलो पत्र,1co,nep
+२ कोरिन्थी,२ कोरिन्थी,कोरिन्थीहरूलाई पावलको दोस्रो पत्र,2co,nep
+गलाती,गलाती,गलातीहरूलाई पावलको पत्र,gal,nep
+एफिसि,एफिसि,एफिसिहरूलाई पावलको पत्र,eph,nep
+फिलिप्पी,फिलिप्पी,फिलिप्पीहरूलाई पावलको पत्र,php,nep
+कलस्सी,कलस्सी,कलस्सीहरूलाई पावलको पत्र,col,nep
+१ थेसलोनिकी,१ थेसलोनिकी,थेसलोनिकीहरूलाई पावलको पहिलो पत्र,1th,nep
+२ थेसलोनिकी,२ थेसलोनिकी,थेसलोनिकीहरूलाई पावलको दोस्रो पत्र,2th,nep
+१ तिमोथी,१ तिमोथी,तिमोथीलाई पावलको पाहिलो पत्र,1ti,nep
+२ तिमोथी,२ तिमोथी,तिमोथीलाई पावलको दोस्रो पत्र,2ti,nep
+तीतस,तीतस,तीतसलाई पावलको पत्र,tit,nep
+फिलेमोन,फिलेमोन,फिलेमोनलाई पावलको पत्र,phm,nep
+हिब्रू,हिब्रू,हिब्रूहरूका निम्ति पत्र,heb,nep
+याकूब,याकूब,याकूबको पत्र,jas,nep
+१ पत्रुस,१ पत्रुस,पत्रुसको पाहिलो पत्र,1pe,nep
+२ पत्रुस,२ पत्रुस,पत्रुसको दोस्रो पत्र,2pe,nep
+१ यूहन्ना,१ यूहन्ना,यूहन्नाको पहिलो पत्र,1jn,nep
+२ यूहन्ना,२ यूहन्ना,यूहन्नाको दोस्रो पत्र,2jn,nep
+३ यूहन्ना,३ यूहन्ना,यूहन्नाको तेस्रो पत्र,3jn,nep
+यहूदा,यहूदा,यहूदाको पत्र,jud,nep
+प्रकाश,प्रकाश,यूहन्नालाई भएको प्रकाश,rev,nep
+mat,Matthew,Matthew,mat,boc
+mrk,Mark,Mark,mrk,boc
+luk,Luke,Luke,luk,boc
+jhn,John,John,jhn,boc
+act,Acts,Acts,act,boc
+rom,Romans,Romans,rom,boc
+1co,1 Corinthians,1 Corinthians,1co,boc
+2co,2 Corinthians,2 Corinthians,2co,boc
+gal,Galatians,Galatians,gal,boc
+eph,Ephesians,Ephesians,eph,boc
+php,Philippians,Philippians,php,boc
+col,Colossians,Colossians,col,boc
+1th,1 Thessalonians,1 Thessalonians,1th,boc
+2th,2 Thessalonians,2 Thessalonians,2th,boc
+1ti,1 Timothy,1 Timothy,1ti,boc
+2ti,2 Timothy,2 Timothy,2ti,boc
+tit,Titus,Titus,tit,boc
+phm,Philemon,Philemon,phm,boc
+heb,Hebrews,Hebrews,heb,boc
+jas,James,James,jas,boc
+1pe,1 Peter,1 Peter,1pe,boc
+2pe,2 Peter,2 Peter,2pe,boc
+1jn,1 John,1 John,1jn,boc
+2jn,2 John,2 John,2jn,boc
+3jn,3 John,3 John,3jn,boc
+jud,Jude,Jude,jud,boc
+rev,Revelation,Revelation,rev,boc
+mat,Matthew,Matthew,mat,dom
+mrk,Mark,Mark,mrk,dom
+luk,Luke,Luke,luk,dom
+jhn,John,John,jhn,dom
+act,Acts,Acts,act,dom
+rom,Romans,Romans,rom,dom
+1co,1 Corinthians,1 Corinthians,1co,dom
+2co,2 Corinthians,2 Corinthians,2co,dom
+gal,Galatians,Galatians,gal,dom
+eph,Ephesians,Ephesians,eph,dom
+php,Philippians,Philippians,php,dom
+col,Colossians,Colossians,col,dom
+1th,1 Thessalonians,1 Thessalonians,1th,dom
+2th,2 Thessalonians,2 Thessalonians,2th,dom
+1ti,1 Timothy,1 Timothy,1ti,dom
+2ti,2 Timothy,2 Timothy,2ti,dom
+tit,Titus,Titus,tit,dom
+phm,Philemon,Philemon,phm,dom
+heb,Hebrews,Hebrews,heb,dom
+jas,James,James,jas,dom
+1pe,1 Peter,1 Peter,1pe,dom
+2pe,2 Peter,2 Peter,2pe,dom
+1jn,1 John,1 John,1jn,dom
+2jn,2 John,2 John,2jn,dom
+3jn,3 John,3 John,3jn,dom
+jud,Jude,Jude,jud,dom
+rev,Revelation,Revelation,rev,dom
+mat,Matthew,Matthew,mat,hai
+mrk,Mark,Mark,mrk,hai
+luk,Luke,Luke,luk,hai
+jhn,John,John,jhn,hai
+act,Acts,Acts,act,hai
+rom,Romans,Romans,rom,hai
+1co,1 Corinthians,1 Corinthians,1co,hai
+2co,2 Corinthians,2 Corinthians,2co,hai
+gal,Galatians,Galatians,gal,hai
+eph,Ephesians,Ephesians,eph,hai
+php,Philippians,Philippians,php,hai
+col,Colossians,Colossians,col,hai
+1th,1 Thessalonians,1 Thessalonians,1th,hai
+2th,2 Thessalonians,2 Thessalonians,2th,hai
+1ti,1 Timothy,1 Timothy,1ti,hai
+2ti,2 Timothy,2 Timothy,2ti,hai
+tit,Titus,Titus,tit,hai
+phm,Philemon,Philemon,phm,hai
+heb,Hebrews,Hebrews,heb,hai
+jas,James,James,jas,hai
+1pe,1 Peter,1 Peter,1pe,hai
+2pe,2 Peter,2 Peter,2pe,hai
+1jn,1 John,1 John,1jn,hai
+2jn,2 John,2 John,2jn,hai
+3jn,3 John,3 John,3jn,hai
+jud,Jude,Jude,jud,hai
+rev,Revelation,Revelation,rev,hai
+mat,Matthew,Matthew,mat,kar
+mrk,Mark,Mark,mrk,kar
+luk,Luke,Luke,luk,kar
+jhn,John,John,jhn,kar
+act,Acts,Acts,act,kar
+rom,Romans,Romans,rom,kar
+1co,1 Corinthians,1 Corinthians,1co,kar
+2co,2 Corinthians,2 Corinthians,2co,kar
+gal,Galatians,Galatians,gal,kar
+eph,Ephesians,Ephesians,eph,kar
+php,Philippians,Philippians,php,kar
+col,Colossians,Colossians,col,kar
+1th,1 Thessalonians,1 Thessalonians,1th,kar
+2th,2 Thessalonians,2 Thessalonians,2th,kar
+1ti,1 Timothy,1 Timothy,1ti,kar
+2ti,2 Timothy,2 Timothy,2ti,kar
+tit,Titus,Titus,tit,kar
+phm,Philemon,Philemon,phm,kar
+heb,Hebrews,Hebrews,heb,kar
+jas,James,James,jas,kar
+1pe,1 Peter,1 Peter,1pe,kar
+2pe,2 Peter,2 Peter,2pe,kar
+1jn,1 John,1 John,1jn,kar
+2jn,2 John,2 John,2jn,kar
+3jn,3 John,3 John,3jn,kar
+jud,Jude,Jude,jud,kar
+rev,Revelation,Revelation,rev,kar
+mat,Matthew,Matthew,mat,kon
+mrk,Mark,Mark,mrk,kon
+luk,Luke,Luke,luk,kon
+jhn,John,John,jhn,kon
+act,Acts,Acts,act,kon
+rom,Romans,Romans,rom,kon
+1co,1 Corinthians,1 Corinthians,1co,kon
+2co,2 Corinthians,2 Corinthians,2co,kon
+gal,Galatians,Galatians,gal,kon
+eph,Ephesians,Ephesians,eph,kon
+php,Philippians,Philippians,php,kon
+col,Colossians,Colossians,col,kon
+1th,1 Thessalonians,1 Thessalonians,1th,kon
+2th,2 Thessalonians,2 Thessalonians,2th,kon
+1ti,1 Timothy,1 Timothy,1ti,kon
+2ti,2 Timothy,2 Timothy,2ti,kon
+tit,Titus,Titus,tit,kon
+phm,Philemon,Philemon,phm,kon
+heb,Hebrews,Hebrews,heb,kon
+jas,James,James,jas,kon
+1pe,1 Peter,1 Peter,1pe,kon
+2pe,2 Peter,2 Peter,2pe,kon
+1jn,1 John,1 John,1jn,kon
+2jn,2 John,2 John,2jn,kon
+3jn,3 John,3 John,3jn,kon
+jud,Jude,Jude,jud,kon
+rev,Revelation,Revelation,rev,kon
+mat,Matthew,Matthew,mat,may
+mrk,Mark,Mark,mrk,may
+luk,Luke,Luke,luk,may
+jhn,John,John,jhn,may
+act,Acts,Acts,act,may
+rom,Romans,Romans,rom,may
+1co,1 Corinthians,1 Corinthians,1co,may
+2co,2 Corinthians,2 Corinthians,2co,may
+gal,Galatians,Galatians,gal,may
+eph,Ephesians,Ephesians,eph,may
+php,Philippians,Philippians,php,may
+col,Colossians,Colossians,col,may
+1th,1 Thessalonians,1 Thessalonians,1th,may
+2th,2 Thessalonians,2 Thessalonians,2th,may
+1ti,1 Timothy,1 Timothy,1ti,may
+2ti,2 Timothy,2 Timothy,2ti,may
+tit,Titus,Titus,tit,may
+phm,Philemon,Philemon,phm,may
+heb,Hebrews,Hebrews,heb,may
+jas,James,James,jas,may
+1pe,1 Peter,1 Peter,1pe,may
+2pe,2 Peter,2 Peter,2pe,may
+1jn,1 John,1 John,1jn,may
+2jn,2 John,2 John,2jn,may
+3jn,3 John,3 John,3jn,may
+jud,Jude,Jude,jud,may
+rev,Revelation,Revelation,rev,may
+mat,Matthew,Matthew,mat,bgl
+mrk,Mark,Mark,mrk,bgl
+luk,Luke,Luke,luk,bgl
+jhn,John,John,jhn,bgl
+act,Acts,Acts,act,bgl
+rom,Romans,Romans,rom,bgl
+1co,1 Corinthians,1 Corinthians,1co,bgl
+2co,2 Corinthians,2 Corinthians,2co,bgl
+gal,Galatians,Galatians,gal,bgl
+eph,Ephesians,Ephesians,eph,bgl
+php,Philippians,Philippians,php,bgl
+col,Colossians,Colossians,col,bgl
+1th,1 Thessalonians,1 Thessalonians,1th,bgl
+2th,2 Thessalonians,2 Thessalonians,2th,bgl
+1ti,1 Timothy,1 Timothy,1ti,bgl
+2ti,2 Timothy,2 Timothy,2ti,bgl
+tit,Titus,Titus,tit,bgl
+phm,Philemon,Philemon,phm,bgl
+heb,Hebrews,Hebrews,heb,bgl
+jas,James,James,jas,bgl
+1pe,1 Peter,1 Peter,1pe,bgl
+2pe,2 Peter,2 Peter,2pe,bgl
+1jn,1 John,1 John,1jn,bgl
+2jn,2 John,2 John,2jn,bgl
+3jn,3 John,3 John,3jn,bgl
+jud,Jude,Jude,jud,bgl
+rev,Revelation,Revelation,rev,bgl
+ઉત્પ.,ઉત્પતી,ઉત્પતી,gen,kfr
+નીરગ.,નીરગમન,નીરગમન,exo,kfr
+યહો.,યહોસુઆ,યહોસુઆ,jos,kfr
+નીયાય.,નીયાયાધીસ,નીયાયાધીસ,jdg,kfr
+રુથ,રુથ,રુથ,rut,kfr
+એસતે.,એસ્તેર,એસ્તેર,est,kfr
+ગી.સા.,ગીતસાસ્ત્ર,ગીતસાસ્ત્ર,psa,kfr
+નીતી.,નીતીવચન,નીતીવચન,pro,kfr
+સભા.,સભાસીક્ષક,સભાસીક્ષક,ecc,kfr
+ખાસો ગીત .,સુલેમાન જો ખાસો ગીત,સુલેમાન જો ખાસો ગીત,sng,kfr
+ઉપે.,ઉપેજ,ઉપેજ,gen,kex
+નિર્ગ.,નિર્ગમન,નિર્ગમન,exo,kex
+યહો.,યહોશુઆ,યહોશુઆ,jos,kex
+નીયાય.,નીયાયાધીશો,નીયાયાધીશો,jdg,kex
+રુથ,રુથ,રુથ,rut,kex
+એસત.,એસતર,એસતર,est,kex
+Ken.,Kendeu,Kendeu Laisiukwak,gen,nme
+Tet.,Tetkempet,Tetkempet Laisiukwak,exo,nme
+Lev.,Levime,Levime Laisiukwak,lev,nme
+Pum.,Pumpei,Pumpei Laisiukwak,num,nme
+Hez.,Hezaidung,Hezaidung Laisiukwak,deu,nme
+Jos.,Joshua,Joshua Laisiukwak,jos,nme
+Tut.,Tutiakme,Tutiakme Laisiukwak,jdg,nme
+Rut.,Ruth,Ruth laisiukwak,rut,nme
+1 Sam.,Samuel Keraibe,Samuel Laisiukwak Keraibe,1sa,nme
+2 Sam.,Samuel Kenabe,Samuel Laisiukwak Kenabe,2sa,nme
+1 Heg.,Hegwangme Karaibe,Hegwangme Laisiukwak Keraibe,1ki,nme
+2 Heg.,Hegwangme Kenabe,Hegwangme Laisiukwak Kenabe,2ki,nme
+1 Hel.,Heleuhha Keraibe,Heleuhha Laisiukwak Keraibe,1ch,nme
+2 Hel.,Heleuhha Kenabe,Heleuhha Laisiukwak Kenabe,2ch,nme
+Ezr.,Ezra,Ezra laisiukwak,ezr,nme
+Neh.,Nehemiah,Nehemiah Laisiukwak,neh,nme
+Est.,Esther,Esther Laisiukwak,est,nme
+Job,Job,Job Laisiukwak,job,nme
+Lei.,Leidung,Leidung Laisiukwak,psa,nme
+Mac.,Macisam,Macisam Laisiukwak,pro,nme
+Kel.,Keledeube,Keledeube Laisiukwak,ecc,nme
+Sol.,Solomon Lei,Solomon Lei Laisiukwak,sng,nme
+Isa.,Isaiah,Isaiah Laisiukwak,isa,nme
+Jer.,Jeremiah,Jeremiah Laisiukwak,jer,nme
+Keh.,Kehap,Kehap Laisiukwak,lam,nme
+Eze.,Ezekiel,Ezekiel Laisiukwak,ezk,nme
+Dan.,Daniel,Daniel Laisiukwak,dan,nme
+Hos.,Hosea,Hosea Laisiukwak,hos,nme
+Joe.,Joel,Joel Laisiukwak,jol,nme
+Amo.,Amos,Amos Laisiukwak,amo,nme
+Oba.,Obadiah,Obadiah Laisiukwak,oba,nme
+Jon.,Jonah,Jonah Laisiukwak,jon,nme
+Mic.,Micah,Micah Laisiukwak,mic,nme
+Nah.,Nahum,Nahum Laisiukwak,nam,nme
+Hab.,Habakkuk,Habakkuk Laisiukwak,hab,nme
+Zep.,Zephaniah,Zephaniah Laisiukwak,zep,nme
+Hag.,Haggai,Haggai Laisiukwak,hag,nme
+Zec.,Zechariah,Zechariah Laisiukwak,zec,nme
+Mal.,Malachi,Malachi Laisiukwak,mal,nme
+Mat.,Matthew,Matthew ne raukegai Samliakeyi,mat,nme
+Mrk.,Mark,Mark ne Raukegai Samliakeyi,mrk,nme
+Luk.,Luke,Luke ne Raukegai Samliakeyi,luk,nme
+Jhn.,John,John ne Raukegai Samliakeyi,jhn,nme
+Bat.,Bata,Langkegaime Bata,act,nme
+Rom.,Romme,Paul ne Romme dage Raukegai Laisiu,rom,nme
+1 Cor.,Corinthiame Keraibe,Paul ne Corinthiame Dage raukegai laisiu Keraibe,1co,nme
+2 Cor.,Corinthiame Kenabe,Paul ne Corinthiame Dage raukegai laisiu Kenabe,2co,nme
+Gal.,Galatiame,Paul ne Galatiame dage raukegai laisiu,gal,nme
+Eph.,Ephesame,Paul ne Ephesame dage raukegai laisiu,eph,nme
+Php.,Philippime,Paul ne Philippime dage raukegai laisiu,php,nme
+Col.,Colossaeme,Paul ne Colossaeme dage raukegai laisiu,col,nme
+1 The.,Thessaloniame Keraibe,Paul ne Thessaloniame Dage Raukegai Laisiu Keraibe,1th,nme
+2 The.,Thessaloniame Kanabe,Paul ne Thessaloniame Dage Raukegai Laisiu Kenabe,2th,nme
+1 Tim.,Timothy Keraibe,Paul ne Timothy dage raukegai laisiu keraibe,1ti,nme
+2 Tim.,Timothy Kenabe,Paul ne Timothy dage raukegai laisu kenabe,2ti,nme
+Tit.,Titus,Paul ne Titus dage raukegai laisiu,tit,nme
+Phm.,Philemon,Paul ne Philemon dage raukegai laisiu,phm,nme
+Heb.,Hebrewme,Hebrewme dage raukegai laisiu,heb,nme
+Jas.,James,James ne raukegai laisiu,jas,nme
+1 Pet.,Peter Keraibe,Peter ne raukegai laisiu keraibe,1pe,nme
+2 Pet.,Peter Kenabe,Peter ne raukegai laisiu kenabe,2pe,nme
+1 Jhn.,John Keraibe,John ne raukegai laisiu keraibe,1jn,nme
+2 Jhn.,John Kenabe,John ne raukegai laisiu kenabe,2jn,nme
+3 Jhn.,John Kecumbe,John ne raukegai laisiu kecumbe,3jn,nme
+Jud.,Jude,Jude ne raukegai laisiu,jud,nme
+Keu.,Keuliakegai,John de Keuliakegai laisiukwak,rev,nme
diff --git a/backend/app/data/languages.csv b/backend/app/data/languages.csv
index 5b3b97fd..ce4d579a 100644
--- a/backend/app/data/languages.csv
+++ b/backend/app/data/languages.csv
@@ -7,7 +7,7 @@ ak,Akan,left-to-right,"{""description"": ""This represents a marco language. A m
am,Amharic,left-to-right,"{""suppress-script"": ""Ethi"", ""region"": ""Ethiopia, Africa"", ""alternate-names"": [""Abyssinian"", ""Amarigna"", ""Amarinya"", ""Amhara"", ""Ethiopian"", ""Falasha""], ""is-gateway-language"": true}"
an,Aragonese,left-to-right,"{""region"": ""Spain, Europe"", ""alternate-names"": [""Altoaragon\u00e9s"", ""Aragoieraz"", ""Aragon\u00e9s"", ""Fabla Aragonesa"", ""High Aragonese"", ""Patu\u00e9s"", ""Grausino"", ""Western Aragonese (Ansotano)"", ""Tensino"", ""Southern Aragonese (Ayerbense)"", ""Semontan\u00e9s"", ""Pandicuto"", ""Eastern Aragonese (Benasqu\u00e9s)"", ""Chistabino"", ""Cheso"", ""Central Aragonese (Belset\u00e1n)"", ""Bergot\u00e9s"", ""Fobano""]}"
ar,Arabic,right-to-left,"{""suppress-script"": ""Arab"", ""description"": ""This represents a marco language. A more specific tag might be available. "", ""region"": ""Saudi Arabia, Asia"", ""is-gateway-language"": true}"
-as,Assamese,left-to-right,"{""suppress-script"": ""Beng"", ""region"": ""India, Asia"", ""alternate-names"": [""Asambe"", ""Asami"", ""Asamiya"", ""Jharwa (Pidgin)"", ""Standard Assamese"", ""Western Assamese (Kamrupi)""], ""is-gateway-language"": true}"
+asm,Assamese,left-to-right,"{""suppress-script"": ""Beng"", ""region"": ""India, Asia"", ""alternate-names"": [""Asambe"", ""Asami"", ""Asamiya"", ""Jharwa (Pidgin)"", ""Standard Assamese"", ""Western Assamese (Kamrupi)""], ""is-gateway-language"": true}"
av,Avaric,left-to-right,"{""region"": ""Russian Federation, Europe"", ""alternate-names"": [""Avaro"", ""Dagestani"", ""Bolmac"", ""Khundzuri"", ""Maarul Dagestani"", ""Qusur"", ""South-East Avar (Andalal)"", ""South-West Avar (Batlukh)"", ""Unkratl"", ""North Avar (Andian Avar)"", ""Zaqatala (Char)"", ""Khunzakh"", ""Zaqatal (Char)"", ""Andalal Shulanin"", ""Karakh"", ""Hid Keleb"", ""Hid Kaxib"", ""Bolmats"", ""Antsukh (Ancux)"", ""Antsukh"", ""Andalal Untib"", ""Qarakh (Bacadin)""]}"
ay,Aymara,left-to-right,"{""suppress-script"": ""Latn"", ""description"": ""This represents a marco language. A more specific tag might be available. "", ""region"": ""Bolivia, Americas""}"
az,Azerbaijani,left-to-right,"{""description"": ""This represents a marco language. A more specific tag might be available. "", ""region"": ""Azerbaijan, Asia""}"
@@ -17,7 +17,7 @@ bg,Bulgarian,left-to-right,"{""suppress-script"": ""Cyrl"", ""region"": ""Bulgar
bh,Bihari languages,,"{""description"": ""This represents a collection of languages. Although a collection subtag can be used in the absence of a more specific tag, you should check whether a more specific language subtag is available. ""}"
bi,Bislama,left-to-right,"{""region"": ""Vanuatu, Pacific"", ""alternate-names"": [""Bichelamar""], ""is-gateway-language"": true}"
bm,Bambara,left-to-right,"{""region"": ""Mali, Africa"", ""alternate-names"": [""Bamako"", ""Bamana"", ""Bamanakan"", ""Wassulunke"", ""Wassulunka"", ""Wasulunkakan (Maninkakan)"", ""Ganadugu"", ""Eastern"", ""Wasuu"", ""Wassulu""]}"
-bn,Bengali,left-to-right,"{""suppress-script"": ""Beng"", ""description"": ""Bangla"", ""region"": ""Bangladesh, Asia"", ""alternate-names"": [""Bangala"", ""Bangla-Bhasa"", ""Siripuria (Kishanganjia)"", ""Barisal"", ""Khulna"", ""Lohari-Malpaharia"", ""Mymensingh"", ""Noakhali""], ""is-gateway-language"": true}"
+ben,Bengali,left-to-right,"{""suppress-script"": ""Beng"", ""description"": ""Bangla"", ""region"": ""Bangladesh, Asia"", ""alternate-names"": [""Bangala"", ""Bangla-Bhasa"", ""Siripuria (Kishanganjia)"", ""Barisal"", ""Khulna"", ""Lohari-Malpaharia"", ""Mymensingh"", ""Noakhali""], ""is-gateway-language"": true}"
bo,Tibetan,left-to-right,"{""region"": ""China, Asia"", ""alternate-names"": [""Bhokha"", ""Byokha"", ""Dbusgtsang"", ""Phoke"", ""Tibetan"", ""U"", ""Wei"", ""Weizang"", ""Zang"", ""Bod"", ""Central Tibetan"", ""Pohbetian"", ""Poke"", ""Skad"", ""Tebilian"", ""Tibate"", ""Bod Skad"", ""Zang Wen"", ""Nganshuenkuan (Anshuenkuan Nyarong)"", ""Hanniu"", ""Utsang"", ""Tsang"", ""Panakha-Panags"", ""Mngahris (Ngari)"", ""Dru"", ""Aba (Batang)"", ""Diaspora Tibetan"", ""Gtsang (Lhasa)"", ""Deqing Zang"", ""Dartsemdo (Tatsienlu)"", ""Paurong""]}"
br,Breton,left-to-right,"{""region"": ""France, Europe"", ""alternate-names"": [""Brezhoneg"", ""Gwenedeg (Vannetais)"", ""Kerneveg (Cornouaillais)"", ""Leoneg (Leonais)"", ""Tregerieg (Tregorrois)""]}"
bs,Bosnian,left-to-right,"{""macrolanguage"": ""sh"", ""suppress-script"": ""Latn"", ""region"": ""Bosnia and Herzegovina, Europe"", ""alternate-names"": [""Ijekav\u00edan"", ""Ikavian""]}"
@@ -36,7 +36,7 @@ dv,Dhivehi,right-to-left,"{""suppress-script"": ""Thaa"", ""description"": ""Div
dz,Dzongkha,left-to-right,"{""suppress-script"": ""Tibt"", ""region"": ""Bhutan, Asia"", ""alternate-names"": [""Bhotia of Bhutan"", ""Bhotia of Dukpa"", ""Bhutanese"", ""Drukha"", ""Drukke"", ""Dukpa"", ""Jonkha"", ""Rdzongkha"", ""Zongkhar"", ""Drukpa"", ""Hloka"", ""Lhoskad"", ""Wang-The (Thimphu-Punakha)""]}"
ee,Ewe,left-to-right,"{""region"": ""Ghana, Africa"", ""alternate-names"": [""Ebwe"", ""Efe"", ""Eibe"", ""Eue"", ""Eve"", ""Gbe"", ""Krepe"", ""Krepi"", ""Popo"", ""Vhe"", ""Ehwe"", ""Be"", ""Vlin"", ""Vo"", ""Togo"", ""Ho"", ""Gbin"", ""Aveno"", ""Anglo (Anlo)"", ""Adan"", ""Agu"", ""Awlan""]}"
el,Modern Greek (1453-),left-to-right,"{""suppress-script"": ""Grek"", ""region"": ""Greece, Europe"", ""alternate-names"": [""Ellinika"", ""Graecae"", ""Grec"", ""Greco"", ""Neo-Hellenic"", ""Romaic"", ""Griko"", ""Tavro-Rumeic"", ""Mariupol Greek (Crimeo-Rumeic)""]}"
-en,English,left-to-right,"{""suppress-script"": ""Latn"", ""region"": ""United Kingdom, Europe"", ""alternate-names"": [""Anglit"", ""Kiingereza"", ""Gustavia English"", ""Saman\u00e1 English"", ""Saint Lucian English"", ""Noongar"", ""Noonga"", ""Newcastle Northumber"", ""Neo-Nyungar (Noogar)"", ""Glaswegian"", ""Brummy"", ""Birmingham (Brummie)"", ""Bay Islands English"", ""Australian Standard English"", ""Aboriginal English"", ""African American Vernacular English (AAVE)""], ""is-gateway-language"": true}"
+eng,English,left-to-right,"{""suppress-script"": ""Latn"", ""region"": ""United Kingdom, Europe"", ""alternate-names"": [""Anglit"", ""Kiingereza"", ""Gustavia English"", ""Saman\u00e1 English"", ""Saint Lucian English"", ""Noongar"", ""Noonga"", ""Newcastle Northumber"", ""Neo-Nyungar (Noogar)"", ""Glaswegian"", ""Brummy"", ""Birmingham (Brummie)"", ""Bay Islands English"", ""Australian Standard English"", ""Aboriginal English"", ""African American Vernacular English (AAVE)""], ""is-gateway-language"": true}"
eo,Esperanto,left-to-right,"{""suppress-script"": ""Latn"", ""region"": ""Poland, Europe"", ""alternate-names"": [""Eo"", ""La Lingvo Internacia""]}"
es,Spanish,left-to-right,"{""suppress-script"": ""Latn"", ""description"": ""Castilian"", ""region"": ""Spain, Europe"", ""alternate-names"": [""Castilian"", ""Espa\u00f1ol"", ""Castillan"", ""Islenyo"", ""Afro-Yungue\u00f1o (Black Spanish)"", ""Silbo Gomero"", ""Rioplatense"", ""Portu\u00f1ol"", ""Portunhol"", ""Lunfardo"", ""Llanito (Yanito)"", ""Isleno (Isle\u00f1o)"", ""Chicano (Cal\u00f3)"", ""Canary Islands Spanish (Isle\u00f1o)"", ""Andaluz"", ""Andalusian (Andal\u00fa)"", ""American Spanish (Chicano)"", ""Andalus\u00ed""], ""is-gateway-language"": true}"
et,Estonian,left-to-right,"{""suppress-script"": ""Latn"", ""description"": ""This represents a marco language. A more specific tag might be available. "", ""region"": ""Estonia, Europe""}"
@@ -52,11 +52,11 @@ ga,Irish,left-to-right,"{""suppress-script"": ""Latn"", ""region"": ""Ireland, E
gd,Scottish Gaelic,left-to-right,"{""description"": ""Gaelic"", ""region"": ""United Kingdom, Europe"", ""alternate-names"": [""Gaelic"", ""G\u00e0idhlig"", ""Scots Gaelic""]}"
gl,Galician,left-to-right,"{""suppress-script"": ""Latn"", ""region"": ""Spain, Europe"", ""alternate-names"": [""Galego"", ""Gallego"", ""Rionorese (Rionor\u00eas)"", ""Guadramilese (Guadramil\u00eas)""]}"
gn,Guarani,left-to-right,"{""suppress-script"": ""Latn"", ""description"": ""This represents a marco language. A more specific tag might be available. "", ""region"": ""Paraguay, Americas""}"
-gu,Gujarati,left-to-right,"{""suppress-script"": ""Gujr"", ""region"": ""India, Asia"", ""alternate-names"": [""Gujerathi"", ""Gujerati"", ""Gujrathi"", ""Kathiyawadi (Bhawnagari)"", ""Standard Gujarati (Mumbai Gujarati)"", ""Sorathi"", ""Patidari"", ""Patani"", ""Gamadia (Ahmedabad Gamadia)"", ""Naga"", ""Surati"", ""Vadodari"", ""Holadi"", ""Gohilwadi"", ""Eastern Broach Gujarati"", ""Charotari"", ""Brathela"", ""Anawla"", ""Jhalawadi"", ""Gramya""], ""is-gateway-language"": true}"
+guj,Gujarati,left-to-right,"{""suppress-script"": ""Gujr"", ""region"": ""India, Asia"", ""alternate-names"": [""Gujerathi"", ""Gujerati"", ""Gujrathi"", ""Kathiyawadi (Bhawnagari)"", ""Standard Gujarati (Mumbai Gujarati)"", ""Sorathi"", ""Patidari"", ""Patani"", ""Gamadia (Ahmedabad Gamadia)"", ""Naga"", ""Surati"", ""Vadodari"", ""Holadi"", ""Gohilwadi"", ""Eastern Broach Gujarati"", ""Charotari"", ""Brathela"", ""Anawla"", ""Jhalawadi"", ""Gramya""], ""is-gateway-language"": true}"
gv,Manx,left-to-right,"{""suppress-script"": ""Latn"", ""region"": ""Isle of Man, Europe"", ""alternate-names"": [""Gaelg"", ""Gailck"", ""Manx Gaelic""]}"
ha,Hausa,left-to-right,"{""region"": ""Nigeria, Africa"", ""alternate-names"": [""Haoussa"", ""Hawsa"", ""Hausawa"", ""Haussa"", ""Abakwariga"", ""Habe"", ""Kado"", ""Mgbakpa""], ""is-gateway-language"": true}"
he,Hebrew,right-to-left,"{""suppress-script"": ""Hebr"", ""region"": ""Israel, Asia"", ""alternate-names"": [""Israeli"", ""Ivrit"", ""General Israeli"", ""Oriental Hebrew (Arabized Hebrew)"", ""Standard Hebrew (Europeanized Hebrew)"", ""Yemenite Hebrew""]}"
-hi,Hindi,left-to-right,"{""suppress-script"": ""Deva"", ""region"": ""India, Asia"", ""alternate-names"": [""Khadi Boli"", ""Khari Boli"", ""Dakhini"", ""Hindi-Urdu"", ""Khariboli""], ""is-gateway-language"": true}"
+hin,Hindi,left-to-right,"{""suppress-script"": ""Deva"", ""region"": ""India, Asia"", ""alternate-names"": [""Khadi Boli"", ""Khari Boli"", ""Dakhini"", ""Hindi-Urdu"", ""Khariboli""], ""is-gateway-language"": true}"
ho,Hiri Motu,left-to-right,"{""region"": ""Papua New Guinea, Pacific"", ""alternate-names"": [""Hiri"", ""Pidgin Motu"", ""Police Motu""]}"
hr,Croatian,left-to-right,"{""macrolanguage"": ""sh"", ""suppress-script"": ""Latn"", ""region"": ""Croatia, Europe"", ""alternate-names"": [""Hrvatski"", ""Molise Croatian"", ""Shtokavski (Ijekavski)"", ""Serbian""]}"
ht,Haitian,left-to-right,"{""suppress-script"": ""Latn"", ""description"": ""Haitian Creole"", ""region"": ""Haiti, Americas"", ""alternate-names"": [""Creole"", ""Haitian Creole""]}"
@@ -81,13 +81,13 @@ ji,Yiddish,,"{""use-instead"": ""yi"", ""description"": ""Deprecated from 1989-0
jv,Javanese,left-to-right,"{""region"": ""Indonesia, Asia"", ""alternate-names"": [""Djawa"", ""Cirebon (Cheribon)"", ""Tjirebon"", ""Surakarta (Sawlaw)"", ""Surabaya"", ""Solo""]}"
jw,Javanese,,"{""use-instead"": ""jv"", ""description"": ""Deprecated from 2001-08-13. published by error in Table 1 of ISO 639:1988""}"
ka,Georgian,left-to-right,"{""suppress-script"": ""Geor"", ""region"": ""Georgia, Asia"", ""alternate-names"": [""Common Kartvelian"", ""Gruzinski"", ""Kartuli"", ""Gruzin"", ""Adzhar (Acharian)"", ""Fereydan (Ferejdan)"", ""Kaxetian (Kakhetian)"", ""Moxev (Mokhev)"", ""Racha-Lexchxum (Lechkhum)"", ""Xevsur (Kheysur)""]}"
-kg,Kongo,left-to-right,"{""description"": ""This represents a marco language. A more specific tag might be available. "", ""region"": ""The Democratic Republic of the Congo, Africa""}"
+kon,Kongo,left-to-right,"{""description"": ""This represents a marco language. A more specific tag might be available. "", ""region"": ""The Democratic Republic of the Congo, Africa""}"
ki,Kikuyu,left-to-right,"{""description"": ""Gikuyu"", ""region"": ""Kenya, Africa"", ""alternate-names"": [""Gekoyo"", ""Gigikuyu"", ""Gichugu (Northern Kirinyaga)"", ""Southern Murang'a"", ""Southern Gikuyu (Kiambu)"", ""Nyeri"", ""Mathira (Karatina)"", ""Ndia (Southern Kirinyaga)"", ""Northern Gikuyu (Northern Murang'a)""]}"
kj,Kuanyama,left-to-right,"{""description"": ""Kwanyama"", ""region"": ""Angola, Africa"", ""alternate-names"": [""Cuanhama"", ""Humba"", ""Kuanjama"", ""Kwancama"", ""Kwanjama"", ""Ochikwanyama"", ""Oshikuanjama"", ""Oshikwanyama"", ""Oshiwambo Ndonga"", ""Ovambo"", ""Oxikuanyama"", ""Kuanyama"", ""Otjiwambo"", ""Owambo""]}"
kk,Kazakh,left-to-right,"{""suppress-script"": ""Cyrl"", ""region"": ""Kazakhstan, Asia"", ""alternate-names"": [""Hazake"", ""Kazax"", ""Gazaqi"", ""Kazakhi"", ""Kaisak"", ""Kosach"", ""Qazaq"", ""Qazaqi""]}"
kl,Kalaallisut,left-to-right,"{""suppress-script"": ""Latn"", ""description"": ""Greenlandic"", ""region"": ""Greenland, Americas"", ""alternate-names"": [""Greenlandic"", ""Inuktitut"", ""Kalaallisut"", ""\""Polar Eskimo\"""", ""Iniktun (Inuktun)"", ""North Greenlandic"", ""Thule Inuit""]}"
km,Khmer,left-to-right,"{""suppress-script"": ""Khmr"", ""description"": ""Central Khmer"", ""region"": ""Cambodia, Asia"", ""alternate-names"": [""Cambodian"", ""Cu Tho"", ""Cur Cul"", ""Khmer Nam Bo"", ""Kho Me"", ""Khome"", ""Krom"", ""Viet Goc Mien"", ""Battambang Khmer"", ""Cardamom Khmer"", ""Khmer Kandal (Central)"", ""Khmer Keh (Stung Treng)"", ""Khmer Krom (Southern)""], ""is-gateway-language"": true}"
-kn,Kannada,left-to-right,"{""suppress-script"": ""Knda"", ""region"": ""India, Asia"", ""alternate-names"": [""Banglori"", ""Canarese"", ""Kanarese"", ""Madrassi"", ""Bellary"", ""Gulbarga"", ""Kumta"", ""Nanjangud""], ""is-gateway-language"": true}"
+kan,Kannada,left-to-right,"{""suppress-script"": ""Knda"", ""region"": ""India, Asia"", ""alternate-names"": [""Banglori"", ""Canarese"", ""Kanarese"", ""Madrassi"", ""Bellary"", ""Gulbarga"", ""Kumta"", ""Nanjangud""], ""is-gateway-language"": true}"
ko,Korean,left-to-right,"{""suppress-script"": ""Kore"", ""region"": ""Republic of Korea, Asia"", ""alternate-names"": [""Chaoxian"", ""Hanguohua"", ""Hanguk Mal"", ""Hanguk Uh"", ""Seoul (Kangwondo)"", ""South P'yong'ando"", ""South Kyongsangdo"", ""South Hamgyongdo"", ""South Chollado"", ""P'yong'ando (North P'yong'ando)"", ""Kyongsangdo (North Kyongsangdo)"", ""Kyonggido"", ""Hamgyongdo (North Hamgyongdo)"", ""Ch'ungch'ongdo (North Ch'ungch'ong)"", ""Chollado (North Chollado)"", ""South Ch'ungch'ong"", ""Hangeul""]}"
kr,Kanuri,left-to-right,"{""description"": ""This represents a marco language. A more specific tag might be available. "", ""region"": ""Nigeria, Africa""}"
ks,Kashmiri,,{}
@@ -108,17 +108,17 @@ mg,Malagasy,left-to-right,"{""suppress-script"": ""Latn"", ""description"": ""Th
mh,Marshallese,left-to-right,"{""suppress-script"": ""Latn"", ""region"": ""Marshall Islands, Pacific"", ""alternate-names"": [""Ebon""]}"
mi,Maori,left-to-right,"{""region"": ""New Zealand, Pacific"", ""alternate-names"": [""New Zealand Maori""]}"
mk,Macedonian,left-to-right,"{""suppress-script"": ""Cyrl"", ""region"": ""North Macedonia, Europe"", ""alternate-names"": [""Macedonian Slavic"", ""Makedonski""]}"
-ml,Malayalam,left-to-right,"{""suppress-script"": ""Mlym"", ""region"": ""India, Asia"", ""alternate-names"": [""Alealum"", ""Malayalani"", ""Malayali"", ""Malean"", ""Maliyad"", ""Mallealle"", ""Mopla"", ""Malayal"", ""Kasargod"", ""Moplah (Mapilla)""], ""is-gateway-language"": true}"
+mal,Malayalam,left-to-right,"{""suppress-script"": ""Mlym"", ""region"": ""India, Asia"", ""alternate-names"": [""Alealum"", ""Malayalani"", ""Malayali"", ""Malean"", ""Maliyad"", ""Mallealle"", ""Mopla"", ""Malayal"", ""Kasargod"", ""Moplah (Mapilla)""], ""is-gateway-language"": true}"
mn,Mongolian,left-to-right,"{""description"": ""This represents a marco language. A more specific tag might be available. "", ""region"": ""Mongolia, Asia"", ""is-gateway-language"": true}"
mo,Moldavian,,"{""use-instead"": ""ro"", ""suppress-script"": ""Latn"", ""description"": ""Deprecated from 2008-11-22. Moldovan""}"
-mr,Marathi,left-to-right,"{""suppress-script"": ""Deva"", ""region"": ""India, Asia"", ""alternate-names"": [""Maharashtra"", ""Maharathi"", ""Malhatee"", ""Marthi"", ""Muruthu"", ""Kosti"", ""Kudali"", ""Gawdi of Goa"", ""Nagpuri Marathi"", ""Kasargod""], ""is-gateway-language"": true}"
+mar,Marathi,left-to-right,"{""suppress-script"": ""Deva"", ""region"": ""India, Asia"", ""alternate-names"": [""Maharashtra"", ""Maharathi"", ""Malhatee"", ""Marthi"", ""Muruthu"", ""Kosti"", ""Kudali"", ""Gawdi of Goa"", ""Nagpuri Marathi"", ""Kasargod""], ""is-gateway-language"": true}"
ms,Malay (macrolanguage),left-to-right,"{""suppress-script"": ""Latn"", ""description"": ""This represents a marco language. A more specific tag might be available. "", ""region"": ""Malaysia, Asia"", ""is-gateway-language"": true}"
mt,Maltese,left-to-right,"{""suppress-script"": ""Latn"", ""region"": ""Malta, Europe"", ""alternate-names"": [""Malti""]}"
-my,Burmese,left-to-right,"{""suppress-script"": ""Mymr"", ""region"": ""Myanmar, Asia"", ""alternate-names"": [""Bama"", ""Bamachaka"", ""Myanmar"", ""Myen"", ""Yangon Burmese"", ""Mergui"", ""Mandalay Burmese"", ""Beik (Merguese)"", ""Bamar""], ""is-gateway-language"": true}"
+may,Burmese,left-to-right,"{""suppress-script"": ""Mymr"", ""region"": ""Myanmar, Asia"", ""alternate-names"": [""Bama"", ""Bamachaka"", ""Myanmar"", ""Myen"", ""Yangon Burmese"", ""Mergui"", ""Mandalay Burmese"", ""Beik (Merguese)"", ""Bamar""], ""is-gateway-language"": true}"
na,Nauru,left-to-right,"{""suppress-script"": ""Latn"", ""region"": ""Nauru, Pacific""}"
nb,Norwegian Bokmål,left-to-right,"{""macrolanguage"": ""no"", ""suppress-script"": ""Latn"", ""region"": ""Norway, Europe""}"
nd,North Ndebele,left-to-right,"{""suppress-script"": ""Latn"", ""region"": ""Zimbabwe, Africa"", ""alternate-names"": [""Isinde'bele"", ""Northern Ndebele"", ""Sindebele"", ""Tabele"", ""Tebele""]}"
-ne,Nepali (macrolanguage),left-to-right,"{""suppress-script"": ""Deva"", ""description"": ""This represents a marco language. A more specific tag might be available. "", ""region"": ""Nepal, Asia"", ""is-gateway-language"": true}"
+nep,Nepali (macrolanguage),left-to-right,"{""suppress-script"": ""Deva"", ""description"": ""This represents a marco language. A more specific tag might be available. "", ""region"": ""Nepal, Asia"", ""is-gateway-language"": true}"
ng,Ndonga,left-to-right,"{""region"": ""Namibia, Africa"", ""alternate-names"": [""Ambo"", ""Ochindonga"", ""Oshindonga"", ""Osindonga"", ""Otjiwambo"", ""Owambo""]}"
nl,Dutch,left-to-right,"{""suppress-script"": ""Latn"", ""description"": ""Flemish"", ""region"": ""Netherlands, Europe"", ""alternate-names"": [""Nederlands"", ""Vlaams"", ""Hollands"", ""Antwerps"", ""Northern North Hollandish (Westfries)""]}"
nn,Norwegian Nynorsk,left-to-right,"{""macrolanguage"": ""no"", ""suppress-script"": ""Latn"", ""region"": ""Sweden, Europe""}"
@@ -131,7 +131,7 @@ oj,Ojibwa,left-to-right,"{""description"": ""This represents a marco language. A
om,Oromo,left-to-right,"{""suppress-script"": ""Latn"", ""description"": ""This represents a marco language. A more specific tag might be available. "", ""region"": ""Ethiopia, Africa""}"
or,Oriya (macrolanguage),left-to-right,"{""suppress-script"": ""Orya"", ""description"": ""This represents a marco language. A more specific tag might be available. Odia (macrolanguage)"", ""region"": ""India, Asia"", ""is-gateway-language"": true}"
os,Ossetian,left-to-right,"{""description"": ""Ossetic"", ""region"": ""Russian Federation, Europe"", ""alternate-names"": [""Osetin"", ""Ossete"", ""Digor (Digorian)"", ""Digoron"", ""Dogor"", ""Kudar (South Osetin)""]}"
-pa,Panjabi,left-to-right,"{""suppress-script"": ""Guru"", ""description"": ""Punjabi"", ""region"": ""India, Asia"", ""alternate-names"": [""Eastern Punjabi"", ""Gurmukhi"", ""Gurumukhi"", ""Bhatti"", ""Bhatyiana (Bhatneri)""], ""is-gateway-language"": true}"
+pan,Panjabi,left-to-right,"{""suppress-script"": ""Guru"", ""description"": ""Punjabi"", ""region"": ""India, Asia"", ""alternate-names"": [""Eastern Punjabi"", ""Gurmukhi"", ""Gurumukhi"", ""Bhatti"", ""Bhatyiana (Bhatneri)""], ""is-gateway-language"": true}"
pi,Pali,left-to-right,"{""region"": ""India, Asia""}"
pl,Polish,left-to-right,"{""suppress-script"": ""Latn"", ""region"": ""Poland, Europe"", ""alternate-names"": [""Polski"", ""Polnisch""]}"
ps,Pushto,right-to-left,"{""suppress-script"": ""Arab"", ""description"": ""This represents a marco language. A more specific tag might be available. Pashto"", ""region"": ""Afghanistan, Asia"", ""is-gateway-language"": true}"
@@ -161,8 +161,8 @@ st,Southern Sotho,left-to-right,"{""suppress-script"": ""Latn"", ""region"": ""L
su,Sundanese,left-to-right,"{""region"": ""Indonesia, Asia"", ""alternate-names"": [""Priangan"", ""Sundanese"", ""Bogor (Krawang)""]}"
sv,Swedish,left-to-right,"{""suppress-script"": ""Latn"", ""region"": ""Sweden, Europe"", ""alternate-names"": [""Ruotsi"", ""Svenska"", ""Eastern Swedish (Estonian Swedish)"", ""Standard Swedish"", ""Southern Swedish"", ""Southern Swedish (Scanian)"", ""Scanian (Eastern Danish)"", ""Sk\u00e5nska"", ""Sk\u00e5ne"", ""Uusimaa Swedish (Nyland Swedish)"", ""\u00d6sterbotten (Ostrobothnian)"", ""Northern Swedish (Norrland)"", ""Jamska"", ""Gutniska (Gotlandic)"", ""Gutnic"", ""Finland Swedish"", ""Gutamal"", ""Dalecarlian"", ""\u00c5land Islands Swedish""]}"
sw,Swahili (macrolanguage),left-to-right,"{""suppress-script"": ""Latn"", ""description"": ""This represents a marco language. A more specific tag might be available. "", ""region"": ""United Republic of Tanzania, Africa"", ""is-gateway-language"": true}"
-ta,Tamil,left-to-right,"{""suppress-script"": ""Taml"", ""region"": ""India, Asia"", ""alternate-names"": [""Damulian"", ""Tamal"", ""Tamalsan"", ""Tambul"", ""Tamili"", ""Madurai""], ""is-gateway-language"": true}"
-te,Telugu,left-to-right,"{""suppress-script"": ""Telu"", ""region"": ""India, Asia"", ""alternate-names"": [""Andhra"", ""Gentoo"", ""Tailangi"", ""Telangire"", ""Telegu"", ""Telgi"", ""Tengu"", ""Terangi"", ""Tolangan"", ""Vishakhapatnam"", ""Yanadi (Yenadi)""], ""is-gateway-language"": true}"
+tam,Tamil,left-to-right,"{""suppress-script"": ""Taml"", ""region"": ""India, Asia"", ""alternate-names"": [""Damulian"", ""Tamal"", ""Tamalsan"", ""Tambul"", ""Tamili"", ""Madurai""], ""is-gateway-language"": true}"
+tel,Telugu,left-to-right,"{""suppress-script"": ""Telu"", ""region"": ""India, Asia"", ""alternate-names"": [""Andhra"", ""Gentoo"", ""Tailangi"", ""Telangire"", ""Telegu"", ""Telgi"", ""Tengu"", ""Terangi"", ""Tolangan"", ""Vishakhapatnam"", ""Yanadi (Yenadi)""], ""is-gateway-language"": true}"
tg,Tajik,left-to-right,"{""region"": ""Tajikistan, Asia"", ""alternate-names"": [""Galcha"", ""Tadzhik"", ""Tajik"", ""Tajiki Persian"", ""Tojiki""]}"
th,Thai,left-to-right,"{""suppress-script"": ""Thai"", ""region"": ""Thailand, Asia"", ""alternate-names"": [""Central Tai"", ""Siamese"", ""Standard Thai"", ""Thaiklang"", ""Thaikorat"", ""Thai Norkor Raja (Siam Nokor)"", ""Thai Koh Kong"", ""Khorat Thai (Korat)"", ""Siam Trang""], ""is-gateway-language"": true}"
ti,Tigrinya,left-to-right,"{""suppress-script"": ""Ethi"", ""region"": ""Ethiopia, Africa"", ""alternate-names"": [""Habashi"", ""Tigray"", ""Falashas""]}"
@@ -177,7 +177,7 @@ tw,Twi,left-to-right,"{""macrolanguage"": ""ak"", ""region"": ""Togo, Africa""}"
ty,Tahitian,left-to-right,"{""region"": ""French Polynesia, Pacific""}"
ug,Uighur,left-to-right,"{""description"": ""Uyghur"", ""region"": ""China, Asia"", ""alternate-names"": [""Uighuir"", ""Uighur"", ""Uiguir"", ""Uigur"", ""Uygur"", ""Weiwu'er"", ""Wiga"", ""Novouygur"", ""Hotan (Hetian)"", ""Kashgar-Yarkand"", ""Kashgar-Yarkand (Yarkandi)"", ""Lop (Luobu)"", ""Taranchi (Kulja)"", ""Uighor"", ""Uyghuri"", ""Wighor""]}"
uk,Ukrainian,left-to-right,"{""suppress-script"": ""Cyrl"", ""region"": ""Ukraine, Europe""}"
-ur,Urdu,right-to-left,"{""suppress-script"": ""Arab"", ""region"": ""Pakistan, Asia"", ""alternate-names"": [""Islami"", ""Undri"", ""Urudu"", ""Bihari"", ""Desia"", ""Rekhta (Rekhti)"", ""Mirgan"", ""Dakkhini"", ""Dakhini (Dakani)"", ""Deccan""], ""is-gateway-language"": true}"
+urd,Urdu,right-to-left,"{""suppress-script"": ""Arab"", ""region"": ""Pakistan, Asia"", ""alternate-names"": [""Islami"", ""Undri"", ""Urudu"", ""Bihari"", ""Desia"", ""Rekhta (Rekhti)"", ""Mirgan"", ""Dakkhini"", ""Dakhini (Dakani)"", ""Deccan""], ""is-gateway-language"": true}"
uz,Uzbek,left-to-right,"{""description"": ""This represents a marco language. A more specific tag might be available. "", ""region"": ""Uzbekistan, Asia""}"
ve,Venda,left-to-right,"{""suppress-script"": ""Latn"", ""region"": ""South Africa, Africa"", ""alternate-names"": [""Chivenda"", ""Cevenda"", ""Tshivenda"", ""Ilafuri"", ""Mbedzi"", ""Lembetu"", ""Guvhu"", ""Manda""]}"
vi,Vietnamese,left-to-right,"{""suppress-script"": ""Latn"", ""region"": ""Viet Nam, Asia"", ""alternate-names"": [""Annamese"", ""Ching"", ""Gin"", ""Jing"", ""Kinh"", ""Viet"", ""Central Vietnamese (Hue)"", ""Northern Vietnamese (Hanoi)"", ""Southern Vietnamese""], ""is-gateway-language"": true}"
@@ -9923,7 +9923,7 @@ tel-x-barda,Barda,left-to-right,"{""region"": ""India, Asia""}"
tel-x-bestalu,Bestalu/Bastalu,left-to-right,"{""region"": ""India, Asia""}"
tel-x-bheel,Bheel,left-to-right,"{""region"": ""India, Asia""}"
tel-x-bhovi,Bhovi,left-to-right,"{""region"": ""India, Asia""}"
-tel-x-bommala,Bommala,left-to-right,"{""region"": ""India, Asia""}"
+boc,Bommala,left-to-right,"{""region"": ""India, Asia""}"
tel-x-chamari,Chamari (Telugu),left-to-right,"{""region"": ""India, Asia""}"
tel-x-dasari,Dasari,left-to-right,"{""region"": ""India, Asia""}"
tel-x-dhaba,Dhaba,left-to-right,"{""region"": ""India, Asia""}"
@@ -9954,7 +9954,7 @@ test,Lapaknon Manobo,left-to-right,"{""region"": ""Philippines, Asia""}"
te-x-bairag,Bairag,left-to-right,"{""region"": ""India, Asia""}"
te-x-budugaja,Telugu Buduga Jangam,left-to-right,"{""region"": ""India, Asia""}"
te-x-dokkalig,Dokka,left-to-right,"{""region"": ""India, Asia""}"
-te-x-dommara,Dommara,left-to-right,"{""region"": ""India, Asia""}"
+dom,Dommara,left-to-right,"{""region"": ""India, Asia""}"
te-x-gangireddula,Gangireddula,left-to-right,"{""region"": ""India, Asia""}"
te-x-kappalollu,Kapalollu,left-to-right,"{""region"": ""India, Asia""}"
te-x-katipapalu,Katipapalu,left-to-right,"{""region"": ""India, Asia""}"
@@ -10263,7 +10263,7 @@ ar-SY,Arabic (Syria),,
ar-TN,Arabic (Tunisia),,
ar-YE,Arabic (Yemen),,
arn-CL,Mapudungun (Chile),,
-as-IN,Assamese (India),,
+asm-IN,Assamese (India),,
az-Cyrl-AZ,Azeri (Cyrillic) (Azerbaijan),,
az-Latn-AZ,Azeri (Latin) (Azerbaijan),,
ba-RU,Bashkir (Russia),,
diff --git a/backend/app/data/versification.json b/backend/app/data/versification.json
new file mode 100644
index 00000000..00d8371b
--- /dev/null
+++ b/backend/app/data/versification.json
@@ -0,0 +1 @@
+{"maxVerses":{"GEN":["31","25","24","26","32","22","24","22","29","32","32","20","18","24","21","16","27","33","38","18","34","24","20","67","34","35","46","22","35","43","55","32","20","31","29","43","36","30","23","23","57","38","34","34","28","34","31","22","33","26"],"EXO":["22","25","22","31","23","30","25","32","35","29","10","51","22","31","27","36","16","27","25","26","36","31","33","18","40","37","21","43","46","38","18","35","23","35","35","38","29","31","43","38"],"LEV":["17","16","17","35","19","30","38","36","24","20","47","8","59","57","33","34","16","30","37","27","24","33","44","23","55","46","34"],"NUM":["54","34","51","49","31","27","89","26","23","36","35","16","33","45","41","50","13","32","22","29","35","41","30","25","18","65","23","31","40","16","54","42","56","29","34","13"],"DEU":["46","37","29","49","33","25","26","20","29","22","32","32","18","29","23","22","20","22","21","20","23","30","25","22","19","19","26","68","29","20","30","52","29","12"],"JOS":["18","24","17","24","15","27","26","35","27","43","23","24","33","15","63","10","18","28","51","9","45","34","16","33"],"JDG":["36","23","31","24","31","40","25","35","57","18","40","15","25","20","20","31","13","31","30","48","25"],"RUT":["22","23","18","22"],"1SA":["28","36","21","22","12","21","17","22","27","27","15","25","23","52","35","23","58","30","24","42","15","23","29","22","44","25","12","25","11","31","13"],"2SA":["27","32","39","12","25","23","29","18","13","19","27","31","39","33","37","23","29","33","43","26","22","51","39","25"],"1KI":["53","46","28","34","18","38","51","66","28","29","43","33","34","31","34","34","24","46","21","43","29","53"],"2KI":["18","25","27","44","27","33","20","29","37","36","21","21","25","29","38","20","41","37","37","21","26","20","37","20","30"],"1CH":["54","55","24","43","26","81","40","40","44","14","47","40","14","17","29","43","27","17","19","8","30","19","32","31","31","32","34","21","30"],"2CH":["17","18","17","22","14","42","22","18","31","19","23","16","22","15","19","14","19","34","11","37","20","12","21","27","28","23","9","27","36","27","21","33","25","33","27","23"],"EZR":["11","70","13","24","17","22","28","36","15","44"],"NEH":["11","20","32","23","19","19","73","18","38","39","36","47","31"],"EST":["22","23","15","17","14","14","10","17","32","3"],"JOB":["22","13","26","21","27","30","21","22","35","22","20","25","28","22","35","22","16","21","29","29","34","30","17","25","6","14","23","28","25","31","40","22","33","37","16","33","24","41","30","24","34","17"],"PSA":["6","12","8","8","12","10","17","9","20","18","7","8","6","7","5","11","15","50","14","9","13","31","6","10","22","12","14","9","11","12","24","11","22","22","28","12","40","22","13","17","13","11","5","26","17","11","9","14","20","23","19","9","6","7","23","13","11","11","17","12","8","12","11","10","13","20","7","35","36","5","24","20","28","23","10","12","20","72","13","19","16","8","18","12","13","17","7","18","52","17","16","15","5","23","11","13","12","9","9","5","8","28","22","35","45","48","43","13","31","7","10","10","9","8","18","19","2","29","176","7","8","9","4","8","5","6","5","6","8","8","3","18","3","3","21","26","9","8","24","13","10","7","12","15","21","10","20","14","9","6"],"PRO":["33","22","35","27","23","35","27","36","18","32","31","28","25","35","33","33","28","24","29","30","31","29","35","34","28","28","27","28","27","33","31"],"ECC":["18","26","22","16","20","12","29","17","18","20","10","14"],"SNG":["17","17","11","16","16","13","13","14"],"ISA":["31","22","26","6","30","13","25","22","21","34","16","6","22","32","9","14","14","7","25","6","17","25","18","23","12","21","13","29","24","33","9","20","24","17","10","22","38","22","8","31","29","25","28","28","25","13","15","22","26","11","23","15","12","17","13","12","21","14","21","22","11","12","19","12","25","24"],"JER":["19","37","25","31","31","30","34","22","26","25","23","17","27","22","21","21","27","23","15","18","14","30","40","10","38","24","22","17","32","24","40","44","26","22","19","32","21","28","18","16","18","22","13","30","5","28","7","47","39","46","64","34"],"LAM":["22","22","66","22","22"],"EZK":["28","10","27","17","17","14","27","18","11","22","25","28","23","23","8","63","24","32","14","49","32","31","49","27","17","21","36","26","21","26","18","32","33","31","15","38","28","23","29","49","26","20","27","31","25","24","23","35"],"DAN":["21","49","30","37","31","28","28","27","27","21","45","13"],"HOS":["11","23","5","19","15","11","16","14","17","15","12","14","16","9"],"JOL":["20","32","21"],"AMO":["15","16","15","13","27","14","17","14","15"],"OBA":["21"],"JON":["17","10","10","11"],"MIC":["16","13","12","13","15","16","20"],"NAM":["15","13","19"],"HAB":["17","20","19"],"ZEP":["18","15","20"],"HAG":["15","23"],"ZEC":["21","13","10","14","11","15","14","23","17","12","17","14","9","21"],"MAL":["14","17","18","6"],"MAT":["25","23","17","25","48","34","29","34","38","42","30","50","58","36","39","28","27","35","30","34","46","46","39","51","46","75","66","20"],"MRK":["45","28","35","41","43","56","37","38","50","52","33","44","37","72","47","20"],"LUK":["80","52","38","44","39","49","50","56","62","42","54","59","35","35","32","31","37","43","48","47","38","71","56","53"],"JHN":["51","25","36","54","47","71","53","59","41","42","57","50","38","31","27","33","26","40","42","31","25"],"ACT":["26","47","26","37","42","15","60","40","43","48","30","25","52","28","41","40","34","28","41","38","40","30","35","27","27","32","44","31"],"ROM":["32","29","31","25","21","23","25","39","33","21","36","21","14","23","33","27"],"1CO":["31","16","23","21","13","20","40","13","27","33","34","31","13","40","58","24"],"2CO":["24","17","18","18","21","18","16","24","15","18","33","21","14"],"GAL":["24","21","29","31","26","18"],"EPH":["23","22","21","32","33","24"],"PHP":["30","30","21","23"],"COL":["29","23","25","18"],"1TH":["10","20","13","18","28"],"2TH":["12","17","18"],"1TI":["20","15","16","16","25","21"],"2TI":["18","26","17","22"],"TIT":["16","15","15"],"PHM":["25"],"HEB":["14","18","19","16","14","20","28","13","28","39","40","29","25"],"JAS":["27","26","18","17","20"],"1PE":["25","25","22","19","14"],"2PE":["21","22","18"],"1JN":["10","29","24","21","21"],"2JN":["13"],"3JN":["15"],"JUD":["25"],"REV":["20","29","22","11","14","17","17","13","21","11","19","18","18","20","8","21","18","24","21","15","27","21"],"TOB":["22","14","17","21","21","17","18","21","6","13","19","22","18","15"],"JDT":["16","28","10","15","24","21","32","36","14","23","23","20","20","19","13","25"],"ESG":["39","23","22","47","28","14","10","39","32","13"],"WIS":["16","24","19","20","23","25","30","21","18","21","26","27","19","31","19","29","21","25","22"],"SIR":["30","18","31","31","15","37","36","19","18","31","34","18","26","27","20","30","32","33","30","32","28","27","27","34","26","29","30","26","28","25","31","24","31","26","20","26","31","34","35","30","23","25","33","23","26","20","25","25","16","29","30"],"BAR":["21","35","37","37","9","73"],"LJE":["73"],"S3Y":["68"],"SUS":["64"],"BEL":["42"],"1MA":["64","70","60","61","68","63","50","32","73","89","74","53","53","49","41","24"],"2MA":["36","32","40","50","27","31","42","36","29","38","38","45","26","46","39"],"3MA":["29","33","30","21","51","41","23"],"4MA":["35","24","21","26","38","35","23","29","32","21","27","19","27","20","32","25","24","24"],"1ES":["58","30","24","63","73","34","15","96","55"],"2ES":["40","48","36","52","56","59","140","63","47","59","46","51","58","48","63","78"],"MAN":["15"],"PS2":["7"],"JSA":["18","24","17","24","15","27","26","35","27","43","23","24","33","15","63","10","18","28","51","9","45","34","16","33"],"JDB":["36","23","31","24","31","40","25","35","57","18","40","15","25","20","20","31","13","31","30","48","25"],"TBS":["22","14","17","21","23","19","17","21","6","14","19","22","18","15"],"SST":["64"],"DNT":["21","49","97","37","30","29","28","27","27","21","45","13"],"BLT":["42"],"DAG":["21","49","97","37","31","28","28","27","27","21","45","13","64","42"],"LAO":["20"]},"mappedVerses":{"GEN 31:55":"GEN 32:1","GEN 32:1-32":"GEN 32:2-33","EXO 8:1-4":"EXO 7:26-29","EXO 8:5-32":"EXO 8:1-28","EXO 22:1":"EXO 21:37","EXO 22:2-31":"EXO 22:1-30","LEV 6:1-7":"LEV 5:20-26","LEV 6:8-30":"LEV 6:1-23","NUM 16:36-50":"NUM 17:1-15","NUM 17:1-13":"NUM 17:16-28","NUM 29:40":"NUM 30:1","NUM 30:1-16":"NUM 30:2-17","DEU 12:32":"DEU 13:1","DEU 13:1-18":"DEU 13:2-19","DEU 22:30":"DEU 23:1","DEU 23:1-25":"DEU 23:2-26","DEU 29:1":"DEU 28:69","DEU 29:2-29":"DEU 29:1-28","1SA 20:42":"1SA 21:1","1SA 21:1-15":"1SA 21:2-16","1SA 23:29":"1SA 24:1","1SA 24:1-22":"1SA 24:2-23","2SA 18:33":"2SA 19:1","2SA 19:1-43":"2SA 19:2-44","1KI 4:21-34":"1KI 5:1-14","1KI 5:1-18":"1KI 5:15-32","1KI 22:43-53":"1KI 22:44-54","2KI 11:21":"2KI 12:1","2KI 12:1-21":"2KI 12:2-22","1CH 6:1-15":"1CH 5:27-41","1CH 6:16-81":"1CH 6:1-66","1CH 12:4-40":"1CH 12:5-41","2CH 2:1":"2CH 1:18","2CH 2:2-18":"2CH 2:1-17","2CH 14:1":"2CH 13:23","2CH 14:2-15":"2CH 14:1-14","NEH 4:1-6":"NEH 3:33-38","NEH 4:7-23":"NEH 4:1-17","NEH 7:69-73":"NEH 7:68-72","NEH 9:38":"NEH 10:1","NEH 10:1-39":"NEH 10:2-40","JOB 41:1-8":"JOB 40:25-32","JOB 41:9-34":"JOB 41:1-26","PSA 3:0-8":"PSA 3:1-9","PSA 4:0-8":"PSA 4:1-9","PSA 5:0-12":"PSA 5:1-13","PSA 6:0-10":"PSA 6:1-11","PSA 7:0-17":"PSA 7:1-18","PSA 8:0-9":"PSA 8:1-10","PSA 9:0-20":"PSA 9:1-21","PSA 12:0-8":"PSA 12:1-9","PSA 13:0-5":"PSA 13:1-6","PSA 18:0-50":"PSA 18:1-51","PSA 19:0-14":"PSA 19:1-15","PSA 20:0-9":"PSA 20:1-10","PSA 21:0-13":"PSA 21:1-14","PSA 22:0-31":"PSA 22:1-32","PSA 30:0-12":"PSA 30:1-13","PSA 31:0-24":"PSA 31:1-25","PSA 34:0-22":"PSA 34:1-23","PSA 36:0-12":"PSA 36:1-13","PSA 38:0-22":"PSA 38:1-23","PSA 39:0-13":"PSA 39:1-14","PSA 40:0-17":"PSA 40:1-18","PSA 41:0-13":"PSA 41:1-14","PSA 42:0-11":"PSA 42:1-12","PSA 44:0-26":"PSA 44:1-27","PSA 45:0-17":"PSA 45:1-18","PSA 46:0-11":"PSA 46:1-12","PSA 47:0-9":"PSA 47:1-10","PSA 48:0-14":"PSA 48:1-15","PSA 49:0-20":"PSA 49:1-21","PSA 51:0":"PSA 51:2","PSA 51:1-19":"PSA 51:3-21","PSA 52:0":"PSA 52:2","PSA 52:1-9":"PSA 52:3-11","PSA 53:0-6":"PSA 53:1-7","PSA 54:0":"PSA 54:2","PSA 54:1-7":"PSA 54:3-9","PSA 55:0-23":"PSA 55:1-24","PSA 56:0-13":"PSA 56:1-14","PSA 57:0-11":"PSA 57:1-12","PSA 58:0-11":"PSA 58:1-12","PSA 59:0-17":"PSA 59:1-18","PSA 60:0":"PSA 60:2","PSA 60:1-12":"PSA 60:3-14","PSA 61:0-8":"PSA 61:1-9","PSA 62:0-12":"PSA 62:1-13","PSA 63:0-11":"PSA 63:1-12","PSA 64:0-10":"PSA 64:1-11","PSA 65:0-13":"PSA 65:1-14","PSA 67:0-7":"PSA 67:1-8","PSA 68:0-35":"PSA 68:1-36","PSA 69:0-36":"PSA 69:1-37","PSA 70:0-5":"PSA 70:1-6","PSA 75:0-10":"PSA 75:1-11","PSA 76:0-12":"PSA 76:1-13","PSA 77:0-20":"PSA 77:1-21","PSA 80:0-19":"PSA 80:1-20","PSA 81:0-16":"PSA 81:1-17","PSA 83:0-18":"PSA 83:1-19","PSA 84:0-12":"PSA 84:1-13","PSA 85:0-13":"PSA 85:1-14","PSA 88:0-18":"PSA 88:1-19","PSA 89:0-52":"PSA 89:1-53","PSA 92:0-15":"PSA 92:1-16","PSA 102:0-28":"PSA 102:1-29","PSA 108:0-13":"PSA 108:1-14","PSA 140:0-13":"PSA 140:1-14","PSA 142:0-7":"PSA 142:1-8","ECC 5:1":"ECC 4:17","ECC 5:2-20":"ECC 5:1-19","SNG 6:13":"SNG 7:1","SNG 7:1-13":"SNG 7:2-14","ISA 9:1":"ISA 8:23","ISA 9:2-21":"ISA 9:1-20","ISA 64:2-12":"ISA 64:1-11","JER 9:1":"JER 8:23","JER 9:2-26":"JER 9:1-25","EZK 20:45-46":"EZK 21:1-2","EZK 20:47":"EZK 21:3","EZK 20:48-49":"EZK 21:4-5","EZK 21:1-32":"EZK 21:6-37","DAN 4:1-3":"DAN 3:31-33","DAN 4:4-37":"DAN 4:1-34","DAN 5:31":"DAN 6:1","DAN 6:1-28":"DAN 6:2-29","HOS 1:10-11":"HOS 2:1-2","HOS 2:1-23":"HOS 2:3-25","HOS 11:12":"HOS 12:1","HOS 12:1-14":"HOS 12:2-15","HOS 13:16":"HOS 14:1","HOS 14:1-9":"HOS 14:2-10","JOL 2:28-32":"JOL 3:1-5","JOL 3:1-21":"JOL 4:1-21","JON 1:17":"JON 2:1","JON 2:1-10":"JON 2:2-11","MIC 5:1":"MIC 4:14","MIC 5:2-15":"MIC 5:1-14","NAM 1:15":"NAM 2:1","NAM 2:1-13":"NAM 2:2-14","ZEC 1:18-21":"ZEC 2:1-4","ZEC 2:1-13":"ZEC 2:5-17","MAL 4:1-6":"MAL 3:19-24","BAR 6:1-73":"LJE 1:1-73","DAG 13:1-63":"SUS 1:1-63","DAG 14:1-42":"BEL 1:1-42","ESG 1:1":"ESG 1:1a","ESG 1:2":"ESG 1:1b","ESG 1:3":"ESG 1:1c","ESG 1:4":"ESG 1:1d","ESG 1:5":"ESG 1:1e","ESG 1:6":"ESG 1:1f","ESG 1:7":"ESG 1:1g","ESG 1:8":"ESG 1:1h","ESG 1:9":"ESG 1:1i","ESG 1:10":"ESG 1:1k","ESG 1:11":"ESG 1:1l","ESG 1:12":"ESG 1:1m","ESG 1:13":"ESG 1:1n","ESG 1:14":"ESG 1:1o","ESG 1:15":"ESG 1:1p","ESG 1:16":"ESG 1:1q","ESG 1:17":"ESG 1:1r","ESG 1:18":"ESG 1:1s","ESG 1:19-39":"ESG 1:2-22","ESG 3:14":"ESG 3:13a","ESG 3:15":"ESG 3:13b","ESG 3:16":"ESG 3:13c","ESG 3:17":"ESG 3:13d","ESG 3:18":"ESG 3:13e","ESG 3:19":"ESG 3:13f","ESG 3:20":"ESG 3:13g","ESG 3:21":"ESG 3:14","ESG 3:22":"ESG 3:15","ESG 4:18":"ESG 4:17a","ESG 4:19":"ESG 4:17b","ESG 4:20":"ESG 4:17c","ESG 4:21":"ESG 4:17c","ESG 4:22":"ESG 4:17d","ESG 4:23":"ESG 4:17d","ESG 4:24":"ESG 4:17e","ESG 4:25":"ESG 4:17f","ESG 4:26":"ESG 4:17g","ESG 4:27":"ESG 4:17h","ESG 4:28":"ESG 4:17i","ESG 4:29":"ESG 4:17k","ESG 4:30":"ESG 4:17k","ESG 4:31":"ESG 4:17k","ESG 4:32":"ESG 4:17l","ESG 4:33":"ESG 4:17m","ESG 4:34":"ESG 4:17n","ESG 4:35":"ESG 4:17n","ESG 4:36":"ESG 4:17o","ESG 4:37":"ESG 4:17o","ESG 4:38":"ESG 4:17p","ESG 4:39":"ESG 4:17q","ESG 4:40":"ESG 4:17r","ESG 4:41":"ESG 4:17s","ESG 4:42":"ESG 4:17t","ESG 4:43":"ESG 4:17u","ESG 4:44":"ESG 4:17w","ESG 4:45":"ESG 4:17x","ESG 4:46":"ESG 4:17y","ESG 4:47":"ESG 4:17z","ESG 5:2":"ESG 5:1a","ESG 5:3":"ESG 5:1a","ESG 5:4":"ESG 5:1a","ESG 5:5":"ESG 5:1b","ESG 5:6":"ESG 5:1c","ESG 5:7":"ESG 5:1d","ESG 5:8":"ESG 5:1e","ESG 5:9":"ESG 5:1f","ESG 5:10":"ESG 5:1f","ESG 5:11":"ESG 5:2","ESG 5:12":"ESG 5:2","ESG 5:13":"ESG 5:2a","ESG 5:14":"ESG 5:2a","ESG 5:15":"ESG 5:2b","ESG 5:16":"ESG 5:2b","ESG 5:17-28":"ESG 5:3-14","ESG 8:13":"ESG 8:12a","ESG 8:14":"ESG 8:12b","ESG 8:15":"ESG 8:12c","ESG 8:16":"ESG 8:12d","ESG 8:17":"ESG 8:12e","ESG 8:18":"ESG 8:12f","ESG 8:19":"ESG 8:12g","ESG 8:20":"ESG 8:12h","ESG 8:21":"ESG 8:12i","ESG 8:22":"ESG 8:12k","ESG 8:23":"ESG 8:12l","ESG 8:24":"ESG 8:12m","ESG 8:25":"ESG 8:12n","ESG 8:26":"ESG 8:12o","ESG 8:27":"ESG 8:12p","ESG 8:28":"ESG 8:12q","ESG 8:29":"ESG 8:12r","ESG 8:30":"ESG 8:12s","ESG 8:31":"ESG 8:12t","ESG 8:32":"ESG 8:12u","ESG 8:33":"ESG 8:12x","ESG 8:34":"ESG 8:12y","ESG 8:35":"ESG 8:12y","ESG 8:36":"ESG 8:12y","ESG 8:37-41":"ESG 8:13-17","ESG 10:4":"ESG 10:3a","ESG 10:5":"ESG 10:3b","ESG 10:6":"ESG 10:3c","ESG 10:7":"ESG 10:3d","ESG 10:8":"ESG 10:3e","ESG 10:9":"ESG 10:3f","ESG 10:10":"ESG 10:3g","ESG 10:11":"ESG 10:3h","ESG 10:12":"ESG 10:3i","ESG 10:13":"ESG 10:3k","ESG 10:14":"ESG 10:3l","S3Y 1:1-29":"DAG 3:24-52","S3Y 1:30-31":"DAG 3:52-53","S3Y 1:33":"DAG 3:54","S3Y 1:32":"DAG 3:55","S3Y 1:34-35":"DAG 3:56-57","S3Y 1:37":"DAG 3:58","S3Y 1:36":"DAG 3:59","S3Y 1:38-68":"DAG 3:60-90"},"excludedVerses":[],"partialVerses":{}}
\ No newline at end of file
diff --git a/backend/app/db_models.py b/backend/app/db_models.py
index e9876408..836db884 100644
--- a/backend/app/db_models.py
+++ b/backend/app/db_models.py
@@ -38,6 +38,8 @@ class Language(Base):
language_code = Column(String, unique=True, nullable=False)
language_name = Column(String, nullable=False)
meta_data = Column(JSONB)
+ local_script_name = Column(String, nullable=True)
+ script_direction = Column(String, nullable=True)
class BookLookup(Base):
'''Corresponds to table bible_books_look_up in vachan DB(postgres)'''
@@ -118,11 +120,10 @@ class Commentary(Base):
"""Corresponds to table commentary in vachan DB(postgres)"""
__tablename__ = "commentary"
commentary_id = Column(Integer, primary_key=True, autoincrement=True)
- resource_id = Column(Integer, ForeignKey("resource.resource_id"),
- primary_key=True)
- book_id = Column(Integer,ForeignKey("book_lookup.book_id"), primary_key=True)
- chapter = Column(Integer, primary_key=True ,nullable=False)
- verse = Column(String, primary_key=True)
+ resource_id = Column(Integer, ForeignKey("resource.resource_id"))
+ book_id = Column(Integer,ForeignKey("book_lookup.book_id"))
+ chapter = Column(Integer ,nullable=False)
+ verse = Column(String)
text = Column(Text,nullable=False)
class Dictionary(Base):
@@ -151,6 +152,19 @@ class AudioBible(Base):
format = Column(Text, nullable=False)
files_missing=Column(JSONB, nullable=True)
test_date = Column(DateTime(timezone=True), nullable=True, default=utcnow)
+class Song(Base):
+ """Corresponds to table song in vachan DB(postgres)"""
+ __tablename__ = "songs"
+
+ id = Column(Integer, primary_key=True, index=True, autoincrement=True)
+ resource_id = Column(
+ Integer,
+ ForeignKey("resource.resource_id", ondelete="CASCADE"),
+ nullable=False,
+ )
+ name = Column(Text, nullable=False)
+ url = Column(Text, nullable=True)
+ lyrics = Column(Text, nullable=True)
class Obs(Base):
"""Corresponds to table obs in vachan DB(postgres)"""
@@ -260,4 +274,48 @@ class ErrorLog(Base):
Index("ix_error_log_user_time", "user_id", "time"),
Index("ix_error_log_error_code_time", "error_code", "time"),
)
-
\ No newline at end of file
+
+class BookName(Base):
+ """
+ Corresponds to table book_names in vachan DB (Postgres)
+ Stores translated book names per language
+ """
+ __tablename__ = "book_names"
+ id = Column(Integer, primary_key=True)
+ book_id = Column(
+ Integer,
+ ForeignKey("book_lookup.book_id", ondelete="CASCADE"),
+ nullable=False
+ )
+ language_id = Column(
+ Integer,
+ ForeignKey("language.language_id", ondelete="CASCADE"),
+ nullable=False
+ )
+ abbr = Column(String, nullable=False)
+ short = Column(String, nullable=False)
+ long = Column(String, nullable=False)
+ __table_args__ = (
+ UniqueConstraint("book_id", "language_id", name="uq_book_language"),
+ )
+
+
+class M2MClient(Base):
+ """Registered M2M clients (Vachan Online, headless apps). Stores hashed secrets only."""
+ __tablename__ = "m2m_client"
+
+ id = Column(Integer, primary_key=True, autoincrement=True)
+ client_id = Column(String, unique=True, index=True, nullable=False)
+ client_secret_hash = Column(String, nullable=False)
+ name = Column(String, nullable=False)
+ is_active = Column(Boolean, nullable=False, default=True)
+ created_at = Column(DateTime(timezone=True), nullable=False, default=utcnow)
+
+class IslVerseMarkers(Base):
+ """Corresponds to table isl_verse_markers in vachan DB(postgres)"""
+ __tablename__ = "isl_verse_marker"
+
+ id = Column(Integer, primary_key=True, index=True)
+ isl_video_id = Column(Integer, ForeignKey("isl_video.id", ondelete="CASCADE"),
+ nullable=False, unique=True)
+ verse_markers_json = Column(JSONB, nullable=False)
diff --git a/backend/app/load_data.py b/backend/app/load_data.py
index c7be2694..b1592882 100644
--- a/backend/app/load_data.py
+++ b/backend/app/load_data.py
@@ -69,9 +69,62 @@ def populate_version_table(db_: Session, file: str) -> None:
db_.bulk_save_objects(rows)
db_.commit()
+def populate_book_names_table(db_: Session, file: str) -> None:
+ """Populates the book_names table from booknames.csv"""
+ missing_languages = set()
+ with open(file, "r", encoding="utf-8") as file_pointer:
+ csv_reader = DictReader(file_pointer)
+
+ rows = []
+
+ for row in csv_reader:
+ book_code = row.get("bookCode")
+ language_code = row.get("language")
+
+ # Fetch Book from db_models
+ book = db_.query(db_models.BookLookup).filter(
+ db_models.BookLookup.book_code == book_code
+ ).first()
+
+ # Fetch Language from db_models
+ language = db_.query(db_models.Language).filter(
+ db_models.Language.language_code == language_code
+ ).first()
+
+ # collect non-matching language codes
+ if not language:
+ missing_languages.add(language_code)
+
+ if not book or not language:
+ continue
+
+ # Check existing record
+ exists = db_.query(db_models.BookName).filter(
+ db_models.BookName.book_id == book.book_id,
+ db_models.BookName.language_id == language.language_id
+ ).first()
+
+ if exists:
+ continue
+
+ rows.append(
+ db_models.BookName(
+ abbr=row.get("abbr"),
+ short=row.get("short"),
+ long=row.get("long"),
+ book_id=book.book_id,
+ language_id=language.language_id
+ )
+ )
+
+ if rows:
+ db_.bulk_save_objects(rows)
+ db_.commit()
+ if missing_languages:
+ print("Missing language codes:", missing_languages)
def load_initial_data():
"""Populate the database"""
with SessionLocal() as session:
@@ -94,6 +147,9 @@ def load_initial_data():
if session.query(db_models.Version).count() == 0:
csv_file_versions = Path('data/versions.csv').resolve()
populate_version_table(session, str(csv_file_versions))
+ if session.query(db_models.BookName).count() == 0:
+ csv_file_booknames = Path("data/booknames.csv").resolve()
+ populate_book_names_table(session, str(csv_file_booknames))
diff --git a/backend/app/main.py b/backend/app/main.py
index c5c8001e..94c8dacb 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -30,6 +30,9 @@
from router.format_checker import router as format_checker_router
from router.logs import router as logs_router
from router.structural import router as structural_router
+from router.m2m_auth import router as m2m_auth_router
+from router.content_songs import router as content_songs_router
+from router.isl_verse_markers import router as isl_verse_marker_router
init_db()
@@ -57,7 +60,7 @@
allow_origins=allowed_origins,
allow_credentials=True,
allow_methods=["GET", "PUT", "POST", "DELETE", "OPTIONS", "PATCH"],
- allow_headers=["Content-Type"] + get_all_cors_headers(),
+ allow_headers=["Content-Type", "X-API-Key"] + get_all_cors_headers(),
)
app.add_middleware(
@@ -68,8 +71,6 @@
"/openapi.json",
"/health",
"/metrics",
- "/v2/admin/audit-logs",
- "/audit-logs" # optional: avoid logging the log viewer itself
],
)
@@ -188,3 +189,6 @@ async def root():
app.include_router(content_router)
app.include_router(format_checker_router)
app.include_router(structural_router)
+app.include_router(m2m_auth_router)
+app.include_router(content_songs_router)
+app.include_router(isl_verse_marker_router)
diff --git a/backend/app/router.py b/backend/app/router.py
deleted file mode 100644
index 7c445039..00000000
--- a/backend/app/router.py
+++ /dev/null
@@ -1,4534 +0,0 @@
-# """API routes for CRUD operations."""
-# from typing import Optional, Union, List
-# from fastapi import (
-# APIRouter,
-# Depends,
-# HTTPException,
-# Query,
-# File,
-# UploadFile,
-# Form,
-# Path,
-# Body,
-# Response,
-# Request
-# )
-# from fastapi.responses import JSONResponse
-# from supertokens_python.recipe.session import SessionContainer
-# from supertokens_python.recipe.session.framework.fastapi import verify_session
-# from sqlalchemy.orm import Session
-# import schema
-# from schema import BibleVersePathParams
-# import crud
-# from dependencies import get_db, logger
-# from auth import (
-# validate_admin_only,
-# validate_admin_editor,
-# validate_all_roles,
-# ensure_user_from_session_async,
-# )
-# import db_models
-# from custom_exceptions import (
-# NotAvailableException,
-# BadRequestException,
-# UnprocessableException,
-# TypeException,
-# )
-
-# router = APIRouter()
-
-# verify_session_data=verify_session()
-
-# # # --- Version Endpoints ---
-# # @router.get(
-# # "/versions",
-# # response_model=Union[schema.VersionResponse, List[schema.VersionResponse]],
-# # tags=["Version"]
-# # )
-
-# # async def get_versions(
-# # version_id: Optional[int] = Query(None),
-# # abbreviation: Optional[str] = Query(None),
-# # db_session: Session = Depends(get_db),
-# # session: SessionContainer = Depends(verify_session_data)
-# # ):
-# # """Get all versions or a single version by ID."""
-# # logger.info("GET Version API")
-# # validate_admin_only(session)
-# # _, _ = await ensure_user_from_session_async(db_session, session)
-# # if version_id is not None or abbreviation is not None:
-# # db_obj = crud.get_version(db_session, version_id,abbreviation)
-# # if not db_obj:
-# # logger.error("Version not found")
-# # raise NotAvailableException(detail="Version not found")
-# # return db_obj
-# # return crud.get_all_versions(db_session)
-
-
-# # @router.post("/versions", response_model=schema.VersionResponse,tags=["Version"])
-# # async def create_version(
-# # version: schema.VersionCreate,
-# # db_session: Session = Depends(get_db),
-# # session: SessionContainer = Depends(verify_session_data)
-# # ):
-# # """Create a new version."""
-# # logger.info("POST Version API")
-# # validate_admin_only(session)
-# # _, _ = await ensure_user_from_session_async(db_session, session)
-# # db_obj = crud.create_version(db_session, version)
-# # return db_obj
-
-
-# # @router.put("/versions/{version_id}", response_model=schema.VersionResponse,tags=["Version"])
-# # async def update_version(
-# # version_id: int,
-# # version: schema.VersionUpdate,
-# # db_session: Session = Depends(get_db),
-# # session: SessionContainer = Depends(verify_session_data)
-# # ):
-# # """Update an existing version by ID."""
-# # logger.info("PUT Version API")
-# # validate_admin_only(session)
-# # _, _ = await ensure_user_from_session_async(db_session, session)
-# # db_obj = crud.update_version(db_session,version_id, version)
-# # return db_obj
-
-# # @router.delete(
-# # "/versions/bulk-delete",
-# # tags=["Version"],
-# # response_model=schema.VersionBulkDeleteResponse
-# # )
-# # async def delete_versions_bulk(
-# # request: schema.VersionBulkDelete,
-# # db_session: Session = Depends(get_db),
-# # session: SessionContainer = Depends(verify_session_data),
-# # ):
-# # logger.info("DELETE BULK Version API")
-
-# # # ---- Auth & role checks ----
-# # validate_admin_only(session)
-# # await ensure_user_from_session_async(db_session, session)
-
-# # # ---- Business logic ----
-# # result = crud.delete_versions_bulk(db_session, request.version_ids)
-
-# # data = result["data"]
-# # meta = result.get("meta", {})
-
-# # deleted_count = data["deletedCount"]
-# # deleted_ids = data["deletedIds"]
-# # errors = data.get("errors")
-
-# # not_found = meta.get("not_found", [])
-# # conflicts = meta.get("conflicts", [])
-
-# # # ---- Status code logic ----
-# # if conflicts and not deleted_ids:
-# # status_code = 409 # Conflict: versions exist but are in use
-# # elif not_found and not deleted_ids:
-# # status_code = 404 # Not Found: versions don’t exist
-# # elif conflicts or not_found:
-# # status_code = 207 # Multi-Status: partial success
-# # else:
-# # status_code = 200 # All deleted successfully
-
-# # # ---- Message ----
-# # if deleted_count > 0:
-# # message = f"Successfully deleted {deleted_count} version(s)"
-# # else:
-# # message = "No versions were deleted"
-
-# # response_data = {
-# # "deletedCount": deleted_count,
-# # "deletedIds": deleted_ids,
-# # "errors": errors,
-# # "message": message,
-# # }
-
-# # return JSONResponse(
-# # status_code=status_code,
-# # content=response_data,
-# # )
-
-# # # --- Language Endpoints ---
-
-# # @router.get(
-# # "/language",
-# # response_model=schema.LanguageResponse,
-# # tags=["Language"]
-# # )
-# # async def get_languages(
-# # params: schema.LanguageQueryParams = Depends(),
-# # db_session: Session = Depends(get_db),
-# # session: SessionContainer = Depends(verify_session_data)
-# # ):
-# # """Get languages with pagination and optional filtering."""
-# # logger.info("GET Languages API")
-
-# # validate_admin_only(session)
-# # _, _roles = await ensure_user_from_session_async(db_session, session)
-
-# # languages, total_items = crud.get_languages_with_pagination(
-# # db_session=db_session,
-# # page=params.page,
-# # page_size=params.page_size,
-# # language_name=params.language_name,
-# # language_code=params.language_code,
-# # )
-
-# async def get_versions(
-# version_id: Optional[int] = Query(None),
-# abbreviation: Optional[str] = Query(None),
-# db_session: Session = Depends(get_db),
-# session: SessionContainer = Depends(verify_session_data)
-# ):
-# """Get all versions or a single version by ID."""
-# logger.info("GET Version API")
-# validate_admin_only(session)
-# _, _ = await ensure_user_from_session_async(db_session, session)
-# if version_id is not None or abbreviation is not None:
-# db_obj = crud.get_version(db_session, version_id,abbreviation)
-# if not db_obj:
-# logger.error("Version not found")
-# raise NotAvailableException(detail="Version not found")
-# return db_obj
-# return crud.get_all_versions(db_session)
-
-
-# @router.post("/versions", response_model=schema.VersionResponse,tags=["Version"])
-# async def create_version(
-# version: schema.VersionCreate,
-# db_session: Session = Depends(get_db),
-# session: SessionContainer = Depends(verify_session_data)
-# ):
-# """Create a new version."""
-# logger.info("POST Version API")
-# validate_admin_only(session)
-# _, _ = await ensure_user_from_session_async(db_session, session)
-# db_obj = crud.create_version(db_session, version)
-# return db_obj
-
-
-# @router.put("/versions/{version_id}", response_model=schema.VersionResponse,tags=["Version"])
-# async def update_version(
-# version_id: int,
-# version: schema.VersionUpdate,
-# db_session: Session = Depends(get_db),
-# session: SessionContainer = Depends(verify_session_data)
-# ):
-# """Update an existing version by ID."""
-# logger.info("PUT Version API")
-# validate_admin_only(session)
-# _, _ = await ensure_user_from_session_async(db_session, session)
-# db_obj = crud.update_version(db_session,version_id, version)
-# return db_obj
-
-
-# from fastapi.responses import JSONResponse
-
-# --- Version Endpoints ---
-@router.get(
- "/versions",
- response_model=Union[schema.VersionResponse, List[schema.VersionResponse]],
- tags=["Version"]
-)
-
-async def get_versions(
- version_id: Optional[int] = Query(None),
- abbreviation: Optional[str] = Query(None),
- db_session: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)
-):
- """Get all versions or a single version by ID."""
- logger.info("GET Version API")
- validate_admin_only(session)
- _, _ = await ensure_user_from_session_async(db_session, session)
- if version_id is not None or abbreviation is not None:
- db_obj = crud.get_version(db_session, version_id,abbreviation)
- if not db_obj:
- logger.error("Version not found")
- raise NotAvailableException(detail="Version not found")
- return db_obj
- return crud.get_all_versions(db_session)
-
-
-@router.post("/versions", response_model=schema.VersionResponse,tags=["Version"])
-async def create_version(
- version: schema.VersionCreate,
- db_session: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)
-):
- """Create a new version."""
- logger.info("POST Version API")
- validate_admin_only(session)
- _, _ = await ensure_user_from_session_async(db_session, session)
- db_obj = crud.create_version(db_session, version)
- return db_obj
-
-
-@router.put("/versions/{version_id}", response_model=schema.VersionResponse,tags=["Version"])
-async def update_version(
- version_id: int,
- version: schema.VersionUpdate,
- db_session: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)
-):
- """Update an existing version by ID."""
- logger.info("PUT Version API")
- validate_admin_only(session)
- _, _ = await ensure_user_from_session_async(db_session, session)
- db_obj = crud.update_version(db_session,version_id, version)
- return db_obj
-
-@router.delete(
- "/versions/bulk-delete",
- tags=["Version"],
- response_model=schema.VersionBulkDeleteResponse
-)
-async def delete_versions_bulk(
- request: schema.VersionBulkDelete,
- db_session: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data),
-):
- logger.info("DELETE BULK Version API")
-
- # ---- Auth & role checks ----
- validate_admin_only(session)
- await ensure_user_from_session_async(db_session, session)
-
- # ---- Business logic ----
- result = crud.delete_versions_bulk(db_session, request.version_ids)
-
- data = result["data"]
- meta = result.get("meta", {})
-
- deleted_count = data["deletedCount"]
- deleted_ids = data["deletedIds"]
- errors = data.get("errors")
-
- not_found = meta.get("not_found", [])
- conflicts = meta.get("conflicts", [])
-
- # ---- Status code logic ----
- if conflicts and not deleted_ids:
- status_code = 409 # Conflict: versions exist but are in use
- elif not_found and not deleted_ids:
- status_code = 404 # Not Found: versions don’t exist
- elif conflicts or not_found:
- status_code = 207 # Multi-Status: partial success
- else:
- status_code = 200 # All deleted successfully
-
- # ---- Message ----
- if deleted_count > 0:
- message = f"Successfully deleted {deleted_count} version(s)"
- else:
- message = "No versions were deleted"
-
- response_data = {
- "deletedCount": deleted_count,
- "deletedIds": deleted_ids,
- "errors": errors,
- "message": message,
- }
-
- return JSONResponse(
- status_code=status_code,
- content=response_data,
- )
-
-# --- Language Endpoints ---
-
-@router.get(
- "/language",
- response_model=schema.LanguageResponse,
- tags=["Language"]
-)
-async def get_languages(
- params: schema.LanguageQueryParams = Depends(),
- db_session: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)
-):
- """Get languages with pagination and optional filtering."""
- logger.info("GET Languages API")
-
- validate_admin_only(session)
- _, _roles = await ensure_user_from_session_async(db_session, session)
-
- languages, total_items = crud.get_languages_with_pagination(
- db_session=db_session,
- page=params.page,
- page_size=params.page_size,
- language_name=params.language_name,
- language_code=params.language_code,
- )
-
- if (params.language_name or params.language_code) and total_items == 0:
- logger.error("Language ID or Language doesn't exist")
- raise NotAvailableException(detail="Language ID or Language doesn't exist")
-
- language_items = [
- schema.LanguageResponseItem(
- language_id=lang.language_id,
- language_name=lang.language_name,
- language_code=lang.language_code,
- metadata=lang.meta_data
- )
- for lang in languages
- ]
-
- return schema.LanguageResponse(
- total_items=total_items,
- current_page=params.page,
- items=language_items
- )
-
-@router.post(
- "/language",
- response_model=schema.LanguageResponseItem,
- tags=["Language"]
-)
-async def create_language(
- lang: schema.LanguageCreate,
- db_session: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)
-):
- """Create a new language."""
- logger.info("POST Languages API")
- validate_admin_only(session)
- _, _ = await ensure_user_from_session_async(db_session, session)
- db_obj = crud.create_language(db_session, lang)
-
- return schema.LanguageResponseItem(
- language_id=db_obj.language_id,
- language_name=db_obj.language_name,
- language_code=db_obj.language_code,
- metadata=db_obj.meta_data
- )
-
-@router.put(
- "/language/{language_id}",
- response_model=schema.LanguageResponseItem,
- tags=["Language"]
-)
-async def update_language(
- language_id: int,
- lang: schema.LanguageUpdate,
- db_session: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data),
-):
- """Update an existing language."""
- logger.info("PUT Languages API")
- validate_admin_only(session)
- _, _ = await ensure_user_from_session_async(db_session, session)
- # Check if language exists before attempting to update
- language_obj = crud.get_language(db_session, language_id)
- if not language_obj:
- logger.error("Language ID or Language doesn't exist")
- raise NotAvailableException(detail="Language ID or Language doesn't exist")
-
- db_obj = crud.update_language(db_session, language_id, lang)
-
- return schema.LanguageResponseItem(
- language_id=db_obj.language_id,
- language_name=db_obj.language_name,
- language_code=db_obj.language_code,
- metadata=db_obj.meta_data
- )
-
-@router.delete(
- "/languages/bulk-delete",
- tags=["Language"],
- response_model=schema.LanguageBulkDeleteResponse
-)
-async def delete_languages_bulk(
- request: schema.LanguageBulkDelete,
- db_session: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)
-):
- logger.info("DELETE BULK Language API")
-
- validate_admin_only(session)
- _, _ = await ensure_user_from_session_async(db_session, session)
-
- result = crud.delete_languages_bulk(db_session, request.language_ids)
-
- deleted_count = result["data"]["deletedCount"]
- errors = result["data"]["errors"]
-
- # ---- Status logic (same as videos & versions) ----
- if result["all_failed"]:
- status_code = 404
- elif result["has_errors"]:
- status_code = 207
- else:
- status_code = 200
-
- # Add message
- message = (
- f"Successfully deleted {deleted_count} language(s)"
- if deleted_count > 0
- else "No languages were deleted"
- )
-
- response_data = {
- **result["data"],
- "message": message
- }
-
- return JSONResponse(
- status_code=status_code,
- content=response_data
- )
-
-# --- License Endpoints ---
-@router.get(
- "/license",
- response_model=List[schema.LicenseResponseItem],
- tags=["License"]
-)
-async def get_licenses(
- license_id: Optional[int] = Query(None, description="Filter by license ID"),
- name: Optional[str] = Query(None, description="Filter by license name (partial match)"),
- db_session: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)
-):
- """Get licenses with optional filtering."""
- logger.info("GET License API")
- validate_admin_only(session)
- _, _ = await ensure_user_from_session_async(db_session, session)
- licenses = crud.get_licenses_with_filters(
- db_session=db_session,
- license_id=license_id,
- name=name
- )
-
- # Transform to response format
- return [schema.LicenseResponseItem.model_validate(license) for license in licenses]
-
-@router.post(
- "/license",
- response_model=schema.LicenseResponseItem,
- tags=["License"]
-)
-async def create_license(
- license_: schema.LicenseCreate,
- db_session: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)
-):
- """Create a new license."""
- logger.info("POST License API")
- validate_admin_only(session)
- _, _ = await ensure_user_from_session_async(db_session, session)
- db_obj = crud.create_license(db_session, license_)
-
- return schema.LicenseResponseItem.model_validate(db_obj)
-
-@router.put(
- "/license/{license_id}",
- response_model=schema.LicenseResponseItem,
- tags=["License"]
-)
-async def update_license(
- license_id: int,
- license_: schema.LicenseUpdate,
- db_session: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)
-):
- """Update an existing license."""
- logger.info("PUT License API")
- validate_admin_only(session)
- _, _ = await ensure_user_from_session_async(db_session, session)
- # Check if license exists before attempting to update
- license_obj = crud.get_license(db_session, license_id)
- if not license_obj:
- logger.error("License ID doesn't exist")
- raise NotAvailableException(detail="License ID doesn't exist")
-
- db_obj = crud.update_license(db_session, license_id, license_)
-
- return schema.LicenseResponseItem.model_validate(db_obj)
-
-@router.delete(
- "/license/bulk-delete",
- tags=["License"],
- response_model=schema.LicenseBulkDeleteResponse
-)
-async def delete_licenses_bulk(
- request: schema.LicenseBulkDelete,
- db_session: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)
-):
- logger.info("DELETE BULK License API")
-
- validate_admin_only(session)
- _, _ = await ensure_user_from_session_async(db_session, session)
-
- result = crud.delete_licenses_bulk(db_session, request.license_ids)
-
- deleted_count = result["data"]["deletedCount"]
- errors = result["data"]["errors"]
-
- # ---- Status code logic ----
- if result["all_failed"]:
- status_code = 404
- elif result["has_errors"]:
- status_code = 207
- else:
- status_code = 200
-
- # ---- Message ----
- message = (
- f"Successfully deleted {deleted_count} license(s)"
- if deleted_count > 0
- else "No licenses were deleted"
- )
-
- response_data = {
- **result["data"],
- "message": message
- }
-
- return JSONResponse(
- status_code=status_code,
- content=response_data
- )
-
-
-# --- Resource Endpoints ---
-@router.get(
- "/resources",
- response_model=List[schema.LanguageGroupOut],
- tags=["Resource"]
-)
-async def list_resources_route(
- params: schema.ResourceQueryParams = Depends(),
- db: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data),
-):
- """Get resources with pagination and optional filtering."""
- logger.info("GET Resource API")
- validate_all_roles(session)
- _, _ = await ensure_user_from_session_async(db, session)
-
- filters = schema.ResourceFilter(
- resource_id=params.resource_id,
- page=params.page,
- page_size=params.page_size,
- published=params.published,
- content_type=params.content_type.value.lower() if params.content_type else None,
- )
-
- return crud.get_resources(db, filters)
-
-@router.post("/resources", response_model=schema.ResourceResponse, tags=["Resource"])
-async def create_resource_route(
- payload: schema.ResourceCreate,
- db: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data
- )
-):
- """ API endpoint to create a new resource."""
- logger.info("POST Resource API")
- validate_admin_only(session)
- user_id, _ = await ensure_user_from_session_async(db, session)
- return crud.create_resource(db, payload, created_by=user_id)
-
-
-
-@router.put("/resources", response_model=schema.ResourceResponse, tags=["Resource"])
-async def update_resource_route(
- payload: schema.ResourceUpdate,
- db: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)
-):
- """ API endpoint to update a resource."""
- logger.info("PUT Resource API")
- validate_admin_only(session)
- user_id, _ = await ensure_user_from_session_async(db, session)
- return crud.update_resource(db, payload, user_id=user_id)
-
-@router.delete(
- "/resources/bulk-delete",
- tags=["Resource"],
- response_model=schema.ResourceBulkDeleteResponse
-)
-async def delete_resources_bulk(
- request: schema.ResourceBulkDelete,
- db: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)
-):
- logger.info("DELETE BULK Resource API")
-
- validate_admin_only(session)
- _, _ = await ensure_user_from_session_async(db, session)
-
- result = crud.delete_resources_bulk(db, request.resource_ids)
-
- deleted_count = result["data"]["deletedCount"]
- errors = result["data"]["errors"]
-
- # ---- Status Code Logic ----
- if result["all_failed"]:
- status_code = 404
- elif result["has_errors"]:
- status_code = 207
- else:
- status_code = 200
-
- # ---- Message ----
- message = (
- f"Successfully deleted {deleted_count} resource(s)"
- if deleted_count > 0
- else "No resources were deleted"
- )
-
- response_data = {
- **result["data"],
- "message": message
- }
-
- return JSONResponse(
- status_code=status_code,
- content=response_data
- )
-
-
-# ----Logs Endpoints-------
-
-@router.get("/log",tags=["logs"])
-def get_latest_log(session: SessionContainer = Depends(verify_session_data)):
- """
- current/activate log file
- """
- validate_admin_only(session)
- return crud.latest_log_file()
-
-
-
-
-@router.get("/log/{log_file_no}",tags=["logs"])
-def get_log_by_number(log_file_no: int,
- session: SessionContainer = Depends(verify_session_data)):
- """
- View rotated log files
- * The handler keeps up to 10 old files
- * vachan_admin_app.log,vachan_admin_app.log.1,vachan_admin_app.log.1 ... vachan_admin_app.log.10
- * log_file_no must be 0–10
- * current log file no is 0
- """
- validate_admin_only(session)
- return crud.get_logfile_by_number(log_file_no)
-
-
-@router.get("/logs",tags=["logs"])
-def get_all_logs(session: SessionContainer = Depends(verify_session_data)):
- """
- get all log files in a zip format
- """
- validate_admin_only(session)
- return crud.get_all_logfiles()
-
-
-
-# --- Bible Endpoints ---
-
-
-# --- Bible Book Management Endpoints ---
-
-@router.post(
- "/bible",
- response_model=dict,
- tags=["Bible"]
-)
-async def upload_bible_book(
- resource_id: int = Form(...),
- usfm: UploadFile = File(...),
- db_session: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)
-):
- """Upload a new bible book USFM file"""
- validate_admin_editor(session)
-
- # Get user ID from session
- actor_id, _ = await ensure_user_from_session_async(db_session, session)
- # Validate USFM file before processing
- validation_result = await crud.validate_usfm_file(usfm)
- if not validation_result["valid"]:
- raise UnprocessableException(detail=validation_result["error"])
- return crud.upload_bible_book(
- db_session=db_session,
- resource_id=resource_id,
- usfm_file=usfm,
- actor_user_id=actor_id
- )
-
-@router.put(
- "/bible",
- response_model=dict,
- tags=["Bible"]
-)
-async def update_bible_book(
- bible_book_id: int = Form(...),
- usfm: UploadFile = File(...),
- db_session: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)
-):
- """Update an existing bible book"""
- validate_admin_editor(session)
-
- # Get user ID from session
- actor_id, _ = await ensure_user_from_session_async(db_session, session)
- validation_result = await crud.validate_usfm_file(usfm)
- if not validation_result["valid"]:
- raise UnprocessableException(detail=validation_result["error"])
- return crud.update_bible_book(
- db_session=db_session,
- bible_book_id=bible_book_id,
- usfm_file=usfm,
- actor_user_id=actor_id
- )
-
-@router.delete(
- "/bible/{resource_id}/books",
- response_model=schema.BulkDeleteResponse,
- response_model_exclude_none=True,
- tags=["Bible"]
-)
-async def delete_bible_books_endpoint(
- resource_id: int,
- delete_request: schema.BulkDeleteRequest,
- db_session: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)
-):
- """Bulk delete Bible books by book codes"""
- validate_admin_editor(session)
- _, _ = await ensure_user_from_session_async(db_session, session)
-
- result = crud.delete_bible_books(
- db_session=db_session,
- resource_id=resource_id,
- book_codes=delete_request.bookIds,
- )
-
- deleted_count = result["data"]["deletedCount"]
- errors = result["data"]["errors"]
-
- # ---- Standard status code logic ----
- if result["all_failed"]:
- status_code = 404
- elif result["has_errors"]:
- status_code = 207
- else:
- status_code = 200
-
- # ---- Message ----
- message = (
- f"Successfully deleted {deleted_count} book(s)"
- if deleted_count > 0
- else "No books were deleted"
- )
-
- response_data = {
- **result["data"],
- "message": message,
- }
-
- return JSONResponse(status_code=status_code, content=response_data)
-
-
-
-# --- Bible Content Retrieval Endpoints ---
-
-@router.get(
- "/bible/{resource_id}/books",
- response_model=schema.BibleBooksListResponse,
- tags=["Bible"]
-)
-async def get_bible_books(
- resource_id: int,
- db_session: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)
-):
- """Get list of books for a bible resource"""
- validate_all_roles(session)
- _, _ = await ensure_user_from_session_async(db_session, session)
- return crud.get_bible_books(db_session, resource_id)
-
-
-
-@router.get(
- "/bible/{resource_id}/content/{output_format}",
- response_model=schema.BibleFullContentResponse,
- tags=["Bible"]
-)
-async def get_full_bible_content(
- resource_id: int,
- output_format: str,
- db_session: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)
-):
- """Get full content of all books in a resource in specified format (json/usfm)"""
- validate_all_roles(session)
- _, _ = await ensure_user_from_session_async(db_session, session)
-
- if output_format.lower() not in ["json", "usfm"]:
- raise BadRequestException("Format must be 'json' or 'usfm'")
-
- return crud.get_full_bible_content(
- db_session=db_session,
- resource_id=resource_id,
- output_format=output_format
- )
-
-
-
-@router.get(
- "/bible/{resource_id}/book/{book_code}/{output_format}",
- response_model=schema.BibleBookContentResponse,
- tags=["Bible"]
-)
-async def get_bible_book_content(
- resource_id: int,
- book_code: str,
- output_format: str,
- db_session: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)
-):
- """Get full content of a book in specified format (json/usfm)"""
- validate_all_roles(session)
- _, _ = await ensure_user_from_session_async(db_session, session)
-
- if output_format.lower() not in ["json", "usfm"]:
- raise BadRequestException("Format must be 'json' or 'usfm'")
-
- return crud.get_bible_book_content(
- db_session=db_session,
- resource_id=resource_id,
- book_code=book_code,
- output_format=output_format
- )
-
-@router.get(
- "/bible/{resource_id}/chapter/{book_code}.{chapter}",
- response_model=schema.BibleChapterResponse,
- tags=["Bible"]
-)
-async def get_bible_chapter(
- resource_id: int,
- book_code: str,
- chapter: int,
- db_session: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)
-):
- """Get chapter content from bible table"""
- validate_all_roles(session)
- _, _ = await ensure_user_from_session_async(db_session, session)
-
- return crud.get_bible_chapter(
- db_session=db_session,
- resource_id=resource_id,
- book_code=book_code,
- chapter=chapter
- )
-
-@router.get(
- "/bible/{resource_id}/cleaned/chapter/{book_code}.{chapter}",
- response_model=schema.CleanBibleChapterResponse,
- tags=["Bible"]
-)
-async def get_clean_bible_chapter(
- resource_id: int,
- book_code: str,
- chapter: int,
- db_session: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)
-):
- """Get cleaned chapter content from clean_bible table"""
- validate_all_roles(session)
- _, _ = await ensure_user_from_session_async(db_session, session)
-
- return crud.get_clean_bible_chapter(
- db_session=db_session,
- resource_id=resource_id,
- book_code=book_code,
- chapter=chapter
- )
-
-async def get_bible_verse_params(
- resource_id: int,
- book_code: str,
- chapter: int,
- verse: int
-) -> BibleVersePathParams:
- """Get specific verse content"""
- return BibleVersePathParams(
- resource_id=resource_id,
- book_code=book_code,
- chapter=chapter,
- verse=verse,
- )
-@router.get(
- "/bible/{resource_id}/verse/{book_code}.{chapter}.{verse}",
- response_model=schema.BibleVerseResponse,
- tags=["Bible"]
-)
-async def get_bible_verse(
- params: schema.BibleVersePathParams = Depends(get_bible_verse_params),
- db_session: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data),
-):
- """Get specific verse content"""
-
- validate_all_roles(session)
- _, _ = await ensure_user_from_session_async(db_session, session)
-
- return crud.get_bible_verse(
- db_session=db_session,
- resource_id=params.resource_id,
- book_code=params.book_code,
- chapter=params.chapter,
- verse=params.verse,
- )
-
-# --- Video Endpoints ---
-
-@router.post("/videos", tags=["Video"], response_model=schema.VideoBulkCreateResponse)
-async def create_videos(
- data: schema.VideoBulkCreate,
- db: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)
-):
- """Create videos"""
- validate_admin_editor(session)
- actor_id, _ = await ensure_user_from_session_async(db, session)
- all_errors = []
- for idx, vid in enumerate(data.videos):
- errs = crud.validate_video_item(db, vid)
- if errs:
- all_errors.append({
- "index": idx,
- "data": vid.model_dump(),
- "errors": errs
- })
- if all_errors:
- raise UnprocessableException(
- detail=(
- {
- "code": "VALIDATION_ERROR",
- "message": "Invalid video entries",
- "errors": all_errors,
- }
- )
- )
- return crud.create_videos(db, data, actor_user_id=actor_id)
-
-@router.put("/videos", tags=["Video"], response_model=schema.VideoBulkCreateResponse)
-async def update_videos(
- data: schema.VideoBulkUpdate,
- db: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)
-):
- """Update videos"""
- validate_admin_editor(session)
- actor_id, _ = await ensure_user_from_session_async(db, session)
- all_errors = []
- for idx, vid in enumerate(data.videos):
- errs = crud.validate_video_item(db, vid)
- if errs:
- all_errors.append({
- "index": idx,
- "data": vid.model_dump(),
- "errors": errs
- })
-
- if all_errors:
- raise UnprocessableException(
- detail=(
- {
- "code": "VALIDATION_ERROR",
- "message": "Invalid video entries",
- "errors": all_errors
- }
- )
- )
- return crud.update_videos(db, data, actor_user_id=actor_id)
-
-@router.get("/videos", tags=["Video"], response_model=schema.VideoGetOut)
-async def get_videos(
- resource_id: Optional[int] = None,
- book_code: Optional[str] = None,
- chapter: Optional[int] = None,
- db: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)
-):
- """Get videos filtered by resource_id, book_code, and chapter"""
- validate_all_roles(session)
- _, _ = await ensure_user_from_session_async(db, session)
- return crud.get_videos_filtered(
- db=db,
- resource_id=resource_id,
- book_code=book_code,
- chapter=chapter
- )
-
-
-@router.delete("/videos/{resource_id}", tags=["Video"], response_model=schema.VideoBulkDeleteResponse)
-async def delete_videos(
- resource_id: int,
- data: schema.VideoBulkDelete,
- response: Response,
- db: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data),
-):
- """Delete multiple videos"""
- validate_admin_editor(session)
- _, _ = await ensure_user_from_session_async(db, session)
-
- result = crud.delete_videos(db, resource_id, data.video_id)
-
- # Set appropriate status code
- if result["all_failed"]:
- response.status_code = 404 # All videos not found
- elif result["has_errors"]:
- response.status_code = 207 # Partial success (Multi-Status)
- else:
- response.status_code = 200 # All successful
-
- return result["data"]
-
-# ---Commentary Endpoints ---
-
-# --- POST ---
-@router.post(
- "/commentary",
- tags=["Commentary"],
- response_model=schema.CommentaryCreateResponse,
- openapi_extra={
- "requestBody": {
- "content": {
- "application/json": {
- "schema": {
- "type": "object",
- "properties": {
- "resource_id": {"type": "integer", "example": 0},
- "commentary": {
- "type": "array",
- "items": {
- "type": "object",
- "properties": {
- "book_id": {"type": "integer", "example": 0},
- "chapter": {"type": "integer", "example": 0},
- "verse": {
- "type": "string",
- "description": "Must be a positive integer or a range like '4-25'"
- },
- "text": {
- "type": "string",
- "example": "Commentary text here
"
- }
- },
- "required": ["book_id", "chapter", "verse", "text"]
- }
- }
- },
- "required": ["resource_id", "commentary"],
- "example": {
- "resource_id": 0,
- "commentary": [
- {
- "book_id": 0,
- "chapter": 0,
- "verse": "string",
- "text": "Sample commentary text
"
- }
- ]
- }
- }
- }
- },
- "required": True
- }
- }
-)
-async def create_commentary(
- request: Request,
- session: SessionContainer = Depends(verify_session()),
-):
- """
- Create commentary
-
- Requires admin or editor role. Authorization is checked before request validation.
-
- The verse field must be either:
- - A positive integer (e.g., "5")
- - A range (e.g., "4-25")
- """
-
- # Call the AuthFirstBody dependency manually
- auth_body = schema.AuthFirstBody(schema.CommentaryBulkCreate)
- payload, actor_id, db = await auth_body(request, session)
-
- try:
- # Validate the payload content
- for item in payload.commentary:
- crud.validate_html(item.text)
- crud.validate_commentary_book_and_chapter(db, item.book_id, item.chapter)
-
- result = crud.create_commentaries(db, payload, actor_user_id=actor_id)
- return result
- finally:
- db.close()
-
-# --- PUT ---
-@router.put(
- "/commentary",
- tags=["Commentary"],
- response_model=schema.CommentaryUpdateResponse,
- openapi_extra={
- "requestBody": {
- "content": {
- "application/json": {
- "schema": {
- "type": "object",
- "properties": {
- "resource_id": {"type": "integer"},
- "commentary": {
- "type": "array",
- "items": {
- "type": "object",
- "properties": {
- "commentary_id": {"type": "integer"},
- "book_id": {"type": "integer"},
- "chapter": {"type": "integer"},
- "verse": {"type": "string"},
- "text": {"type": "string"}
- },
- "required": ["commentary_id", "book_id", "chapter", "verse", "text"]
- }
- }
- },
- "required": ["commentary"]
- }
- }
- },
- "required": True
- }
- }
-)
-async def update_commentary(
- request: Request,
- session: SessionContainer = Depends(verify_session()),
-):
- """
- Update commentary
-
- Requires admin or editor role. Authorization is checked before request validation.
- """
-
- # Call the AuthFirstBody dependency manually
- auth_body = schema.AuthFirstBody(schema.CommentaryBulkUpdate)
- payload, actor_id, db = await auth_body(request, session)
-
- try:
- # Validate the payload content
- for item in payload.commentary:
- crud.validate_html(item.text)
- crud.validate_commentary_book_and_chapter(db, item.book_id, item.chapter)
-
- result = crud.update_commentaries(db, payload, actor_user_id=actor_id)
- return result
- finally:
- db.close()
-
-# --- GET (full content for a resource) ---
-@router.get("/commentary/{resource_id}",tags=["Commentary"])
-async def get_full(
- resource_id: int = Path(..., ge=1),
- db: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)
-):
- """Get full commentary"""
- validate_all_roles(session)
- _, _ = await ensure_user_from_session_async(db, session)
- return crud.get_full_commentary(db, resource_id)
-
-# --- GET (full content of a chapter; supports path like book_code.chapter) ---
-@router.get("/commentary/{resource_id}/chapter/{book_code}.{chapter}",tags=["Commentary"])
-async def get_chapter(
- resource_id: int = Path(..., ge=1),
- book_code: str = Path(..., description="Book code, e.g., 'mat'"),
- chapter: int = Path(..., ge=0, description="Chapter number (0 allowed)"),
- db: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)
-):
- """Get chapter commentary"""
- validate_all_roles(session)
- _, _ = await ensure_user_from_session_async(db, session)
- # 1) Resolve book_code -> bookId (404 if unknown code entirely)
- book = (
- db.query(db_models.BookLookup)
- .filter(db_models.BookLookup.book_code.ilike(book_code.strip()))
- .first()
- )
- if not book:
- raise NotAvailableException(detail=f"Book with code '{book_code}' not found")
-
- # 2) Ensure this resource has ANY commentary for that book
- #(book-level existence in commentary data)
- has_book_commentary = (
- db.query(db_models.Commentary.commentary_id)
- .filter(
- db_models.Commentary.resource_id == resource_id,
- db_models.Commentary.book_id == book.book_id,
- )
- .first()
- )
- if not has_book_commentary:
- raise NotAvailableException(
- detail=f"No commentary found for book_code '{book_code}' in resource {resource_id}"
- )
-
- # 3) Ensure this chapter exists in commentary data for that book & resource
- has_chapter_commentary = (
- db.query(db_models.Commentary.commentary_id)
- .filter(
- db_models.Commentary.resource_id == resource_id,
- db_models.Commentary.book_id == book.book_id,
- db_models.Commentary.chapter == chapter,
- )
- .first()
- )
- if not has_chapter_commentary:
- raise NotAvailableException(
- detail=(
- f"Chapter {chapter} not found in commentary for book_code '"
- f"{book_code}' (resource {resource_id})"
-
- )
- )
-
- # 4) Return the chapter payload (this includes the book intro if present)
- return crud.get_commentary_chapter(db, resource_id, book.book_code, chapter)
-
-# --- DELETE by commentary_id ---
-@router.delete(
- "/commentary/bulk-delete",
- tags=["Commentary"],
- response_model=schema.CommentaryBulkDeleteResponse
-)
-async def delete_commentary_bulk(
- request: schema.CommentaryBulkDelete,
- db: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)
-):
- logger.info("DELETE BULK Commentary API")
-
- validate_admin_editor(session)
- _, _ = await ensure_user_from_session_async(db, session)
-
- deleted_ids, errors = crud.delete_commentary_bulk(db, request.commentary_ids)
-
- # --- Status Code Rules ---
- if len(deleted_ids) == 0 and errors: # All failed → 404
- status_code = 404
- elif errors: # Partial success → 207
- status_code = 207
- else: # All succeeded → 200
- status_code = 200
-
- return JSONResponse(
- status_code=status_code,
- content={
- "deletedCount": len(deleted_ids),
- "deletedIds": deleted_ids,
- "errors": errors if errors else None,
- "message": f"Successfully deleted {len(deleted_ids)} commentary(s)"
- }
- )
-
-
-
-
-# ---Dictionary Endpoints ---
-
-@router.post('/dictionary',
- response_model=schema.DictionaryCreateResponse,
- status_code=201, tags=["Dictionary"])
-async def add_dictionary_words(
- dictionary_data: schema.DictionaryCreate = Body(...),
- db_: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data )
- ):
- '''Uploads dictionary words and their details.
- Returns all dictionary words with complete details'''
- logger.info('In add_dictionary_words')
- logger.debug(
- 'resource_id: %s, dictionary_words: %s',
- dictionary_data.resource_id,
- dictionary_data.dictionary
- )
- validate_admin_editor(session)
- actor_id, _ = await ensure_user_from_session_async(db_, session)
- result_data = crud.upload_dictionary_words(
- db_=db_,
- resource_id=dictionary_data.resource_id,
- dictionary_words=dictionary_data.dictionary,
- actor_id=actor_id
- )
- dictionary_content = result_data['db_content']
-
- return {
- 'message': "Dictionary words added successfully",
- 'data': dictionary_content
- }
-
-
-@router.put('/dictionary',
- response_model=schema.DictionaryUpdateResponse,
- status_code=200, tags=["Dictionary"])
-async def edit_dictionary_words(
- dictionary_data: schema.DictionaryUpdate = Body(...),
- db_: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data )
- ):
- '''Updates dictionary words using wordId.
- All fields except wordId can be updated.'''
- logger.info('In edit_dictionary_words')
- logger.debug(
- 'resource_id: %s, dictionary_words: %s',
- dictionary_data.resource_id,
- dictionary_data.dictionary
- )
- validate_admin_editor(session)
- actor_id, _ = await ensure_user_from_session_async(db_, session)
- result_data = crud.update_dictionary_words(
- db_=db_,
- resource_id=dictionary_data.resource_id,
- dictionary_words=dictionary_data.dictionary,
- actor_id=actor_id
- )
- dictionary_content = result_data['db_content']
-
- return {
- 'message': "Dictionary words updated successfully",
- 'data': dictionary_content
- }
-
-
-@router.get(
- "/dictionary/{resource_id}",
- response_model=schema.DictionaryFullResponse,
- status_code=200,
- tags=["Dictionary"],
-)
-async def get_dictionary_full_content(
- resource_id: int = Path(..., examples=1),
- params: schema.DictionaryQueryParams = Depends(),
- db_: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data),
-):
- """Fetches full content of a dictionary by resource_id.
- Returns all dictionary words with complete details."""
-
- logger.info("In get_dictionary_full_content")
- logger.debug(
- "resource_id: %s, skip: %s, limit: %s",
- resource_id,
- params.skip,
- params.limit,
- )
-
- validate_all_roles(session)
- _, _ = await ensure_user_from_session_async(db_, session)
-
- response = crud.get_dictionary_words(
- db_,
- resource_id=resource_id,
- skip=params.skip,
- limit=params.limit
- #actor_id=actor_id
- )
-
- return {
- "resourceId": resource_id,
- "content": response["content"],
- }
-
-@router.get('/dictionary/{resource_id}/index',
- response_model=schema.DictionaryIndexResponse,
- status_code=200, tags=["Dictionary"])
-async def get_dictionary_index(
- resource_id: int = Path(..., examples=1),
- db_: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data )
- ):
- '''Fetches index of a dictionary grouped by first letter.
- Returns wordId and keyword organized by starting letter.'''
- logger.info('In get_dictionary_index')
- logger.debug('resource_id: %s', resource_id)
- validate_all_roles(session)
- _, _ = await ensure_user_from_session_async(db_, session)
- response = crud.get_dictionary_index(
- db_,
- resource_id=resource_id
- )
-
- return {
- 'resourceId': resource_id,
- 'index': response['index']
- }
-
-
-@router.get('/dictionary/{resource_id}/word/{word_id}',
- response_model=schema.DictionaryWordDetailResponse,
- status_code=200, tags=["Dictionary"])
-async def get_dictionary_word(
- resource_id: int = Path(..., examples=1),
- word_id: int = Path(..., examples=100001),
- db_: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data )
- ):
- '''Fetches details of a specific dictionary word by wordId.'''
- logger.info('In get_dictionary_word')
- logger.debug('resource_id: %s, word_id: %s', resource_id, word_id)
- validate_all_roles(session)
- _, _ = await ensure_user_from_session_async(db_, session)
- response = crud.get_dictionary_word_by_id(
- db_,
- resource_id=resource_id,
- word_id=word_id
- )
-
- if not response['db_content']:
- raise NotAvailableException(
- detail=f"Word id {word_id} not found in resource {resource_id}"
- )
-
- word_data = response['db_content']
- return {
- 'resourceId': resource_id,
- 'wordId': word_data.word_id,
- 'keyword': word_data.keyword,
- 'wordForms': word_data.word_forms,
- 'strongs': word_data.strongs,
- 'definition': word_data.definition,
- 'translationHelp': word_data.translation_help,
- 'seeAlso': word_data.see_also,
- 'ref': word_data.ref,
- 'examples': word_data.examples
- }
-
-
-@router.delete(
- "/dictionary/{resource_id}/word",
- response_model=schema.DictionaryDeleteResponse,
- tags=["Dictionary"]
-)
-async def delete_dictionary_words(
- resource_id: int = Path(..., examples=1),
- delete_request: schema.DictionaryDeleteRequest = Body(...),
- db_: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)
-):
- """Delete multiple dictionary words by their wordIds."""
- logger.info('In delete_dictionary_words')
- validate_admin_editor(session)
- _, _ = await ensure_user_from_session_async(db_, session)
-
- result = crud.delete_dictionary_words(
- db_=db_,
- resource_id=resource_id,
- word_ids=delete_request.wordIds
- )
-
- deleted = result["deleted_ids"]
- has_errors = result["has_errors"]
-
- # -------------------------
- # Status Code Logic
- # -------------------------
- if len(deleted) == 0 and has_errors:
- status_code = 404 # All invalid
- elif has_errors:
- status_code = 207 # Partial success
- else:
- status_code = 200 # Full success
-
- return JSONResponse(
- status_code=status_code,
- content={
- "message": result["message"],
- "deletedCount": result["deleted_count"],
- "deletedIds": result["deleted_ids"],
- "error": result.get("error"),
- }
- )
-
-
-# --- AudioBible Endpoints ---
-
-
-@router.post("/audio-bible", tags=["Audio Bible"], response_model=schema.AudioBibleOut)
-async def create_audio_bible(data: schema.AudioBibleCreate, db: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)):
- """Create a new audio bible entry"""
- validate_admin_editor(session)
- actor_id, _ = await ensure_user_from_session_async(db, session)
- errors = crud.validate_audio_bible_books(db, data.books)
- if errors:
- raise UnprocessableException(
- detail={"code": "VALIDATION_ERROR", "errors": errors}
- )
- return crud.create_audio_bible(db, data, actor_user_id=actor_id)
-
-
-@router.get(
- "/audio-bible",
- tags=["Audio Bible"],
- response_model=List[schema.AudioBibleListItem]
-)
-async def list_audio_bibles(
- params: schema.AudioBibleQueryParams = Depends(),
- db: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)
-):
- """
- List all audio bibles with optional filtering and pagination.
-
- - resource_id: Optional filter for specific audio bible
- - limit: Max items to return
- - offset: Pagination offset
- - files_missing: Filter by missing/available files
- - test_date: Return audio bibles tested on/after the timestamp
- """
- validate_all_roles(session)
- _, _ = await ensure_user_from_session_async(db, session)
-
- return crud.list_audio_bibles(
- db=db,
- resource_id=params.resource_id,
- limit=params.limit,
- offset=params.offset,
- files_missing=params.files_missing,
- test_date=params.test_date
- )
-
-
-@router.put("/audio-bible/{resource_id}", tags=["Audio Bible"], response_model=schema.AudioBibleOut)
-async def update_audio_bible(
- resource_id: int,
- update_data: schema.AudioBibleUpdate,
- db: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)
-):
- """Update an existing audio bible"""
- validate_admin_editor(session)
- actor_id,_ = await ensure_user_from_session_async(db, session)
- result = crud.update_audio_bible(db, resource_id, update_data, actor_user_id=actor_id)
- if not result:
- raise NotAvailableException(detail="Audio Bible not found")
- if update_data.books is not None:
- errors = crud.validate_audio_bible_books(db, update_data.books)
- if errors:
- raise UnprocessableException(
- detail={"code": "VALIDATION_ERROR", "errors": errors}
- )
- return result
-
-
-@router.delete("/audio-bible/{resource_id}", tags=["Audio Bible"])
-async def delete_audio_bible(resource_id: int, db: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)):
- """Delete an audio bible"""
- validate_admin_editor(session)
- _, _ = await ensure_user_from_session_async(db, session)
- result = crud.delete_audio_bible(db, resource_id)
- if not result:
- raise NotAvailableException(detail="Audio Bible not found")
- return {"detail": f"Audio Bible deleted successfully for resource_id {resource_id}"}
-# @router.delete(
-# "/versions/bulk-delete",
-# tags=["Version"],
-# response_model=schema.VersionBulkDeleteResponse
-# )
-# async def delete_versions_bulk(
-# request: schema.VersionBulkDelete,
-# db_session: Session = Depends(get_db),
-# session: SessionContainer = Depends(verify_session_data)
-# ):
-# logger.info("DELETE BULK Version API")
-
-# validate_admin_only(session)
-# _, _ = await ensure_user_from_session_async(db_session, session)
-
-# result = crud.delete_versions_bulk(db_session, request.version_ids)
-
-# deleted_count = result["data"]["deletedCount"]
-# errors = result["data"]["errors"]
-
-# # ---- Status Code Logic ----
-# if result["all_failed"]:
-# status_code = 404
-# elif result["has_errors"]:
-# status_code = 207
-# else:
-# status_code = 200
-
-# message = (
-# f"Successfully deleted {deleted_count} version(s)"
-# if deleted_count > 0
-# else "No versions were deleted"
-# )
-
-# response_data = {
-# **result["data"],
-# "message": message
-# }
-
-# return JSONResponse(
-# status_code=status_code,
-# content=response_data
-# )
-
-
-
-# # --- Language Endpoints ---
-
-# @router.get(
-# "/language",
-# response_model=schema.LanguageResponse,
-# tags=["Language"]
-# )
-# async def get_languages(
-# params: schema.LanguageQueryParams = Depends(),
-# db_session: Session = Depends(get_db),
-# session: SessionContainer = Depends(verify_session_data)
-# ):
-# """Get languages with pagination and optional filtering."""
-# logger.info("GET Languages API")
-
-# validate_admin_only(session)
-# _, _roles = await ensure_user_from_session_async(db_session, session)
-
-# languages, total_items = crud.get_languages_with_pagination(
-# db_session=db_session,
-# page=params.page,
-# page_size=params.page_size,
-# language_name=params.language_name,
-# language_code=params.language_code,
-# )
-
-# if (params.language_name or params.language_code) and total_items == 0:
-# logger.error("Language ID or Language doesn't exist")
-# raise NotAvailableException(detail="Language ID or Language doesn't exist")
-
-# language_items = [
-# schema.LanguageResponseItem(
-# language_id=lang.language_id,
-# language_name=lang.language_name,
-# language_code=lang.language_code,
-# metadata=lang.meta_data
-# )
-# for lang in languages
-# ]
-
-# return schema.LanguageResponse(
-# total_items=total_items,
-# current_page=params.page,
-# items=language_items
-# )
-
-# @router.post(
-# "/language",
-# response_model=schema.LanguageResponseItem,
-# tags=["Language"]
-# )
-# async def create_language(
-# lang: schema.LanguageCreate,
-# db_session: Session = Depends(get_db),
-# session: SessionContainer = Depends(verify_session_data)
-# ):
-# """Create a new language."""
-# logger.info("POST Languages API")
-# validate_admin_only(session)
-# _, _ = await ensure_user_from_session_async(db_session, session)
-# db_obj = crud.create_language(db_session, lang)
-
-# return schema.LanguageResponseItem(
-# language_id=db_obj.language_id,
-# language_name=db_obj.language_name,
-# language_code=db_obj.language_code,
-# metadata=db_obj.meta_data
-# )
-
-# @router.put(
-# "/language/{language_id}",
-# response_model=schema.LanguageResponseItem,
-# tags=["Language"]
-# )
-# async def update_language(
-# language_id: int,
-# lang: schema.LanguageUpdate,
-# db_session: Session = Depends(get_db),
-# session: SessionContainer = Depends(verify_session_data),
-# ):
-# """Update an existing language."""
-# logger.info("PUT Languages API")
-# validate_admin_only(session)
-# _, _ = await ensure_user_from_session_async(db_session, session)
-# # Check if language exists before attempting to update
-# language_obj = crud.get_language(db_session, language_id)
-# if not language_obj:
-# logger.error("Language ID or Language doesn't exist")
-# raise NotAvailableException(detail="Language ID or Language doesn't exist")
-
-# db_obj = crud.update_language(db_session, language_id, lang)
-
-# return schema.LanguageResponseItem(
-# language_id=db_obj.language_id,
-# language_name=db_obj.language_name,
-# language_code=db_obj.language_code,
-# metadata=db_obj.meta_data
-# )
-
-# @router.delete(
-# "/languages/bulk-delete",
-# tags=["Language"],
-# response_model=schema.LanguageBulkDeleteResponse
-# )
-# async def delete_languages_bulk(
-# request: schema.LanguageBulkDelete,
-# db_session: Session = Depends(get_db),
-# session: SessionContainer = Depends(verify_session_data)
-# ):
-# logger.info("DELETE BULK Language API")
-
-# validate_admin_only(session)
-# _, _ = await ensure_user_from_session_async(db_session, session)
-
-# result = crud.delete_languages_bulk(db_session, request.language_ids)
-
-# deleted_count = result["data"]["deletedCount"]
-# errors = result["data"]["errors"]
-
-# # ---- Status logic (same as videos & versions) ----
-# if result["all_failed"]:
-# status_code = 404
-# elif result["has_errors"]:
-# status_code = 207
-# else:
-# status_code = 200
-
-# # Add message
-# message = (
-# f"Successfully deleted {deleted_count} language(s)"
-# if deleted_count > 0
-# else "No languages were deleted"
-# )
-
-# response_data = {
-# **result["data"],
-# "message": message
-# }
-
-# return JSONResponse(
-# status_code=status_code,
-# content=response_data
-# )
-
-# # --- License Endpoints ---
-# @router.get(
-# "/license",
-# response_model=List[schema.LicenseResponseItem],
-# tags=["License"]
-# )
-# async def get_licenses(
-# license_id: Optional[int] = Query(None, description="Filter by license ID"),
-# name: Optional[str] = Query(None, description="Filter by license name (partial match)"),
-# db_session: Session = Depends(get_db),
-# session: SessionContainer = Depends(verify_session_data)
-# ):
-# """Get licenses with optional filtering."""
-# logger.info("GET License API")
-# validate_admin_only(session)
-# _, _ = await ensure_user_from_session_async(db_session, session)
-# licenses = crud.get_licenses_with_filters(
-# db_session=db_session,
-# license_id=license_id,
-# name=name
-# )
-
-# # Transform to response format
-# return [schema.LicenseResponseItem.model_validate(license) for license in licenses]
-
-# @router.post(
-# "/license",
-# response_model=schema.LicenseResponseItem,
-# tags=["License"]
-# )
-# async def create_license(
-# license_: schema.LicenseCreate,
-# db_session: Session = Depends(get_db),
-# session: SessionContainer = Depends(verify_session_data)
-# ):
-# """Create a new license."""
-# logger.info("POST License API")
-# validate_admin_only(session)
-# _, _ = await ensure_user_from_session_async(db_session, session)
-# db_obj = crud.create_license(db_session, license_)
-
-# return schema.LicenseResponseItem.model_validate(db_obj)
-
-# @router.put(
-# "/license/{license_id}",
-# response_model=schema.LicenseResponseItem,
-# tags=["License"]
-# )
-# async def update_license(
-# license_id: int,
-# license_: schema.LicenseUpdate,
-# db_session: Session = Depends(get_db),
-# session: SessionContainer = Depends(verify_session_data)
-# ):
-# """Update an existing license."""
-# logger.info("PUT License API")
-# validate_admin_only(session)
-# _, _ = await ensure_user_from_session_async(db_session, session)
-# # Check if license exists before attempting to update
-# license_obj = crud.get_license(db_session, license_id)
-# if not license_obj:
-# logger.error("License ID doesn't exist")
-# raise NotAvailableException(detail="License ID doesn't exist")
-
-# db_obj = crud.update_license(db_session, license_id, license_)
-
-# return schema.LicenseResponseItem.model_validate(db_obj)
-
-# @router.delete(
-# "/license/bulk-delete",
-# tags=["License"],
-# response_model=schema.LicenseBulkDeleteResponse
-# )
-# async def delete_licenses_bulk(
-# request: schema.LicenseBulkDelete,
-# db_session: Session = Depends(get_db),
-# session: SessionContainer = Depends(verify_session_data)
-# ):
-# logger.info("DELETE BULK License API")
-
-# validate_admin_only(session)
-# _, _ = await ensure_user_from_session_async(db_session, session)
-
-# result = crud.delete_licenses_bulk(db_session, request.license_ids)
-
-# deleted_count = result["data"]["deletedCount"]
-# errors = result["data"]["errors"]
-
-# # ---- Status code logic ----
-# if result["all_failed"]:
-# status_code = 404
-# elif result["has_errors"]:
-# status_code = 207
-# else:
-# status_code = 200
-
-# # ---- Message ----
-# message = (
-# f"Successfully deleted {deleted_count} license(s)"
-# if deleted_count > 0
-# else "No licenses were deleted"
-# )
-
-# response_data = {
-# **result["data"],
-# "message": message
-# }
-
-# return JSONResponse(
-# status_code=status_code,
-# content=response_data
-# )
-
-
-# # --- Resource Endpoints ---
-# @router.get(
-# "/resources",
-# response_model=List[schema.LanguageGroupOut],
-# tags=["Resource"]
-# )
-# async def list_resources_route(
-# params: schema.ResourceQueryParams = Depends(),
-# db: Session = Depends(get_db),
-# session: SessionContainer = Depends(verify_session_data),
-# ):
-# """Get resources with pagination and optional filtering."""
-# logger.info("GET Resource API")
-# validate_all_roles(session)
-# _, _ = await ensure_user_from_session_async(db, session)
-
-# filters = schema.ResourceFilter(
-# resource_id=params.resource_id,
-# page=params.page,
-# page_size=params.page_size,
-# published=params.published,
-# content_type=params.content_type.value.lower() if params.content_type else None,
-# )
-
-# return crud.get_resources(db, filters)
-
-# @router.post("/resources", response_model=schema.ResourceResponse, tags=["Resource"])
-# async def create_resource_route(
-# payload: schema.ResourceCreate,
-# db: Session = Depends(get_db),
-# session: SessionContainer = Depends(verify_session_data
-# )
-# ):
-# """ API endpoint to create a new resource."""
-# logger.info("POST Resource API")
-# validate_admin_only(session)
-# user_id, _ = await ensure_user_from_session_async(db, session)
-# return crud.create_resource(db, payload, created_by=user_id)
-
-
-
-# @router.put("/resources", response_model=schema.ResourceResponse, tags=["Resource"])
-# async def update_resource_route(
-# payload: schema.ResourceUpdate,
-# db: Session = Depends(get_db),
-# session: SessionContainer = Depends(verify_session_data)
-# ):
-# """ API endpoint to update a resource."""
-# logger.info("PUT Resource API")
-# validate_admin_only(session)
-# user_id, _ = await ensure_user_from_session_async(db, session)
-# return crud.update_resource(db, payload, user_id=user_id)
-
-# @router.delete(
-# "/resources/bulk-delete",
-# tags=["Resource"],
-# response_model=schema.ResourceBulkDeleteResponse
-# )
-# async def delete_resources_bulk(
-# request: schema.ResourceBulkDelete,
-# db: Session = Depends(get_db),
-# session: SessionContainer = Depends(verify_session_data)
-# ):
-# logger.info("DELETE BULK Resource API")
-
-# validate_admin_only(session)
-# _, _ = await ensure_user_from_session_async(db, session)
-
-# result = crud.delete_resources_bulk(db, request.resource_ids)
-
-# deleted_count = result["data"]["deletedCount"]
-# errors = result["data"]["errors"]
-
-# # ---- Status Code Logic ----
-# if result["all_failed"]:
-# status_code = 404
-# elif result["has_errors"]:
-# status_code = 207
-# else:
-# status_code = 200
-
-# # ---- Message ----
-# message = (
-# f"Successfully deleted {deleted_count} resource(s)"
-# if deleted_count > 0
-# else "No resources were deleted"
-# )
-
-# response_data = {
-# **result["data"],
-# "message": message
-# }
-
-# return JSONResponse(
-# status_code=status_code,
-# content=response_data
-# )
-
-# # @router.post(
-# # "/language",
-# # response_model=schema.LanguageResponseItem,
-# # tags=["Language"]
-# # )
-# # async def create_language(
-# # lang: schema.LanguageCreate,
-# # db_session: Session = Depends(get_db),
-# # session: SessionContainer = Depends(verify_session_data)
-# # ):
-# # """Create a new language."""
-# # logger.info("POST Languages API")
-# # validate_admin_only(session)
-# # _, _ = await ensure_user_from_session_async(db_session, session)
-# # db_obj = crud.create_language(db_session, lang)
-
-# # return schema.LanguageResponseItem(
-# # language_id=db_obj.language_id,
-# # language_name=db_obj.language_name,
-# # language_code=db_obj.language_code,
-# # metadata=db_obj.meta_data
-# # )
-
-# # @router.put(
-# # "/language/{language_id}",
-# # response_model=schema.LanguageResponseItem,
-# # tags=["Language"]
-# # )
-# # async def update_language(
-# # language_id: int,
-# # lang: schema.LanguageUpdate,
-# # db_session: Session = Depends(get_db),
-# # session: SessionContainer = Depends(verify_session_data),
-# # ):
-# # """Update an existing language."""
-# # logger.info("PUT Languages API")
-# # validate_admin_only(session)
-# # _, _ = await ensure_user_from_session_async(db_session, session)
-# # # Check if language exists before attempting to update
-# # language_obj = crud.get_language(db_session, language_id)
-# # if not language_obj:
-# # logger.error("Language ID or Language doesn't exist")
-# # raise NotAvailableException(detail="Language ID or Language doesn't exist")
-
-# # db_obj = crud.update_language(db_session, language_id, lang)
-
-# # return schema.LanguageResponseItem(
-# # language_id=db_obj.language_id,
-# # language_name=db_obj.language_name,
-# # language_code=db_obj.language_code,
-# # metadata=db_obj.meta_data
-# # )
-
-# # @router.delete(
-# # "/languages/bulk-delete",
-# # tags=["Language"],
-# # response_model=schema.LanguageBulkDeleteResponse
-# # )
-# # async def delete_languages_bulk(
-# # request: schema.LanguageBulkDelete,
-# # db_session: Session = Depends(get_db),
-# # session: SessionContainer = Depends(verify_session_data)
-# # ):
-# # logger.info("DELETE BULK Language API")
-
-# # validate_admin_only(session)
-# # _, _ = await ensure_user_from_session_async(db_session, session)
-
-# # result = crud.delete_languages_bulk(db_session, request.language_ids)
-
-# # deleted_count = result["data"]["deletedCount"]
-# # errors = result["data"]["errors"]
-
-# # # ---- Status logic (same as videos & versions) ----
-# # if result["all_failed"]:
-# # status_code = 404
-# # elif result["has_errors"]:
-# # status_code = 207
-# # else:
-# # status_code = 200
-
-# # # Add message
-# # message = (
-# # f"Successfully deleted {deleted_count} language(s)"
-# # if deleted_count > 0
-# # else "No languages were deleted"
-# # )
-
-# # response_data = {
-# # **result["data"],
-# # "message": message
-# # }
-
-# # return JSONResponse(
-# # status_code=status_code,
-# # content=response_data
-# # )
-
-# # # --- License Endpoints ---
-# # @router.get(
-# # "/license",
-# # response_model=List[schema.LicenseResponseItem],
-# # tags=["License"]
-# # )
-# # async def get_licenses(
-# # license_id: Optional[int] = Query(None, description="Filter by license ID"),
-# # name: Optional[str] = Query(None, description="Filter by license name (partial match)"),
-# # db_session: Session = Depends(get_db),
-# # session: SessionContainer = Depends(verify_session_data)
-# # ):
-# # """Get licenses with optional filtering."""
-# # logger.info("GET License API")
-# # validate_admin_only(session)
-# # _, _ = await ensure_user_from_session_async(db_session, session)
-# # licenses = crud.get_licenses_with_filters(
-# # db_session=db_session,
-# # license_id=license_id,
-# # name=name
-# # )
-
-# # # Transform to response format
-# # return [schema.LicenseResponseItem.model_validate(license) for license in licenses]
-
-# # @router.post(
-# # "/license",
-# # response_model=schema.LicenseResponseItem,
-# # tags=["License"]
-# # )
-# # async def create_license(
-# # license_: schema.LicenseCreate,
-# # db_session: Session = Depends(get_db),
-# # session: SessionContainer = Depends(verify_session_data)
-# # ):
-# # """Create a new license."""
-# # logger.info("POST License API")
-# # validate_admin_only(session)
-# # _, _ = await ensure_user_from_session_async(db_session, session)
-# # db_obj = crud.create_license(db_session, license_)
-
-# # return schema.LicenseResponseItem.model_validate(db_obj)
-
-# # @router.put(
-# # "/license/{license_id}",
-# # response_model=schema.LicenseResponseItem,
-# # tags=["License"]
-# # )
-# # async def update_license(
-# # license_id: int,
-# # license_: schema.LicenseUpdate,
-# # db_session: Session = Depends(get_db),
-# # session: SessionContainer = Depends(verify_session_data)
-# # ):
-# # """Update an existing license."""
-# # logger.info("PUT License API")
-# # validate_admin_only(session)
-# # _, _ = await ensure_user_from_session_async(db_session, session)
-# # # Check if license exists before attempting to update
-# # license_obj = crud.get_license(db_session, license_id)
-# # if not license_obj:
-# # logger.error("License ID doesn't exist")
-# # raise NotAvailableException(detail="License ID doesn't exist")
-
-# # db_obj = crud.update_license(db_session, license_id, license_)
-
-# # return schema.LicenseResponseItem.model_validate(db_obj)
-
-# # @router.delete(
-# # "/license/bulk-delete",
-# # tags=["License"],
-# # response_model=schema.LicenseBulkDeleteResponse
-# # )
-# # async def delete_licenses_bulk(
-# # request: schema.LicenseBulkDelete,
-# # db_session: Session = Depends(get_db),
-# # session: SessionContainer = Depends(verify_session_data)
-# # ):
-# # logger.info("DELETE BULK License API")
-
-# # validate_admin_only(session)
-# # _, _ = await ensure_user_from_session_async(db_session, session)
-
-# # result = crud.delete_licenses_bulk(db_session, request.license_ids)
-
-# # deleted_count = result["data"]["deletedCount"]
-# # errors = result["data"]["errors"]
-
-# # # ---- Status code logic ----
-# # if result["all_failed"]:
-# # status_code = 404
-# # elif result["has_errors"]:
-# # status_code = 207
-# # else:
-# # status_code = 200
-
-# @router.post(
-# "/bible",
-# response_model=dict,
-# tags=["Bible"]
-# )
-# async def upload_bible_book(
-# resource_id: int = Form(...),
-# usfm: UploadFile = File(...),
-# db_session: Session = Depends(get_db),
-# session: SessionContainer = Depends(verify_session_data)
-# ):
-# """Upload a new bible book USFM file"""
-# validate_admin_editor(session)
-
-# # Get user ID from session
-# actor_id, _ = await ensure_user_from_session_async(db_session, session)
-# # Validate USFM file before processing
-# validation_result = crud.validate_usfm_file(usfm)
-# if not validation_result["valid"]:
-# raise UnprocessableException(detail=validation_result["error"])
-# return crud.upload_bible_book(
-# db_session=db_session,
-# resource_id=resource_id,
-# usfm_file=usfm,
-# actor_user_id=actor_id
-# )
-
-# @router.put(
-# "/bible",
-# response_model=dict,
-# tags=["Bible"]
-# )
-# async def update_bible_book(
-# bible_book_id: int = Form(...),
-# usfm: UploadFile = File(...),
-# db_session: Session = Depends(get_db),
-# session: SessionContainer = Depends(verify_session_data)
-# ):
-# """Update an existing bible book"""
-# validate_admin_editor(session)
-
-# # Get user ID from session
-# actor_id, _ = await ensure_user_from_session_async(db_session, session)
-# validation_result = crud.validate_usfm_file(usfm)
-# if not validation_result["valid"]:
-# raise UnprocessableException(detail=validation_result["error"])
-# return crud.update_bible_book(
-# db_session=db_session,
-# bible_book_id=bible_book_id,
-# usfm_file=usfm,
-# actor_user_id=actor_id
-# )
-
-# @router.delete(
-# "/bible/{resource_id}/books",
-# response_model=schema.BulkDeleteResponse,
-# response_model_exclude_none=True,
-# tags=["Bible"]
-# )
-# async def delete_bible_books_endpoint(
-# resource_id: int,
-# delete_request: schema.BulkDeleteRequest,
-# db_session: Session = Depends(get_db),
-# session: SessionContainer = Depends(verify_session_data)
-# ):
-# """Bulk delete Bible books by book codes"""
-# validate_admin_editor(session)
-# _, _ = await ensure_user_from_session_async(db_session, session)
-
-# result = crud.delete_bible_books(
-# db_session=db_session,
-# resource_id=resource_id,
-# book_codes=delete_request.bookIds,
-# )
-
-# deleted_count = result["data"]["deletedCount"]
-# errors = result["data"]["errors"]
-
-# # ---- Standard status code logic ----
-# if result["all_failed"]:
-# status_code = 404
-# elif result["has_errors"]:
-# status_code = 207
-# else:
-# status_code = 200
-
-# # ---- Message ----
-# message = (
-# f"Successfully deleted {deleted_count} book(s)"
-# if deleted_count > 0
-# else "No books were deleted"
-# )
-
-# response_data = {
-# **result["data"],
-# "message": message,
-# }
-
-# return JSONResponse(status_code=status_code, content=response_data)
-
-
-
-# # --- Bible Content Retrieval Endpoints ---
-
-# @router.get(
-# "/bible/{resource_id}/books",
-# response_model=schema.BibleBooksListResponse,
-# tags=["Bible"]
-# )
-# async def get_bible_books(
-# resource_id: int,
-# db_session: Session = Depends(get_db),
-# session: SessionContainer = Depends(verify_session_data)
-# ):
-# """Get list of books for a bible resource"""
-# validate_all_roles(session)
-# _, _ = await ensure_user_from_session_async(db_session, session)
-# return crud.get_bible_books(db_session, resource_id)
-
-
-
-# @router.get(
-# "/bible/{resource_id}/content/{output_format}",
-# response_model=schema.BibleFullContentResponse,
-# tags=["Bible"]
-# )
-# async def get_full_bible_content(
-# resource_id: int,
-# output_format: str,
-# db_session: Session = Depends(get_db),
-# session: SessionContainer = Depends(verify_session_data)
-# ):
-# """Get full content of all books in a resource in specified format (json/usfm)"""
-# validate_all_roles(session)
-# _, _ = await ensure_user_from_session_async(db_session, session)
-
-# if output_format.lower() not in ["json", "usfm"]:
-# raise BadRequestException("Format must be 'json' or 'usfm'")
-
-# return crud.get_full_bible_content(
-# db_session=db_session,
-# resource_id=resource_id,
-# output_format=output_format
-# )
-
-
-
-# @router.get(
-# "/bible/{resource_id}/book/{book_code}/{output_format}",
-# response_model=schema.BibleBookContentResponse,
-# tags=["Bible"]
-# )
-# async def get_bible_book_content(
-# resource_id: int,
-# book_code: str,
-# output_format: str,
-# db_session: Session = Depends(get_db),
-# session: SessionContainer = Depends(verify_session_data)
-# ):
-# """Get full content of a book in specified format (json/usfm)"""
-# validate_all_roles(session)
-# _, _ = await ensure_user_from_session_async(db_session, session)
-
-# if output_format.lower() not in ["json", "usfm"]:
-# raise BadRequestException("Format must be 'json' or 'usfm'")
-
-# return crud.get_bible_book_content(
-# db_session=db_session,
-# resource_id=resource_id,
-# book_code=book_code,
-# output_format=output_format
-# )
-
-# @router.get(
-# "/bible/{resource_id}/chapter/{book_code}.{chapter}",
-# response_model=schema.BibleChapterResponse,
-# tags=["Bible"]
-# )
-# async def get_bible_chapter(
-# resource_id: int,
-# book_code: str,
-# chapter: int,
-# db_session: Session = Depends(get_db),
-# session: SessionContainer = Depends(verify_session_data)
-# ):
-# """Get chapter content from bible table"""
-# validate_all_roles(session)
-# _, _ = await ensure_user_from_session_async(db_session, session)
-
-# return crud.get_bible_chapter(
-# db_session=db_session,
-# resource_id=resource_id,
-# book_code=book_code,
-# chapter=chapter
-# )
-
-# @router.get(
-# "/bible/{resource_id}/cleaned/chapter/{book_code}.{chapter}",
-# response_model=schema.CleanBibleChapterResponse,
-# tags=["Bible"]
-# )
-# async def get_clean_bible_chapter(
-# resource_id: int,
-# book_code: str,
-# chapter: int,
-# db_session: Session = Depends(get_db),
-# session: SessionContainer = Depends(verify_session_data)
-# ):
-# """Get cleaned chapter content from clean_bible table"""
-# validate_all_roles(session)
-# _, _ = await ensure_user_from_session_async(db_session, session)
-
-# return crud.get_clean_bible_chapter(
-# db_session=db_session,
-# resource_id=resource_id,
-# book_code=book_code,
-# chapter=chapter
-# )
-
-# async def get_bible_verse_params(
-# resource_id: int,
-# book_code: str,
-# chapter: int,
-# verse: int
-# ) -> BibleVersePathParams:
-# """Get specific verse content"""
-# return BibleVersePathParams(
-# resource_id=resource_id,
-# book_code=book_code,
-# chapter=chapter,
-# verse=verse,
-# )
-# @router.get(
-# "/bible/{resource_id}/verse/{book_code}.{chapter}.{verse}",
-# response_model=schema.BibleVerseResponse,
-# tags=["Bible"]
-# )
-# async def get_bible_verse(
-# params: schema.BibleVersePathParams = Depends(get_bible_verse_params),
-# db_session: Session = Depends(get_db),
-# session: SessionContainer = Depends(verify_session_data),
-# ):
-# """Get specific verse content"""
-
-# validate_all_roles(session)
-# _, _ = await ensure_user_from_session_async(db_session, session)
-
-# return crud.get_bible_verse(
-# db_session=db_session,
-# resource_id=params.resource_id,
-# book_code=params.book_code,
-# chapter=params.chapter,
-# verse=params.verse,
-# )
-
-# # --- Video Endpoints ---
-
-# @router.post("/videos", tags=["Video"], response_model=schema.VideoBulkCreateResponse)
-# async def create_videos(
-# data: schema.VideoBulkCreate,
-# db: Session = Depends(get_db),
-# session: SessionContainer = Depends(verify_session_data)
-# ):
-# """Create videos"""
-# validate_admin_editor(session)
-# actor_id, _ = await ensure_user_from_session_async(db, session)
-# all_errors = []
-# for idx, vid in enumerate(data.videos):
-# errs = crud.validate_video_item(db, vid)
-# if errs:
-# all_errors.append({
-# "index": idx,
-# "data": vid.model_dump(),
-# "errors": errs
-# })
-# if all_errors:
-# raise UnprocessableException(
-# detail=(
-# {
-# "code": "VALIDATION_ERROR",
-# "message": "Invalid video entries",
-# "errors": all_errors,
-# }
-# )
-# )
-# return crud.create_videos(db, data, actor_user_id=actor_id)
-
-# @router.put("/videos", tags=["Video"], response_model=schema.VideoBulkCreateResponse)
-# async def update_videos(
-# data: schema.VideoBulkUpdate,
-# db: Session = Depends(get_db),
-# session: SessionContainer = Depends(verify_session_data)
-# ):
-# """Update videos"""
-# validate_admin_editor(session)
-# actor_id, _ = await ensure_user_from_session_async(db, session)
-# all_errors = []
-# for idx, vid in enumerate(data.videos):
-# errs = crud.validate_video_item(db, vid)
-# if errs:
-# all_errors.append({
-# "index": idx,
-# "data": vid.model_dump(),
-# "errors": errs
-# })
-
-# if all_errors:
-# raise UnprocessableException(
-# detail=(
-# {
-# "code": "VALIDATION_ERROR",
-# "message": "Invalid video entries",
-# "errors": all_errors
-# }
-# )
-# )
-# return crud.update_videos(db, data, actor_user_id=actor_id)
-
-# @router.get("/videos", tags=["Video"], response_model=schema.VideoGetOut)
-# async def get_videos(
-# resource_id: Optional[int] = None,
-# book_code: Optional[str] = None,
-# chapter: Optional[int] = None,
-# db: Session = Depends(get_db),
-# session: SessionContainer = Depends(verify_session_data)
-# ):
-# """Get videos filtered by resource_id, book_code, and chapter"""
-# validate_all_roles(session)
-# _, _ = await ensure_user_from_session_async(db, session)
-# return crud.get_videos_filtered(
-# db=db,
-# resource_id=resource_id,
-# book_code=book_code,
-# chapter=chapter
-# )
-
-
-# @router.delete("/videos/{resource_id}", tags=["Video"], response_model=schema.VideoBulkDeleteResponse)
-# async def delete_videos(
-# resource_id: int,
-# data: schema.VideoBulkDelete,
-# response: Response,
-# db: Session = Depends(get_db),
-# session: SessionContainer = Depends(verify_session_data),
-# ):
-# """Delete multiple videos"""
-# validate_admin_editor(session)
-# _, _ = await ensure_user_from_session_async(db, session)
-
-# result = crud.delete_videos(db, resource_id, data.video_id)
-
-# # Set appropriate status code
-# if result["all_failed"]:
-# response.status_code = 404 # All videos not found
-# elif result["has_errors"]:
-# response.status_code = 207 # Partial success (Multi-Status)
-# else:
-# response.status_code = 200 # All successful
-
-# return result["data"]
-
-# # ---Commentary Endpoints ---
-
-# # --- POST ---
-# @router.post(
-# "/commentary",
-# tags=["Commentary"],
-# response_model=schema.CommentaryCreateResponse,
-# openapi_extra={
-# "requestBody": {
-# "content": {
-# "application/json": {
-# "schema": {
-# "type": "object",
-# "properties": {
-# "resource_id": {"type": "integer", "example": 0},
-# "commentary": {
-# "type": "array",
-# "items": {
-# "type": "object",
-# "properties": {
-# "book_id": {"type": "integer", "example": 0},
-# "chapter": {"type": "integer", "example": 0},
-# "verse": {
-# "type": "string",
-# "description": "Must be a positive integer or a range like '4-25'"
-# },
-# "text": {
-# "type": "string",
-# "example": "Commentary text here
"
-# }
-# },
-# "required": ["book_id", "chapter", "verse", "text"]
-# }
-# }
-# },
-# "required": ["resource_id", "commentary"],
-# "example": {
-# "resource_id": 0,
-# "commentary": [
-# {
-# "book_id": 0,
-# "chapter": 0,
-# "verse": "string",
-# "text": "Sample commentary text
"
-# }
-# ]
-# }
-# }
-# }
-# },
-# "required": True
-# }
-# }
-# )
-# async def create_commentary(
-# request: Request,
-# session: SessionContainer = Depends(verify_session()),
-# ):
-# """
-# Create commentary
-
-# Requires admin or editor role. Authorization is checked before request validation.
-
-# The verse field must be either:
-# - A positive integer (e.g., "5")
-# - A range (e.g., "4-25")
-# """
-
-# # Call the AuthFirstBody dependency manually
-# auth_body = schema.AuthFirstBody(schema.CommentaryBulkCreate)
-# payload, actor_id, db = await auth_body(request, session)
-
-# try:
-# # Validate the payload content
-# for item in payload.commentary:
-# crud.validate_html(item.text)
-# crud.validate_commentary_book_and_chapter(db, item.book_id, item.chapter)
-
-# result = crud.create_commentaries(db, payload, actor_user_id=actor_id)
-# return result
-# finally:
-# db.close()
-
-# # --- PUT ---
-# @router.put(
-# "/commentary",
-# tags=["Commentary"],
-# response_model=schema.CommentaryUpdateResponse,
-# openapi_extra={
-# "requestBody": {
-# "content": {
-# "application/json": {
-# "schema": {
-# "type": "object",
-# "properties": {
-# "resource_id": {"type": "integer"},
-# "commentary": {
-# "type": "array",
-# "items": {
-# "type": "object",
-# "properties": {
-# "commentary_id": {"type": "integer"},
-# "book_id": {"type": "integer"},
-# "chapter": {"type": "integer"},
-# "verse": {"type": "string"},
-# "text": {"type": "string"}
-# },
-# "required": ["commentary_id", "book_id", "chapter", "verse", "text"]
-# }
-# }
-# },
-# "required": ["commentary"]
-# }
-# }
-# },
-# "required": True
-# }
-# }
-# )
-# async def update_commentary(
-# request: Request,
-# session: SessionContainer = Depends(verify_session()),
-# ):
-# """
-# Update commentary
-
-# Requires admin or editor role. Authorization is checked before request validation.
-# """
-
-# # Call the AuthFirstBody dependency manually
-# auth_body = schema.AuthFirstBody(schema.CommentaryBulkUpdate)
-# payload, actor_id, db = await auth_body(request, session)
-
-# try:
-# # Validate the payload content
-# for item in payload.commentary:
-# crud.validate_html(item.text)
-# crud.validate_commentary_book_and_chapter(db, item.book_id, item.chapter)
-
-# result = crud.update_commentaries(db, payload, actor_user_id=actor_id)
-# return result
-# finally:
-# db.close()
-
-# # --- GET (full content for a resource) ---
-# @router.get("/commentary/{resource_id}",tags=["Commentary"])
-# async def get_full(
-# resource_id: int = Path(..., ge=1),
-# db: Session = Depends(get_db),
-# session: SessionContainer = Depends(verify_session_data)
-# ):
-# """Get full commentary"""
-# validate_all_roles(session)
-# _, _ = await ensure_user_from_session_async(db, session)
-# return crud.get_full_commentary(db, resource_id)
-
-# # --- GET (full content of a chapter; supports path like book_code.chapter) ---
-# @router.get("/commentary/{resource_id}/chapter/{book_code}.{chapter}",tags=["Commentary"])
-# async def get_chapter(
-# resource_id: int = Path(..., ge=1),
-# book_code: str = Path(..., description="Book code, e.g., 'mat'"),
-# chapter: int = Path(..., ge=0, description="Chapter number (0 allowed)"),
-# db: Session = Depends(get_db),
-# session: SessionContainer = Depends(verify_session_data)
-# ):
-# """Get chapter commentary"""
-# validate_all_roles(session)
-# _, _ = await ensure_user_from_session_async(db, session)
-# # 1) Resolve book_code -> bookId (404 if unknown code entirely)
-# book = (
-# db.query(db_models.BookLookup)
-# .filter(db_models.BookLookup.book_code.ilike(book_code.strip()))
-# .first()
-# )
-# if not book:
-# raise NotAvailableException(detail=f"Book with code '{book_code}' not found")
-
-# # 2) Ensure this resource has ANY commentary for that book
-# #(book-level existence in commentary data)
-# has_book_commentary = (
-# db.query(db_models.Commentary.commentary_id)
-# .filter(
-# db_models.Commentary.resource_id == resource_id,
-# db_models.Commentary.book_id == book.book_id,
-# )
-# .first()
-# )
-# if not has_book_commentary:
-# raise NotAvailableException(
-# detail=f"No commentary found for book_code '{book_code}' in resource {resource_id}"
-# )
-
-# # 3) Ensure this chapter exists in commentary data for that book & resource
-# has_chapter_commentary = (
-# db.query(db_models.Commentary.commentary_id)
-# .filter(
-# db_models.Commentary.resource_id == resource_id,
-# db_models.Commentary.book_id == book.book_id,
-# db_models.Commentary.chapter == chapter,
-# )
-# .first()
-# )
-# if not has_chapter_commentary:
-# raise NotAvailableException(
-# detail=(
-# f"Chapter {chapter} not found in commentary for book_code '"
-# f"{book_code}' (resource {resource_id})"
-
-# )
-# )
-
-# # 4) Return the chapter payload (this includes the book intro if present)
-# return crud.get_commentary_chapter(db, resource_id, book.book_code, chapter)
-
-# # --- DELETE by commentary_id ---
-# @router.delete(
-# "/commentary/bulk-delete",
-# tags=["Commentary"],
-# response_model=schema.CommentaryBulkDeleteResponse
-# )
-# async def delete_commentary_bulk(
-# request: schema.CommentaryBulkDelete,
-# db: Session = Depends(get_db),
-# session: SessionContainer = Depends(verify_session_data)
-# ):
-# logger.info("DELETE BULK Commentary API")
-
-# # response_data = {
-# # **result["data"],
-# # "message": message
-# # }
-
-# # return JSONResponse(
-# # status_code=status_code,
-# # content=response_data
-# # )
-
-
-# # # --- Resource Endpoints ---
-# # @router.get(
-# # "/resources",
-# # response_model=List[schema.LanguageGroupOut],
-# # tags=["Resource"]
-# # )
-# # async def list_resources_route(
-# # params: schema.ResourceQueryParams = Depends(),
-# # db: Session = Depends(get_db),
-# # session: SessionContainer = Depends(verify_session_data),
-# # ):
-# # """Get resources with pagination and optional filtering."""
-# # logger.info("GET Resource API")
-# # validate_all_roles(session)
-# # _, _ = await ensure_user_from_session_async(db, session)
-
-# # filters = schema.ResourceFilter(
-# # resource_id=params.resource_id,
-# # page=params.page,
-# # page_size=params.page_size,
-# # published=params.published,
-# # content_type=params.content_type.value.lower() if params.content_type else None,
-# # )
-
-# # return crud.get_resources(db, filters)
-
-# # @router.post("/resources", response_model=schema.ResourceResponse, tags=["Resource"])
-# # async def create_resource_route(
-# # payload: schema.ResourceCreate,
-# # db: Session = Depends(get_db),
-# # session: SessionContainer = Depends(verify_session_data
-# # )
-# # ):
-# # """ API endpoint to create a new resource."""
-# # logger.info("POST Resource API")
-# # validate_admin_only(session)
-# # user_id, _ = await ensure_user_from_session_async(db, session)
-# # return crud.create_resource(db, payload, created_by=user_id)
-
-
-
-# # @router.put("/resources", response_model=schema.ResourceResponse, tags=["Resource"])
-# # async def update_resource_route(
-# # payload: schema.ResourceUpdate,
-# # db: Session = Depends(get_db),
-# # session: SessionContainer = Depends(verify_session_data)
-# # ):
-# # """ API endpoint to update a resource."""
-# # logger.info("PUT Resource API")
-# # validate_admin_only(session)
-# # user_id, _ = await ensure_user_from_session_async(db, session)
-# # return crud.update_resource(db, payload, user_id=user_id)
-
-# # @router.delete(
-# # "/resources/bulk-delete",
-# # tags=["Resource"],
-# # response_model=schema.ResourceBulkDeleteResponse
-# # )
-# # async def delete_resources_bulk(
-# # request: schema.ResourceBulkDelete,
-# # db: Session = Depends(get_db),
-# # session: SessionContainer = Depends(verify_session_data)
-# # ):
-# # logger.info("DELETE BULK Resource API")
-
-# # validate_admin_only(session)
-# # _, _ = await ensure_user_from_session_async(db, session)
-
-# # result = crud.delete_resources_bulk(db, request.resource_ids)
-
-# # deleted_count = result["data"]["deletedCount"]
-# # errors = result["data"]["errors"]
-
-# # # ---- Status Code Logic ----
-# # if result["all_failed"]:
-# # status_code = 404
-# # elif result["has_errors"]:
-# # status_code = 207
-# # else:
-# # status_code = 200
-
-# # # ---- Message ----
-# # message = (
-# # f"Successfully deleted {deleted_count} resource(s)"
-# # if deleted_count > 0
-# # else "No resources were deleted"
-# # )
-
-# # response_data = {
-# # **result["data"],
-# # "message": message
-# # }
-
-# # return JSONResponse(
-# # status_code=status_code,
-# # content=response_data
-# # )
-
-
-# # ----Logs Endpoints-------
-
-# # @router.get("/log",tags=["logs"])
-# # def get_latest_log(session: SessionContainer = Depends(verify_session_data)):
-# # """
-# # current/activate log file
-# # """
-# # validate_admin_only(session)
-# # return crud.latest_log_file()
-
-
-
-
-# # @router.get("/log/{log_file_no}",tags=["logs"])
-# # def get_log_by_number(log_file_no: int,
-# # session: SessionContainer = Depends(verify_session_data)):
-# # """
-# # View rotated log files
-# # * The handler keeps up to 10 old files
-# # * vachan_admin_app.log,vachan_admin_app.log.1,vachan_admin_app.log.1 ... vachan_admin_app.log.10
-# # * log_file_no must be 0–10
-# # * current log file no is 0
-# # """
-# # validate_admin_only(session)
-# # return crud.get_logfile_by_number(log_file_no)
-
-
-# # @router.get("/logs",tags=["logs"])
-# # def get_all_logs(session: SessionContainer = Depends(verify_session_data)):
-# # """
-# # get all log files in a zip format
-# # """
-# # validate_admin_only(session)
-# # return crud.get_all_logfiles()
-
-# # # --- Bible Book Management Endpoints ---
-
-# # @router.post(
-# # "/bible",
-# # response_model=dict,
-# # tags=["Bible"]
-# # )
-# # async def upload_bible_book(
-# # resource_id: int = Form(...),
-# # usfm: UploadFile = File(...),
-# # db_session: Session = Depends(get_db),
-# # session: SessionContainer = Depends(verify_session_data)
-# # ):
-# # """Upload a new bible book USFM file"""
-# # validate_admin_editor(session)
-
-# # # Get user ID from session
-# # actor_id, _ = await ensure_user_from_session_async(db_session, session)
-# # # Validate USFM file before processing
-# # validation_result = await crud.validate_usfm_file(usfm)
-# # if not validation_result["valid"]:
-# # raise UnprocessableException(detail=validation_result["error"])
-# # return crud.upload_bible_book(
-# # db_session=db_session,
-# # resource_id=resource_id,
-# # usfm_file=usfm,
-# # actor_user_id=actor_id
-# # )
-
-# # @router.put(
-# # "/bible",
-# # response_model=dict,
-# # tags=["Bible"]
-# # )
-# # async def update_bible_book(
-# # bible_book_id: int = Form(...),
-# # usfm: UploadFile = File(...),
-# # db_session: Session = Depends(get_db),
-# # session: SessionContainer = Depends(verify_session_data)
-# # ):
-# # """Update an existing bible book"""
-# # validate_admin_editor(session)
-
-# # # Get user ID from session
-# # actor_id, _ = await ensure_user_from_session_async(db_session, session)
-# # validation_result = await crud.validate_usfm_file(usfm)
-# # if not validation_result["valid"]:
-# # raise UnprocessableException(detail=validation_result["error"])
-# # return crud.update_bible_book(
-# # db_session=db_session,
-# # bible_book_id=bible_book_id,
-# # usfm_file=usfm,
-# # actor_user_id=actor_id
-# # )
-
-# # @router.delete(
-# # "/bible/{resource_id}/books",
-# # response_model=schema.BulkDeleteResponse,
-# # response_model_exclude_none=True,
-# # tags=["Bible"]
-# # )
-# # async def delete_bible_books_endpoint(
-# # resource_id: int,
-# # delete_request: schema.BulkDeleteRequest,
-# # db_session: Session = Depends(get_db),
-# # session: SessionContainer = Depends(verify_session_data)
-# # ):
-# # """Bulk delete Bible books by book codes"""
-# # validate_admin_editor(session)
-# # _, _ = await ensure_user_from_session_async(db_session, session)
-
-# # result = crud.delete_bible_books(
-# # db_session=db_session,
-# # resource_id=resource_id,
-# # book_codes=delete_request.bookIds,
-# # )
-
-# # deleted_count = result["data"]["deletedCount"]
-# # errors = result["data"]["errors"]
-
-# # # ---- Standard status code logic ----
-# # if result["all_failed"]:
-# # status_code = 404
-# # elif result["has_errors"]:
-# # status_code = 207
-# # else:
-# # status_code = 200
-
-# # # ---- Message ----
-# # message = (
-# # f"Successfully deleted {deleted_count} book(s)"
-# # if deleted_count > 0
-# # else "No books were deleted"
-# # )
-
-# # response_data = {
-# # **result["data"],
-# # "message": message,
-# # }
-
-# # return JSONResponse(status_code=status_code, content=response_data)
-
-
-
-# # # --- Bible Content Retrieval Endpoints ---
-
-# # @router.get(
-# # "/bible/{resource_id}/books",
-# # response_model=schema.BibleBooksListResponse,
-# # tags=["Bible"]
-# # )
-# # async def get_bible_books(
-# # resource_id: int,
-# # db_session: Session = Depends(get_db),
-# # session: SessionContainer = Depends(verify_session_data)
-# # ):
-# # """Get list of books for a bible resource"""
-# # validate_all_roles(session)
-# # _, _ = await ensure_user_from_session_async(db_session, session)
-# # return crud.get_bible_books(db_session, resource_id)
-
-
-# # ===== GET - List of Stories for a Language =====
-# @router.get(
-# "/obs/language/{language_code}",
-# response_model=schema.OBSStoriesListResponse,
-# tags=["OBS"]
-# )
-# def get_obs_stories_for_language(
-# language_code: str = Path(..., description="Language identifier"),
-# page: int = Query(1, ge=1, description="Page number"),
-# limit: int = Query(50, ge=1, le=100, description="Results per page"),
-# db_session: Session = Depends(get_db),
-# session: SessionContainer = Depends(verify_session_data)
-# ):
-# """
-# Get all Open Bible Stories for a specific language with pagination.
-
-# Returns stories without full text content (use story detail endpoint for full content).
-
-# **Authorization:** Admin, Editor, Viewer
-# """
-# logger.info(
-# "GET OBS Stories API - Language: %s, Page: %s, Limit: %s",
-# language_code,
-# page,
-# limit
-# )
-# validate_all_roles(session)
-
-# return crud.get_obs_stories_by_language(db_session, language_code, page, limit)
-
-
-# # ===== GET - Specific Story Details =====
-# @router.get(
-# "/obs/language/{language_code}/story/{story_id}",
-# response_model=schema.OBSStoryDetailResponse,
-# tags=["OBS"]
-# )
-# def get_obs_story_detail(
-# language_code: str = Path(..., description="Language identifier"),
-# story_id: int = Path(..., ge=1, description="Story identifier"),
-# db_session: Session = Depends(get_db),
-# session: SessionContainer = Depends(verify_session_data)
-# ):
-# """
-# Get full details of a specific Open Bible Story.
-
-# Returns complete story information including full text content.
-
-# **Authorization:** Admin, Editor, Viewer
-# """
-# logger.info(
-# "GET OBS Story Detail API - Language: %s, Story: %s",
-# language_code,
-# story_id
-# )
-# validate_all_roles(session)
-
-# return crud.get_obs_story_by_id(db_session, language_code, story_id)
-
-# # ===== PUT - Update Story =====
-# @router.put(
-# "/obs/language/{language_code}/story/{story_id}",
-# response_model=schema.OBSStoryUpdateResponse,
-# tags=["OBS"]
-# )
-# def update_obs_story_endpoint(
-# params: schema.OBSStoryUpdateParams = Depends(),
-# story_data: schema.OBSStoryUpdate = Body(...),
-# db_session: Session = Depends(get_db),
-# session: SessionContainer = Depends(verify_session_data),
-# ):
-# """
-# Update an existing Open Bible Story.
-
-# All fields are optional – only provided fields will be updated.
-
-# **Path Parameters**
-# - `language_code`: Language identifier (from language table)
-# - `story_id`: Story identifier to update
-
-# **Body Fields (optional)**
-# - `resource_id`: New resource ID (must be of type 'obs')
-# - `story_no`: New story number (must be unique within resource)
-# - `title`: New story title (max 255 characters)
-# - `url`: New external URL
-# - `text`: New story content or video description
-
-# **Authorization:** Admin, Editor
-# """
-
-# logger.info(
-# "PUT OBS Story API - Language: %s, Story: %s",
-# params.language_code,
-# params.story_id
-# )
-
-# validate_admin_editor(session)
-
-# return crud.update_obs_story(
-# db_session,
-# params.language_code,
-# params.story_id,
-# story_data
-# )
-
-# # ===== DELETE - Delete Story =====
-
-# @router.delete(
-# "/obs/language/{language_code}/story",
-# tags=["OBS"],
-# response_model=schema.OBSBulkDeleteResponse
-# )
-# def delete_obs_story_endpoint(
-# language_code: str = Path(..., description="Language identifier"),
-# body: schema.OBSBulkDeleteRequest = Body(...),
-# db_session: Session = Depends(get_db),
-# session: SessionContainer = Depends(verify_session_data)
-# ):
-# """
-# Bulk delete OBS stories by story numbers.
-# Body:
-# {
-# "story_nos": [1, 2, 3]
-# }
-# """
-
-# # 1. Validate permissions
-# validate_admin_editor(session)
-
-# # 2. Call CRUD
-# result = crud.delete_obs_story(db_session, language_code, body.story_nos)
-
-# if not result.get("deletedStoryNos"):
-# status = 404
-# elif result.get("invalidStoryNos"):
-# status = 207 # Partial success
-# else:
-# status = 200 # All deleted successfully
-
-# # 4. Return response
-# return JSONResponse(status_code=status, content=result)
-
-
-# ### ENDPOINTS INFOGRAPHICS
-# # ---------- CREATE ----------
-# @router.post("/infographics",
-# response_model=schema.CreateInfographicResponse,
-# status_code=201,
-# tags=["Infographic"]
-# )
-# async def create_infographics(payload:
-# schema.BatchInfographicCreateIn,
-# db: Session = Depends(get_db),
-# session: SessionContainer = Depends(verify_session_data)
-# ):
-# """Create infographics"""
-# validate_admin_editor(session)
-# actor_id, _ = await ensure_user_from_session_async(db, session)
-# created, errors = crud.create_infographic_batch(db, payload, actor_user_id=actor_id)
-
-# if not created["created"] and errors:
-# raise UnprocessableException(
-# detail={
-# "code": "UNPROCESSABLE_ENTITY",
-# "message": "No records created",
-# "errors": errors,
-# },
-# )
-
-# if errors:
-# # Partial success (207-like body)
-# return JSONResponse(
-# status_code=207,
-# content={
-# "status": "partial_success",
-# "data": {
-# "created_count": len(created["created"]),
-# "failed_count": len(errors),
-# "infographics": created["created"],
-# "errors": errors,
-# },
-# })
-
-# return JSONResponse(
-# status_code=201,
-# content={
-# "status": "success",
-# "data": {
-# "created_count": len(created["created"]),
-# "infographics": created["created"],
-# },
-# })
-
-
-# # ---------- LIST ----------
-# @router.get(
-# "/infographics",
-# response_model=schema.ListInfographicResponse,
-# tags=["Infographic"]
-# )
-# async def list_infographics(
-# db: Session = Depends(get_db),
-# params: schema.InfographicListParams = Depends(),
-# session: SessionContainer = Depends(verify_session_data),
-# ):
-# """
-# List infographics with pagination and optional filtering."""
-# validate_all_roles(session)
-# _, _ = await ensure_user_from_session_async(db, session)
-
-# try:
-# data, pagination, _ = crud.list_infographic_items(db, params)
-# except ValueError as e:
-# msg = str(e)
-
-# if msg == "RESOURCE_NOT_FOUND":
-# raise NotAvailableException(
-# detail={
-# "code": "RESOURCE_NOT_FOUND",
-# "message": "resource_id not found"
-# },
-# ) from e
-
-# if msg == "INVALID_RESOURCE_TYPE":
-# raise TypeException(
-# detail={
-# "code": "INVALID_RESOURCE_TYPE",
-# "message": "Only resources with content_type 'infographics' can be listed here",
-# },
-# ) from e
-
-# raise BadRequestException(
-# detail={"code": "INVALID_QUERY_PARAMETER", "message": msg},
-# ) from e
-
-# return {"status": "success", "data": data, "pagination": pagination}
-
-
-# # ---------- GET ONE ----------
-# @router.get(
-# "/infographics/{infographic_id}",
-# response_model=schema.InfographicOut,
-# tags=["Infographic"]
-# )
-# async def get_one_infographic(
-# infographic_id: int,
-# db: Session = Depends(get_db),
-# session: SessionContainer = Depends(verify_session_data),
-# ):
-# """Get one infographic"""
-# validate_all_roles(session)
-# _, _ = await ensure_user_from_session_async(db, session)
-
-# row = crud.get_one_infographics(db, infographic_id)
-# if not row:
-# raise NotAvailableException(
-# detail={"code": "NOT_FOUND", "message": "Infographic not found"},
-# )
-
-# return row
-
-# # ---------- UPDATE ----------
-# @router.put(
-# "/infographics",
-# response_model=schema.UpdateInfographicBatchResponse,
-# tags=["Infographic"],
-# )
-# async def update_one(
-# body: schema.BatchInfographicUpdateIn,
-# db: Session = Depends(get_db),
-# session: SessionContainer = Depends(verify_session_data),
-# ):
-# """Update infographics"""
-# # --- 0. Validate session and get actor ---
-# validate_admin_editor(session)
-# actor_id, _ = await ensure_user_from_session_async(db, session)
-
-# # --- 1. Pre-validate each item (structure + types) ---
-# errors = []
-# for idx, item in enumerate(body.infographics):
-# tmp_obj = schema.InfographicUpdateItem(
-# id=item.id,
-# book_id=item.book_id,
-# title=item.title,
-# file_name=item.file_name,
-# )
-# err = crud.validate_item(idx, tmp_obj)
-# if err:
-# errors.append(err)
-
-# if errors:
-# raise UnprocessableException(
-# detail={
-# "code": "VALIDATION_ERROR",
-# "message": "Validation failed",
-# "errors": errors,
-# },
-# )
-
-# # --- 2. Call CRUD batch update (ctx style handles resource/books internally) ---
-# try:
-# out = crud.update_infographic_batch(db, body, actor_user_id=actor_id)
-# except ValueError as e:
-# msg = str(e)
-# if msg == "RESOURCE_NOT_FOUND":
-# raise NotAvailableException(
-# detail={"code": "RESOURCE_NOT_FOUND", "message": "resource_id not found"},
-# ) from e
-# if msg == "INVALID_RESOURCE_TYPE":
-# raise TypeException(
-# detail={
-# "code": "INVALID_RESOURCE_TYPE",
-# "message": (
-# "Only resources with content_type 'infographics' "
-# "can hold infographic records"
-# ),
-# },
-# ) from e
-# if msg == "INVALID_BOOK":
-# raise UnprocessableException(
-# detail={"code": "VALIDATION_ERROR", "message": "book_id must be between 1 and 66"},
-# ) from e
-# # generic
-# raise UnprocessableException(
-# detail={
-# "code": "VALIDATION_ERROR",
-# "message": msg}
-# )from e
-
-# updated = out.get("updated", [])
-# errs = out.get("errors", [])
-
-# # --- 3. Return proper status code based on outcome ---
-# if not updated and errs:
-# # all failed
-# raise UnprocessableException(
-# detail={
-# "code": "UNPROCESSABLE_ENTITY",
-# "message": "No records updated",
-# "errors": errs,
-# },
-# )
-
-# if updated and errs:
-# # partial success
-# return JSONResponse(
-# status_code=207,
-# content={"updated": updated, "errors": errs},
-# )
-
-# # all succeeded
-# return JSONResponse(status_code=200, content={"updated": updated, "errors": []})
-
-
-
-
-# # ---------- DELETE ----------
-# @router.delete(
-# "/infographics",
-# response_model=schema.BulkInfographicDeleteResponse,
-# tags=["Infographic"],
-# )
-# async def delete_bulk(
-# body: schema.BulkInfographicDeleteIn,
-# db: Session = Depends(get_db),
-# session: SessionContainer = Depends(verify_session_data),
-# ):
-# """Bulk delete infographics"""
-# validate_admin_editor(session)
-# _, _ = await ensure_user_from_session_async(db, session)
-
-# if not body.ids:
-# raise HTTPException(status_code=400, detail="No IDs provided")
-
-# result = crud.delete_bulk_details(db, body.ids)
-
-# # Determine status code
-# if not result["deletedIds"]:
-# status_code = 404 # all invalid
-# elif result.get("invalidIds"):
-# status_code = 207 # partial success
-# else:
-# status_code = 200 # all deleted
-
-# return JSONResponse(status_code=status_code, content=result)
-
-
-# # --- Verse of the Day / Endpoints ---
-# @router.get("/verse_of_the_day",
-# response_model=schema.VerseOfTheDayListResponse,
-# tags=["Verse Of The Day"])
-# async def get_all_verse_of_the_day(db: Session = Depends(get_db),
-# session: SessionContainer = Depends(verify_session_data)):
-# """Get all daily verse references for all years"""
-# validate_all_roles(session)
-# return crud.get_all_verse_of_the_day(db)
-
-# @router.get(
-# "/verse_of_the_day/{year}/{month}/{day}",
-# response_model=schema.VerseOfTheDaySingleResponse,
-# tags=["Verse Of The Day"]
-# )
-# async def get_verse_by_date(year: int, month: int, day: int,
-# db: Session = Depends(get_db),
-# session: SessionContainer = Depends(verify_session_data)):
-# """Get the verse of the day for a specific date"""
-# validate_all_roles(session)
-# return crud.get_verse_for_date(db,year, month, day)
-
-# @router.post(
-# "/verse_of_the_day",
-# response_model=schema.VerseUploadResponse,
-# tags=["Verse Of The Day"]
-# )
-# async def upload_verse_of_the_day(
-# file: UploadFile = File(...),
-# db_session: Session = Depends(get_db),
-# session: SessionContainer = Depends(verify_session_data)
-# ):
-# """
-# Upload a CSV file to replace Verse of the Day table content.
-# Deletes existing entries and adds new ones from CSV.
-# """
-# validate_admin_editor(session)
-# return crud.upload_verse_of_the_day_csv(db_session, file)
-
-# @router.delete("/verse_of_the_day",tags=["Verse Of The Day"])
-# def delete_all_verse_of_the_day(db_session: Session = Depends(get_db),
-# session: SessionContainer = Depends(verify_session_data)):
-# """
-# Delete all entries in verse_of_the_day table.
-# """
-# validate_admin_editor(session)
-# return crud.delete_all_verse_of_the_day(db_session)
-
-# # --- Reading Plan Endpoints ---
-
-# @router.post(
-# "/reading-plans/upload",
-# response_model=schema.ReadingPlanUploadResponse,
-# tags=["Reading Plan"]
-# )
-# async def upload_reading_plans(
-# file: UploadFile = File(..., description="JSON or CSV file containing reading plans"),
-# db_session: Session = Depends(get_db),
-# session: SessionContainer = Depends(verify_session_data)
-# ):
-# """
-# Upload reading plans from JSON or CSV file.
-
-# **File Format:**
-# - JSON: Array of objects with 'date' (MM-DD) and 'reading' (array of reading items)
-# - CSV: Columns 'date' (MM-DD) and 'reading' (JSON string of reading items)
-
-# **Example JSON:**
-# ```json
-# [
-# {
-# "date": "01-01",
-# "reading": [
-# {"ref": "gen 1", "text": "Genesis 1"},
-# {"ref": "mat 1", "text": "Matthew 1"}
-# ]
-# }
-# ]
-# ```
-
-# # **Example CSV:**
-# # ```csv
-# # date,reading
-# # 01-01,"[{""ref"": ""gen 1"", ""text"": ""Genesis 1""}]"
-# # 01-02,"[{""ref"": ""gen 2"", ""text"": ""Genesis 2""}]"
-# # ```
-# """
-# logger.info("POST Reading Plans Upload API")
-# validate_admin_editor(session)
-# _, _ = await ensure_user_from_session_async(db_session, session)
-
-# # Validate file type
-# file_ext = file.filename.lower().split('.')[-1]
-# if file_ext not in ['json', 'csv']:
-# raise TypeException(
-# detail="Invalid file type. Only JSON and CSV files are supported."
-# )
-
-# # Read file content
-# try:
-# file_content = await file.read()
-# except Exception as e:
-# logger.error("Error reading file: %s", str(e))
-# raise BadRequestException(detail="Error reading file") from e
-
-# # Check if file is empty
-# if len(file_content) == 0:
-# raise BadRequestException(detail="Uploaded file is empty")
-
-# # Process file
-# result = crud.upload_reading_plans(
-# db=db_session,
-# file_content=file_content,
-# file_type=file_ext
-# )
-
-# deleted = result.get("deletedIds", [])
-# has_errors = bool(result.get("error"))
-# if len(deleted) == 0 and has_errors:
-# status = 404 # all invalid
-# elif has_errors:
-# status = 207 # partial success
-# else:
-# status = 200 # full success
-
-# return JSONResponse(status_code=status, content=result)
-
-# --- obs Endpoints ---
-
-
-@router.post(
- "/obs",
- response_model=schema.OBSBulkCreateFullResponse,
- status_code=201,
- tags=["OBS"]
-)
-async def create_obs_story_endpoint(
- story_data: schema.OBSBulkCreate = Body(...),
- db: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)
-):
- """
- Create a new Open Bible Story for a specific language.
-
- - **resource_id**: Resource ID (must be of type 'obs')
- - **story_no**: Story number (1-50, must be unique within resource)
- - **title**: Story title (max 255 characters)
- - **url**: External URL for video OBS (optional)
- - **text**: Story content for text OBS or video description for video OBS
-
- **Authorization:** Admin, Editor
- """
- logger.info(
- "POST OBS Story API - Resource_id: %s, Story_nos: %s",
- story_data.resource_id,
- [item.story_no for item in story_data.obs]
- )
- validate_admin_editor(session)
- actor_id,_ = await ensure_user_from_session_async(db, session)
- # CRUD handles everything - validation, creation, and response formatting
- return crud.create_obs_bulk(db, story_data,actor_id)
-
-# # ===== GET - List of Languages with OBS =====
-# @router.get(
-# "/obs",
-# response_model=schema.OBSLanguageListResponse,
-# tags=["OBS"]
-# )
-# def get_obs_languages(
-# db_session: Session = Depends(get_db),
-# session: SessionContainer = Depends(verify_session_data)
-# ):
-# """
-# Get list of all languages that have Open Bible Stories available.
-
-# Returns language ID, name, and story count for each language.
-
-# **Authorization:** Admin, Editor, Viewer
-# """
-# logger.info("GET OBS Languages API")
-# validate_all_roles(session)
-
-# return crud.get_languages_with_obs(db_session)
-
-
-# # ===== GET - List of Stories for a Language =====
-# @router.get(
-# "/obs/language/{language_code}",
-# response_model=schema.OBSStoriesListResponse,
-# tags=["OBS"]
-# )
-# def get_obs_stories_for_language(
-# language_code: str = Path(..., description="Language identifier"),
-# page: int = Query(1, ge=1, description="Page number"),
-# limit: int = Query(50, ge=1, le=100, description="Results per page"),
-# db_session: Session = Depends(get_db),
-# session: SessionContainer = Depends(verify_session_data)
-# ):
-# """
-# Get all Open Bible Stories for a specific language with pagination.
-
-# Returns stories without full text content (use story detail endpoint for full content).
-
-# **Authorization:** Admin, Editor, Viewer
-# """
-# logger.info(
-# "GET OBS Stories API - Language: %s, Page: %s, Limit: %s",
-# language_code,
-# page,
-# limit
-# )
-# validate_all_roles(session)
-
-# return crud.get_obs_stories_by_language(db_session, language_code, page, limit)
-
-
-# ===== GET - Specific Story Details =====
-@router.get(
- "/obs/{resource_id}",
- response_model=schema.OBSGetResponse,
- tags=["OBS"]
-)
-def get_obs_story_detail(
- resource_id: int = Path(...),
- db_session: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)
-):
- """
- Get full details of Open Bible Story.
-
- Returns complete story information including full text content.
-
- **Authorization:** Admin, Editor, Viewer
- """
- logger.info(
- "GET OBS Story Detail API - resource_id: %s",
- resource_id
- )
- validate_all_roles(session)
-
- return crud.get_obs_by_resource(db_session, resource_id)
-
-# ===== PUT - Update Story =====
-@router.put(
- "/obs/{resource_id}/story/{story_id}",
- response_model=schema.OBSStoryUpdateResponse,
- tags=["OBS"]
-)
-def update_obs_story_endpoint(
- params: schema.OBSStoryUpdateParams = Depends(),
- story_data: schema.OBSStoryUpdate = Body(...),
- db_session: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data),
-):
- """
- Update an existing Open Bible Story.
-
- All fields are optional – only provided fields will be updated.
-
- **Path Parameters**
- - `resource_id`: resource ID of type 'obs'
- - `story_id`: Story identifier to update
-
- **Body Fields (optional)**
- - `story_no`: New story number (must be unique within resource)
- - `title`: New story title (max 255 characters)
- - `url`: New external URL
- - `text`: New story content or video description
-
- **Authorization:** Admin, Editor
- """
-
- logger.info(
- "PUT OBS Story API - resource_id: %s, Story: %s",
- params.resource_id,
- params.story_id
- )
-
- validate_admin_editor(session)
-
- return crud.update_obs_story(
- db_session,
- params.resource_id,
- params.story_id,
- story_data
- )
-
-# ===== DELETE - Delete Story =====
-
-@router.delete(
- "/obs/{resource_id}",
- tags=["OBS"],
- response_model=schema.OBSBulkDeleteResponse
-)
-def delete_obs_story_endpoint(
- resource_id: int = Path(...),
- body: schema.OBSBulkDeleteRequest = Body(...),
- db_session: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)
-):
- """
- Bulk delete OBS stories by story numbers.
- Body:
- {
- "story_nos": [1, 2, 3]
- }
- """
-
- # 1. Validate permissions
- validate_admin_editor(session)
-
- # 2. Call CRUD
- result = crud.delete_obs_bulk(db_session, resource_id, body.story_nos)
-
- if not result.get("deletedStoryNos"):
- status = 404
- elif result.get("invalidStoryNos"):
- status = 207 # Partial success
- else:
- status = 200 # All deleted successfully
-
- # 4. Return response
- return JSONResponse(
- status_code=status,
- content={
- "deletedCount": len(result["deletedStoryNos"]),
- "deletedStoryNos": result["deletedStoryNos"],
- "error": (
- f"Invalid story_nos: {result['invalidStoryNos']}"
- if result.get("invalidStoryNos")
- else None
- ),
- },
- )
-
-
-### ENDPOINTS INFOGRAPHICS
-# ---------- CREATE ----------
-@router.post("/infographics",
-response_model=schema.CreateInfographicResponse,
-status_code=201,
-tags=["Infographic"]
-)
-async def create_infographics(payload:
- schema.BatchInfographicCreateIn,
- db: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)
-):
- """Create infographics"""
- validate_admin_editor(session)
- actor_id, _ = await ensure_user_from_session_async(db, session)
- created, errors = crud.create_infographic_batch(db, payload, actor_user_id=actor_id)
-
- if not created["created"] and errors:
- raise UnprocessableException(
- detail={
- "code": "UNPROCESSABLE_ENTITY",
- "message": "No records created",
- "errors": errors,
- },
- )
-
- if errors:
- # Partial success (207-like body)
- return JSONResponse(
- status_code=207,
- content={
- "status": "partial_success",
- "data": {
- "created_count": len(created["created"]),
- "failed_count": len(errors),
- "infographics": created["created"],
- "errors": errors,
- },
- })
-
- return JSONResponse(
- status_code=201,
- content={
- "status": "success",
- "data": {
- "created_count": len(created["created"]),
- "infographics": created["created"],
- },
- })
-
-
-# ---------- LIST ----------
-@router.get(
- "/infographics",
- response_model=schema.ListInfographicResponse,
- tags=["Infographic"]
-)
-async def list_infographics(
- db: Session = Depends(get_db),
- params: schema.InfographicListParams = Depends(),
- session: SessionContainer = Depends(verify_session_data),
-):
- """
- List infographics with pagination and optional filtering."""
- validate_all_roles(session)
- _, _ = await ensure_user_from_session_async(db, session)
-
- try:
- data, pagination, _ = crud.list_infographic_items(db, params)
- except ValueError as e:
- msg = str(e)
-
- if msg == "RESOURCE_NOT_FOUND":
- raise NotAvailableException(
- detail={
- "code": "RESOURCE_NOT_FOUND",
- "message": "resource_id not found"
- },
- ) from e
-
- if msg == "INVALID_RESOURCE_TYPE":
- raise TypeException(
- detail={
- "code": "INVALID_RESOURCE_TYPE",
- "message": "Only resources with content_type 'infographics' can be listed here",
- },
- ) from e
-
- raise BadRequestException(
- detail={"code": "INVALID_QUERY_PARAMETER", "message": msg},
- ) from e
-
- return {"status": "success", "data": data, "pagination": pagination}
-
-
-# ---------- GET ONE ----------
-@router.get(
- "/infographics/{infographic_id}",
- response_model=schema.InfographicOut,
- tags=["Infographic"]
-)
-async def get_one_infographic(
- infographic_id: int,
- db: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data),
-):
- """Get one infographic"""
- validate_all_roles(session)
- _, _ = await ensure_user_from_session_async(db, session)
-
- row = crud.get_one_infographics(db, infographic_id)
- if not row:
- raise NotAvailableException(
- detail={"code": "NOT_FOUND", "message": "Infographic not found"},
- )
-
- return row
-
-# ---------- UPDATE ----------
-@router.put(
- "/infographics",
- response_model=schema.UpdateInfographicBatchResponse,
- tags=["Infographic"],
-)
-async def update_one(
- body: schema.BatchInfographicUpdateIn,
- db: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data),
-):
- """Update infographics"""
- # --- 0. Validate session and get actor ---
- validate_admin_editor(session)
- actor_id, _ = await ensure_user_from_session_async(db, session)
-
- # --- 1. Pre-validate each item (structure + types) ---
- errors = []
- for idx, item in enumerate(body.infographics):
- tmp_obj = schema.InfographicUpdateItem(
- id=item.id,
- book_id=item.book_id,
- title=item.title,
- file_name=item.file_name,
- )
- err = crud.validate_item(idx, tmp_obj)
- if err:
- errors.append(err)
-
- if errors:
- raise UnprocessableException(
- detail={
- "code": "VALIDATION_ERROR",
- "message": "Validation failed",
- "errors": errors,
- },
- )
-
- # --- 2. Call CRUD batch update (ctx style handles resource/books internally) ---
- try:
- out = crud.update_infographic_batch(db, body, actor_user_id=actor_id)
- except ValueError as e:
- msg = str(e)
- if msg == "RESOURCE_NOT_FOUND":
- raise NotAvailableException(
- detail={"code": "RESOURCE_NOT_FOUND", "message": "resource_id not found"},
- ) from e
- if msg == "INVALID_RESOURCE_TYPE":
- raise TypeException(
- detail={
- "code": "INVALID_RESOURCE_TYPE",
- "message": (
- "Only resources with content_type 'infographics' "
- "can hold infographic records"
- ),
- },
- ) from e
- if msg == "INVALID_BOOK":
- raise UnprocessableException(
- detail={"code": "VALIDATION_ERROR", "message": "book_id must be between 1 and 66"},
- ) from e
- # generic
- raise UnprocessableException(
- detail={
- "code": "VALIDATION_ERROR",
- "message": msg}
- )from e
-
- updated = out.get("updated", [])
- errs = out.get("errors", [])
-
- # --- 3. Return proper status code based on outcome ---
- if not updated and errs:
- # all failed
- raise UnprocessableException(
- detail={
- "code": "UNPROCESSABLE_ENTITY",
- "message": "No records updated",
- "errors": errs,
- },
- )
-
- if updated and errs:
- # partial success
- return JSONResponse(
- status_code=207,
- content={"updated": updated, "errors": errs},
- )
-
- # all succeeded
- return JSONResponse(status_code=200, content={"updated": updated, "errors": []})
-
-
-
-
-# ---------- DELETE ----------
-@router.delete(
- "/infographics",
- response_model=schema.BulkInfographicDeleteResponse,
- tags=["Infographic"],
-)
-async def delete_bulk(
- body: schema.BulkInfographicDeleteIn,
- db: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data),
-):
- """Bulk delete infographics"""
- validate_admin_editor(session)
- _, _ = await ensure_user_from_session_async(db, session)
-
- if not body.ids:
- raise HTTPException(status_code=400, detail="No IDs provided")
-
- result = crud.delete_bulk_details(db, body.ids)
-
- # Determine status code
- if not result["deletedIds"]:
- status_code = 404 # all invalid
- elif result.get("invalidIds"):
- status_code = 207 # partial success
- else:
- status_code = 200 # all deleted
-
- return JSONResponse(status_code=status_code, content=result)
-
-
-# --- Verse of the Day / Endpoints ---
-@router.get("/verse_of_the_day",
-response_model=schema.VerseOfTheDayListResponse,
-tags=["Verse Of The Day"])
-async def get_all_verse_of_the_day(db: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)):
- """Get all daily verse references for all years"""
- validate_all_roles(session)
- return crud.get_all_verse_of_the_day(db)
-
-@router.get(
- "/verse_of_the_day/{year}/{month}/{day}",
- response_model=schema.VerseOfTheDaySingleResponse,
- tags=["Verse Of The Day"]
-)
-async def get_verse_by_date(year: int, month: int, day: int,
- db: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)):
- """Get the verse of the day for a specific date"""
- validate_all_roles(session)
- return crud.get_verse_for_date(db,year, month, day)
-
-@router.post(
- "/verse_of_the_day",
- response_model=schema.VerseUploadResponse,
- tags=["Verse Of The Day"]
-)
-async def upload_verse_of_the_day(
- file: UploadFile = File(...),
- db_session: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)
-):
- """
- Upload a CSV file to replace Verse of the Day table content.
- Deletes existing entries and adds new ones from CSV.
- """
- validate_admin_editor(session)
- return crud.upload_verse_of_the_day_csv(db_session, file)
-
-@router.delete("/verse_of_the_day",tags=["Verse Of The Day"])
-def delete_all_verse_of_the_day(db_session: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)):
- """
- Delete all entries in verse_of_the_day table.
- """
- validate_admin_editor(session)
- return crud.delete_all_verse_of_the_day(db_session)
-
-# --- Reading Plan Endpoints ---
-
-@router.post(
- "/reading-plans/upload",
- response_model=schema.ReadingPlanUploadResponse,
- tags=["Reading Plan"]
-)
-async def upload_reading_plans(
- file: UploadFile = File(..., description="JSON or CSV file containing reading plans"),
- db_session: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)
-):
- """
- Upload reading plans from JSON or CSV file.
-
- **File Format:**
- - JSON: Array of objects with 'date' (MM-DD) and 'reading' (array of reading items)
- - CSV: Columns 'date' (MM-DD) and 'reading' (JSON string of reading items)
-
- **Example JSON:**
-```json
- [
- {
- "date": "01-01",
- "reading": [
- {"ref": "gen 1", "text": "Genesis 1"},
- {"ref": "mat 1", "text": "Matthew 1"}
- ]
- }
- ]
-```
-
-# **Example CSV:**
-# ```csv
-# date,reading
-# 01-01,"[{""ref"": ""gen 1"", ""text"": ""Genesis 1""}]"
-# 01-02,"[{""ref"": ""gen 2"", ""text"": ""Genesis 2""}]"
-# ```
- """
- logger.info("POST Reading Plans Upload API")
- validate_admin_editor(session)
- _, _ = await ensure_user_from_session_async(db_session, session)
-
- # Validate file type
- file_ext = file.filename.lower().split('.')[-1]
- if file_ext not in ['json', 'csv']:
- raise TypeException(
- detail="Invalid file type. Only JSON and CSV files are supported."
- )
-
- # Read file content
- try:
- file_content = await file.read()
- except Exception as e:
- logger.error("Error reading file: %s", str(e))
- raise BadRequestException(detail="Error reading file") from e
-
- # Check if file is empty
- if len(file_content) == 0:
- raise BadRequestException(detail="Uploaded file is empty")
-
- # Process file
- result = crud.upload_reading_plans(
- db=db_session,
- file_content=file_content,
- file_type=file_ext
- )
-
- return schema.ReadingPlanUploadResponse(
- message="Reading plans uploaded successfully",
- total_uploaded=result["total"],
- total_updated=result["updated"],
- total_created=result["created"],
- skipped=result.get("skipped", 0)
- )
-
-
-@router.get(
- "/reading-plans",
- response_model=List[schema.ReadingPlanResponse],
- tags=["Reading Plan"]
-)
-async def get_reading_plans(
- month: Optional[int] = Query(None, ge=1, le=12, description="Month (1-12)"),
- day: Optional[int] = Query(None, ge=1, le=31, description="Day (1-31)"),
- db_session: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)
-):
- """
- Get reading plans.
-
- - Without parameters: Returns all 365 reading plans
- - With month and day: Returns reading plan for specific date
-
- **Example:**
- - Get all: `/reading-plans`
- - Get specific date: `/reading-plans?month=11&day=23`
-
- """
- logger.info("GET Reading Plans API")
- validate_all_roles(session)
- _, _ = await ensure_user_from_session_async(db_session, session)
-
- # Validate that both month and day are provided together or neither
- if (month is None) != (day is None):
- raise BadRequestException(
- detail="Both month and day must be provided together, or neither"
- )
-
- plans = crud.get_reading_plans(db_session, month=month, day=day)
-
- return [
- schema.ReadingPlanResponse(
- id=plan.id,
- month=plan.month,
- day=plan.day,
- readings=plan.readings
- )
- for plan in plans
- ]
-
-
-@router.delete(
- "/reading-plans",
- response_model=schema.ReadingPlanDeleteResponse,
- tags=["Reading Plan"]
-)
-async def delete_reading_plans(
- db_session: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)
-):
- """
- Delete all reading plans from the database.
-
- **Warning:** This action cannot be undone.
- """
- logger.info("DELETE Reading Plans API")
- validate_admin_editor(session)
- _, _ = await ensure_user_from_session_async(db_session, session)
-
- deleted_count = crud.delete_all_reading_plans(db_session)
-
- return schema.ReadingPlanDeleteResponse(
- message="All reading plans deleted successfully",
- deleted_count=deleted_count
- )
-
-@router.get("/infographics/test/{resource_id}",
- response_model=schema.InfographicCheckResponse,
- tags=["Check remote data"])
-async def check_infographics_remote_data(resource_id: int,
- db: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)):
- """
- Test if infographic files exist in remote repository for the given resource.
- Verifies full image and its thumb version using remote HEAD requests.
- """
- validate_admin_only(session)
- return crud.check_infographics_by_resource(db, resource_id)
-
-@router.get("/commentary/test/{resource_id}", tags=["Check remote data"],
- response_model=schema.CommentaryImageCheckResponse)
-async def test_commentary_images(
- resource_id: int,
- db: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)
-):
- """
- Test if commentary images exist in remote repository for the given resource."""
- validate_admin_only(session)
- _, _ = await ensure_user_from_session_async(db, session)
-
- return crud.test_commentary_images(db, resource_id)
-
-@router.get(
- "/audit-logs",
- response_model=schema.AuditLogListResponse,
- tags=["AuditLog"],
-)
-async def list_audit_logs(
- params: schema.AuditLogQueryParams = Depends(),
- db: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data),
-):
- """
- List all audit logs with optional filtering and pagination.
-
- - **user_id**: Optional filter to get logs for a specific user
- - **path**: Optional substring match on path
- - **status_code**: Optional filter based on status_code
- - **date_from** / **date_to**: Optional date range filtering
- - **page**: Page number (default 0)
- - **page_size**: Number of results per page (default 100, max 500)
- """
-
- # Validate session
- validate_admin_only(session)
- _, _ = await ensure_user_from_session_async(db, session)
-
- # Validate user_id if provided
- if params.user_id is not None:
- exists = (
- db.query(db_models.User.id)
- .filter(db_models.User.id == params.user_id)
- .first()
- )
- if exists is None:
- raise NotAvailableException(
- detail="User_id does not exist",
- )
-
- # Fetch logs
- logs, total = crud.get_audit_logs(
- db,
- user_id=params.user_id,
- path=params.path,
- status_code=params.status_code,
- date_from=params.date_from,
- date_to=params.date_to,
- page=params.page,
- page_size=params.page_size,
- )
-
- # Standardized response
- return schema.AuditLogListResponse(
- items=logs,
- total=total,
- page=params.page,
- page_size=params.page_size,
- )
-
-@router.get("/audio-bible/test/{resource_id}",tags=["Check remote data"],
- response_model=schema.AudioBibleTestResponse)
-async def test_audio_bible_files(
- resource_id: int,
- db: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)
-):
- """Check DigitalOcean Spaces for missing audio bible files."""
- validate_admin_only(session)
- _, _ = await ensure_user_from_session_async(db, session)
- out = crud.check_audio_bible_remote(db, resource_id)
- if "error" in out:
- raise BadRequestException(detail=out["error"])
- return out
-
-@router.post("/bible/usfm/validate", tags=["Usfm Format Checker"])
-async def validate_usfm_api(file: UploadFile = File(...),
- db: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)):
- """Validate USFM file"""
- validate_admin_editor(session)
- _, _ = await ensure_user_from_session_async(db, session)
- return await crud.validate_usfm_file(file)
-@router.get("/videos/test/{resource_id}",
- tags=["Check remote data"],response_model=schema.VideoRemoteTestResponse
-)
-async def test_videos_remote_data(
- resource_id: int,
- db: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)):
- """test videos remote data"""
- validate_admin_only(session)
- _, _ = await ensure_user_from_session_async(db, session)
- return crud.test_videos_for_resource(db, resource_id)
-
-@router.get("/isl-bible/test/{resource_id}",
- tags=["Check remote data"],response_model=schema.IslVideoTestResponse
-)
-async def test_isl_bible_remote_data(
- resource_id: int,
- db: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)):
- """test isl-bible remote data"""
- validate_admin_only(session)
- _, _ = await ensure_user_from_session_async(db, session)
- return crud.test_isl_bible_videos_for_resource(db, resource_id)
-@router.post("/isl-bible", response_model=schema.IslVideoListResponse, tags=["ISL Videos"])
-async def api_create_isl_videos(
- payload: schema.IslVideoCreateRequest,
- db: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)
-):
- """Create isl bible"""
- validate_admin_editor(session)
- _, _ = await ensure_user_from_session_async(db, session)
- return crud.create_isl_videos(db, payload)
-
-
-@router.put("/isl-bible", response_model=schema.IslVideoListResponse, tags=["ISL Videos"])
-async def api_update_isl_videos(
- payload: schema.IslVideoUpdateRequest,
- db: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)
-):
- """Create isl bible"""
- validate_admin_editor(session)
- _, _ = await ensure_user_from_session_async(db, session)
- return crud.update_isl_videos(db, payload)
-
-
-@router.get("/isl-bible/{resource_id}",
- response_model=schema.IslVideoGetResponse, tags=["ISL Videos"])
-async def api_get_isl_videos(
- resource_id: int,
- #make this noptionl bookcode
- book_code: Optional[str] = Query(None, description="book code, e.g. 'gen'"),
- chapter: Optional[int] = Query(None, description="chapter number (0 for whole book)"),
- db: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)
-):
- """get isl bible according to resource id,book code,chapter"""
- validate_all_roles(session)
- _, _ = await ensure_user_from_session_async(db, session)
- return crud.get_isl_videos(db, resource_id, book_code, chapter)
-
-
-
-@router.delete(
- "/isl-bible/{resource_id}",
- tags=["ISL Videos"],
- response_model=schema.IslVideoDeleteResponse
-)
-async def api_delete_isl_videos(
- resource_id: int,
- payload: schema.IslVideoDeleteRequest,
- db: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)
-):
- """delete isl bible"""
- validate_admin_editor(session)
- _, _ = await ensure_user_from_session_async(db, session)
-
- result = crud.delete_isl_videos(db, resource_id, payload.videoIds)
-
- deleted = result["deleted_count"]
- deleted_ids = result["deleted_ids"]
- invalid_ids = result["invalid_ids"]
-
- # Case 1: All invalid → 422
- if deleted == 0 and invalid_ids:
- raise HTTPException(
- status_code=422,
- detail={
- "deletedCount": 0,
- "deletedIds": [],
- "invalidIds": invalid_ids,
- "message": "No valid video_ids found"
- }
- )
-
- # Case 2: Partial success → 207
- if deleted > 0 and invalid_ids:
- return JSONResponse(
- status_code=207,
- content={
- "deletedCount": deleted,
- "deletedIds": deleted_ids,
- "invalidIds": invalid_ids,
- "message": "Partially deleted videos"
- }
- )
-
- # Case 3: Full success → 200
- return {
- "deletedCount": deleted,
- "deletedIds": deleted_ids,
- "invalidIds": [],
- "message": f"Successfully deleted {deleted} videos"
- }
-
-
-@router.get("/error-log", response_model=schema.ErrorLogListResponse, tags=["ErrorLog"])
-async def get_error_logs(
- params: schema.ErrorLogQueryParams = Depends(),
- db: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data),
-):
- """
- Retrieve error logs (admin only).
-
- Filters:
- - user_id: filter logs by user
- - start_time, end_time: filter between these datetimes (inclusive)
- - limit: max number of rows (<= 1000)
- """
- validate_admin_only(session)
- _, _ = await ensure_user_from_session_async(db, session)
-
- query = db.query(db_models.ErrorLog)
-
- # Validate user_id if provided
- if params.user_id is not None:
- exists = (
- db.query(db_models.User.id)
- .filter(db_models.User.id == params.user_id)
- .first()
- )
- if exists is None:
- raise NotAvailableException(
- detail="User_id does not exist",
- )
- query = query.filter(db_models.ErrorLog.user_id == params.user_id)
- # --- Filter by time range ---
- if params.start_time and params.end_time:
- query = query.filter(
- db_models.ErrorLog.time.between(params.start_time, params.end_time)
- )
- elif params.start_time:
- query = query.filter(db_models.ErrorLog.time >= params.start_time)
- elif params.end_time:
- query = query.filter(db_models.ErrorLog.time <= params.end_time)
-
- query = query.order_by(db_models.ErrorLog.time.desc()).limit(params.limit)
-
- items = query.all()
- total = len(items)
-
- return schema.ErrorLogListResponse(items=items, total=total)
diff --git a/backend/app/router/content.py b/backend/app/router/content.py
index edbfd289..ce320e68 100644
--- a/backend/app/router/content.py
+++ b/backend/app/router/content.py
@@ -1,5 +1,5 @@
"""Content Endpoints"""
-from typing import Optional,List
+from typing import Optional,List,Dict,Any
from fastapi import (
APIRouter,
Depends,
@@ -21,6 +21,7 @@
validate_admin_editor,
validate_all_roles,
ensure_user_from_session_async,
+ verify_session_or_api_key
)
from custom_exceptions import (
NotAvailableException,
@@ -109,7 +110,7 @@ async def get_dictionary_full_content(
resource_id: int = Path(..., examples=1),
params: schema.DictionaryQueryParams = Depends(),
db_: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data),
+ auth: Dict[str, Any] = Depends(verify_session_or_api_key)
):
"""Fetches full content of a dictionary by resource_id.
Returns all dictionary words with complete details."""
@@ -122,8 +123,10 @@ async def get_dictionary_full_content(
params.limit,
)
- validate_all_roles(session)
- _, _ = await ensure_user_from_session_async(db_, session)
+ if auth["auth_type"] == "session":
+ session = auth["session"]
+ validate_all_roles(session)
+ _, _ = await ensure_user_from_session_async(db_, session)
response = content_crud.get_dictionary_words(
db_,
@@ -144,14 +147,16 @@ async def get_dictionary_full_content(
async def get_dictionary_index(
resource_id: int = Path(..., examples=1),
db_: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data )
+ auth: Dict[str, Any] = Depends(verify_session_or_api_key),
):
'''Fetches index of a dictionary grouped by first letter.
Returns wordId and keyword organized by starting letter.'''
logger.info('In get_dictionary_index')
logger.debug('resource_id: %s', resource_id)
- validate_all_roles(session)
- _, _ = await ensure_user_from_session_async(db_, session)
+ if auth["auth_type"] == "session":
+ session = auth["session"]
+ validate_all_roles(session)
+ _, _ = await ensure_user_from_session_async(db_, session)
response = content_crud.get_dictionary_index(
db_,
resource_id=resource_id
@@ -170,13 +175,15 @@ async def get_dictionary_word(
resource_id: int = Path(..., examples=1),
word_id: int = Path(..., examples=100001),
db_: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data )
+ auth: Dict[str, Any] = Depends(verify_session_or_api_key),
):
'''Fetches details of a specific dictionary word by wordId.'''
logger.info('In get_dictionary_word')
logger.debug('resource_id: %s, word_id: %s', resource_id, word_id)
- validate_all_roles(session)
- _, _ = await ensure_user_from_session_async(db_, session)
+ if auth["auth_type"] == "session":
+ session = auth["session"]
+ validate_all_roles(session)
+ _, _ = await ensure_user_from_session_async(db_, session)
response = content_crud.get_dictionary_word_by_id(
db_,
resource_id=resource_id,
@@ -272,7 +279,7 @@ async def create_audio_bible(data: schema.AudioBibleCreate, db: Session = Depend
async def list_audio_bibles(
params: schema.AudioBibleQueryParams = Depends(),
db: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)
+ auth: Dict[str, Any] = Depends(verify_session_or_api_key)
):
"""
List all audio bibles with optional filtering and pagination.
@@ -283,8 +290,10 @@ async def list_audio_bibles(
- files_missing: Filter by missing/available files
- test_date: Return audio bibles tested on/after the timestamp
"""
- validate_all_roles(session)
- _, _ = await ensure_user_from_session_async(db, session)
+ if auth["auth_type"] == "session":
+ session = auth["session"]
+ validate_all_roles(session)
+ _, _ = await ensure_user_from_session_async(db, session)
return content_crud.list_audio_bibles(
db=db,
@@ -430,7 +439,7 @@ async def create_obs_story_endpoint(
def get_obs_story_detail(
resource_id: int = Path(...),
db_session: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)
+ auth: Dict[str, Any] = Depends(verify_session_or_api_key)
):
"""
Get full details of Open Bible Story.
@@ -443,7 +452,9 @@ def get_obs_story_detail(
"GET OBS Story Detail API - resource_id: %s",
resource_id
)
- validate_all_roles(session)
+ if auth["auth_type"] == "session":
+ session = auth["session"]
+ validate_all_roles(session)
return content_crud.get_obs_by_resource(db_session, resource_id)
@@ -601,12 +612,14 @@ async def create_infographics(payload:
async def list_infographics(
db: Session = Depends(get_db),
params: schema.InfographicListParams = Depends(),
- session: SessionContainer = Depends(verify_session_data),
+ auth: Dict[str, Any] = Depends(verify_session_or_api_key),
):
"""
List infographics with pagination and optional filtering."""
- validate_all_roles(session)
- _, _ = await ensure_user_from_session_async(db, session)
+ if auth["auth_type"] == "session":
+ session = auth["session"]
+ validate_all_roles(session)
+ _, _ = await ensure_user_from_session_async(db, session)
try:
data, pagination, _ = content_crud.list_infographic_items(db, params)
@@ -645,11 +658,13 @@ async def list_infographics(
async def get_one_infographic(
infographic_id: int,
db: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data),
+ auth: Dict[str, Any] = Depends(verify_session_or_api_key),
):
"""Get one infographic"""
- validate_all_roles(session)
- _, _ = await ensure_user_from_session_async(db, session)
+ if auth["auth_type"] == "session":
+ session = auth["session"]
+ validate_all_roles(session)
+ _, _ = await ensure_user_from_session_async(db, session)
row = content_crud.get_one_infographics(db, infographic_id)
if not row:
@@ -790,9 +805,11 @@ async def delete_bulk(
response_model=schema.VerseOfTheDayListResponse,
tags=["Verse Of The Day"])
async def get_all_verse_of_the_day(db: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)):
+ auth: Dict[str, Any] = Depends(verify_session_or_api_key)):
"""Get all daily verse references for all years"""
- validate_all_roles(session)
+ if auth["auth_type"] == "session":
+ session = auth["session"]
+ validate_all_roles(session)
return content_crud.get_all_verse_of_the_day(db)
@router.get(
@@ -802,9 +819,12 @@ async def get_all_verse_of_the_day(db: Session = Depends(get_db),
)
async def get_verse_by_date(year: int, month: int, day: int,
db: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)):
+ auth: Dict[str, Any] = Depends(verify_session_or_api_key)):
+
"""Get the verse of the day for a specific date"""
- validate_all_roles(session)
+ if auth["auth_type"] == "session":
+ session = auth["session"]
+ validate_all_roles(session)
return content_crud.get_verse_for_date(db,year, month, day)
@router.post(
@@ -920,7 +940,7 @@ async def get_reading_plans(
month: Optional[int] = Query(None, ge=1, le=12, description="Month (1-12)"),
day: Optional[int] = Query(None, ge=1, le=31, description="Day (1-31)"),
db_session: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)
+ auth: Dict[str, Any] = Depends(verify_session_or_api_key)
):
"""
Get reading plans.
@@ -934,8 +954,10 @@ async def get_reading_plans(
"""
logger.info("GET Reading Plans API")
- validate_all_roles(session)
- _, _ = await ensure_user_from_session_async(db_session, session)
+ if auth["auth_type"] == "session":
+ session = auth["session"]
+ validate_all_roles(session)
+ _, _ = await ensure_user_from_session_async(db_session, session)
# Validate that both month and day are provided together or neither
if (month is None) != (day is None):
diff --git a/backend/app/router/content_bible.py b/backend/app/router/content_bible.py
index f09d5cf1..f0f68e6c 100644
--- a/backend/app/router/content_bible.py
+++ b/backend/app/router/content_bible.py
@@ -1,12 +1,15 @@
"""Bible Endpoints."""
+from typing import Optional,Dict,Any,List
from fastapi import (
APIRouter,
Depends,
File,
UploadFile,
Form,
+ Query
)
from fastapi.responses import JSONResponse
+from fastapi.concurrency import run_in_threadpool
from supertokens_python.recipe.session import SessionContainer
from supertokens_python.recipe.session.framework.fastapi import verify_session
from sqlalchemy.orm import Session
@@ -14,16 +17,28 @@
from schema import BibleVersePathParams
from crud import content_bible
from crud import remote_filecheck_crud
-from dependencies import get_db
+from dependencies import get_db, logger
from auth import (
validate_admin_editor,
validate_all_roles,
ensure_user_from_session_async,
+ verify_session_or_api_key
)
from custom_exceptions import (
BadRequestException,
UnprocessableException,
+ NotAvailableException
)
+import time
+
+def _ms(start: float) -> float:
+ return (time.perf_counter() - start) * 1000
+
+def tprint(step: str, start: float, **kw):
+ # Example: tprint("parse done", t0, book="GEN", verses=31102)
+ meta = " ".join([f"{k}={v}" for k, v in kw.items()]) if kw else ""
+ print(f"[{_ms(start):9.2f} ms] {step} {meta}")
+ logger.info(f"[{_ms(start):9.2f} ms] {step} {meta}")
router = APIRouter()
@@ -31,32 +46,115 @@
# --- Bible Book Management Endpoints ---
-@router.post(
- "/bible",
- response_model=dict,
- tags=["Bible"]
-)
+# @router.post(
+# "/bible",
+# response_model=dict,
+# tags=["Bible"]
+# )
+# async def upload_bible_book(
+# resource_id: int = Form(...),
+# usfm: UploadFile = File(...),
+# db_session: Session = Depends(get_db),
+# session: SessionContainer = Depends(verify_session_data)
+# ):
+# """Upload a new bible book USFM file"""
+# validate_admin_editor(session)
+
+# # Get user ID from session
+# actor_id, _ = await ensure_user_from_session_async(db_session, session)
+# # Validate USFM file before processing
+# validation_result = await remote_filecheck_crud.validate_usfm_file_internal(usfm)
+# if not validation_result["valid"]:
+# raise UnprocessableException(detail=validation_result.get("error"))
+
+# # Pass pre-parsed data to avoid re-parsing
+# return await run_in_threadpool(
+# content_bible.upload_bible_book,
+# db_session=db_session,
+# resource_id=resource_id,
+# usfm_file=usfm,
+# actor_user_id=actor_id,
+# pre_parsed_usj_data=validation_result.get("usj_data"),
+# usfm_content=validation_result.get("usfm_content"),
+# )
+
+@router.post("/bible", response_model=dict, tags=["Bible"])
async def upload_bible_book(
resource_id: int = Form(...),
usfm: UploadFile = File(...),
+ session: SessionContainer = Depends(verify_session_data),
db_session: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)
):
- """Upload a new bible book USFM file"""
+ t0 = time.perf_counter()
+ tprint("API START /bible", t0, resource_id=resource_id, filename=usfm.filename)
+ logger.info("API START /bible")
validate_admin_editor(session)
+ tprint("validate_admin_editor OK", t0)
+ logger.info("validate_admin_editor OK")
- # Get user ID from session
actor_id, _ = await ensure_user_from_session_async(db_session, session)
- # Validate USFM file before processing
- validation_result = await remote_filecheck_crud.validate_usfm_file(usfm)
- if not validation_result["valid"]:
- raise UnprocessableException(detail=validation_result["error"])
- return content_bible.upload_bible_book(
+ tprint("ensure_user_from_session_async OK", t0, actor_id=actor_id)
+ logger.info("ensure_user_from_session_async OK")
+ v_start = time.perf_counter()
+ v = await remote_filecheck_crud.validate_usfm_file_internal(
db_session=db_session,
resource_id=resource_id,
- usfm_file=usfm,
- actor_user_id=actor_id
+ file=usfm,
)
+ tprint("validate_usfm_file_internal DONE", t0, step_ms=_ms(v_start), book_code=v.get("book_code"))
+ logger.info("validate_usfm_file_internal DONE")
+ crud_start = time.perf_counter()
+ result = await run_in_threadpool(
+ content_bible.upload_bible_book,
+ db_session=db_session,
+ resource_id=resource_id,
+ actor_user_id=actor_id,
+ usj_data=v["usj_data"],
+ usfm_content=v["usfm_content"],
+ book_id=v["book_id"],
+ book_code=v["book_code"],
+ chapter_count=v["chapter_count"],
+ )
+ tprint("CRUD upload_bible_book DONE", t0, step_ms=_ms(crud_start))
+ logger.info("CRUD upload_bible_book DONE")
+
+ tprint("API END /bible", t0, bible_book_id=result.get("bible_book_id"))
+ logger.info("API END /bible")
+ return result
+
+# @router.put(
+# "/bible",
+# response_model=dict,
+# tags=["Bible"]
+# )
+# async def update_bible_book(
+# bible_book_id: int = Form(...),
+# usfm: UploadFile = File(...),
+# db_session: Session = Depends(get_db),
+# session: SessionContainer = Depends(verify_session_data)
+# ):
+# """Update an existing bible book"""
+# validate_admin_editor(session)
+
+# # Get user ID from session
+# actor_id, _ = await ensure_user_from_session_async(db_session, session)
+
+# # Validate USFM file AND get parsed data
+# validation_result = await remote_filecheck_crud.validate_usfm_file_internal(usfm)
+# if not validation_result["valid"]:
+# raise UnprocessableException(detail=validation_result.get("error"))
+
+# # Pass pre-parsed data to avoid re-parsing
+# return await run_in_threadpool(
+# content_bible.update_bible_book,
+# db_session=db_session,
+# bible_book_id=bible_book_id,
+# usfm_file=usfm,
+# actor_user_id=actor_id,
+# pre_parsed_usj_data=validation_result.get("usj_data"),
+# usfm_content=validation_result.get("usfm_content"),
+# )
+
@router.put(
"/bible",
@@ -66,22 +164,37 @@ async def upload_bible_book(
async def update_bible_book(
bible_book_id: int = Form(...),
usfm: UploadFile = File(...),
+ session: SessionContainer = Depends(verify_session_data),
db_session: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)
):
- """Update an existing bible book"""
+ t0 = time.perf_counter()
+ tprint("API START PUT /bible", t0, bible_book_id=bible_book_id, filename=usfm.filename)
+
validate_admin_editor(session)
+ tprint("validate_admin_editor OK", t0)
- # Get user ID from session
actor_id, _ = await ensure_user_from_session_async(db_session, session)
- validation_result = await remote_filecheck_crud.validate_usfm_file(usfm)
- if not validation_result["valid"]:
- raise UnprocessableException(detail=validation_result["error"])
- return content_bible.update_bible_book(
+ tprint("ensure_user_from_session_async OK", t0, actor_id=actor_id)
+
+ # validation + parse ONCE (same function, but update-mode)
+ v = await remote_filecheck_crud.validate_usfm_file_internal(
+ db_session=db_session,
+ bible_book_id=bible_book_id,
+ file=usfm,
+ mode="update",
+ )
+ tprint("validate_usfm_file_internal DONE", t0, book_code=v["book_code"])
+
+ return await run_in_threadpool(
+ content_bible.update_bible_book,
db_session=db_session,
bible_book_id=bible_book_id,
- usfm_file=usfm,
- actor_user_id=actor_id
+ actor_user_id=actor_id,
+ usj_data=v["usj_data"],
+ usfm_content=v["usfm_content"],
+ book_id=v["book_id"],
+ book_code=v["book_code"],
+ chapter_count=v["chapter_count"],
)
@router.delete(
@@ -142,11 +255,13 @@ async def delete_bible_books_endpoint(
async def get_bible_books(
resource_id: int,
db_session: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)
+ auth: Dict[str, Any] = Depends(verify_session_or_api_key)
):
"""Get list of books for a bible resource"""
- validate_all_roles(session)
- _, _ = await ensure_user_from_session_async(db_session, session)
+ if auth["auth_type"] == "session":
+ session = auth["session"]
+ validate_all_roles(session)
+ _, _ = await ensure_user_from_session_async(db_session, session)
return content_bible.get_bible_books(db_session, resource_id)
@@ -160,11 +275,13 @@ async def get_full_bible_content(
resource_id: int,
output_format: str,
db_session: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)
+ auth: Dict[str, Any] = Depends(verify_session_or_api_key)
):
"""Get full content of all books in a resource in specified format (json/usfm)"""
- validate_all_roles(session)
- _, _ = await ensure_user_from_session_async(db_session, session)
+ if auth["auth_type"] == "session":
+ session = auth["session"]
+ validate_all_roles(session)
+ _, _ = await ensure_user_from_session_async(db_session, session)
if output_format.lower() not in ["json", "usfm"]:
raise BadRequestException("Format must be 'json' or 'usfm'")
@@ -187,11 +304,13 @@ async def get_bible_book_content(
book_code: str,
output_format: str,
db_session: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)
+ auth: Dict[str, Any] = Depends(verify_session_or_api_key)
):
"""Get full content of a book in specified format (json/usfm)"""
- validate_all_roles(session)
- _, _ = await ensure_user_from_session_async(db_session, session)
+ if auth["auth_type"] == "session":
+ session = auth["session"]
+ validate_all_roles(session)
+ _, _ = await ensure_user_from_session_async(db_session, session)
if output_format.lower() not in ["json", "usfm"]:
raise BadRequestException("Format must be 'json' or 'usfm'")
@@ -213,11 +332,13 @@ async def get_bible_chapter(
book_code: str,
chapter: int,
db_session: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)
+ auth: Dict[str, Any] = Depends(verify_session_or_api_key)
):
"""Get chapter content from bible table"""
- validate_all_roles(session)
- _, _ = await ensure_user_from_session_async(db_session, session)
+ if auth["auth_type"] == "session":
+ session = auth["session"]
+ validate_all_roles(session)
+ _, _ = await ensure_user_from_session_async(db_session, session)
return content_bible.get_bible_chapter(
db_session=db_session,
@@ -236,11 +357,13 @@ async def get_clean_bible_chapter(
book_code: str,
chapter: int,
db_session: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)
+ auth: Dict[str, Any] = Depends(verify_session_or_api_key)
):
"""Get cleaned chapter content from clean_bible table"""
- validate_all_roles(session)
- _, _ = await ensure_user_from_session_async(db_session, session)
+ if auth["auth_type"] == "session":
+ session = auth["session"]
+ validate_all_roles(session)
+ _, _ = await ensure_user_from_session_async(db_session, session)
return content_bible.get_clean_bible_chapter(
db_session=db_session,
@@ -270,13 +393,14 @@ async def get_bible_verse_params(
async def get_bible_verse(
params: schema.BibleVersePathParams = Depends(get_bible_verse_params),
db_session: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data),
+ auth: Dict[str, Any] = Depends(verify_session_or_api_key)
):
"""Get specific verse content"""
-
- validate_all_roles(session)
- _, _ = await ensure_user_from_session_async(db_session, session)
-
+ if auth["auth_type"] == "session":
+ session = auth["session"]
+ validate_all_roles(session)
+ _, _ = await ensure_user_from_session_async(db_session, session)
+
return content_bible.get_bible_verse(
db_session=db_session,
resource_id=params.resource_id,
@@ -284,3 +408,140 @@ async def get_bible_verse(
chapter=params.chapter,
verse=params.verse,
)
+# -- BookNames--
+@router.post(
+ "/booknames",
+ response_model=dict,
+ tags=["BookNames"]
+)
+async def create_booknames_route(
+ payload: list[schema.BookNameBase],
+ db_session: Session = Depends(get_db),
+ session: SessionContainer = Depends(verify_session_data)
+):
+ """Create a book name using languageCode + bookCode"""
+ validate_admin_editor(session)
+ actor_id, _ = await ensure_user_from_session_async(db_session, session)
+
+ return content_bible.create_booknames(
+ db=db_session,
+ payload=payload,
+ _actor_user_id=actor_id
+ )
+@router.put(
+ "/booknames",
+ response_model=dict,
+ tags=["BookNames"]
+)
+async def update_bookname_route(
+ payload: schema.BookNameUpdate,
+ db_session: Session = Depends(get_db),
+ session: SessionContainer = Depends(verify_session_data)
+):
+ """Update a book name by its primary key ID"""
+ validate_admin_editor(session)
+ actor_id, _ = await ensure_user_from_session_async(db_session, session)
+
+ return content_bible.update_bookname(
+ db=db_session,
+ payload=payload,
+ _actor_user_id=actor_id
+ )
+@router.delete(
+ "/booknames",
+ tags=["BookNames"]
+)
+async def delete_booknames_route(
+ payload: schema.BookNameDelete,
+ db_session: Session = Depends(get_db),
+ session: SessionContainer = Depends(verify_session_data)
+):
+ """Delete book names"""
+
+ validate_admin_editor(session)
+
+ actor_id, _ = await ensure_user_from_session_async(
+ db_session, session
+ )
+
+ result = content_bible.delete_booknames(
+ db=db_session,
+ ids=payload.ids,
+ _actor_user_id=actor_id
+ )
+
+ deleted = result["deleted_count"]
+ deleted_ids = result["deleted_ids"]
+ invalid_ids = result["invalid_ids"]
+
+ if deleted == 0 and invalid_ids:
+ raise NotAvailableException(
+ detail={
+ "deletedCount": 0,
+ "deletedIds": [],
+ "invalidIds": invalid_ids,
+ "message": "No valid bookname IDs found"
+ }
+ )
+
+ if deleted > 0 and invalid_ids:
+ return JSONResponse(
+ status_code=207,
+ content={
+ "deletedCount": deleted,
+ "deletedIds": deleted_ids,
+ "invalidIds": invalid_ids,
+ "message": "Partially deleted book names"
+ }
+ )
+
+ return {
+ "deletedCount": deleted,
+ "deletedIds": deleted_ids,
+ "invalidIds": [],
+ "message": f"Successfully deleted {deleted} book name(s)"
+ }
+
+@router.get(
+ "/booknames",
+ response_model=schema.BookNameListResponse,
+ tags=["BookNames"]
+)
+async def get_booknames_route(
+ language_code: Optional[str] = Query(None),
+ db_session: Session = Depends(get_db),
+ auth: Dict[str, Any] = Depends(verify_session_or_api_key)
+):
+ """Fetch grouped book names by language"""
+ if auth["auth_type"] == "session":
+ session = auth["session"]
+ validate_admin_editor(session)
+ _, _ = await ensure_user_from_session_async(db_session, session)
+
+ return content_bible.get_booknames(
+ db=db_session,
+ language_code=language_code,
+ )
+
+@router.get(
+ "/search/{resource_id}",
+ response_model=List[schema.BibleSearchItem],
+ tags=["Bible"]
+)
+async def bible_keyword_search(
+ resource_id: int,
+ keyword: str = Query(...),
+ db_session: Session = Depends(get_db),
+ auth: Dict[str, Any] = Depends(verify_session_or_api_key)
+):
+ """Search bible for keyword"""
+ if auth["auth_type"] == "session":
+ session = auth["session"]
+ validate_all_roles(session)
+ _, _ = await ensure_user_from_session_async(db_session, session)
+
+ return content_bible.search_bible(
+ db_session=db_session,
+ resource_id=resource_id,
+ keyword=keyword
+ )
diff --git a/backend/app/router/content_commentary.py b/backend/app/router/content_commentary.py
index 38b56998..b1791711 100644
--- a/backend/app/router/content_commentary.py
+++ b/backend/app/router/content_commentary.py
@@ -1,4 +1,5 @@
"""Commentary Endpoints."""
+from typing import Optional,Dict,Any
from fastapi import (
APIRouter,
Depends,
@@ -18,6 +19,7 @@
validate_admin_editor,
validate_all_roles,
ensure_user_from_session_async,
+ verify_session_or_api_key
)
import db_models
from custom_exceptions import (
@@ -104,7 +106,8 @@ async def create_commentary(
try:
# Validate the payload content
for item in payload.commentary:
- utils.validate_html(item.text)
+ ref=f"{item.book_id},{item.chapter},{item.verse}"
+ # utils.validate_html(ref,item.text)
remote_filecheck_crud.validate_commentary_book_and_chapter(
db, item.book_id,
item.chapter
@@ -174,7 +177,8 @@ async def update_commentary(
try:
# Validate the payload content
for item in payload.commentary:
- utils.validate_html(item.text)
+ ref=f"{item.book_id},{item.chapter},{item.verse}"
+ # utils.validate_html(ref,item.text)
remote_filecheck_crud.validate_commentary_book_and_chapter(
db,
item.book_id,
@@ -190,11 +194,13 @@ async def update_commentary(
async def get_full(
resource_id: int = Path(..., ge=1),
db: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)
+ auth: Dict[str, Any] = Depends(verify_session_or_api_key)
):
"""Get full commentary"""
- validate_all_roles(session)
- _, _ = await ensure_user_from_session_async(db, session)
+ if auth["auth_type"] == "session":
+ session = auth["session"]
+ validate_all_roles(session)
+ _, _ = await ensure_user_from_session_async(db, session)
return content_commentary.get_full_commentary(db, resource_id)
# --- GET (full content of a chapter; supports path like book_code.chapter) ---
@@ -204,11 +210,13 @@ async def get_chapter(
book_code: str = Path(..., description="Book code, e.g., 'mat'"),
chapter: int = Path(..., ge=0, description="Chapter number (0 allowed)"),
db: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)
+ auth: Dict[str, Any] = Depends(verify_session_or_api_key)
):
"""Get chapter commentary"""
- validate_all_roles(session)
- _, _ = await ensure_user_from_session_async(db, session)
+ if auth["auth_type"] == "session":
+ session = auth["session"]
+ validate_all_roles(session)
+ _, _ = await ensure_user_from_session_async(db, session)
# 1) Resolve book_code -> bookId (404 if unknown code entirely)
book = (
db.query(db_models.BookLookup)
diff --git a/backend/app/router/content_songs.py b/backend/app/router/content_songs.py
new file mode 100644
index 00000000..ebc42ecf
--- /dev/null
+++ b/backend/app/router/content_songs.py
@@ -0,0 +1,212 @@
+""" API endpoint for songs """
+from typing import Dict, Any
+from fastapi import APIRouter, Depends, Response,status, HTTPException
+from fastapi.responses import JSONResponse
+from sqlalchemy.orm import Session
+from supertokens_python.recipe.session import SessionContainer
+from supertokens_python.recipe.session.framework.fastapi import verify_session
+
+import schema
+from crud import content_songs
+from dependencies import get_db
+
+from auth import (
+ validate_admin_editor,
+ validate_all_roles,
+ ensure_user_from_session_async,
+ verify_session_or_api_key
+)
+
+from custom_exceptions import UnprocessableException,NotAvailableException,BadRequestException
+
+router = APIRouter()
+
+verify_session_data = verify_session()
+
+
+@router.post(
+ "/songs",
+ tags=["Songs"],
+ response_model=schema.SongCreateResponse,
+ status_code=201
+)
+async def create_songs(
+ data: schema.SongBulkCreate,
+ db: Session = Depends(get_db),
+ session: SessionContainer = Depends(verify_session_data)
+):
+ """Create songs"""
+ validate_admin_editor(session)
+ actor_id, _ = await ensure_user_from_session_async(db, session)
+
+ if not data.songs:
+ raise HTTPException(
+ status_code=400,
+ detail="songs list must not be empty"
+ )
+
+ ids = content_songs.create_songs(
+ db,
+ resource_id=data.resource_id,
+ songs_in=data.songs,
+ actor_user_id=actor_id
+ )
+
+ return {
+ "message": "Songs created successfully",
+ "ids": ids
+ }
+
+@router.put("/songs", tags=["Songs"], response_model=schema.SongUpdateResponse)
+async def update_songs(
+ data: schema.SongBulkUpdate,
+ response: Response,
+ db: Session = Depends(get_db),
+ session: SessionContainer = Depends(verify_session_data)
+):
+ """Update songs"""
+ validate_admin_editor(session)
+ actor_id, _ = await ensure_user_from_session_async(db, session)
+ if not data.songs:
+ raise BadRequestException(
+ detail="songs list must not be empty"
+ )
+ updated_ids, not_found_ids = content_songs.update_songs(
+ db,
+ resource_id=data.resource_id,
+ songs_in=data.songs,
+ actor_user_id=actor_id
+ )
+
+ if not updated_ids and not_found_ids:
+ raise NotAvailableException(
+ detail={
+ "message": "No valid song ids found to update",
+ "not_found_ids": not_found_ids
+ }
+ )
+
+ if updated_ids and not_found_ids:
+ response.status_code = status.HTTP_207_MULTI_STATUS
+
+ return {
+ "message": "Songs updated successfully",
+ "updated_ids": updated_ids,
+ "not_found_ids": not_found_ids
+ }
+
+@router.get(
+ "/songs/{resource_id}",
+ tags=["Songs"],
+ response_model=schema.SongsListOut
+)
+async def get_songs(
+ resource_id: int,
+ db: Session = Depends(get_db),
+ auth: Dict[str, Any] = Depends(verify_session_or_api_key)
+):
+ """Get songs"""
+ if auth["auth_type"] == "session":
+ session = auth["session"]
+ validate_all_roles(session)
+ await ensure_user_from_session_async(db, session)
+
+ songs = content_songs.get_songs_by_resource(db, resource_id)
+
+ if not songs:
+ raise NotAvailableException(
+ detail={
+ "message": "No songs found for this resource"
+ }
+ )
+
+ return {
+ "resourceId": resource_id,
+ "content": songs
+ }
+
+@router.get(
+ "/songs/lyrics/{song_id}",
+ tags=["Songs"]
+)
+async def get_song_lyrics(
+ song_id: int,
+ db: Session = Depends(get_db),
+ auth: Dict[str, Any] = Depends(verify_session_or_api_key)
+):
+ """Get song lyrics"""
+ if auth["auth_type"] == "session":
+ session = auth["session"]
+ validate_all_roles(session)
+ await ensure_user_from_session_async(db, session)
+
+ song = content_songs.get_song_by_id(db, song_id)
+
+ if not song:
+ raise NotAvailableException(
+ detail={
+ "code": "NOT_FOUND",
+ "message": "Song not found"
+ }
+ )
+
+ return {
+ "song_id": song.id,
+ "lyrics": song.lyrics
+ }
+
+@router.delete(
+ "/songs/{resource_id}",
+ tags=["Songs"],
+ response_model=schema.SongDeleteResponse
+)
+async def delete_songs(
+ resource_id: int,
+ data: schema.SongBulkDelete,
+ db: Session = Depends(get_db),
+ session: SessionContainer = Depends(verify_session_data)
+):
+ """Delete songs"""
+ validate_admin_editor(session)
+ _, _ = await ensure_user_from_session_async(db, session)
+
+ if not data.song_ids:
+ raise BadRequestException(
+ detail="song_ids must not be empty"
+ )
+
+ deleted_ids, not_found_ids = content_songs.delete_songs(
+ db,
+ resource_id=resource_id,
+ song_ids=data.song_ids,
+ actor_user_id=None
+ )
+
+ deleted_count = len(deleted_ids)
+
+ if deleted_count == 0 and not_found_ids:
+ raise HTTPException(
+ status_code=404,
+ detail={
+ "deletedCount": 0,
+ "deletedIds": [],
+ "invalidIds": not_found_ids,
+ "message": "No valid song_ids found"
+ }
+ )
+ if deleted_count > 0 and not_found_ids:
+ return JSONResponse(
+ status_code=207,
+ content={
+ "deletedCount": deleted_count,
+ "deletedIds": deleted_ids,
+ "invalidIds": not_found_ids,
+ "message": "Partially deleted songs"
+ }
+ )
+ return {
+ "deletedCount": deleted_count,
+ "deletedIds": deleted_ids,
+ "invalidIds": [],
+ "message": f"Successfully deleted {deleted_count} songs"
+ }
diff --git a/backend/app/router/content_videos_isl.py b/backend/app/router/content_videos_isl.py
index 96263089..2b7cc3c6 100644
--- a/backend/app/router/content_videos_isl.py
+++ b/backend/app/router/content_videos_isl.py
@@ -1,5 +1,5 @@
""" API endpoint for videos """
-from typing import Optional
+from typing import Optional,Dict,Any
from fastapi import (
APIRouter,
Depends,
@@ -18,6 +18,7 @@
validate_admin_editor,
validate_all_roles,
ensure_user_from_session_async,
+ verify_session_or_api_key
)
from custom_exceptions import (
UnprocessableException,
@@ -96,11 +97,13 @@ async def get_videos(
book_code: Optional[str] = None,
chapter: Optional[int] = None,
db: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)
+ auth: Dict[str, Any] = Depends(verify_session_or_api_key)
):
"""Get videos filtered by resource_id, book_code, and chapter"""
- validate_all_roles(session)
- _, _ = await ensure_user_from_session_async(db, session)
+ if auth["auth_type"] == "session":
+ session = auth["session"]
+ validate_all_roles(session)
+ _, _ = await ensure_user_from_session_async(db, session)
return content_videos.get_videos_filtered(
db=db,
resource_id=resource_id,
@@ -170,11 +173,13 @@ async def api_get_isl_videos(
book_code: Optional[str] = Query(None, description="book code, e.g. 'gen'"),
chapter: Optional[int] = Query(None, description="chapter number (0 for whole book)"),
db: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)
+ auth: Dict[str, Any] = Depends(verify_session_or_api_key)
):
"""get isl bible according to resource id,book code,chapter"""
- validate_all_roles(session)
- _, _ = await ensure_user_from_session_async(db, session)
+ if auth["auth_type"] == "session":
+ session = auth["session"]
+ validate_all_roles(session)
+ _, _ = await ensure_user_from_session_async(db, session)
return content_videos.get_isl_videos(db, resource_id, book_code, chapter)
diff --git a/backend/app/router/format_checker.py b/backend/app/router/format_checker.py
index 66b85b91..d17304f5 100644
--- a/backend/app/router/format_checker.py
+++ b/backend/app/router/format_checker.py
@@ -1,4 +1,5 @@
""" Format checker endpoints."""
+from typing import Dict, Any
from fastapi import (
APIRouter,
Depends,
@@ -15,6 +16,7 @@
validate_admin_only,
validate_admin_editor,
ensure_user_from_session_async,
+ verify_session_or_api_key
)
from custom_exceptions import (
BadRequestException,
@@ -28,12 +30,14 @@
tags=["Check remote data"])
async def check_infographics_remote_data(resource_id: int,
db: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)):
+ auth: Dict[str, Any] = Depends(verify_session_or_api_key)):
"""
Test if infographic files exist in remote repository for the given resource.
Verifies full image and its thumb version using remote HEAD requests.
"""
- validate_admin_only(session)
+ if auth["auth_type"] == "session":
+ session = auth["session"]
+ validate_admin_only(session)
return await content_crud.check_infographics_by_resource(db, resource_id)
@router.get("/commentary/test/{resource_id}", tags=["Check remote data"],
@@ -41,13 +45,15 @@ async def check_infographics_remote_data(resource_id: int,
async def test_commentary_images(
resource_id: int,
db: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)
+ auth: Dict[str, Any] = Depends(verify_session_or_api_key)
):
"""
Test if commentary images exist in remote repository for the given resource."""
- validate_admin_only(session)
- _, _ = await ensure_user_from_session_async(db, session)
-
+ if auth["auth_type"] == "session":
+ session = auth["session"]
+ validate_admin_only(session)
+ _, _ = await ensure_user_from_session_async(db, session)
+
return remote_filecheck_crud.test_commentary_images(db, resource_id)
@router.get("/audio-bible/test/{resource_id}",tags=["Check remote data"],
@@ -55,11 +61,13 @@ async def test_commentary_images(
async def test_audio_bible_files(
resource_id: int,
db: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)
+ auth: Dict[str, Any] = Depends(verify_session_or_api_key)
):
"""Check DigitalOcean Spaces for missing audio bible files."""
- validate_admin_only(session)
- _, _ = await ensure_user_from_session_async(db, session)
+ if auth["auth_type"] == "session":
+ session = auth["session"]
+ validate_admin_only(session)
+ _, _ = await ensure_user_from_session_async(db, session)
out = remote_filecheck_crud.check_audio_bible_remote(db, resource_id)
if "error" in out:
raise BadRequestException(detail=out["error"])
@@ -72,17 +80,20 @@ async def validate_usfm_api(file: UploadFile = File(...),
"""Validate USFM file"""
validate_admin_editor(session)
_, _ = await ensure_user_from_session_async(db, session)
- return await remote_filecheck_crud.validate_usfm_file(file)
+
+ return await remote_filecheck_crud.validate_usfm_file_api(file)
@router.get("/videos/test/{resource_id}",
tags=["Check remote data"],response_model=schema.VideoRemoteTestResponse
)
async def test_videos_remote_data(
resource_id: int,
db: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)):
+ auth: Dict[str, Any] = Depends(verify_session_or_api_key)):
"""test videos remote data"""
- validate_admin_only(session)
- _, _ = await ensure_user_from_session_async(db, session)
+ if auth["auth_type"] == "session":
+ session = auth["session"]
+ validate_admin_only(session)
+ _, _ = await ensure_user_from_session_async(db, session)
return await remote_filecheck_crud.test_videos_for_resource(db, resource_id)
@router.get("/isl-bible/test/{resource_id}",
@@ -91,8 +102,10 @@ async def test_videos_remote_data(
async def test_isl_bible_remote_data(
resource_id: int,
db: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)):
+ auth: Dict[str, Any] = Depends(verify_session_or_api_key)):
"""test isl-bible remote data"""
- validate_admin_only(session)
- _, _ = await ensure_user_from_session_async(db, session)
+ if auth["auth_type"] == "session":
+ session = auth["session"]
+ validate_admin_only(session)
+ _, _ = await ensure_user_from_session_async(db, session)
return await remote_filecheck_crud.test_isl_bible_videos_for_resource(db, resource_id)
diff --git a/backend/app/router/isl_verse_markers.py b/backend/app/router/isl_verse_markers.py
new file mode 100644
index 00000000..7e7c1e2d
--- /dev/null
+++ b/backend/app/router/isl_verse_markers.py
@@ -0,0 +1,187 @@
+"""ISL verse markers Endpoints."""
+from typing import Optional, List, Union,Dict,Any
+from fastapi import APIRouter, Depends,Query
+from fastapi.responses import JSONResponse
+from sqlalchemy.orm import Session
+from supertokens_python.recipe.session import SessionContainer
+from supertokens_python.recipe.session.framework.fastapi import verify_session
+
+
+import schema
+from crud import isl_verse_markers_crud
+from dependencies import get_db, logger
+from auth import (
+ ensure_user_from_session_async,
+ validate_admin_editor,
+ validate_all_roles,
+ verify_session_or_api_key
+)
+router = APIRouter(tags=["ISL Verse Markers"])
+verify_session_data=verify_session()
+
+
+
+@router.post(
+ "/isl-verse-markers",
+ status_code=201
+)
+async def add_verse_markers(
+ request: schema.IslVerseMarkersBulkCreateRequest,
+ session: SessionContainer = Depends(
+ verify_session_data
+ ),
+ db_session: Session = Depends(get_db)
+):
+ """
+ Bulk create ISL verse markers.
+
+ Request format:
+ {
+ "1": [
+ {
+ "verse": 0,
+ "time": "00:00:00:00"
+ }
+ ],
+ "2": [
+ {
+ "verse": "12_13",
+ "time": "00:01:20:10"
+ }
+ ]
+ }
+
+ Each top-level key represents an isl_video_id from the isl_video table,
+ and its value contains the verse markers for that specific ISL video.
+
+ Example: "1" means isl_video_id = 1, and all markers inside that array
+ will be mapped to that ISL video.
+ """
+
+ logger.info("POST bulk ISL verse markers API")
+
+ validate_admin_editor(session)
+
+ await ensure_user_from_session_async(
+ db_session,
+ session
+ )
+
+ records = isl_verse_markers_crud.add_verse_markers_bulk(
+ db_session,
+ request.root
+ )
+
+ return {
+ "message": "Verse markers created successfully",
+ "created": records
+ }
+@router.put(
+ "/isl-verse-markers/{isl_bible_id}",
+ response_model=schema.VerseMarkersResponse
+)
+async def update_verse_markers(
+ isl_bible_id: int,
+ request: schema.VerseMarkersCreateRequest,
+ session: SessionContainer = Depends(verify_session_data),
+ db_session: Session = Depends(get_db)
+):
+ """Updates verse markers for the given ISL Bible ID."""
+ logger.info("PUT ISL verse markers API")
+ validate_admin_editor(session)
+ await ensure_user_from_session_async(db_session, session)
+
+ record = isl_verse_markers_crud.update_verse_markers(
+ db_session,
+ isl_bible_id,
+ [m.model_dump() for m in request.markers]
+ )
+
+ return {
+ "id": record.id,
+ "isl_bible_id": record.isl_video_id,
+ "markers": record.verse_markers_json,
+ "message": "Verse markers updated successfully"}
+
+
+@router.get(
+ "/isl-verse-markers",
+ response_model=Union[schema.VerseMarkersResponse, List[schema.VerseMarkersResponse]]
+)
+async def get_verse_markers(
+ isl_bible_id: Optional[int] = Query(None),
+ auth: Dict[str, Any] = Depends(verify_session_or_api_key),
+ db_session: Session = Depends(get_db)
+):
+ """Retrives all verse markers"""
+ logger.info("GET ISL verse markers API")
+ if auth["auth_type"] == "session":
+ session = auth["session"]
+ validate_all_roles(session)
+ _, _ = await ensure_user_from_session_async(db_session, session)
+
+ if isl_bible_id is not None:
+ record = isl_verse_markers_crud.get_verse_markers(
+ db_session,
+ isl_bible_id
+ )
+ return {
+ "id": record.id,
+ "isl_bible_id": record.isl_video_id,
+ "markers": record.verse_markers_json
+ }
+
+ records = isl_verse_markers_crud.get_all_verse_markers(db_session)
+
+ return [
+ {
+ "id": r.id,
+ "isl_bible_id": r.isl_video_id,
+ "markers": r.verse_markers_json
+ }
+ for r in records
+ ]
+
+def _build_bulk_delete_http_response(result):
+ data = result["data"]
+ deleted_count = data["deletedCount"]
+
+ # Reverse order of checks to avoid duplication pattern
+ if not result["all_failed"] and not result["has_errors"]:
+ status_code = 200
+ elif result["has_errors"] and not result["all_failed"]:
+ status_code = 207
+ else:
+ status_code = 404
+
+ if deleted_count > 0:
+ message = f"Successfully deleted {deleted_count} ISL verse marker(s)"
+ else:
+ message = "No verse markers were deleted"
+
+ return JSONResponse(
+ status_code=status_code,
+ content={**data, "message": message},
+ )
+
+
+@router.delete(
+ "/isl-verse-markers/bulk-delete",
+ response_model=schema.IslVerseMarkersBulkDeleteResponse
+)
+async def delete_verse_markers_bulk(
+ request: schema.IslVerseMarkersBulkDelete,
+ session: SessionContainer = Depends(verify_session_data),
+ db_session: Session = Depends(get_db)
+):
+ """Deletes all verse markers for the given ISL Bible IDs."""
+ logger.info("DELETE ISL verse markers API")
+ validate_admin_editor(session)
+ await ensure_user_from_session_async(db_session, session)
+
+ result = isl_verse_markers_crud.delete_verse_markers_bulk(
+ db_session,
+ request.isl_bible_ids
+ )
+
+ return _build_bulk_delete_http_response(result)
diff --git a/backend/app/router/m2m_auth.py b/backend/app/router/m2m_auth.py
new file mode 100644
index 00000000..ec53f8d7
--- /dev/null
+++ b/backend/app/router/m2m_auth.py
@@ -0,0 +1,97 @@
+from fastapi import APIRouter, Depends, HTTPException, status
+from sqlalchemy.orm import Session
+import auth
+from dependencies import get_db, logger
+from jose import jwt, JWTError
+
+
+router = APIRouter(prefix="/auth/m2m", tags=["M2M Auth"])
+
+
+@router.post("/token", response_model=auth.TokenResponse)
+async def get_token(
+ body: auth.TokenRequest,
+ db_session: Session = Depends(get_db),
+):
+ """
+ Exchange client_id + client_secret for access_token + refresh_token.
+ """
+ client = auth.authenticate_m2m_client(
+ db=db_session,
+ client_id=body.client_id,
+ client_secret=body.client_secret,
+ )
+
+ access_token, refresh_token, expires_in = auth.create_m2m_token_pair(
+ client_id=client.client_id,
+ )
+
+ logger.info("Issued M2M token pair for client_id: %s", client.client_id)
+
+ return auth.TokenResponse(
+ access_token=access_token,
+ refresh_token=refresh_token,
+ token_type="bearer",
+ expires_in=expires_in,
+ )
+
+
+@router.post("/refresh-token", response_model=auth.TokenResponse)
+async def refresh_token(
+ body: auth.RefreshTokenRequest,
+ db_session: Session = Depends(get_db),
+):
+ """
+ Exchange a valid refresh token for a new access token + refresh token pair.
+ Verifies the client is still active in the DB.
+ """
+ try:
+ payload = jwt.decode(
+ body.refresh_token,
+ auth._secret_key(),
+ algorithms=[auth._jwt_algorithm()],
+ )
+ except JWTError:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Invalid or expired refresh token",
+ )
+
+ if payload.get("type") != "refresh":
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Invalid token type",
+ )
+
+ client_id = payload.get("sub", "")
+ if not client_id:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Invalid refresh token payload",
+ )
+
+ # Confirm client is still active before issuing new tokens
+ client = auth.get_active_m2m_client_by_client_id(db_session, client_id)
+
+ expire_minutes = auth._m2m_token_expire_minutes()
+
+ access_token = auth._create_m2m_token(
+ client_id=client_id,
+ token_type="client_credentials",
+ expires_delta=auth.timedelta(minutes=expire_minutes),
+ )
+
+ new_refresh_token = auth._create_m2m_token(
+ client_id=client_id,
+ token_type="refresh",
+ expires_delta=auth.timedelta(days=auth._m2m_refresh_expire_days()),
+ )
+
+ logger.info("M2M token refreshed for client: %s (%s)", client.name, client_id)
+
+ return auth.TokenResponse(
+ access_token=access_token,
+ refresh_token=new_refresh_token,
+ token_type="bearer",
+ expires_in=expire_minutes * 60,
+ )
\ No newline at end of file
diff --git a/backend/app/router/structural.py b/backend/app/router/structural.py
index 0d78f758..2e38ed37 100644
--- a/backend/app/router/structural.py
+++ b/backend/app/router/structural.py
@@ -1,5 +1,5 @@
"""Structural Endpoints."""
-from typing import Optional, Union, List
+from typing import Optional, Union, List, Dict, Any
from fastapi import (
APIRouter,
Depends,
@@ -16,6 +16,7 @@
validate_admin_only,
validate_all_roles,
ensure_user_from_session_async,
+ verify_session_or_api_key
)
from custom_exceptions import (
NotAvailableException,
@@ -31,23 +32,28 @@
response_model=Union[schema.VersionResponse, List[schema.VersionResponse]],
tags=["Version"]
)
-
async def get_versions(
version_id: Optional[int] = Query(None),
abbreviation: Optional[str] = Query(None),
+ auth: Dict[str, Any] = Depends(verify_session_or_api_key),
db_session: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)
):
"""Get all versions or a single version by ID."""
+
+ if auth["auth_type"] == "session":
+ session = auth["session"]
+ validate_admin_only(session)
+ _, _ = await ensure_user_from_session_async(db_session, session)
+
logger.info("GET Version API")
- validate_admin_only(session)
- _, _ = await ensure_user_from_session_async(db_session, session)
+
if version_id is not None or abbreviation is not None:
- db_obj = structural_crud.get_version(db_session, version_id,abbreviation)
+ db_obj = structural_crud.get_version(db_session, version_id, abbreviation)
if not db_obj:
logger.error("Version not found")
raise NotAvailableException(detail="Version not found")
return db_obj
+
return structural_crud.get_all_versions(db_session)
@@ -145,14 +151,16 @@ async def delete_versions_bulk(
)
async def get_languages(
params: schema.LanguageQueryParams = Depends(),
+ auth: Dict[str, Any] = Depends(verify_session_or_api_key),
db_session: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)
):
"""Get languages with pagination and optional filtering."""
logger.info("GET Languages API")
-
- validate_admin_only(session)
- _, _roles = await ensure_user_from_session_async(db_session, session)
+
+ if auth["auth_type"] == "session":
+ session = auth["session"]
+ validate_admin_only(session)
+ _, _roles = await ensure_user_from_session_async(db_session, session)
languages, total_items = structural_crud.get_languages_with_pagination(
db_session=db_session,
@@ -168,10 +176,12 @@ async def get_languages(
language_items = [
schema.LanguageResponseItem(
- language_id=lang.language_id,
- language_name=lang.language_name,
- language_code=lang.language_code,
- metadata=lang.meta_data
+ language_id=lang["id"],
+ language_name=lang["name"],
+ language_code=lang["code"],
+ metadata=lang.get("meta_data"),
+ local_script_name=lang.get("local_script_name"),
+ script_direction=lang.get("script_direction"),
)
for lang in languages
]
@@ -202,7 +212,9 @@ async def create_language(
language_id=db_obj.language_id,
language_name=db_obj.language_name,
language_code=db_obj.language_code,
- metadata=db_obj.meta_data
+ metadata=db_obj.meta_data,
+ local_script_name=db_obj.local_script_name,
+ script_direction=db_obj.script_direction,
)
@router.put(
@@ -232,7 +244,9 @@ async def update_language(
language_id=db_obj.language_id,
language_name=db_obj.language_name,
language_code=db_obj.language_code,
- metadata=db_obj.meta_data
+ metadata=db_obj.meta_data,
+ local_script_name=db_obj.local_script_name,
+ script_direction=db_obj.script_direction,
)
@router.delete(
@@ -290,12 +304,16 @@ async def get_licenses(
license_id: Optional[int] = Query(None, description="Filter by license ID"),
name: Optional[str] = Query(None, description="Filter by license name (partial match)"),
db_session: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data)
+ auth: Dict[str, Any] = Depends(verify_session_or_api_key),
):
"""Get licenses with optional filtering."""
logger.info("GET License API")
- validate_admin_only(session)
- _, _ = await ensure_user_from_session_async(db_session, session)
+
+ if auth["auth_type"] == "session":
+ session = auth["session"]
+ validate_admin_only(session)
+ _, _ = await ensure_user_from_session_async(db_session, session)
+
licenses = structural_crud.get_licenses_with_filters(
db_session=db_session,
license_id=license_id,
@@ -403,12 +421,14 @@ async def delete_licenses_bulk(
async def list_resources_route(
params: schema.ResourceQueryParams = Depends(),
db: Session = Depends(get_db),
- session: SessionContainer = Depends(verify_session_data),
+ auth: Dict[str, Any] = Depends(verify_session_or_api_key),
):
"""Get resources with pagination and optional filtering."""
logger.info("GET Resource API")
- validate_all_roles(session)
- _, _ = await ensure_user_from_session_async(db, session)
+ if auth["auth_type"] == "session":
+ session = auth["session"]
+ validate_all_roles(session)
+ _, _ = await ensure_user_from_session_async(db, session)
filters = schema.ResourceFilter(
resource_id=params.resource_id,
diff --git a/backend/app/schema.py b/backend/app/schema.py
index 5cb75dc5..64f6446e 100644
--- a/backend/app/schema.py
+++ b/backend/app/schema.py
@@ -2,6 +2,7 @@
from typing import Any, Union,Literal,Optional,Dict, List,Set
import re
from enum import Enum
+from pydantic import RootModel
from datetime import datetime,date
from pydantic_core import PydanticCustomError
from pydantic import BaseModel,field_validator,Field, model_validator,ValidationError
@@ -38,6 +39,7 @@ class ContentTypeEnum(str, Enum):
INFOGRAPHICS = "infographics"
OBS = "obs"
ISL_BIBLE = "isl_bible"
+ SONG = "song"
#--- Content Type Schemas---#
# class ContentTypeBase(BaseModel):
@@ -71,15 +73,24 @@ class VersionBase(BaseModel):
abbreviation: str
metadata: Any = Field(None, alias="meta_data", serialization_alias="metadata")
- @field_validator("name", "abbreviation")
+ @field_validator("name")
@classmethod
- def must_be_alphanumeric(cls, value: str) -> str:
- """Validate name and abbreviation."""
+ def validate_name(cls, value: str) -> str:
+ """Validate name — only reject empty/blank."""
if not value.strip():
- raise ValueError("Field must not be empty or blank")
+ raise ValueError("Name must not be empty or blank")
+ return value.strip()
+
+ @field_validator("abbreviation")
+ @classmethod
+ def validate_abbreviation(cls, value: str) -> str:
+ """Validate abbreviation — only letters, numbers, hyphens."""
+ if not value.strip():
+ raise ValueError("Abbreviation must not be empty or blank")
if not re.match(r"^[A-Za-z0-9\- ]+$", value):
raise ValueError("Only letters, numbers, spaces, and hyphens are allowed")
- return value
+ return value.strip()
+
model_config = {
"from_attributes": True,
"populate_by_name": True
@@ -111,6 +122,8 @@ class LanguageBase(BaseModel):
"""Base schema for language with shared attributes."""
language_code: str = Field(..., alias="languageCode")
language_name: str = Field(..., alias="languageName")
+ local_script_name: str = Field(..., alias="localScriptName")
+ script_direction: str = Field(..., alias="scriptDirection")
metadata: Optional[Dict[str, Any]] = Field(
default=None,
example={"key": "value"}
@@ -138,6 +151,8 @@ class LanguageResponseItem(BaseModel):
language_id: int
language_name: str
language_code: str
+ local_script_name: Optional[str] = None # New field
+ script_direction: Optional[str] = None # New field
metadata: Optional[Dict[str, Any]] = None
model_config = {
@@ -398,6 +413,8 @@ class LanguageBrief(BaseModel):
id: int
code:str
name: str
+ local_script_name: Optional[str] = None
+ script_direction: Optional[str] = None
# --- Response for GET ---
@@ -626,6 +643,11 @@ class BulkDeleteResponse(BaseModel):
results: List[DeleteResult]
summary: DeleteSummary
+class BibleSearchItem(BaseModel):
+ bookCode: str
+ chapter: int
+ verse: int
+ text: str
# --- Video Schemas ---
class VideoItem(BaseModel):
@@ -873,10 +895,10 @@ class CommentaryBulkDeleteResponse(BaseModel):
class AuthFirstBody:
"""Custom body parser that validates authorization before parsing request body"""
-
+
def __init__(self, model: type):
self.model = model
-
+
async def __call__(
self,
request: Request,
@@ -884,15 +906,15 @@ async def __call__(
):
# Import inside to avoid circular imports
from auth import validate_admin_editor, ensure_user_from_session_async
-
+
# Check authorization FIRST
validate_admin_editor(session)
-
+
# Create database session manually
db = SessionLocal()
try:
actor_id, _ = await ensure_user_from_session_async(db, session)
-
+
# Parse JSON with proper error handling
try:
body = await request.json()
@@ -914,7 +936,7 @@ async def __call__(
"type": "value_error"
}]
)
-
+
# Validate with Pydantic
try:
payload = self.model(**body)
@@ -929,7 +951,7 @@ async def __call__(
"type": "value_error"
}]
)
-
+
return payload, actor_id, db
except HTTPException:
db.close()
@@ -1429,7 +1451,7 @@ class AudioBibleQueryParams(BaseModel):
class AudioBibleBulkDeleteRequest(BaseModel):
"""Bulk delete request for audio bibles."""
audio_bible_ids: List[int] = Field(
- ...,
+ ...,
description="List of audio_bible_ids to delete"
)
@@ -1986,7 +2008,7 @@ class VideoTestItem(BaseModel):
public: bool
class IslVideoTestItem(BaseModel):
"""Single ISL Video test result row"""
- islvideoId: int
+ islvideoId: int
book: int
chapter: int
url: str
@@ -2009,7 +2031,7 @@ class VideoRemoteTestResponse(BaseModel):
videos: List[VideoTestItem]
class IslVideoTestItem(BaseModel):
"""Single ISL Video test result row"""
- islvideoId: int
+ islvideoId: int
book: int
chapter: int
url: str
@@ -2136,4 +2158,306 @@ class ErrorLogQueryParams(BaseModel):
default=1000,
le=1000,
description="Maximum number of logs to return (<= 1000)",
- )
\ No newline at end of file
+ )
+
+class BookNameBase(BaseModel):
+ abbr: str
+ short: str
+ long: str
+ bookCode: str
+ languageCode: str
+
+
+class BookNameDelete(BaseModel):
+ ids: List[int]
+
+class BookNameResponse(BaseModel):
+ id: int
+ abbr: str
+ short: str
+ long: str
+ book_id: int
+ language_id: int
+
+ class Config:
+ from_attributes = True
+
+
+class LanguageBasicResponse(BaseModel):
+ id: int
+ code: str
+ name: str
+
+ class Config:
+ from_attributes = True
+
+
+class LanguageWithBooksResponse(BaseModel):
+ language: LanguageBasicResponse
+ bookNames: List[BookNameResponse]
+
+
+class BookNameListResponse(BaseModel):
+ data: List[LanguageWithBooksResponse]
+class BookNameUpdate(BookNameBase):
+ id: int # primary key of the BookName record to update
+
+class SongBase(BaseModel):
+ """Base schema for song with shared attributes."""
+ name: str
+ url: Optional[str] = None
+
+ @field_validator("name")
+ def validate_name(cls, v):
+ if not v or not v.strip():
+ raise ValueError("Song name must not be empty or blank")
+ return v.strip()
+
+
+class SongCreate(SongBase):
+ """Schema for creating a new song."""
+ lyrics: Optional[str] = None # nullable
+
+ @field_validator("url")
+ def validate_url(cls, v):
+ if v is not None and not v.strip().endswith(".mp3"):
+ raise ValueError("url must be a valid .mp3 file path e.g. 'mp1/SongName.mp3'")
+ return v
+
+ @field_validator("lyrics")
+ def validate_lyrics(cls, v):
+ if v is not None and not v.strip():
+ raise ValueError("Lyrics must not be empty or blank")
+ return v
+
+
+class SongListItem(SongBase):
+ """Schema for listing songs (without lyrics)."""
+ id: int
+ model_config = {"from_attributes": True}
+
+
+class SongUpdate(BaseModel):
+ """Schema for updating a song."""
+ id: int
+ name: Optional[str] = None
+ url: Optional[str] = None
+ lyrics: Optional[str] = None
+
+ @field_validator("url")
+ def validate_url(cls, v):
+ if v is not None and not v.strip().endswith(".mp3"):
+ raise ValueError("url must be a valid .mp3 file path e.g. 'mp1/SongName.mp3'")
+ return v
+
+ @model_validator(mode="after")
+ def validate_fields(self):
+ if not any([self.name, self.url, self.lyrics]):
+ raise ValueError("At least one field must be provided")
+ return self
+
+class SongBulkCreate(BaseModel):
+ """Schema for bulk creating songs."""
+ resource_id: int
+ songs: List[SongCreate]
+
+
+class SongBulkUpdate(BaseModel):
+ """Schema for bulk updating songs."""
+ resource_id: int
+ songs: List[SongUpdate]
+
+
+class SongBulkDelete(BaseModel):
+ """Schema for bulk deleting songs."""
+ song_ids: List[int]
+
+class SongOut(SongBase):
+ """Schema for returning a song."""
+ id: int
+ lyrics: Optional[str] = None
+
+ model_config = {"from_attributes": True}
+
+
+class SongsListOut(BaseModel):
+ """Schema for returning a list of songs."""
+ resourceId: int
+ content: List[SongListItem]
+
+class SongCreateResponse(BaseModel):
+ """Schema for response when creating a song."""
+ message: str
+ ids: List[int]
+
+
+class SongUpdateResponse(BaseModel):
+ """Schema for response when updating a song."""
+ message: str
+ updated_ids: List[int]
+ not_found_ids: List[int]
+
+
+class SongDeleteResponse(BaseModel):
+ deletedCount: int
+ deletedIds: List[int]
+ invalidIds: List[int]
+ message: str
+
+# --- ISL Verse Markers Schemas ---
+TIME_PATTERN = re.compile(r"^\d{2}:\d{2}:\d{2}:\d{2}$")
+
+class VerseMarkerItem(BaseModel):
+ """Schema for isl marker item"""
+ verse: Union[int, str] = Field(...)
+ time: str = Field(..., example="00:00:00:00")
+
+ @field_validator("verse")
+ @classmethod
+ def validate_verse(cls, v):
+ """Validate verse - must be non-negative int or a range string like '1_3'"""
+ if isinstance(v, str):
+ if v.isdigit():
+ return int(v)
+
+ # Check for negative numeric string
+ if v.lstrip("-").isdigit():
+ raise ValueError("verse cannot be negative")
+
+ # Allow range format like "1_3"
+ parts = v.split("_")
+ if len(parts) == 2:
+ try:
+ start, end = int(parts[0]), int(parts[1])
+ if start < 0 or end < 0:
+ raise ValueError("verse range values cannot be negative")
+ if start >= end:
+ raise ValueError("verse range start must be less than end")
+ return v
+ except ValueError as exc:
+ raise ValueError(
+ "verse range must be in format 'start_end' "
+ "with integers"
+ ) from exc
+ raise ValueError("verse must be a non-negative integer or range string like '1_3'")
+
+ if isinstance(v, int):
+ if v < 0:
+ raise ValueError("verse cannot be negative")
+ return v
+
+ raise ValueError("verse must be an integer or string")
+
+ @field_validator("time")
+ @classmethod
+ def validate_time_format(cls, v):
+ """Validate time format"""
+ if not v or not v.strip():
+ raise ValueError("time is required and cannot be empty")
+
+ if not TIME_PATTERN.match(v):
+ raise ValueError("time must be in format HH:MM:SS:FF")
+
+ hh, mm, ss, ff = map(int, v.split(":"))
+
+ if mm >= 60 or ss >= 60:
+ raise ValueError("minutes and seconds must be between 00 and 59")
+
+ return v
+
+
+def timestamp_to_frames(timestamp: str) -> int:
+ """
+ Converts HH:MM:SS:FF to sortable integer.
+ """
+ hh, mm, ss, ff = map(int, timestamp.split(":"))
+ return (((hh * 60) + mm) * 60 + ss) * 100 + ff
+
+
+class VerseMarkersCreateRequest(BaseModel):
+ """Schema for create and update isl marker request"""
+ markers: List[VerseMarkerItem] = Field(..., min_length=1)
+
+ @field_validator("markers")
+ @classmethod
+ def validate_markers(cls, markers):
+ """Validate markers"""
+
+ verse_numbers = [str(m.verse) for m in markers]
+
+ duplicates = {
+ v for v in verse_numbers
+ if verse_numbers.count(v) > 1
+ }
+
+ if duplicates:
+ raise ValueError(
+ f"Duplicate verse numbers are not allowed: {sorted(duplicates)}"
+ )
+
+ previous_time = -1
+
+ for marker in markers:
+ current_time = timestamp_to_frames(marker.time)
+
+ if current_time <= previous_time:
+ raise ValueError(
+ "timestamps must be in strictly increasing order"
+ )
+ previous_time = current_time
+
+ return markers
+
+# class IslVerseMarkersBulkCreateRequest(BaseModel):
+# """
+# Bulk create request where key is isl_video_id
+# """
+
+# data: Dict[int, List[VerseMarkerItem]]
+
+# @field_validator("data")
+# @classmethod
+# def validate_data(cls, value):
+# if not value:
+# raise ValueError("data cannot be empty")
+
+# return value
+
+class IslVerseMarkersBulkCreateRequest(
+ RootModel[Dict[int, List[VerseMarkerItem]]]
+):
+ class Config:
+ json_schema_extra = {
+ "example": {
+ "1": [
+ {
+ "verse": 1,
+ "time": "00:09:00:00"
+ }
+ ],
+ "2": [
+ {
+ "verse": "2",
+ "time": "00:10:20:10"
+ }
+ ]
+ }
+ }
+
+class VerseMarkersResponse(BaseModel):
+ """Schema for bulk delete isl marker response"""
+ id: int
+ isl_bible_id: int
+ markers: List[VerseMarkerItem]
+ message: Optional[str] = None
+
+class IslVerseMarkersBulkDelete(BaseModel):
+ """Schema for bulk delete isl marker response"""
+ isl_bible_ids: List[int]
+
+class IslVerseMarkersBulkDeleteResponse(BaseModel):
+ """bulk delete isl marker item"""
+ deletedCount: int
+ deletedIds: List[int]
+ errors: Optional[List[str]]
+
diff --git a/backend/app/scripts/generate_credentials.py b/backend/app/scripts/generate_credentials.py
new file mode 100644
index 00000000..6de8407f
--- /dev/null
+++ b/backend/app/scripts/generate_credentials.py
@@ -0,0 +1,62 @@
+"""
+Run once to provision a new M2M client.
+Usage:
+ python scripts/generate_credentials.py --client-id vachan-nextjs-prod --name "Vachan Online Next.js Prod"
+Output:
+ M2M credentials created successfully
+
+ - client_id: vachan-nextjs-prod
+ - client_secret: pJ8ZkQlYmb-kG7Gcc3NNehIVeclix_EoAGIiWt9gFZE
+ - name: Vachan Online Next.js Prod
+"""
+import argparse
+import secrets
+from passlib.context import CryptContext
+import db_models
+from database import SessionLocal
+
+pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
+
+
+def main():
+ parser = argparse.ArgumentParser(description="Generate M2M client credentials")
+ parser.add_argument("--client-id", required=True, help="Unique client_id")
+ parser.add_argument("--name", required=True, help="Display name for the client")
+ args = parser.parse_args()
+ client_id = args.client_id.strip()
+ name = args.name.strip()
+ # Keep secret short enough for bcrypt. token_urlsafe(32) is usually ~43 chars.
+ plain_client_secret = secrets.token_urlsafe(32)
+ client_secret_hash = pwd_context.hash(plain_client_secret)
+
+ db = SessionLocal()
+ try:
+ existing = (
+ db.query(db_models.M2MClient)
+ .filter_by(client_id=client_id)
+ .first()
+ )
+ if existing:
+ raise ValueError(f"client_id already exists: {client_id}")
+
+ client = db_models.M2MClient(
+ client_id=client_id,
+ client_secret_hash=client_secret_hash,
+ name=name,
+ is_active=True,
+ )
+ db.add(client)
+ db.commit()
+ db.refresh(client)
+ print("\nM2M credentials created successfully\n")
+ print(f"client_id: {client_id}")
+ print(f"client_secret: {plain_client_secret}")
+ print(f"name: {name}")
+ print("\nStore client_secret only in the calling app's secure env.\n")
+
+ finally:
+ db.close()
+
+
+if __name__ == "__main__":
+ main()
\ No newline at end of file
diff --git a/backend/requirements.txt b/backend/requirements.txt
index 6405b1b5..5793c3ba 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -8,5 +8,9 @@ beautifulsoup4==4.12.3
html5lib==1.1
pylint==4.0.3
sniffio==1.3.1
+python-jose==3.3.0
+passlib==1.7.4
+bcrypt==4.3.0
+python-dotenv==1.1.1
diff --git a/docker/docker_backend/Readme.md b/docker/docker_backend/Readme.md
index b9a32796..828c5317 100644
--- a/docker/docker_backend/Readme.md
+++ b/docker/docker_backend/Readme.md
@@ -90,6 +90,9 @@ SUPERTOKENS_COOKIE_SECURE=true
SUPERTOKENS_COOKIE_DOMAIN=api.dev-admin.vachanengine.org
SUPERTOKENS_COOKIE_SAME_SITE=lax
```
+# ---- valid api key ---#
+VALID_API_KEYS=your api key
+
**Important Security Notes:**
- Replace default passwords with strong, unique passwords
diff --git a/docker/docker_backend/docker-compose-prod.yml b/docker/docker_backend/docker-compose-prod.yml
deleted file mode 100644
index 317340d5..00000000
--- a/docker/docker_backend/docker-compose-prod.yml
+++ /dev/null
@@ -1,134 +0,0 @@
-version: "3.9"
-services:
- # === SUPERTOKENS DB ===
- supertokens-db:
- image: "postgres:latest"
- environment:
- - POSTGRES_USER=${SUPERTOKENS_DB_USER}
- - POSTGRES_PASSWORD=${SUPERTOKENS_DB_PASSWORD}
- - POSTGRES_DB=${SUPERTOKENS_DB_NAME}
- ports:
- - 5437:5432
- networks:
- - va-network
- restart: unless-stopped
- volumes:
- - supertokens_postgres_data:/var/lib/postgresql/data
- healthcheck:
- test: ["CMD", "pg_isready", "-U", "supertokens_user", "-d", "supertokens"]
- interval: 5s
- timeout: 5s
- retries: 5
-
- # === SUPERTOKENS CORE ===
- supertokens:
- image: registry.supertokens.io/supertokens/supertokens-postgresql:latest
- depends_on:
- supertokens-db:
- condition: service_healthy
- ports:
- - 3567:3567
- environment:
- - POSTGRESQL_CONNECTION_URI=${SUPERTOKENS_DB_CONNECTION_URI}
- - API_KEYS=${SUPERTOKENS_API_KEY}
- networks:
- - va-network
- restart: unless-stopped
- healthcheck:
- test: >
- bash -c 'exec 3<>/dev/tcp/127.0.0.1/3567 && echo -e "GET /hello HTTP/1.1\r\nhost: 127.0.0.1:3567\r\nConnection: close\r\n\r\n" >&3 && cat <&3 | grep "Hello"'
- interval: 10s
- timeout: 5s
- retries: 5
-
- # === VACHAN ADMIN DB ===
- vachan-admin-db:
- image: postgres:15.2
- healthcheck:
- timeout: 45s
- interval: 10s
- retries: 10
- restart: always
- environment:
- - POSTGRES_USER=${VACHAN_ADMIN_POSTGRES_USER}
- - POSTGRES_PASSWORD=${VACHAN_ADMIN_POSTGRES_PASSWORD}
- - POSTGRES_DB=${VACHAN_ADMIN_POSTGRES_DATABASE}
- - POSTGRES_HOST_AUTH_METHOD=md5
- logging:
- options:
- max-size: 10m
- max-file: "3"
- expose:
- - 5432
- # ports:
- # - "5440:5432"
- networks:
- - va-network
- volumes:
- - vachan-admin-db-vol:/var/lib/postgresql/data
-
- # === VACHAN ADMIN APP ===
- vachan_admin_app:
- build:
- context: ../../backend
- dockerfile: ../docker/docker_backend/Dockerfile
- healthcheck:
- timeout: 45s
- interval: 10s
- retries: 10
- environment:
- # === Database (Vachan Admin) ===
- - VACHAN_ADMIN_POSTGRES_HOST=vachan-admin-db
- - LOG_LEVEL=${LOG_LEVEL}
- - VACHAN_ADMIN_POSTGRES_PORT=${VACHAN_ADMIN_POSTGRES_PORT}
- - VACHAN_ADMIN_POSTGRES_USER=${VACHAN_ADMIN_POSTGRES_USER}
- - VACHAN_ADMIN_POSTGRES_PASSWORD=${VACHAN_ADMIN_POSTGRES_PASSWORD}
- - VACHAN_ADMIN_POSTGRES_DATABASE=${VACHAN_ADMIN_POSTGRES_DATABASE}
-
- # === Auth (SuperTokens) ===
- - SUPERTOKENS_CONNECTION_URI=http://supertokens:3567
-
- # === SuperTokens App Info ===
- - SUPERTOKENS_API_DOMAIN=${SUPERTOKENS_API_DOMAIN}
- - SUPERTOKENS_WEBSITE_DOMAIN=${SUPERTOKENS_WEBSITE_DOMAIN}
- - SUPERTOKENS_API_BASE_PATH=/auth
- - SUPERTOKENS_WEBSITE_BASE_PATH=/auth
-
- # === SuperTokens Security ===
- # Disable anti-CSRF only on staging to allow Swagger to call POST
- - SUPERTOKENS_ANTI_CSRF=${SUPERTOKENS_ANTI_CSRF}
- # Cookie settings (optional; sensible defaults are inferred)
- - SUPERTOKENS_COOKIE_DOMAIN=${SUPERTOKENS_COOKIE_DOMAIN}
- - SUPERTOKENS_COOKIE_SECURE=${SUPERTOKENS_COOKIE_SECURE}
- - SUPERTOKENS_COOKIE_SAME_SITE=${SUPERTOKENS_COOKIE_SAME_SITE}
-
- # === SMTP (Email) ===
- - SMTP_HOST=${SMTP_HOST}
- - SMTP_PORT=${SMTP_PORT}
- - SMTP_NAME=${SMTP_NAME}
- - SMTP_EMAIL=${SMTP_EMAIL}
- - SMTP_PASSWORD=${SMTP_PASSWORD}
- - SMTP_SECURE=${SMTP_SECURE}
- - DOCKER_RUN=True
-
- command: uvicorn main:app --host 0.0.0.0 --port 8000
- volumes:
- - logs-vol:/app/logs
- restart: always
- depends_on:
- - vachan-admin-db
- # expose:
- # - 8000
- ports:
- - "8000:8000"
- networks:
- - va-network
- container_name: vachan_admin_app
-
-networks:
- va-network:
-
-volumes:
- vachan-admin-db-vol:
- logs-vol:
- supertokens_postgres_data:
diff --git a/docker/docker_backend/docker-compose.yml b/docker/docker_backend/docker-compose.yml
index 1e727b42..91475910 100644
--- a/docker/docker_backend/docker-compose.yml
+++ b/docker/docker_backend/docker-compose.yml
@@ -67,8 +67,70 @@ services:
volumes:
- vachan-admin-db-vol:/var/lib/postgresql/data
- # === VACHAN ADMIN APP ===
- vachan_admin_app:
+ # === VACHAN ADMIN APP (READ) — handles GET requests from Vachan Online ===
+ vachan_admin_app_read:
+ mem_limit: 768m
+ memswap_limit: 1g
+ build:
+ context: ../../backend
+ dockerfile: ../docker/docker_backend/Dockerfile
+ healthcheck:
+ timeout: 45s
+ interval: 10s
+ retries: 10
+ environment:
+ # === Database (Vachan Admin) ===
+ - VACHAN_ADMIN_POSTGRES_HOST=vachan-admin-db
+ - LOG_LEVEL=${LOG_LEVEL}
+ - VACHAN_ADMIN_POSTGRES_PORT=${VACHAN_ADMIN_POSTGRES_PORT}
+ - VACHAN_ADMIN_POSTGRES_USER=${VACHAN_ADMIN_POSTGRES_USER}
+ - VACHAN_ADMIN_POSTGRES_PASSWORD=${VACHAN_ADMIN_POSTGRES_PASSWORD}
+ - VACHAN_ADMIN_POSTGRES_DATABASE=${VACHAN_ADMIN_POSTGRES_DATABASE}
+
+ # === Auth (SuperTokens) ===
+ - SUPERTOKENS_CONNECTION_URI=http://supertokens:3567
+
+ # === SuperTokens App Info ===
+ - SUPERTOKENS_API_DOMAIN=${SUPERTOKENS_API_DOMAIN}
+ - SUPERTOKENS_WEBSITE_DOMAIN=${SUPERTOKENS_WEBSITE_DOMAIN}
+ - SUPERTOKENS_API_BASE_PATH=/auth
+ - SUPERTOKENS_WEBSITE_BASE_PATH=/auth
+
+ # === SuperTokens Security ===
+ - SUPERTOKENS_ANTI_CSRF=${SUPERTOKENS_ANTI_CSRF}
+ - SUPERTOKENS_COOKIE_DOMAIN=${SUPERTOKENS_COOKIE_DOMAIN}
+ - SUPERTOKENS_COOKIE_SECURE=${SUPERTOKENS_COOKIE_SECURE}
+ - SUPERTOKENS_COOKIE_SAME_SITE=${SUPERTOKENS_COOKIE_SAME_SITE}
+
+ # === SMTP (Email) ===
+ - SMTP_HOST=${SMTP_HOST}
+ - SMTP_PORT=${SMTP_PORT}
+ - SMTP_NAME=${SMTP_NAME}
+ - SMTP_EMAIL=${SMTP_EMAIL}
+ - SMTP_PASSWORD=${SMTP_PASSWORD}
+ - SMTP_SECURE=${SMTP_SECURE}
+ - DOCKER_RUN=True
+ - VALID_API_KEYS=${VALID_API_KEYS}
+ - JWT_SECRET_KEY=${JWT_SECRET_KEY}
+ - JWT_ALGORITHM=${JWT_ALGORITHM}
+ - M2M_TOKEN_EXPIRE_MINUTES=${M2M_TOKEN_EXPIRE_MINUTES}
+ - M2M_REFRESH_TOKEN_EXPIRE_DAYS=${M2M_REFRESH_TOKEN_EXPIRE_DAYS}
+
+
+ command: uvicorn main:app --host 0.0.0.0 --port 8001 --workers 4
+ expose:
+ - 8001
+ volumes:
+ - logs-vol:/app/logs
+ restart: always
+ depends_on:
+ - vachan-admin-db
+ networks:
+ - va-network
+ container_name: vachan_admin_app_read
+
+ # === VACHAN ADMIN APP (WRITE) — handles POST/PUT/DELETE from VA Admin UI ===
+ vachan_admin_app_write:
mem_limit: 512m
memswap_limit: 768m
build:
@@ -97,9 +159,8 @@ services:
- SUPERTOKENS_WEBSITE_BASE_PATH=/auth
# === SuperTokens Security ===
- # Disable anti-CSRF only on staging to allow Swagger to call POST
+ # Disable anti-CSRF on staging to allow Swagger to call POST
- SUPERTOKENS_ANTI_CSRF=${SUPERTOKENS_ANTI_CSRF}
- # Cookie settings (optional; sensible defaults are inferred)
- SUPERTOKENS_COOKIE_DOMAIN=${SUPERTOKENS_COOKIE_DOMAIN}
- SUPERTOKENS_COOKIE_SECURE=${SUPERTOKENS_COOKIE_SECURE}
- SUPERTOKENS_COOKIE_SAME_SITE=${SUPERTOKENS_COOKIE_SAME_SITE}
@@ -112,20 +173,38 @@ services:
- SMTP_PASSWORD=${SMTP_PASSWORD}
- SMTP_SECURE=${SMTP_SECURE}
- DOCKER_RUN=True
+ - VALID_API_KEYS=${VALID_API_KEYS}
+ - JWT_SECRET_KEY=${JWT_SECRET_KEY}
+ - JWT_ALGORITHM=${JWT_ALGORITHM}
+ - M2M_TOKEN_EXPIRE_MINUTES=${M2M_TOKEN_EXPIRE_MINUTES}
+ - M2M_REFRESH_TOKEN_EXPIRE_DAYS=${M2M_REFRESH_TOKEN_EXPIRE_DAYS}
+
- command: uvicorn main:app --host 0.0.0.0 --port 8000
+ command: uvicorn main:app --host 0.0.0.0 --port 8002 --workers 2
+ expose:
+ - 8002
volumes:
- - logs-vol:/app/logs
+ - logs-vol:/app/logs
restart: always
depends_on:
- vachan-admin-db
- # expose:
- # - 8000
+ networks:
+ - va-network
+ container_name: vachan_admin_app_write
+
+ nginx:
+ image: nginx:1.25-alpine
ports:
- "8000:8000"
+ volumes:
+ - ../../docker/docker_backend/nginx.conf:/etc/nginx/nginx.conf:ro
+ depends_on:
+ - vachan_admin_app_read
+ - vachan_admin_app_write
+ restart: always
networks:
- va-network
- container_name: vachan_admin_app
+ container_name: vachan_nginx
networks:
va-network:
diff --git a/docker/docker_backend/nginx.conf b/docker/docker_backend/nginx.conf
new file mode 100644
index 00000000..7be0e595
--- /dev/null
+++ b/docker/docker_backend/nginx.conf
@@ -0,0 +1,68 @@
+worker_processes auto;
+
+events {
+
+ worker_connections 1024;
+
+}
+
+http {
+
+ map $request_method $backend_upstream {
+
+ GET http://vachan_read;
+
+ HEAD http://vachan_read;
+
+ OPTIONS http://vachan_read;
+
+ default http://vachan_write;
+
+ }
+
+ upstream vachan_read {
+
+ server vachan_admin_app_read:8001;
+
+ keepalive 32;
+
+ }
+
+ upstream vachan_write {
+
+ server vachan_admin_app_write:8002;
+
+ keepalive 32;
+
+ }
+
+ server {
+
+ listen 8000;
+
+ proxy_read_timeout 120s;
+
+ proxy_send_timeout 120s;
+
+ client_max_body_size 50m;
+
+ location / {
+
+ proxy_http_version 1.1;
+
+ proxy_pass $backend_upstream;
+
+ proxy_set_header Host $host;
+
+ proxy_set_header X-Real-IP $remote_addr;
+
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+
+ proxy_set_header X-Forwarded-Proto $scheme;
+
+ }
+
+ }
+
+}
+
\ No newline at end of file
diff --git a/frontend/index.html b/frontend/index.html
index e4b78eae..89937481 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -2,9 +2,9 @@
-
+
- Vite + React + TS
+ Vachan Admin
diff --git a/frontend/package.json b/frontend/package.json
index a751a9ba..d28881b9 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -30,6 +30,7 @@
"lucide-react": "^0.542.0",
"next-themes": "^0.4.6",
"papaparse": "^5.5.3",
+ "radix-ui": "^1.4.3",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-markdown": "^10.1.0",
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml
index f77693c8..044fc00d 100644
--- a/frontend/pnpm-lock.yaml
+++ b/frontend/pnpm-lock.yaml
@@ -68,6 +68,9 @@ importers:
papaparse:
specifier: ^5.5.3
version: 5.5.3
+ radix-ui:
+ specifier: ^1.4.3
+ version: 1.4.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
react:
specifier: ^19.1.1
version: 19.1.1
@@ -540,9 +543,51 @@ packages:
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
engines: {node: '>= 8'}
+ '@radix-ui/number@1.1.1':
+ resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
+
'@radix-ui/primitive@1.1.3':
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
+ '@radix-ui/react-accessible-icon@1.1.7':
+ resolution: {integrity: sha512-XM+E4WXl0OqUJFovy6GjmxxFyx9opfCAIUku4dlKRd5YEPqt4kALOkQOp0Of6reHuUkJuiPBEc5k0o4z4lTC8A==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-accordion@1.2.12':
+ resolution: {integrity: sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-alert-dialog@1.1.15':
+ resolution: {integrity: sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-arrow@1.1.7':
resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==}
peerDependencies:
@@ -556,6 +601,19 @@ packages:
'@types/react-dom':
optional: true
+ '@radix-ui/react-aspect-ratio@1.1.7':
+ resolution: {integrity: sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-avatar@1.1.10':
resolution: {integrity: sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==}
peerDependencies:
@@ -569,6 +627,32 @@ packages:
'@types/react-dom':
optional: true
+ '@radix-ui/react-checkbox@1.3.3':
+ resolution: {integrity: sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-collapsible@1.1.12':
+ resolution: {integrity: sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-collection@1.1.7':
resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==}
peerDependencies:
@@ -591,6 +675,19 @@ packages:
'@types/react':
optional: true
+ '@radix-ui/react-context-menu@2.2.16':
+ resolution: {integrity: sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-context@1.1.2':
resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==}
peerDependencies:
@@ -670,6 +767,32 @@ packages:
'@types/react-dom':
optional: true
+ '@radix-ui/react-form@0.1.8':
+ resolution: {integrity: sha512-QM70k4Zwjttifr5a4sZFts9fn8FzHYvQ5PiB19O2HsYibaHSVt9fH9rzB0XZo/YcM+b7t/p7lYCT/F5eOeF5yQ==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-hover-card@1.1.15':
+ resolution: {integrity: sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-id@1.1.1':
resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==}
peerDependencies:
@@ -679,6 +802,19 @@ packages:
'@types/react':
optional: true
+ '@radix-ui/react-label@2.1.7':
+ resolution: {integrity: sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-menu@2.1.16':
resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==}
peerDependencies:
@@ -692,6 +828,58 @@ packages:
'@types/react-dom':
optional: true
+ '@radix-ui/react-menubar@1.1.16':
+ resolution: {integrity: sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-navigation-menu@1.2.14':
+ resolution: {integrity: sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-one-time-password-field@0.1.8':
+ resolution: {integrity: sha512-ycS4rbwURavDPVjCb5iS3aG4lURFDILi6sKI/WITUMZ13gMmn/xGjpLoqBAalhJaDk8I3UbCM5GzKHrnzwHbvg==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-password-toggle-field@0.1.3':
+ resolution: {integrity: sha512-/UuCrDBWravcaMix4TdT+qlNdVwOM1Nck9kWx/vafXsdfj1ChfhOdfi3cy9SGBpWgTXwYCuboT/oYpJy3clqfw==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-popover@1.1.15':
resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==}
peerDependencies:
@@ -757,6 +945,32 @@ packages:
'@types/react-dom':
optional: true
+ '@radix-ui/react-progress@1.1.7':
+ resolution: {integrity: sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-radio-group@1.3.8':
+ resolution: {integrity: sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-roving-focus@1.1.11':
resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==}
peerDependencies:
@@ -770,6 +984,58 @@ packages:
'@types/react-dom':
optional: true
+ '@radix-ui/react-scroll-area@1.2.10':
+ resolution: {integrity: sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-select@2.2.6':
+ resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-separator@1.1.7':
+ resolution: {integrity: sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-slider@1.3.6':
+ resolution: {integrity: sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-slot@1.2.3':
resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==}
peerDependencies:
@@ -805,6 +1071,58 @@ packages:
'@types/react-dom':
optional: true
+ '@radix-ui/react-toast@1.2.15':
+ resolution: {integrity: sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-toggle-group@1.1.11':
+ resolution: {integrity: sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-toggle@1.1.10':
+ resolution: {integrity: sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-toolbar@1.1.11':
+ resolution: {integrity: sha512-4ol06/1bLoFu1nwUqzdD4Y5RZ9oDdKeiHIsntug54Hcr1pgaHiPqHFEaXI1IFP/EsOfROQZ8Mig9VTIRza6Tjg==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-tooltip@1.2.8':
resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==}
peerDependencies:
@@ -2139,6 +2457,19 @@ packages:
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
+ radix-ui@1.4.3:
+ resolution: {integrity: sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
react-dom@19.1.1:
resolution: {integrity: sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==}
peerDependencies:
@@ -2913,8 +3244,50 @@ snapshots:
'@nodelib/fs.scandir': 2.1.5
fastq: 1.19.1
+ '@radix-ui/number@1.1.1': {}
+
'@radix-ui/primitive@1.1.3': {}
+ '@radix-ui/react-accessible-icon@1.1.7(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
+ dependencies:
+ '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ react: 19.1.1
+ react-dom: 19.1.1(react@19.1.1)
+ optionalDependencies:
+ '@types/react': 19.1.12
+ '@types/react-dom': 19.1.9(@types/react@19.1.12)
+
+ '@radix-ui/react-accordion@1.2.12(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.12)(react@19.1.1)
+ react: 19.1.1
+ react-dom: 19.1.1(react@19.1.1)
+ optionalDependencies:
+ '@types/react': 19.1.12
+ '@types/react-dom': 19.1.9(@types/react@19.1.12)
+
+ '@radix-ui/react-alert-dialog@1.1.15(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-slot': 1.2.3(@types/react@19.1.12)(react@19.1.1)
+ react: 19.1.1
+ react-dom: 19.1.1(react@19.1.1)
+ optionalDependencies:
+ '@types/react': 19.1.12
+ '@types/react-dom': 19.1.9(@types/react@19.1.12)
+
'@radix-ui/react-arrow@1.1.7(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
dependencies:
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
@@ -2924,44 +3297,321 @@ snapshots:
'@types/react': 19.1.12
'@types/react-dom': 19.1.9(@types/react@19.1.12)
- '@radix-ui/react-avatar@1.1.10(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
+ '@radix-ui/react-aspect-ratio@1.1.7(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
+ dependencies:
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ react: 19.1.1
+ react-dom: 19.1.1(react@19.1.1)
+ optionalDependencies:
+ '@types/react': 19.1.12
+ '@types/react-dom': 19.1.9(@types/react@19.1.12)
+
+ '@radix-ui/react-avatar@1.1.10(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
+ dependencies:
+ '@radix-ui/react-context': 1.1.2(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.12)(react@19.1.1)
+ react: 19.1.1
+ react-dom: 19.1.1(react@19.1.1)
+ optionalDependencies:
+ '@types/react': 19.1.12
+ '@types/react-dom': 19.1.9(@types/react@19.1.12)
+
+ '@radix-ui/react-checkbox@1.3.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-use-previous': 1.1.1(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-use-size': 1.1.1(@types/react@19.1.12)(react@19.1.1)
+ react: 19.1.1
+ react-dom: 19.1.1(react@19.1.1)
+ optionalDependencies:
+ '@types/react': 19.1.12
+ '@types/react-dom': 19.1.9(@types/react@19.1.12)
+
+ '@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.12)(react@19.1.1)
+ react: 19.1.1
+ react-dom: 19.1.1(react@19.1.1)
+ optionalDependencies:
+ '@types/react': 19.1.12
+ '@types/react-dom': 19.1.9(@types/react@19.1.12)
+
+ '@radix-ui/react-collection@1.1.7(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
+ dependencies:
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-slot': 1.2.3(@types/react@19.1.12)(react@19.1.1)
+ react: 19.1.1
+ react-dom: 19.1.1(react@19.1.1)
+ optionalDependencies:
+ '@types/react': 19.1.12
+ '@types/react-dom': 19.1.9(@types/react@19.1.12)
+
+ '@radix-ui/react-compose-refs@1.1.2(@types/react@19.1.12)(react@19.1.1)':
+ dependencies:
+ react: 19.1.1
+ optionalDependencies:
+ '@types/react': 19.1.12
+
+ '@radix-ui/react-context-menu@2.2.16(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-context': 1.1.2(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.12)(react@19.1.1)
+ react: 19.1.1
+ react-dom: 19.1.1(react@19.1.1)
+ optionalDependencies:
+ '@types/react': 19.1.12
+ '@types/react-dom': 19.1.9(@types/react@19.1.12)
+
+ '@radix-ui/react-context@1.1.2(@types/react@19.1.12)(react@19.1.1)':
+ dependencies:
+ react: 19.1.1
+ optionalDependencies:
+ '@types/react': 19.1.12
+
+ '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-slot': 1.2.3(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.12)(react@19.1.1)
+ aria-hidden: 1.2.6
+ react: 19.1.1
+ react-dom: 19.1.1(react@19.1.1)
+ react-remove-scroll: 2.7.1(@types/react@19.1.12)(react@19.1.1)
+ optionalDependencies:
+ '@types/react': 19.1.12
+ '@types/react-dom': 19.1.9(@types/react@19.1.12)
+
+ '@radix-ui/react-direction@1.1.1(@types/react@19.1.12)(react@19.1.1)':
+ dependencies:
+ react: 19.1.1
+ optionalDependencies:
+ '@types/react': 19.1.12
+
+ '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.1.12)(react@19.1.1)
+ react: 19.1.1
+ react-dom: 19.1.1(react@19.1.1)
+ optionalDependencies:
+ '@types/react': 19.1.12
+ '@types/react-dom': 19.1.9(@types/react@19.1.12)
+
+ '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.12)(react@19.1.1)
+ react: 19.1.1
+ react-dom: 19.1.1(react@19.1.1)
+ optionalDependencies:
+ '@types/react': 19.1.12
+ '@types/react-dom': 19.1.9(@types/react@19.1.12)
+
+ '@radix-ui/react-focus-guards@1.1.3(@types/react@19.1.12)(react@19.1.1)':
+ dependencies:
+ react: 19.1.1
+ optionalDependencies:
+ '@types/react': 19.1.12
+
+ '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
+ dependencies:
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.12)(react@19.1.1)
+ react: 19.1.1
+ react-dom: 19.1.1(react@19.1.1)
+ optionalDependencies:
+ '@types/react': 19.1.12
+ '@types/react-dom': 19.1.9(@types/react@19.1.12)
+
+ '@radix-ui/react-form@0.1.8(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-label': 2.1.7(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ react: 19.1.1
+ react-dom: 19.1.1(react@19.1.1)
+ optionalDependencies:
+ '@types/react': 19.1.12
+ '@types/react-dom': 19.1.9(@types/react@19.1.12)
+
+ '@radix-ui/react-hover-card@1.1.15(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.12)(react@19.1.1)
+ react: 19.1.1
+ react-dom: 19.1.1(react@19.1.1)
+ optionalDependencies:
+ '@types/react': 19.1.12
+ '@types/react-dom': 19.1.9(@types/react@19.1.12)
+
+ '@radix-ui/react-id@1.1.1(@types/react@19.1.12)(react@19.1.1)':
+ dependencies:
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.12)(react@19.1.1)
+ react: 19.1.1
+ optionalDependencies:
+ '@types/react': 19.1.12
+
+ '@radix-ui/react-label@2.1.7(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
+ dependencies:
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ react: 19.1.1
+ react-dom: 19.1.1(react@19.1.1)
+ optionalDependencies:
+ '@types/react': 19.1.12
+ '@types/react-dom': 19.1.9(@types/react@19.1.12)
+
+ '@radix-ui/react-menu@2.1.16(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-slot': 1.2.3(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.12)(react@19.1.1)
+ aria-hidden: 1.2.6
+ react: 19.1.1
+ react-dom: 19.1.1(react@19.1.1)
+ react-remove-scroll: 2.7.1(@types/react@19.1.12)(react@19.1.1)
+ optionalDependencies:
+ '@types/react': 19.1.12
+ '@types/react-dom': 19.1.9(@types/react@19.1.12)
+
+ '@radix-ui/react-menubar@1.1.16(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.12)(react@19.1.1)
'@radix-ui/react-context': 1.1.2(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
- '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.12)(react@19.1.1)
- '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.1.12)(react@19.1.1)
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.12)(react@19.1.1)
react: 19.1.1
react-dom: 19.1.1(react@19.1.1)
optionalDependencies:
'@types/react': 19.1.12
'@types/react-dom': 19.1.9(@types/react@19.1.12)
- '@radix-ui/react-collection@1.1.7(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
+ '@radix-ui/react-navigation-menu@1.2.14(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.12)(react@19.1.1)
'@radix-ui/react-context': 1.1.2(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
- '@radix-ui/react-slot': 1.2.3(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-use-previous': 1.1.1(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
react: 19.1.1
react-dom: 19.1.1(react@19.1.1)
optionalDependencies:
'@types/react': 19.1.12
'@types/react-dom': 19.1.9(@types/react@19.1.12)
- '@radix-ui/react-compose-refs@1.1.2(@types/react@19.1.12)(react@19.1.1)':
+ '@radix-ui/react-one-time-password-field@0.1.8(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
dependencies:
+ '@radix-ui/number': 1.1.1
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.12)(react@19.1.1)
react: 19.1.1
+ react-dom: 19.1.1(react@19.1.1)
optionalDependencies:
'@types/react': 19.1.12
+ '@types/react-dom': 19.1.9(@types/react@19.1.12)
- '@radix-ui/react-context@1.1.2(@types/react@19.1.12)(react@19.1.1)':
+ '@radix-ui/react-password-toggle-field@0.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.1.12)(react@19.1.1)
react: 19.1.1
+ react-dom: 19.1.1(react@19.1.1)
optionalDependencies:
'@types/react': 19.1.12
+ '@types/react-dom': 19.1.9(@types/react@19.1.12)
- '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
+ '@radix-ui/react-popover@1.1.15(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.12)(react@19.1.1)
@@ -2970,6 +3620,7 @@ snapshots:
'@radix-ui/react-focus-guards': 1.1.3(@types/react@19.1.12)(react@19.1.1)
'@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-id': 1.1.1(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
@@ -2983,105 +3634,136 @@ snapshots:
'@types/react': 19.1.12
'@types/react-dom': 19.1.9(@types/react@19.1.12)
- '@radix-ui/react-direction@1.1.1(@types/react@19.1.12)(react@19.1.1)':
+ '@radix-ui/react-popper@1.2.8(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
dependencies:
+ '@floating-ui/react-dom': 2.1.6(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-use-rect': 1.1.1(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-use-size': 1.1.1(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/rect': 1.1.1
react: 19.1.1
+ react-dom: 19.1.1(react@19.1.1)
optionalDependencies:
'@types/react': 19.1.12
+ '@types/react-dom': 19.1.9(@types/react@19.1.12)
- '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
+ '@radix-ui/react-portal@1.1.9(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
dependencies:
- '@radix-ui/primitive': 1.1.3
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.12)(react@19.1.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
- '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.12)(react@19.1.1)
- '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.12)(react@19.1.1)
react: 19.1.1
react-dom: 19.1.1(react@19.1.1)
optionalDependencies:
'@types/react': 19.1.12
'@types/react-dom': 19.1.9(@types/react@19.1.12)
- '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
+ '@radix-ui/react-presence@1.1.5(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
dependencies:
- '@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.12)(react@19.1.1)
- '@radix-ui/react-context': 1.1.2(@types/react@19.1.12)(react@19.1.1)
- '@radix-ui/react-id': 1.1.1(@types/react@19.1.12)(react@19.1.1)
- '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.12)(react@19.1.1)
react: 19.1.1
react-dom: 19.1.1(react@19.1.1)
optionalDependencies:
'@types/react': 19.1.12
'@types/react-dom': 19.1.9(@types/react@19.1.12)
- '@radix-ui/react-focus-guards@1.1.3(@types/react@19.1.12)(react@19.1.1)':
+ '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
dependencies:
+ '@radix-ui/react-slot': 1.2.3(@types/react@19.1.12)(react@19.1.1)
react: 19.1.1
+ react-dom: 19.1.1(react@19.1.1)
optionalDependencies:
'@types/react': 19.1.12
+ '@types/react-dom': 19.1.9(@types/react@19.1.12)
- '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
+ '@radix-ui/react-progress@1.1.7(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
dependencies:
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.1.12)(react@19.1.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
- '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.12)(react@19.1.1)
react: 19.1.1
react-dom: 19.1.1(react@19.1.1)
optionalDependencies:
'@types/react': 19.1.12
'@types/react-dom': 19.1.9(@types/react@19.1.12)
- '@radix-ui/react-id@1.1.1(@types/react@19.1.12)(react@19.1.1)':
+ '@radix-ui/react-radio-group@1.3.8(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
dependencies:
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-use-previous': 1.1.1(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-use-size': 1.1.1(@types/react@19.1.12)(react@19.1.1)
react: 19.1.1
+ react-dom: 19.1.1(react@19.1.1)
optionalDependencies:
'@types/react': 19.1.12
+ '@types/react-dom': 19.1.9(@types/react@19.1.12)
- '@radix-ui/react-menu@2.1.16(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
+ '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.12)(react@19.1.1)
'@radix-ui/react-context': 1.1.2(@types/react@19.1.12)(react@19.1.1)
'@radix-ui/react-direction': 1.1.1(@types/react@19.1.12)(react@19.1.1)
- '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
- '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.1.12)(react@19.1.1)
- '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-id': 1.1.1(@types/react@19.1.12)(react@19.1.1)
- '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
- '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.12)(react@19.1.1)
+ react: 19.1.1
+ react-dom: 19.1.1(react@19.1.1)
+ optionalDependencies:
+ '@types/react': 19.1.12
+ '@types/react-dom': 19.1.9(@types/react@19.1.12)
+
+ '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
+ dependencies:
+ '@radix-ui/number': 1.1.1
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.1.12)(react@19.1.1)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
- '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
- '@radix-ui/react-slot': 1.2.3(@types/react@19.1.12)(react@19.1.1)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.12)(react@19.1.1)
- aria-hidden: 1.2.6
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.12)(react@19.1.1)
react: 19.1.1
react-dom: 19.1.1(react@19.1.1)
- react-remove-scroll: 2.7.1(@types/react@19.1.12)(react@19.1.1)
optionalDependencies:
'@types/react': 19.1.12
'@types/react-dom': 19.1.9(@types/react@19.1.12)
- '@radix-ui/react-popover@1.1.15(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
+ '@radix-ui/react-select@2.2.6(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
dependencies:
+ '@radix-ui/number': 1.1.1
'@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.12)(react@19.1.1)
'@radix-ui/react-context': 1.1.2(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.1.12)(react@19.1.1)
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-focus-guards': 1.1.3(@types/react@19.1.12)(react@19.1.1)
'@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-id': 1.1.1(@types/react@19.1.12)(react@19.1.1)
'@radix-ui/react-popper': 1.2.8(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
- '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-slot': 1.2.3(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.12)(react@19.1.1)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-use-previous': 1.1.1(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
aria-hidden: 1.2.6
react: 19.1.1
react-dom: 19.1.1(react@19.1.1)
@@ -3090,102 +3772,127 @@ snapshots:
'@types/react': 19.1.12
'@types/react-dom': 19.1.9(@types/react@19.1.12)
- '@radix-ui/react-popper@1.2.8(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
+ '@radix-ui/react-separator@1.1.7(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
dependencies:
- '@floating-ui/react-dom': 2.1.6(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
- '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.12)(react@19.1.1)
- '@radix-ui/react-context': 1.1.2(@types/react@19.1.12)(react@19.1.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
- '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.12)(react@19.1.1)
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.12)(react@19.1.1)
- '@radix-ui/react-use-rect': 1.1.1(@types/react@19.1.12)(react@19.1.1)
- '@radix-ui/react-use-size': 1.1.1(@types/react@19.1.12)(react@19.1.1)
- '@radix-ui/rect': 1.1.1
react: 19.1.1
react-dom: 19.1.1(react@19.1.1)
optionalDependencies:
'@types/react': 19.1.12
'@types/react-dom': 19.1.9(@types/react@19.1.12)
- '@radix-ui/react-portal@1.1.9(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
+ '@radix-ui/react-slider@1.3.6(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
dependencies:
+ '@radix-ui/number': 1.1.1
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.1.12)(react@19.1.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.12)(react@19.1.1)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-use-previous': 1.1.1(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-use-size': 1.1.1(@types/react@19.1.12)(react@19.1.1)
react: 19.1.1
react-dom: 19.1.1(react@19.1.1)
optionalDependencies:
'@types/react': 19.1.12
'@types/react-dom': 19.1.9(@types/react@19.1.12)
- '@radix-ui/react-presence@1.1.5(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
+ '@radix-ui/react-slot@1.2.3(@types/react@19.1.12)(react@19.1.1)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.12)(react@19.1.1)
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.12)(react@19.1.1)
+ react: 19.1.1
+ optionalDependencies:
+ '@types/react': 19.1.12
+
+ '@radix-ui/react-switch@1.2.6(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-use-previous': 1.1.1(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-use-size': 1.1.1(@types/react@19.1.12)(react@19.1.1)
react: 19.1.1
react-dom: 19.1.1(react@19.1.1)
optionalDependencies:
'@types/react': 19.1.12
'@types/react-dom': 19.1.9(@types/react@19.1.12)
- '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
+ '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
dependencies:
- '@radix-ui/react-slot': 1.2.3(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-context': 1.1.2(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.12)(react@19.1.1)
react: 19.1.1
react-dom: 19.1.1(react@19.1.1)
optionalDependencies:
'@types/react': 19.1.12
'@types/react-dom': 19.1.9(@types/react@19.1.12)
- '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
+ '@radix-ui/react-toast@1.2.15(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.12)(react@19.1.1)
'@radix-ui/react-context': 1.1.2(@types/react@19.1.12)(react@19.1.1)
- '@radix-ui/react-direction': 1.1.1(@types/react@19.1.12)(react@19.1.1)
- '@radix-ui/react-id': 1.1.1(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.12)(react@19.1.1)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
react: 19.1.1
react-dom: 19.1.1(react@19.1.1)
optionalDependencies:
'@types/react': 19.1.12
'@types/react-dom': 19.1.9(@types/react@19.1.12)
- '@radix-ui/react-slot@1.2.3(@types/react@19.1.12)(react@19.1.1)':
+ '@radix-ui/react-toggle-group@1.1.11(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
dependencies:
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-context': 1.1.2(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.12)(react@19.1.1)
react: 19.1.1
+ react-dom: 19.1.1(react@19.1.1)
optionalDependencies:
'@types/react': 19.1.12
+ '@types/react-dom': 19.1.9(@types/react@19.1.12)
- '@radix-ui/react-switch@1.2.6(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
+ '@radix-ui/react-toggle@1.1.10(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.12)(react@19.1.1)
- '@radix-ui/react-context': 1.1.2(@types/react@19.1.12)(react@19.1.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.12)(react@19.1.1)
- '@radix-ui/react-use-previous': 1.1.1(@types/react@19.1.12)(react@19.1.1)
- '@radix-ui/react-use-size': 1.1.1(@types/react@19.1.12)(react@19.1.1)
react: 19.1.1
react-dom: 19.1.1(react@19.1.1)
optionalDependencies:
'@types/react': 19.1.12
'@types/react-dom': 19.1.9(@types/react@19.1.12)
- '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
+ '@radix-ui/react-toolbar@1.1.11(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-context': 1.1.2(@types/react@19.1.12)(react@19.1.1)
'@radix-ui/react-direction': 1.1.1(@types/react@19.1.12)(react@19.1.1)
- '@radix-ui/react-id': 1.1.1(@types/react@19.1.12)(react@19.1.1)
- '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-separator': 1.1.7(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
react: 19.1.1
react-dom: 19.1.1(react@19.1.1)
optionalDependencies:
@@ -4585,6 +5292,69 @@ snapshots:
queue-microtask@1.2.3: {}
+ radix-ui@1.4.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1):
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-accessible-icon': 1.1.7(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-accordion': 1.2.12(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-alert-dialog': 1.1.15(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-aspect-ratio': 1.1.7(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-avatar': 1.1.10(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-checkbox': 1.3.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-context-menu': 2.2.16(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-dropdown-menu': 2.1.16(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-form': 0.1.8(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-hover-card': 1.1.15(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-label': 2.1.7(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-menubar': 1.1.16(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-navigation-menu': 1.2.14(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-one-time-password-field': 0.1.8(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-password-toggle-field': 0.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-popover': 1.1.15(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-progress': 1.1.7(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-radio-group': 1.3.8(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-scroll-area': 1.2.10(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-select': 2.2.6(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-separator': 1.1.7(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-slider': 1.3.6(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-slot': 1.2.3(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-switch': 1.2.6(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-toast': 1.2.15(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-toolbar': 1.1.11(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-tooltip': 1.2.8(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-use-size': 1.1.1(@types/react@19.1.12)(react@19.1.1)
+ '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ react: 19.1.1
+ react-dom: 19.1.1(react@19.1.1)
+ optionalDependencies:
+ '@types/react': 19.1.12
+ '@types/react-dom': 19.1.9(@types/react@19.1.12)
+
react-dom@19.1.1(react@19.1.1):
dependencies:
react: 19.1.1
diff --git a/frontend/public/vachan-logo.png b/frontend/public/vachan-logo.png
new file mode 100644
index 00000000..255f4af9
Binary files /dev/null and b/frontend/public/vachan-logo.png differ
diff --git a/frontend/src/App.css b/frontend/src/App.css
index 637277ea..4a2893a4 100644
--- a/frontend/src/App.css
+++ b/frontend/src/App.css
@@ -19,3 +19,29 @@
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
+
+[data-sonner-toast] {
+ max-height: 250px;
+}
+
+[data-sonner-toast] [data-content] {
+ max-height: 200px;
+ overflow-y: scroll !important;
+ word-break: break-word;
+ padding-right: 4px;
+ touch-action: pan-y;
+ overscroll-behavior: contain;
+}
+
+[data-sonner-toast] [data-content]::-webkit-scrollbar {
+ width: 6px;
+}
+
+[data-sonner-toast] [data-content]::-webkit-scrollbar-thumb {
+ background-color: rgba(100, 100, 100, 0.5);
+ border-radius: 3px;
+}
+
+[data-sonner-toast] [data-content]::-webkit-scrollbar-track {
+ background: transparent;
+}
\ No newline at end of file
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index a832d7e8..01886694 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -28,6 +28,7 @@ import ServerLogs from "./pages/ServerLogs";
import ErrorLogs from "./pages/ErrorLogs";
import ReadingPlans from "./pages/ReadingPlans";
import VerseOfTheDay from "./pages/VerseOfTheDay";
+import BookNames from "./pages/BookNames";
/**
* App Component and Supertokens Setup
@@ -154,6 +155,7 @@ function App() {
} />
} />
} />
+ } />
diff --git a/frontend/src/components/AddDialogContent.tsx b/frontend/src/components/AddDialogContent.tsx
index 9fed37fd..efbd7dbb 100644
--- a/frontend/src/components/AddDialogContent.tsx
+++ b/frontend/src/components/AddDialogContent.tsx
@@ -42,6 +42,22 @@ export const AddDialogContent = ({
setFormData({ ...formData, language_name: e.target.value })
}
/>
+ Local Script Name
+
+ setFormData({ ...formData, localScriptName: e.target.value })
+ }
+ />
+ Script Direction
+
+ setFormData({ ...formData, scriptDirection: e.target.value })
+ }
+ />
Metadata (optional)