diff --git a/.github/workflows/build_executables.yml b/.github/workflows/build_executables.yml new file mode 100644 index 0000000..955a225 --- /dev/null +++ b/.github/workflows/build_executables.yml @@ -0,0 +1,86 @@ +name: Build Executables with Nuitka + +on: + push: + branches: + - dev-1.0 # Adjust if your target branch is different (e.g., main) + workflow_dispatch: # Allows manual triggering + +jobs: + build: + name: Build on ${{ matrix.os_name }} + runs-on: ${{ matrix.os }} + strategy: + matrix: + include: + - os: ubuntu-latest + os_name: Linux + python-version: '3.10' + output_main_app: uniexam-server_linux + output_cli_app: uniexam-cli_linux + nuitka_plugins: "fastapi,pydantic,multiprocessing" # multiprocessing might be needed by uvicorn/fastapi + - os: windows-latest + os_name: Windows + python-version: '3.10' + output_main_app: uniexam-server_windows.exe + output_cli_app: uniexam-cli_windows.exe + nuitka_plugins: "fastapi,pydantic,multiprocessing" + +steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install nuitka # Ensure Nuitka is installed + + - name: Install platform-specific dependencies (if any) + # Example: If you had GUI libs for Linux like PyQt5 + # if: matrix.os == 'ubuntu-latest' + # run: sudo apt-get update && sudo apt-get install -y libxcb-xinerama0 + run: echo "No platform-specific system dependencies to install for this build." + + - name: Compile Main Application (run.py) with Nuitka + run: | + python -m nuitka --standalone --onefile \ + --output-dir=dist/${{ matrix.os_name }}_main \ + --output-filename=${{ matrix.output_main_app }} \ + --enable-plugin=${{ matrix.nuitka_plugins }} \ + --include-package=app \ + --include-data-dir=data=data \ + --include-data-file=.env.example=.env.example \ + --remove-output \ + run.py + # Note: --onefile can lead to slower startup. Consider removing if that's an issue. + # For data, .env.example is included; actual .env should be handled at runtime. + + - name: Compile CLI Tool (examctl.py) with Nuitka + run: | + python -m nuitka --standalone --onefile \ + --output-dir=dist/${{ matrix.os_name }}_cli \ + --output-filename=${{ matrix.output_cli_app }} \ + --enable-plugin=${{ matrix.nuitka_plugins }} \ + --include-package=app \ + --include-data-dir=data=data \ + --include-data-file=.env.example=.env.example \ + --remove-output \ + examctl.py + + - name: Upload Main Application Artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.output_main_app }} + path: dist/${{ matrix.os_name }}_main/${{ matrix.output_main_app }} + + - name: Upload CLI Tool Artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.output_cli_app }} + path: dist/${{ matrix.os_name }}_cli/${{ matrix.output_cli_app }} diff --git a/.gitignore b/.gitignore index 4e20462..058a958 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,6 @@ Dockerfile # Other *.swp + +# MkDocs build output +/site/ diff --git a/app/admin_routes.py b/app/admin_routes.py index bcefd9f..a8ad71c 100644 --- a/app/admin_routes.py +++ b/app/admin_routes.py @@ -12,10 +12,12 @@ 所有此模块下的路由都需要管理员权限(通过 `require_admin` 依赖项进行验证)。 """ # region 模块导入 +import asyncio +import json import logging -import asyncio # Added for background task in grading -from typing import List, Optional -from uuid import UUID # Added for paper_id type hint +from datetime import datetime +from typing import Any, Dict, List, Optional +from uuid import UUID from fastapi import ( APIRouter, @@ -23,11 +25,22 @@ HTTPException, Path, Query, + Request, status as http_status, ) -from .core.config import DifficultyLevel -from .core.security import require_admin +from app.utils.export_utils import data_to_csv, data_to_xlsx + +from ..services.audit_logger import audit_logger_service +from ..utils.helpers import get_client_ip_from_request +from .core.config import DifficultyLevel, settings +from .core.security import ( + RequireTags, + get_all_active_token_info, + invalidate_all_tokens_for_user, + invalidate_token, + require_admin, +) from .crud import ( paper_crud_instance as paper_crud, qb_crud_instance as qb_crud, @@ -38,13 +51,13 @@ SettingsResponseModel, SettingsUpdatePayload, ) -from .models.enums import QuestionTypeEnum # Already imported in previous version, ensure it stays +from .models.enums import QuestionTypeEnum from .models.paper_models import ( + GradeSubmissionPayload, PaperAdminView, PaperFullDetailModel, - PendingGradingPaperItem, # New model for grading - SubjectiveQuestionForGrading, # New model for grading - GradeSubmissionPayload, # New model for grading + PendingGradingPaperItem, + SubjectiveQuestionForGrading, ) from .models.qb_models import ( LibraryIndexItem, @@ -54,7 +67,7 @@ from .models.user_models import ( AdminUserUpdate, UserPublicProfile, - UserTag, # Required for RequireTags if we use GRADER, though current plan is ADMIN only for grading + UserTag, ) # endregion @@ -78,10 +91,13 @@ "/settings", response_model=SettingsResponseModel, summary="获取当前系统配置", - description="管理员获取当前应用的主要配置项信息...", # Truncated for brevity + description="管理员获取当前应用的主要配置项信息...", ) -async def admin_get_settings(): - _admin_routes_logger.info("管理员请求获取应用配置。") +async def admin_get_settings(request: Request): + actor_uid = getattr(request.state, "current_user_uid", "unknown_admin") + client_ip = get_client_ip_from_request(request) + _admin_routes_logger.info(f"管理员 '{actor_uid}' (IP: {client_ip}) 请求获取应用配置。") + current_settings_from_file = settings_crud.get_current_settings_from_file() try: return SettingsResponseModel(**current_settings_from_file) @@ -92,70 +108,289 @@ async def admin_get_settings(): @admin_router.post( "/settings", response_model=SettingsResponseModel, - summary="更新系统配置", - description="管理员更新应用的部分或全部可配置项...", # Truncated for brevity + summary="更新系统配置 (仅限高级管理员)", + description="高级管理员 (具有 MANAGER 标签) 更新应用的部分或全部可配置项...", + dependencies=[Depends(RequireTags({UserTag.MANAGER}))] ) -async def admin_update_settings(payload: SettingsUpdatePayload): - _admin_routes_logger.info(f"管理员尝试更新应用配置,数据: {payload.model_dump_json(indent=2)}") +async def admin_update_settings(request: Request, payload: SettingsUpdatePayload): # payload is body, request is dependency + actor_info = getattr(request.state, "user_info_from_token", {"user_uid": "unknown_manager", "tags": [UserTag.MANAGER]}) + actor_uid = actor_info.get("user_uid", "unknown_manager") + client_ip = get_client_ip_from_request(request) + updated_keys = list(payload.model_dump(exclude_unset=True).keys()) + _admin_routes_logger.info(f"管理员 '{actor_uid}' (IP: {client_ip}) 尝试更新应用配置,数据: {payload.model_dump_json(indent=2)}") + try: await settings_crud.update_settings_file_and_reload(payload.model_dump(exclude_unset=True)) settings_from_file_after_update = settings_crud.get_current_settings_from_file() - _admin_routes_logger.info("应用配置已成功更新并重新加载。") + _admin_routes_logger.info(f"管理员 '{actor_uid}' 成功更新并重新加载了应用配置。") + await audit_logger_service.log_event( + action_type="ADMIN_UPDATE_CONFIG", status="SUCCESS", + actor_uid=actor_uid, actor_ip=client_ip, + details={"message": "应用配置已成功更新", "updated_keys": updated_keys} + ) return SettingsResponseModel(**settings_from_file_after_update) except ValueError as e_val: - _admin_routes_logger.warning(f"管理员更新配置失败 (数据验证错误): {e_val}") + _admin_routes_logger.warning(f"管理员 '{actor_uid}' 更新配置失败 (数据验证错误): {e_val}") + await audit_logger_service.log_event( + action_type="ADMIN_UPDATE_CONFIG", status="FAILURE", + actor_uid=actor_uid, actor_ip=client_ip, + details={"error": str(e_val), "attempted_keys": updated_keys} + ) raise HTTPException(status_code=http_status.HTTP_400_BAD_REQUEST, detail=str(e_val)) from e_val except IOError as e_io: - _admin_routes_logger.error(f"管理员更新配置失败 (文件写入错误): {e_io}") + _admin_routes_logger.error(f"管理员 '{actor_uid}' 更新配置失败 (文件写入错误): {e_io}") + await audit_logger_service.log_event( + action_type="ADMIN_UPDATE_CONFIG", status="FAILURE", + actor_uid=actor_uid, actor_ip=client_ip, + details={"error": f"文件写入错误: {e_io}", "attempted_keys": updated_keys} + ) raise HTTPException(status_code=http_status.HTTP_500_INTERNAL_SERVER_ERROR, detail="配置文件写入失败。") from e_io except RuntimeError as e_rt: - _admin_routes_logger.error(f"管理员更新配置失败 (运行时错误): {e_rt}") + _admin_routes_logger.error(f"管理员 '{actor_uid}' 更新配置失败 (运行时错误): {e_rt}") + await audit_logger_service.log_event( + action_type="ADMIN_UPDATE_CONFIG", status="FAILURE", + actor_uid=actor_uid, actor_ip=client_ip, + details={"error": f"运行时错误: {e_rt}", "attempted_keys": updated_keys} + ) raise HTTPException(status_code=http_status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e_rt)) from e_rt except Exception as e: - _admin_routes_logger.error(f"管理员更新配置时发生未知错误: {e}", exc_info=True) + _admin_routes_logger.error(f"管理员 '{actor_uid}' 更新配置时发生未知错误: {e}", exc_info=True) + await audit_logger_service.log_event( + action_type="ADMIN_UPDATE_CONFIG", status="FAILURE", + actor_uid=actor_uid, actor_ip=client_ip, + details={"error": f"未知错误: {e}", "attempted_keys": updated_keys} + ) raise HTTPException(status_code=http_status.HTTP_500_INTERNAL_SERVER_ERROR, detail="更新配置时发生意外错误。") from e # endregion # region Admin User Management API 端点 -@admin_router.get("/users", response_model=List[UserPublicProfile], summary="管理员获取用户列表") -async def admin_get_all_users(skip: int = Query(0, ge=0), limit: int = Query(100, ge=1, le=200)): - _admin_routes_logger.info(f"管理员请求用户列表,skip={skip}, limit={limit}。") - users_in_db = await user_crud.admin_get_all_users(skip=skip, limit=limit) +@admin_router.get( + "/users", + summary="管理员获取用户列表 (支持CSV/XLSX导出)", + description="获取用户列表。可通过 'format' 查询参数导出为 CSV 或 XLSX 文件。" +) +async def admin_get_all_users( + request: Request, + skip: int = Query(0, ge=0, description="跳过的用户数"), + limit: int = Query(100, ge=1, le=1000, description="返回的用户数上限 (导出时此限制可能被忽略或调整)"), + export_format: Optional[str] = Query(None, description="导出格式 (csv 或 xlsx)", alias="format", regex="^(csv|xlsx)$") +): + actor_uid = getattr(request.state, "current_user_uid", "unknown_admin") + client_ip = get_client_ip_from_request(request) + _admin_routes_logger.info(f"管理员 '{actor_uid}' (IP: {client_ip}) 请求用户列表,skip={skip}, limit={limit}, format={export_format}。") + + effective_limit = limit + if export_format: + _admin_routes_logger.info(f"导出请求: 正在尝试获取所有用户进行导出 (原 limit={limit} 可能被覆盖)。") + effective_limit = 1_000_000 + + users_in_db = await user_crud.admin_get_all_users(skip=0 if export_format else skip, limit=effective_limit) + + if export_format: + if not users_in_db: + _admin_routes_logger.info("没有用户数据可导出。") + + data_to_export: List[Dict[str, Any]] = [] + for user in users_in_db: + tags_str = ", ".join([tag.value for tag in user.tags]) if user.tags else "" + data_to_export.append({ + "用户ID": user.uid, + "昵称": user.nickname, + "邮箱": user.email, + "QQ": user.qq, + "标签": tags_str, + }) + + headers = ["用户ID", "昵称", "邮箱", "QQ", "标签"] + current_time = datetime.now().strftime('%Y%m%d_%H%M%S') + filename = f"用户列表_{current_time}.{export_format}" + + if export_format == "csv": + _admin_routes_logger.info(f"准备导出用户列表到 CSV 文件: {filename}") + return data_to_csv(data_list=data_to_export, headers=headers, filename=filename) + elif export_format == "xlsx": + _admin_routes_logger.info(f"准备导出用户列表到 XLSX 文件: {filename}") + return data_to_xlsx(data_list=data_to_export, headers=headers, filename=filename) + + if not users_in_db and skip > 0 : + _admin_routes_logger.info(f"用户列表查询结果为空 (skip={skip}, limit={limit})。") + return [UserPublicProfile.model_validate(user) for user in users_in_db] @admin_router.get("/users/{user_uid}", response_model=UserPublicProfile, summary="管理员获取特定用户信息") -async def admin_get_user(*, user_uid: str = Path(..., description="要获取详情的用户的UID")): - _admin_routes_logger.info(f"管理员请求用户 '{user_uid}' 的详细信息。") +async def admin_get_user(user_uid: str = Path(..., description="要获取详情的用户的UID"), request: Request = Depends(lambda r: r) ): + actor_uid = getattr(request.state, "current_user_uid", "unknown_admin") + client_ip = get_client_ip_from_request(request) + _admin_routes_logger.info(f"管理员 '{actor_uid}' (IP: {client_ip}) 请求用户 '{user_uid}' 的详细信息。") user = await user_crud.get_user_by_uid(user_uid) if not user: - _admin_routes_logger.warning(f"管理员请求用户 '{user_uid}' 失败:用户未找到。") + _admin_routes_logger.warning(f"管理员 '{actor_uid}' 请求用户 '{user_uid}' 失败:用户未找到。") raise HTTPException(status_code=http_status.HTTP_404_NOT_FOUND, detail="用户未找到") return UserPublicProfile.model_validate(user) @admin_router.put("/users/{user_uid}", response_model=UserPublicProfile, summary="管理员更新特定用户信息") -async def admin_update_user_info(*, user_uid: str = Path(..., description="要更新信息的用户的UID"), update_payload: AdminUserUpdate): - _admin_routes_logger.info(f"管理员尝试更新用户 '{user_uid}' 的信息,数据: {update_payload.model_dump_json(exclude_none=True)}") +async def admin_update_user_info( + update_payload: AdminUserUpdate, # Body parameter first + user_uid: str = Path(..., description="要更新信息的用户的UID"), + request: Request = Depends(lambda r: r) +): + current_admin_info = getattr(request.state, "user_info_from_token", {"user_uid": "unknown_admin", "tags": []}) + actor_uid = current_admin_info.get("user_uid", "unknown_admin") + current_admin_tags = set(current_admin_info.get("tags", [])) + client_ip = get_client_ip_from_request(request) + + _admin_routes_logger.info(f"管理员 '{actor_uid}' (IP: {client_ip}) 尝试更新用户 '{user_uid}' 的信息,数据: {update_payload.model_dump_json(exclude_none=True)}") + + target_user = await user_crud.get_user_by_uid(user_uid) + if not target_user: + _admin_routes_logger.warning(f"管理员 '{actor_uid}' 更新用户 '{user_uid}' 失败:目标用户未找到。") + await audit_logger_service.log_event( + action_type="ADMIN_UPDATE_USER", status="FAILURE", + actor_uid=actor_uid, actor_ip=client_ip, + target_resource_type="USER", target_resource_id=user_uid, + details={"message": "目标用户未找到"} + ) + raise HTTPException(status_code=http_status.HTTP_404_NOT_FOUND, detail="目标用户未找到。") + + target_user_tags = set(target_user.tags) + + if UserTag.MANAGER in target_user_tags: + if UserTag.MANAGER not in current_admin_tags: + _admin_routes_logger.warning(f"权限拒绝:管理员 '{actor_uid}' 尝试修改高级管理员 '{user_uid}' 的信息。") + await audit_logger_service.log_event( + action_type="ADMIN_UPDATE_USER", status="FAILURE", + actor_uid=actor_uid, actor_ip=client_ip, + target_resource_type="USER", target_resource_id=user_uid, + details={"message": "权限不足:普通管理员不能修改高级管理员的信息。"} + ) + raise HTTPException(status_code=http_status.HTTP_403_FORBIDDEN, detail="普通管理员不能修改高级管理员的信息。") + + is_modifying_sensitive_fields = update_payload.tags is not None + if UserTag.ADMIN in target_user_tags and UserTag.MANAGER not in target_user_tags: + if is_modifying_sensitive_fields and UserTag.MANAGER not in current_admin_tags: + _admin_routes_logger.warning(f"权限拒绝:管理员 '{actor_uid}' 尝试修改管理员 '{user_uid}' 的敏感信息 (标签)。") + await audit_logger_service.log_event( + action_type="ADMIN_UPDATE_USER", status="FAILURE", + actor_uid=actor_uid, actor_ip=client_ip, + target_resource_type="USER", target_resource_id=user_uid, + details={"message": "权限不足:普通管理员不能修改其他管理员的标签。"} + ) + raise HTTPException(status_code=http_status.HTTP_403_FORBIDDEN, detail="普通管理员不能修改其他管理员的标签。只有高级管理员可以。") + updated_user = await user_crud.admin_update_user(user_uid, update_payload) + if not updated_user: - _admin_routes_logger.warning(f"管理员更新用户 '{user_uid}' 失败:用户未找到或更新无效。") - raise HTTPException(status_code=http_status.HTTP_404_NOT_FOUND, detail="用户未找到或更新失败。") - _admin_routes_logger.info(f"管理员成功更新用户 '{user_uid}' 的信息。") + _admin_routes_logger.warning(f"管理员 '{actor_uid}' 更新用户 '{user_uid}' 失败:CRUD操作返回None (可能是内部错误)。") + await audit_logger_service.log_event( + action_type="ADMIN_UPDATE_USER", status="FAILURE", + actor_uid=actor_uid, actor_ip=client_ip, + target_resource_type="USER", target_resource_id=user_uid, + details={"message": "用户更新操作在数据库层面失败。"} + ) + raise HTTPException(status_code=http_status.HTTP_500_INTERNAL_SERVER_ERROR, detail="用户更新操作失败。") + + _admin_routes_logger.info(f"管理员 '{actor_uid}' 成功更新用户 '{user_uid}' 的信息。") + await audit_logger_service.log_event( + action_type="ADMIN_UPDATE_USER", status="SUCCESS", + actor_uid=actor_uid, actor_ip=client_ip, + target_resource_type="USER", target_resource_id=user_uid, + details={"updated_fields": list(update_payload.model_dump(exclude_unset=True).keys())} + ) return UserPublicProfile.model_validate(updated_user) # endregion # region Admin Paper Management API 端点 -@admin_router.get("/papers", response_model=List[PaperAdminView], summary="管理员获取所有试卷摘要列表") -async def admin_get_all_papers_summary(skip: int = Query(0, ge=0), limit: int = Query(100, ge=1, le=200)): - _admin_routes_logger.info(f"管理员请求试卷摘要列表,skip={skip}, limit={limit}。") +@admin_router.get( + "/papers", + summary="管理员获取所有试卷摘要列表 (支持CSV/XLSX导出)", + description="获取所有试卷的摘要列表。支持分页、筛选和导出功能。" +) +async def admin_get_all_papers_summary( + request: Request, + skip: int = Query(0, ge=0, description="跳过的记录数"), + limit: int = Query(100, ge=1, le=1000, description="返回的最大记录数 (导出时可能被覆盖)"), + user_uid_filter: Optional[str] = Query(None, alias="user_uid", description="按用户ID筛选"), + difficulty_filter: Optional[DifficultyLevel] = Query(None, alias="difficulty", description="按难度筛选"), + status_filter: Optional[str] = Query(None, alias="status", description="按状态筛选 (例如: 'completed', 'in_progress')"), + export_format: Optional[str] = Query(None, description="导出格式 (csv 或 xlsx)", alias="format", regex="^(csv|xlsx)$") +): + _admin_routes_logger.info( + f"管理员请求试卷摘要列表,skip={skip}, limit={limit}, user_uid={user_uid_filter}, " + f"difficulty={difficulty_filter.value if difficulty_filter else None}, status={status_filter}, format={export_format}。" + ) + + effective_limit = limit + fetch_skip = skip + if export_format: + _admin_routes_logger.info("试卷列表导出请求: 正在尝试获取所有匹配筛选条件的试卷进行导出。") + effective_limit = 1_000_000 + fetch_skip = 0 + try: - all_papers_data = await paper_crud.admin_get_all_papers_summary(skip, limit) + all_papers_data = await paper_crud.admin_get_all_papers_summary( + skip=fetch_skip, + limit=effective_limit, + user_uid=user_uid_filter, + difficulty=difficulty_filter.value if difficulty_filter else None, + status=status_filter + ) + + if export_format: + if not all_papers_data: + _admin_routes_logger.info("没有试卷数据可导出 (基于当前筛选条件)。") + + data_to_export: List[Dict[str, Any]] = [] + for paper_dict in all_papers_data: + pass_status_str = "" + if paper_dict.get('pass_status') is True: + pass_status_str = "通过" + elif paper_dict.get('pass_status') is False: + pass_status_str = "未通过" + + difficulty_val = paper_dict.get('difficulty', '') + if isinstance(difficulty_val, DifficultyLevel): + difficulty_str = difficulty_val.value + else: + difficulty_str = str(difficulty_val) if difficulty_val is not None else '' + + status_val = paper_dict.get('status', '') + status_str = str(status_val) if status_val is not None else '' + + + data_to_export.append({ + "试卷ID": str(paper_dict.get('paper_id', '')), + "用户ID": paper_dict.get('user_uid', ''), + "难度": difficulty_str, + "状态": status_str, + "总得分": paper_dict.get('total_score_obtained', ''), + "百分制得分": f"{paper_dict.get('score_percentage'):.2f}" if paper_dict.get('score_percentage') is not None else '', + "通过状态": pass_status_str, + "创建时间": paper_dict.get('created_at').strftime('%Y-%m-%d %H:%M:%S') if paper_dict.get('created_at') else '', + "完成时间": paper_dict.get('completed_at').strftime('%Y-%m-%d %H:%M:%S') if paper_dict.get('completed_at') else '', + }) + + headers = ["试卷ID", "用户ID", "难度", "状态", "总得分", "百分制得分", "通过状态", "创建时间", "完成时间"] + current_time = datetime.now().strftime('%Y%m%d_%H%M%S') + filename = f"试卷列表_{current_time}.{export_format}" + + if export_format == "csv": + _admin_routes_logger.info(f"准备导出试卷列表到 CSV 文件: {filename}") + return data_to_csv(data_list=data_to_export, headers=headers, filename=filename) + elif export_format == "xlsx": + _admin_routes_logger.info(f"准备导出试卷列表到 XLSX 文件: {filename}") + return data_to_xlsx(data_list=data_to_export, headers=headers, filename=filename) + + if not all_papers_data and skip > 0: + _admin_routes_logger.info(f"试卷列表查询结果为空 (skip={skip}, limit={limit}, filters applied).") + return [PaperAdminView(**paper_data) for paper_data in all_papers_data] + except Exception as e: _admin_routes_logger.error(f"管理员获取试卷列表时发生意外错误: {e}", exc_info=True) raise HTTPException(status_code=http_status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"获取试卷列表时发生错误: {str(e)}") from e @admin_router.get("/papers/{paper_id}", response_model=PaperFullDetailModel, summary="管理员获取特定试卷的完整信息") -async def admin_get_paper_detail(paper_id: str = Path(..., description="要获取详情的试卷ID (UUID格式)")): +async def admin_get_paper_detail(request: Request, paper_id: str = Path(..., description="要获取详情的试卷ID (UUID格式)")): _admin_routes_logger.info(f"管理员请求试卷 '{paper_id}' 的详细信息。") paper_data = await paper_crud.admin_get_paper_detail(paper_id) if not paper_data: @@ -170,7 +405,7 @@ async def admin_get_paper_detail(paper_id: str = Path(..., description="要获 raise HTTPException(status_code=http_status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"试卷数据格式错误或不完整: {str(e)}") from e @admin_router.delete("/papers/{paper_id}", status_code=http_status.HTTP_204_NO_CONTENT, summary="管理员删除特定试卷") -async def admin_delete_paper(paper_id: str = Path(..., description="要删除的试卷ID (UUID格式)")): +async def admin_delete_paper(request: Request, paper_id: str = Path(..., description="要删除的试卷ID (UUID格式)")): _admin_routes_logger.info(f"管理员尝试删除试卷 '{paper_id}'。") deleted = await paper_crud.admin_delete_paper(paper_id) if not deleted: @@ -182,7 +417,7 @@ async def admin_delete_paper(paper_id: str = Path(..., description="要删除的 # region Admin Question Bank Management API 端点 @admin_router.get("/question-banks", response_model=List[LibraryIndexItem], summary="管理员获取所有题库的元数据列表") -async def admin_get_all_qbank_metadata(): +async def admin_get_all_qbank_metadata(request: Request): _admin_routes_logger.info("管理员请求获取所有题库的元数据。") try: metadata_list = await qb_crud.get_all_library_metadatas() @@ -192,7 +427,7 @@ async def admin_get_all_qbank_metadata(): raise HTTPException(status_code=http_status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"获取题库元数据列表时发生错误: {str(e)}") @admin_router.get("/question-banks/{difficulty_id}/content", response_model=QuestionBank, summary="管理员获取特定难度题库的完整内容") -async def admin_get_question_bank_content(difficulty_id: DifficultyLevel = Path(..., description="要获取内容的题库难度ID")): +async def admin_get_question_bank_content(request: Request, difficulty_id: DifficultyLevel = Path(..., description="要获取内容的题库难度ID")): _admin_routes_logger.info(f"管理员请求获取难度为 '{difficulty_id.value}' 的题库内容。") try: full_bank = await qb_crud.get_question_bank_with_content(difficulty_id) @@ -200,13 +435,14 @@ async def admin_get_question_bank_content(difficulty_id: DifficultyLevel = Path( _admin_routes_logger.warning(f"管理员请求难度 '{difficulty_id.value}' 的题库内容失败:题库未找到或为空。") raise HTTPException(status_code=http_status.HTTP_404_NOT_FOUND, detail=f"难度为 '{difficulty_id.value}' 的题库未加载或不存在。") return full_bank - except HTTPException: raise + except HTTPException: + raise except Exception as e: _admin_routes_logger.error(f"管理员获取题库 '{difficulty_id.value}' 内容时发生意外错误: {e}", exc_info=True) raise HTTPException(status_code=http_status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"获取题库 '{difficulty_id.value}' 内容时发生服务器错误。") from e @admin_router.post("/question-banks/{difficulty_id}/questions", response_model=QuestionModel, status_code=http_status.HTTP_201_CREATED, summary="管理员向特定题库添加新题目") -async def admin_add_question_to_bank(question: QuestionModel, difficulty_id: DifficultyLevel = Path(..., description="要添加题目的题库难度ID")): +async def admin_add_question_to_bank(request: Request, question: QuestionModel, difficulty_id: DifficultyLevel = Path(..., description="要添加题目的题库难度ID")): _admin_routes_logger.info(f"管理员尝试向题库 '{difficulty_id.value}' 添加新题目: {question.body[:50]}...") try: added_question = await qb_crud.add_question_to_bank(difficulty_id, question) @@ -223,7 +459,7 @@ async def admin_add_question_to_bank(question: QuestionModel, difficulty_id: Dif raise HTTPException(status_code=http_status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"向题库 '{difficulty_id.value}' 添加题目时发生服务器错误。") @admin_router.delete("/question-banks/{difficulty_id}/questions", status_code=http_status.HTTP_204_NO_CONTENT, summary="管理员从特定题库删除题目") -async def admin_delete_question_from_bank(difficulty_id: DifficultyLevel = Path(..., description="要删除题目的题库难度ID"), question_index: int = Query(..., alias="index", ge=0)): +async def admin_delete_question_from_bank(request: Request, difficulty_id: DifficultyLevel = Path(..., description="要删除题目的题库难度ID"), question_index: int = Query(..., alias="index", ge=0)): _admin_routes_logger.info(f"管理员尝试从题库 '{difficulty_id.value}' 删除索引为 {question_index} 的题目。") try: deleted_question_data = await qb_crud.delete_question_from_bank(difficulty_id, question_index) @@ -245,7 +481,7 @@ async def admin_delete_question_from_bank(difficulty_id: DifficultyLevel = Path( grading_router = APIRouter( prefix="/grading", tags=["阅卷接口 (Grading)"], - dependencies=[Depends(require_admin)] # Initially restrict to ADMIN + dependencies=[Depends(require_admin)] ) @grading_router.get( @@ -281,12 +517,10 @@ async def get_subjective_questions_for_grading( subjective_questions_for_grading = [] for q_internal in paper_data.get("paper_questions", []): if q_internal.get("question_type") == QuestionTypeEnum.ESSAY_QUESTION.value: - # internal_question_id is already part of q_internal from PaperQuestionInternalDetail subjective_questions_for_grading.append(SubjectiveQuestionForGrading(**q_internal)) if not subjective_questions_for_grading: _admin_routes_logger.info(f"试卷 '{paper_id}' 不包含主观题或主观题数据缺失。") - # Not an error, just might be an empty list if no subjective questions or they are malformed return subjective_questions_for_grading @@ -309,19 +543,13 @@ async def grade_single_subjective_question( teacher_comment=payload.teacher_comment ) if not success: - # grade_subjective_question raises ValueError for known issues like not found, - # so False here might indicate a repository update failure. raise HTTPException(status_code=http_status.HTTP_500_INTERNAL_SERVER_ERROR, detail="更新题目批阅结果失败。") - # Asynchronously attempt to finalize paper grading if all subjective questions are now graded. - # The client does not wait for this, ensuring a quick response for grading a single question. - # The paper's status will be updated in the background if finalization occurs. asyncio.create_task(paper_crud.finalize_paper_grading_if_ready(paper_id)) - return None # HTTP 204 + return None except ValueError as ve: _admin_routes_logger.warning(f"批改主观题失败 (paper_id: {paper_id}, q_id: {question_internal_id}): {ve}") - # Determine if it's a 404 (not found) or 400 (bad request, e.g. not an essay question) if "未找到" in str(ve): raise HTTPException(status_code=http_status.HTTP_404_NOT_FOUND, detail=str(ve)) else: @@ -330,9 +558,170 @@ async def grade_single_subjective_question( _admin_routes_logger.error(f"批改主观题时发生意外错误 (paper_id: {paper_id}, q_id: {question_internal_id}): {e}", exc_info=True) raise HTTPException(status_code=http_status.HTTP_500_INTERNAL_SERVER_ERROR, detail="批改主观题时发生意外错误。") -admin_router.include_router(grading_router) # 将阅卷路由挂载到管理员路由下 +admin_router.include_router(grading_router) +# endregion + +# region Admin Token Management API 端点 +token_admin_router = APIRouter( + prefix="/tokens", + tags=["管理接口 - Token管理 (Admin - Token Management)"], + dependencies=[Depends(RequireTags({UserTag.MANAGER}))] +) + +@token_admin_router.get( + "/", + response_model=List[Dict[str, Any]], + summary="管理员获取当前所有活动Token的摘要列表", + description="获取系统中所有当前活动(未过期)用户访问Token的摘要信息列表。此列表主要用于监控和审计目的。" +) +async def admin_list_active_tokens(request: Request): + actor_uid = getattr(request.state, "current_user_uid", "unknown_admin") + client_ip = get_client_ip_from_request(request) + _admin_routes_logger.info(f"管理员 '{actor_uid}' (IP: {client_ip}) 请求获取所有活动Token的列表。") + + active_tokens_info = await get_all_active_token_info() + + await audit_logger_service.log_event( + action_type="ADMIN_LIST_TOKENS", status="SUCCESS", + actor_uid=actor_uid, actor_ip=client_ip, + details={"message": f"管理员查看了活动Token列表 (共 {len(active_tokens_info)} 个)"} + ) + return active_tokens_info + +@token_admin_router.delete( + "/user/{user_uid}", + summary="管理员吊销特定用户的所有活动Token", + description="立即吊销(删除)指定用户ID的所有活动访问Token。此操作会强制该用户在所有设备上登出。" +) +async def admin_invalidate_user_tokens(user_uid: str = Path(..., description="要吊销其Token的用户的UID"), request: Request = Depends(lambda r: r)): + actor_uid = getattr(request.state, "current_user_uid", "unknown_admin") + client_ip = get_client_ip_from_request(request) + _admin_routes_logger.info(f"管理员 '{actor_uid}' (IP: {client_ip}) 尝试吊销用户 '{user_uid}' 的所有Token。") + + invalidated_count = await invalidate_all_tokens_for_user(user_uid) + + _admin_routes_logger.info(f"管理员 '{actor_uid}' 为用户 '{user_uid}' 吊销了 {invalidated_count} 个Token。") + await audit_logger_service.log_event( + action_type="ADMIN_INVALIDATE_USER_TOKENS", status="SUCCESS", + actor_uid=actor_uid, actor_ip=client_ip, + target_resource_type="USER_TOKENS", target_resource_id=user_uid, + details={"message": f"管理员吊销了用户 '{user_uid}' 的 {invalidated_count} 个Token。", "count": invalidated_count} + ) + return { + "message": f"成功为用户 '{user_uid}' 吊销了 {invalidated_count} 个Token。", + "invalidated_count": invalidated_count + } + +@token_admin_router.delete( + "/{token_string}", + status_code=http_status.HTTP_204_NO_CONTENT, + summary="管理员吊销指定的单个活动Token", + description="立即吊销(删除)指定的单个活动访问Token。管理员需要提供完整的Token字符串。请谨慎使用,确保Token字符串的准确性。" +) +async def admin_invalidate_single_token(token_string: str = Path(..., description="要吊销的完整Token字符串"), request: Request = Depends(lambda r: r)): + actor_uid = getattr(request.state, "current_user_uid", "unknown_admin") + client_ip = get_client_ip_from_request(request) + token_prefix_for_log = token_string[:8] + "..." + _admin_routes_logger.info(f"管理员 '{actor_uid}' (IP: {client_ip}) 尝试吊销单个Token (前缀: {token_prefix_for_log})。") + + await invalidate_token(token_string) + + await audit_logger_service.log_event( + action_type="ADMIN_INVALIDATE_SINGLE_TOKEN", status="SUCCESS", + actor_uid=actor_uid, actor_ip=client_ip, + target_resource_type="TOKEN", target_resource_id=token_prefix_for_log, + details={"message": "管理员吊销了单个Token"} + ) + return None + +admin_router.include_router(token_admin_router) # endregion +# region Admin Audit Log Viewing API 端点 + +def _parse_log_timestamp(timestamp_str: str) -> Optional[datetime]: + """安全地将ISO格式的时间戳字符串解析为datetime对象。""" + if not timestamp_str: + return None + try: + return datetime.fromisoformat(timestamp_str) + except ValueError: + try: + _admin_routes_logger.warning(f"无法解析审计日志中的时间戳字符串: '{timestamp_str}'") + return None + except Exception: + _admin_routes_logger.warning(f"解析审计日志时间戳时发生未知错误: '{timestamp_str}'") + return None + + +@admin_router.get( + "/audit-logs", + response_model=List[Dict[str, Any]], + summary="管理员查看审计日志", + description="获取应用审计日志,支持分页和筛选 (操作者UID, 操作类型, 时间范围)。日志默认按时间倒序(最新在前)。" +) +async def admin_view_audit_logs( + request: Request, + page: int = Query(1, ge=1, description="页码"), + per_page: int = Query(50, ge=1, le=200, description="每页条目数"), + actor_uid_filter: Optional[str] = Query(None, alias="actor_uid", description="按操作者UID筛选"), + action_type_filter: Optional[str] = Query(None, alias="action_type", description="按操作类型筛选"), + start_time_filter: Optional[datetime] = Query(None, alias="start_time", description="起始时间筛选 (ISO格式)"), + end_time_filter: Optional[datetime] = Query(None, alias="end_time", description="结束时间筛选 (ISO格式)") +): + log_file_path = settings.audit_log_file_path + if not Path(log_file_path).exists(): + _admin_routes_logger.info(f"审计日志文件 '{log_file_path}' 未找到。") + return [] + + all_log_entries: List[Dict[str, Any]] = [] + try: + with open(log_file_path, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if line: + try: + log_entry_dict = json.loads(line) + all_log_entries.append(log_entry_dict) + except json.JSONDecodeError: + _admin_routes_logger.warning(f"无法解析的审计日志行 (JSON无效): '{line[:200]}...'") + continue + except IOError as e: + _admin_routes_logger.error(f"读取审计日志文件 '{log_file_path}' 时发生IO错误: {e}") + raise HTTPException(status_code=http_status.HTTP_500_INTERNAL_SERVER_ERROR, detail="读取审计日志失败。") + + filtered_logs = [] + for entry in all_log_entries: + log_timestamp_str = entry.get("timestamp") + log_datetime = _parse_log_timestamp(log_timestamp_str) + + if log_datetime is None and (start_time_filter or end_time_filter): + _admin_routes_logger.debug(f"跳过时间范围筛选无效时间戳的日志条目: event_id={entry.get('event_id')}") + continue + + if actor_uid_filter and entry.get("actor_uid") != actor_uid_filter: + continue + if action_type_filter and entry.get("action_type") != action_type_filter: + continue + if start_time_filter and (log_datetime is None or log_datetime < start_time_filter): + continue + if end_time_filter and (log_datetime is None or log_datetime > end_time_filter): + continue + + filtered_logs.append(entry) + try: + filtered_logs.sort(key=lambda x: x.get("timestamp", ""), reverse=True) + except Exception as e_sort: + _admin_routes_logger.error(f"排序审计日志时出错: {e_sort}. 日志可能未按时间排序。") + + start_index = (page - 1) * per_page + end_index = start_index + per_page + + paginated_logs = filtered_logs[start_index:end_index] + + return paginated_logs + +# endregion __all__ = ["admin_router"] diff --git a/app/core/config.py b/app/core/config.py index d36d3cf..26a0ca6 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -16,8 +16,9 @@ import asyncio # 导入 asyncio 用于锁 (Import asyncio for locks) import json import logging # 导入标准日志模块 (Import standard logging module) -import logging.handlers # 导入日志处理器模块 (Import logging handlers module) +import logging.handlers # 导入日志处理器模块 (Import logging handlers module) import os +from datetime import datetime, timezone # 确保 timezone 也被导入 for JsonFormatter from enum import Enum # 确保 Enum 被导入 (Ensure Enum is imported) from pathlib import Path # 用于处理文件路径 (For handling file paths) from typing import Any, Dict, List, Optional @@ -34,25 +35,28 @@ # 导入自定义枚举类型 (Import custom enum type) from ..models.enums import AuthStatusCodeEnum, LogLevelEnum # 导入认证状态码枚举 -from datetime import datetime, timezone # 确保 timezone 也被导入 for JsonFormatter # endregion + # region 自定义JSON日志格式化器 (Custom JSON Log Formatter) class JsonFormatter(logging.Formatter): """ 自定义日志格式化器,将日志记录转换为JSON格式字符串。 (Custom log formatter that converts log records into JSON formatted strings.) """ + def format(self, record: logging.LogRecord) -> str: """ 将 LogRecord 对象格式化为JSON字符串。 (Formats a LogRecord object into a JSON string.) """ log_object: Dict[str, Any] = { - "timestamp": datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(), + "timestamp": datetime.fromtimestamp( + record.created, tz=timezone.utc + ).isoformat(), "level": record.levelname, - "message": record.getMessage(), # 获取格式化后的主消息 + "message": record.getMessage(), # 获取格式化后的主消息 "logger_name": record.name, "module": record.module, "function": record.funcName, @@ -71,21 +75,50 @@ def format(self, record: logging.LogRecord) -> str: # 添加通过 extra 传递的额外字段 (Add extra fields passed via extra) # 标准 LogRecord 属性列表,用于排除它们,只提取 "extra" 内容 standard_record_attrs = { - 'args', 'asctime', 'created', 'exc_info', 'exc_text', 'filename', - 'funcName', 'levelname', 'levelno', 'lineno', 'message', 'module', - 'msecs', 'msg', 'name', 'pathname', 'process', 'processName', - 'relativeCreated', 'stack_info', 'thread', 'threadName', + "args", + "asctime", + "created", + "exc_info", + "exc_text", + "filename", + "funcName", + "levelname", + "levelno", + "lineno", + "message", + "module", + "msecs", + "msg", + "name", + "pathname", + "process", + "processName", + "relativeCreated", + "stack_info", + "thread", + "threadName", # Formatter可能添加的内部属性,以及我们已经明确记录的 - 'currentframe', 'taskName', 'timestamp', 'level', 'logger_name', 'function', 'line', - 'thread_id', 'thread_name', 'process_id' + "currentframe", + "taskName", + "timestamp", + "level", + "logger_name", + "function", + "line", + "thread_id", + "thread_name", + "process_id", } # 遍历record中所有非下划线开头的属性 for key, value in record.__dict__.items(): - if not key.startswith('_') and key not in standard_record_attrs: + if not key.startswith("_") and key not in standard_record_attrs: log_object[key] = value - return json.dumps(log_object, ensure_ascii=False, default=str) # default=str 处理无法序列化的对象 + return json.dumps( + log_object, ensure_ascii=False, default=str + ) # default=str 处理无法序列化的对象 + # endregion @@ -440,6 +473,9 @@ class Settings(BaseModel): LogLevelEnum.INFO, # Pydantic将自动使用枚举成员的值 (如 "INFO") description="应用日志级别 (DEBUG, INFO, WARNING, ERROR, CRITICAL) (Application log level)", ) + audit_log_file_path: str = Field( + "data/logs/audit.log", description="审计日志文件路径 (Audit log file path)" + ) database_files: DatabaseFilesConfig = Field( default_factory=DatabaseFilesConfig, @@ -654,22 +690,22 @@ def setup_logging( # 为文件处理器创建并设置JSON格式化器 (Create and set JSON formatter for file handler) log_file_path = data_dir / log_file_name try: - json_formatter = JsonFormatter() # 使用自定义的JsonFormatter + json_formatter = JsonFormatter() # 使用自定义的JsonFormatter # 使用 TimedRotatingFileHandler 实现日志按天轮转,保留7天备份 # (Use TimedRotatingFileHandler for daily log rotation, keep 7 backups) file_handler = logging.handlers.TimedRotatingFileHandler( filename=log_file_path, - when='midnight', # 每天午夜轮转 (Rotate at midnight) - interval=1, # 每天一次 (Once a day) - backupCount=7, # 保留7个备份文件 (Keep 7 backup files) - encoding='utf-8', - utc=True, # 使用UTC时间进行轮转 (Use UTC for rotation) - delay=False # False: 在创建处理器时即打开文件 (Open file on handler creation) + when="midnight", # 每天午夜轮转 (Rotate at midnight) + interval=1, # 每天一次 (Once a day) + backupCount=7, # 保留7个备份文件 (Keep 7 backup files) + encoding="utf-8", + utc=True, # 使用UTC时间进行轮转 (Use UTC for rotation) + delay=False, # False: 在创建处理器时即打开文件 (Open file on handler creation) ) - file_handler.setFormatter(json_formatter) # 应用JsonFormatter + file_handler.setFormatter(json_formatter) # 应用JsonFormatter app_root_logger.addHandler(file_handler) # 初始日志消息仍将使用根记录器的控制台格式,直到文件处理器被添加。 - _config_module_logger.info( # 此消息本身会通过 console_handler 以文本格式输出 + _config_module_logger.info( # 此消息本身会通过 console_handler 以文本格式输出 f"应用日志将以JSON格式按天轮转写入到 (Application logs will be written in daily rotated JSON format to): {log_file_path} (级别 (Level): {log_level_str})" ) except Exception as e: @@ -802,9 +838,9 @@ def load_settings() -> Settings: dotenv_path=project_root / ".env" ) # 加载 .env 文件中的环境变量 (Load .env file) - json_config: Dict[str, Any] = ( - {} - ) # 用于存放从 settings.json 读取的配置 (For config from settings.json) + json_config: Dict[ + str, Any + ] = {} # 用于存放从 settings.json 读取的配置 (For config from settings.json) if settings_file.exists() and settings_file.is_file(): try: with open(settings_file, "r", encoding="utf-8") as f: diff --git a/app/core/rate_limiter.py b/app/core/rate_limiter.py index dffae95..17d5e0d 100644 --- a/app/core/rate_limiter.py +++ b/app/core/rate_limiter.py @@ -35,12 +35,12 @@ # 键为IP地址 (str),值为对应操作的请求时间戳列表 (List[float]) # (Key is IP address (str), value is a list of request timestamps (List[float]) for the corresponding action) # 分开存储不同操作的速率限制数据 (Store rate limit data for different actions separately) -ip_exam_request_timestamps: Dict[str, List[float]] = ( - {} -) # 获取新试卷 ("get_exam" action) -ip_auth_attempt_timestamps: Dict[str, List[float]] = ( - {} -) # 登录/注册等认证尝试 ("auth_attempts" action) +ip_exam_request_timestamps: Dict[ + str, List[float] +] = {} # 获取新试卷 ("get_exam" action) +ip_auth_attempt_timestamps: Dict[ + str, List[float] +] = {} # 登录/注册等认证尝试 ("auth_attempts" action) # endregion diff --git a/app/core/security.py b/app/core/security.py index cabf99b..dd23a60 100644 --- a/app/core/security.py +++ b/app/core/security.py @@ -125,9 +125,7 @@ async def create_access_token(user_uid: str, user_tags: List[UserTag]) -> str: 返回 (Returns): str: 生成的访问Token字符串。(The generated access token string.) """ - async with ( - _token_lock - ): # 确保对 _active_tokens 的操作是原子性的 (Ensure atomic operation on _active_tokens) + async with _token_lock: # 确保对 _active_tokens 的操作是原子性的 (Ensure atomic operation on _active_tokens) token_bytes_length = ( settings.token_length_bytes ) # 从配置获取Token长度 (Get token length from config) @@ -252,6 +250,96 @@ async def cleanup_expired_tokens_periodically(): # 函数名已修正,原为 _security_module_logger.info( f"后台任务:共清理了 {expired_count} 个过期Token。(Background task: Cleaned a total of {expired_count} expired tokens.)" ) + if expired_count > 0: + _security_module_logger.info( + f"后台任务:共清理了 {expired_count} 个过期Token。(Background task: Cleaned a total of {expired_count} expired tokens.)" + ) + + +async def get_all_active_token_info() -> List[Dict[str, Any]]: + """ + 获取所有当前活动Token的信息列表。 + (Retrieves a list of information for all currently active tokens.) + + 返回 (Returns): + List[Dict[str, Any]]: 每个字典包含token_prefix, user_uid, tags, 和 expires_at (ISO格式字符串)。 + (Each dictionary contains token_prefix, user_uid, tags, and expires_at (ISO format string).) + """ + active_token_details = [] + async with _token_lock: + if not _active_tokens: + return [] + + current_time = time.time() + # Iterate over a copy of items in case of modification (though less likely here than in cleanup) + for token_str, token_data in list(_active_tokens.items()): + # Double check expiry, though cleanup should handle most, this function might be called between cleanups + if token_data["expires_at"] <= current_time: + # Token is expired, might as well remove it if found here, though cleanup is primary + # _active_tokens.pop(token_str, None) # Avoid modifying during unprotected iteration if not list(_active_tokens.items()) + # For safety, let cleanup_expired_tokens_periodically handle actual removal + continue + + active_token_details.append( + { + "token_prefix": token_str[:8] + "...", + "user_uid": token_data["user_uid"], + "tags": token_data[ + "tags" + ], # Tags are already stored as list of strings + "expires_at": datetime.fromtimestamp( + token_data["expires_at"], tz=timezone.utc + ).isoformat(), + } + ) + return active_token_details + + +async def invalidate_all_tokens_for_user(user_uid: str) -> int: + """ + 使指定用户的所有活动Token立即失效。 + (Invalidates all active tokens for a specified user immediately.) + + 参数 (Args): + user_uid (str): 需要使其Token失效的用户的UID。 + (The UID of the user whose tokens need to be invalidated.) + + 返回 (Returns): + int: 被成功失效的Token数量。 + (The number of tokens that were successfully invalidated.) + """ + invalidated_count = 0 + tokens_to_remove = [] + async with _token_lock: + # First, identify tokens to remove to avoid modifying dict while iterating + for token_str, token_data in _active_tokens.items(): + if token_data["user_uid"] == user_uid: + tokens_to_remove.append(token_str) + + # Now, remove the identified tokens + for token_str in tokens_to_remove: + if ( + token_str in _active_tokens + ): # Check if still exists (might be removed by another process/task if not careful) + _active_tokens.pop(token_str) + invalidated_count += 1 + _security_module_logger.info( + f"已为用户 '{user_uid}' 失效Token (部分): {token_str[:8]}..." + f"(Invalidated token (partial) for user '{user_uid}': {token_str[:8]}...)" + ) + + if invalidated_count > 0: + _security_module_logger.info( + f"共为用户 '{user_uid}' 失效了 {invalidated_count} 个Token。" + f"(Invalidated a total of {invalidated_count} tokens for user '{user_uid}'.)" + ) + else: + _security_module_logger.info( + f"未找到用户 '{user_uid}' 的活动Token进行失效操作。" + f"(No active tokens found for user '{user_uid}' to invalidate.)" + ) + + return invalidated_count # endregion @@ -288,7 +376,7 @@ async def get_current_user_info_from_token( ) raise HTTPException( status_code=http_status.HTTP_401_UNAUTHORIZED, - detail="无效或已过期的Token", # 完全中文 (Fully Chinese) + detail="无效或已过期的Token", # 完全中文 (Fully Chinese) headers={ "WWW-Authenticate": "Bearer scheme='QueryToken'" }, # 提示客户端使用QueryToken方案 @@ -301,7 +389,7 @@ async def get_current_user_info_from_token( ) raise HTTPException( status_code=http_status.HTTP_403_FORBIDDEN, - detail="用户账户已被封禁", # 完全中文 (Fully Chinese) + detail="用户账户已被封禁", # 完全中文 (Fully Chinese) ) return user_info @@ -446,6 +534,8 @@ async def __call__( "validate_token_and_get_user_info", "invalidate_token", "cleanup_expired_tokens_periodically", # 函数名已修正 + "get_all_active_token_info", # New function + "invalidate_all_tokens_for_user", # New function "get_current_user_info_from_token", "get_current_active_user_uid", # "get_current_admin_user", # 已移除 (Removed) diff --git a/app/crud/__init__.py b/app/crud/__init__.py index 87a0f99..41c0ddd 100644 --- a/app/crud/__init__.py +++ b/app/crud/__init__.py @@ -45,7 +45,12 @@ async def initialize_crud_instances(): 然后使用该存储库实例化各个 CRUD 操作类。 此函数应在应用启动时调用。 """ - global user_crud_instance, paper_crud_instance, qb_crud_instance, settings_crud_instance, repository_instance + global \ + user_crud_instance, \ + paper_crud_instance, \ + qb_crud_instance, \ + settings_crud_instance, \ + repository_instance if settings.data_storage_type == "json": file_paths_config = { diff --git a/app/crud/json_repository.py b/app/crud/json_repository.py index 512e3c7..818e170 100644 --- a/app/crud/json_repository.py +++ b/app/crud/json_repository.py @@ -18,47 +18,124 @@ _json_repo_logger = logging.getLogger(__name__) +# 常见的ID字段名列表,用于自动索引 +# (List of common ID field names for automatic indexing) +COMMON_ID_FIELDS = ["id", "uid", "paper_id"] + class JsonStorageRepository(IDataStorageRepository): """ 一个使用JSON文件进行持久化的数据存储库实现。 它在内存中管理数据,并提供异步文件I/O操作。 + 此实现包含对常见ID字段的内存索引,以加速 `get_by_id` 操作。 + + 性能提示 (Performance Notes): + - `get_by_id`: 如果查询的ID字段被索引 (见 `COMMON_ID_FIELDS`),则查找速度非常快 (O(1))。 + 否则,如果需要回退到线性扫描 (当前未实现回退),则为 O(n)。 + - `create`, `delete`: 除了文件I/O,还包括对索引的更新,通常很快。 + - `update`: 如果ID不变且直接修改内存中的对象引用,索引不需要更新。 + - `query`, `get_all`: 这些操作目前依赖于Python的列表迭代和字典比较, + 对于大型数据集,其性能可能不如数据库的查询引擎。 + - `_persist_data_to_file`: **重要**: 此方法在每次创建、更新或删除操作后重写整个JSON文件。 + 对于频繁写入或大型数据集,这可能成为严重的性能瓶颈,并增加I/O负载。 + 考虑使用更高级的数据存储方案(如SQLite、NoSQL数据库)或更复杂的 + 文件更新策略(例如,仅追加日志式的更改,定期压缩文件)以优化性能。 """ def __init__(self, file_paths_config: Dict[str, Path], base_data_dir: Path): """ 初始化 JsonStorageRepository。 + (Initializes the JsonStorageRepository.) - 参数: + 参数 (Args): file_paths_config (Dict[str, Path]): 一个字典,将实体类型映射到它们各自的 JSON文件路径 (相对于 `base_data_dir`)。 - 例如: {"users": Path("users_db.json"), "papers": Path("papers_db.json")} + (A dictionary mapping entity types to their respective + JSON file paths (relative to `base_data_dir`).) base_data_dir (Path): 存储数据文件的基础目录。 + (The base directory for storing data files.) """ self.base_data_dir = base_data_dir - # 构建每个实体类型的完整文件路径 self.file_paths: Dict[str, Path] = { entity_type: self.base_data_dir / path_suffix for entity_type, path_suffix in file_paths_config.items() } - self.in_memory_data: Dict[str, List[Dict[str, Any]]] = {} # 内存数据副本 - # 为每种实体类型的文件操作创建一个异步锁 + self.in_memory_data: Dict[str, List[Dict[str, Any]]] = {} + # 内存ID索引: {entity_type: {id_field_name: {entity_id_value: entity_object_reference}}} + # (In-memory ID index: {entity_type: {id_field_name: {entity_id_value: entity_object_reference}}}) + self.id_indexes: Dict[str, Dict[str, Dict[str, Any]]] = {} + + # 为每种预定义实体类型的文件操作创建一个异步锁 + # (Create an async lock for file operations for each predefined entity type) self.file_locks: Dict[str, asyncio.Lock] = { entity_type: asyncio.Lock() for entity_type in self.file_paths } - self._load_all_data_on_startup() # 初始化时加载所有数据 + # 注意: 如果后续通过 `create` 方法动态添加新的实体类型, + # 需要确保也为这些新类型创建锁 (已在 `create` 方法中处理)。 + # (Note: If new entity types are dynamically added via the `create` method, + # ensure locks are also created for these new types (handled in `create` method).) + + self._load_all_data_on_startup() # 初始化时加载所有数据并构建索引 + # (Load all data and build indexes on initialization) + + def _build_id_indexes(self, entity_type: str) -> None: + """ + 为指定的实体类型构建内存ID索引。 + (Builds in-memory ID indexes for the specified entity type.) + + 此方法会遍历实体类型的数据,并为 `COMMON_ID_FIELDS` 中定义的每个ID字段创建索引。 + (This method iterates through the data of an entity type and creates an index + for each ID field defined in `COMMON_ID_FIELDS`.) + + 参数 (Args): + entity_type (str): 要为其构建索引的实体类型。 + (The entity type for which to build indexes.) + """ + _json_repo_logger.debug(f"开始为实体类型 '{entity_type}' 构建ID索引。") + # 清除该实体类型现有的所有ID字段索引 (Clear all existing ID field indexes for this entity type) + self.id_indexes[entity_type] = {} + + if ( + entity_type not in self.in_memory_data + or not self.in_memory_data[entity_type] + ): + _json_repo_logger.debug(f"实体类型 '{entity_type}' 无数据,跳过索引构建。") + return + + for item in self.in_memory_data[entity_type]: + for id_field_name in COMMON_ID_FIELDS: + if id_field_name in item: + entity_id_value = str( + item[id_field_name] + ) # 确保ID值为字符串 (Ensure ID value is string) + + # 如果该ID字段的索引尚未初始化,则创建它 + # (If the index for this ID field hasn't been initialized, create it) + if id_field_name not in self.id_indexes[entity_type]: + self.id_indexes[entity_type][id_field_name] = {} + + # 添加到索引,值为对内存中实际对象的引用 + # (Add to index, value is a reference to the actual object in memory) + self.id_indexes[entity_type][id_field_name][entity_id_value] = item + + indexed_fields_count = { + field: len(idx) for field, idx in self.id_indexes[entity_type].items() + } + _json_repo_logger.info( + f"为实体类型 '{entity_type}' 构建ID索引完成。索引字段及条目数: {indexed_fields_count}" + ) def _load_all_data_on_startup(self) -> None: - """在启动时从所有配置的JSON文件加载数据到内存中。""" + """在启动时从所有配置的JSON文件加载数据到内存中,并为每个实体类型构建ID索引。""" for entity_type, file_path in self.file_paths.items(): if entity_type not in self.in_memory_data: - self.in_memory_data[entity_type] = [] # 确保实体类型的键存在 + self.in_memory_data[entity_type] = [] if file_path.exists() and file_path.is_file(): try: with open(file_path, "r", encoding="utf-8") as f: data = json.load(f) - if isinstance(data, list): # 期望文件内容是一个JSON数组 + if isinstance(data, list): self.in_memory_data[entity_type] = data _json_repo_logger.info( f"成功为实体类型 '{entity_type}' 从 '{file_path}' 加载了 {len(data)} 条记录。" @@ -78,18 +155,31 @@ def _load_all_data_on_startup(self) -> None: f"实体类型 '{entity_type}' 的文件在 '{file_path}' 未找到。将初始化为空列表。" ) self.in_memory_data[entity_type] = [] - # 可选:如果希望在启动时即创建文件(即使是空的) - # self._ensure_file_exists(entity_type, file_path) # (这是一个异步方法,此处调用需注意) + + # 为加载的数据构建索引 (Build indexes for the loaded data) + self._build_id_indexes(entity_type) async def _persist_data_to_file(self, entity_type: str) -> bool: - """将指定实体类型的内存数据异步持久化到其JSON文件。""" + """ + 将指定实体类型的内存数据异步持久化到其JSON文件。 + (Persists in-memory data of the specified entity type to its JSON file asynchronously.) + + 性能警告 (Performance Warning): + 此方法会重写实体类型的整个JSON文件。对于频繁写入或大型数据集, + 这可能成为一个显著的性能瓶颈,并可能导致大量的磁盘I/O。 + 在生产环境中,应考虑更健壮的数据存储解决方案或优化写入策略。 + (This method rewrites the entire JSON file for the entity type. For frequent writes + or large datasets, this can become a significant performance bottleneck and may + lead to substantial disk I/O. In production environments, consider more robust + data storage solutions or optimized writing strategies.) + """ if entity_type not in self.file_paths: _json_repo_logger.error(f"尝试持久化未知的实体类型 '{entity_type}'。") return False file_path = self.file_paths[entity_type] lock = self.file_locks.get(entity_type) - if not lock: # 如果实体类型是动态添加的,锁可能尚未创建 + if not lock: _json_repo_logger.warning( f"实体类型 '{entity_type}' 的文件锁未找到,可能是一个新的动态实体类型。" ) @@ -97,19 +187,18 @@ async def _persist_data_to_file(self, entity_type: str) -> bool: async with lock: try: - file_path.parent.mkdir(parents=True, exist_ok=True) # 确保父目录存在 - # 使用深拷贝以防止在异步写入过程中内存数据被修改 + file_path.parent.mkdir(parents=True, exist_ok=True) data_to_write = copy.deepcopy(self.in_memory_data.get(entity_type, [])) with open(file_path, "w", encoding="utf-8") as f: json.dump(data_to_write, f, indent=4, ensure_ascii=False) - _json_repo_logger.debug( # 日志级别改为debug以减少频繁操作时的日志噪音 + _json_repo_logger.debug( f"成功持久化实体类型 '{entity_type}' 的数据到 '{file_path}'。" ) return True - except Exception as e: # 捕获所有可能的写入错误 + except Exception as e: _json_repo_logger.error( f"持久化实体类型 '{entity_type}' 的数据到 '{file_path}' 失败: {e}", - exc_info=True, # 记录完整的异常信息 + exc_info=True, ) return False @@ -126,34 +215,51 @@ async def disconnect(self) -> None: async def get_by_id( self, entity_type: str, entity_id: str ) -> Optional[Dict[str, Any]]: - """根据ID从内存中检索单个实体。""" - if entity_type not in self.in_memory_data: + """ + 根据ID从内存中检索单个实体,优先使用ID索引。 + (Retrieves a single entity from memory by ID, prioritizing ID indexes.) + """ + if entity_type not in self.id_indexes: _json_repo_logger.warning( - f"尝试按ID获取实体,但实体类型 '{entity_type}' 不在内存数据中。" + f"尝试通过ID获取实体,但实体类型 '{entity_type}' 的索引不存在。可能需要先加载数据或该类型无数据。" ) + if entity_type in self.in_memory_data: + _json_repo_logger.debug( + f"实体类型 '{entity_type}' 索引缺失,尝试线性扫描..." + ) + for item in self.in_memory_data[entity_type]: + for id_field_name in COMMON_ID_FIELDS: + if id_field_name in item and str(item[id_field_name]) == str( + entity_id + ): + return copy.deepcopy(item) return None - # 假设 'id' 字段是标准的,或者需要一种方式来指定ID字段。 - # (Assuming the 'id' field is standard, or a way to specify the ID field is needed.) - # 为了简化和通用性,这里假设ID字段是 'id', 'uid', 或 'paper_id' 等。 - # 在更复杂的场景下,可能需要为每种实体类型指定主键字段名。 - id_fields_to_check = [ - "id", - f"{entity_type}_id", - "uid", - "paper_id", - ] # 常见的ID字段名 + entity_id_str = str(entity_id) - for item in self.in_memory_data[entity_type]: - for id_field in id_fields_to_check: - if id_field in item and str(item[id_field]) == str(entity_id): - return copy.deepcopy(item) # 返回深拷贝以防止外部修改内存数据 + for id_field_name, id_map in self.id_indexes[entity_type].items(): + if id_field_name in COMMON_ID_FIELDS: + indexed_item = id_map.get(entity_id_str) + if indexed_item is not None: + _json_repo_logger.debug( + f"实体 '{entity_type}/{entity_id_str}' 通过索引字段 '{id_field_name}' 找到。" + ) + return copy.deepcopy(indexed_item) + + _json_repo_logger.debug( + f"实体 '{entity_type}/{entity_id_str}' 在ID索引中未找到。考虑它是否使用非标准ID字段或确实不存在。" + ) return None async def get_all( self, entity_type: str, skip: int = 0, limit: int = 100 ) -> List[Dict[str, Any]]: - """检索指定类型的所有实体(内存中的副本),支持分页。""" + """ + 检索指定类型的所有实体(内存中的副本),支持分页。 + 性能提示:此操作直接对内存列表进行切片,对于非常大的列表,深拷贝成本可能较高。 + (Retrieves all entities of a specified type (in-memory copies), supports pagination. + Performance note: This operation slices the in-memory list directly; deepcopy cost might be high for very large lists.) + """ if entity_type not in self.in_memory_data: _json_repo_logger.warning( f"尝试获取所有实体,但实体类型 '{entity_type}' 不在内存数据中。" @@ -161,78 +267,114 @@ async def get_all( return [] all_items = self.in_memory_data[entity_type] - # 对内存中的列表进行切片以实现分页 return copy.deepcopy(all_items[skip : skip + limit]) async def create( self, entity_type: str, entity_data: Dict[str, Any] ) -> Dict[str, Any]: - """在内存中创建新实体并异步持久化到文件。""" + """在内存中创建新实体,更新ID索引,并异步持久化到文件。""" if entity_type not in self.in_memory_data: - # 如果实体类型是首次遇到,则在内存和文件路径配置中初始化它 self.in_memory_data[entity_type] = [] + self.id_indexes[entity_type] = {} if entity_type not in self.file_paths: - # 为新的实体类型定义一个默认的文件名规则 self.file_paths[entity_type] = ( self.base_data_dir / f"{entity_type}_db.json" ) - self.file_locks[entity_type] = asyncio.Lock() # 并为其创建文件锁 + self.file_locks[entity_type] = asyncio.Lock() _json_repo_logger.info( f"实体类型 '{entity_type}' 为新类型,已使用默认路径 '{self.file_paths[entity_type]}' 进行初始化。" ) - # 检查ID是否已存在,以避免重复创建 (依赖于 get_by_id 和ID字段的约定) - # 此处假设ID字段存在于entity_data中,或者get_by_id可以处理 - temp_id_fields = ["id", "uid", "paper_id", f"{entity_type}_id"] - entity_id_val = None - for id_field_key in temp_id_fields: - if id_field_key in entity_data: - entity_id_val = str(entity_data[id_field_key]) - break + new_entity_id_val_str: Optional[str] = None - if entity_id_val and await self.get_by_id(entity_type, entity_id_val): - _json_repo_logger.error( - f"尝试创建重复的实体: 类型='{entity_type}', ID='{entity_id_val}'" - ) - raise ValueError( - f"实体类型 '{entity_type}' 中 ID 为 '{entity_id_val}' 的实体已存在。" - ) + for id_field_name in COMMON_ID_FIELDS: + if id_field_name in entity_data: + new_entity_id_val_str = str(entity_data[id_field_name]) + if ( + self.id_indexes[entity_type] + .get(id_field_name, {}) + .get(new_entity_id_val_str) + ): + _json_repo_logger.error( + f"尝试使用已存在的ID创建重复实体: 类型='{entity_type}', 字段='{id_field_name}', ID='{new_entity_id_val_str}'" + ) + raise ValueError( + f"实体类型 '{entity_type}' 中,字段 '{id_field_name}' 的 ID 为 '{new_entity_id_val_str}' 的实体已存在。" + ) + break new_entity = copy.deepcopy(entity_data) - self.in_memory_data[entity_type].append(new_entity) # 添加到内存列表 - await self._persist_data_to_file(entity_type) # 异步持久化到文件 + self.in_memory_data[entity_type].append(new_entity) + + for id_field_name in COMMON_ID_FIELDS: + if id_field_name in new_entity: + entity_id_value = str(new_entity[id_field_name]) + if id_field_name not in self.id_indexes[entity_type]: + self.id_indexes[entity_type][id_field_name] = {} + self.id_indexes[entity_type][id_field_name][entity_id_value] = ( + new_entity + ) + + await self._persist_data_to_file(entity_type) return new_entity async def update( self, entity_type: str, entity_id: str, update_data: Dict[str, Any] ) -> Optional[Dict[str, Any]]: - """根据ID更新内存中的现有实体,并异步持久化更改。""" - if entity_type not in self.in_memory_data: - _json_repo_logger.warning( - f"尝试更新实体,但实体类型 '{entity_type}' 不存在于内存中。" - ) - return None - - id_fields_to_check = ["id", f"{entity_type}_id", "uid", "paper_id"] - found_idx = -1 # 初始化找到的索引为-1 - - # 遍历查找具有匹配ID的实体 - for i, item in enumerate(self.in_memory_data[entity_type]): - for id_field in id_fields_to_check: - if id_field in item and str(item[id_field]) == str(entity_id): - found_idx = i - break - if found_idx != -1: - break - - if found_idx != -1: - entity_to_update = self.in_memory_data[entity_type][found_idx] - entity_to_update.update(update_data) # 执行部分更新 - self.in_memory_data[entity_type][ - found_idx - ] = entity_to_update # 更新内存中的记录 - await self._persist_data_to_file(entity_type) # 异步持久化 - return copy.deepcopy(entity_to_update) + """ + 根据ID更新内存中的现有实体,并异步持久化更改。 + 假设ID字段本身不被修改。如果ID字段可变,则需要更复杂的索引更新逻辑(删除旧索引,添加新索引)。 + (Updates an existing entity in memory by ID and persists changes asynchronously. + Assumes ID fields themselves are not modified. If ID fields are mutable, + more complex index update logic (remove old index, add new index) would be required.) + """ + entity_to_update = await self.get_by_id(entity_type, entity_id) # type: ignore + + if entity_to_update: + actual_item_reference = None + + entity_id_str = str(entity_id) + found_via_index = False + for id_field_name, id_map in self.id_indexes.get(entity_type, {}).items(): + if id_field_name in COMMON_ID_FIELDS: + indexed_item_ref = id_map.get(entity_id_str) + if indexed_item_ref is not None: + actual_item_reference = indexed_item_ref + found_via_index = True + break + + if not found_via_index: + _json_repo_logger.warning( + f"更新 '{entity_type}/{entity_id_str}': 索引未命中,尝试线性扫描获取引用(罕见)。" + ) + for _i, item_in_list in enumerate( # B007: Renamed i to _i + self.in_memory_data.get(entity_type, []) + ): + for id_field in COMMON_ID_FIELDS: + if ( + id_field in item_in_list + and str(item_in_list[id_field]) == entity_id_str + ): + actual_item_reference = item_in_list + break + if actual_item_reference: + break + + if actual_item_reference: + for id_field_name in COMMON_ID_FIELDS: + if id_field_name in update_data and str( + update_data[id_field_name] + ) != str(actual_item_reference.get(id_field_name)): + _json_repo_logger.error( + f"禁止通过 update 方法修改ID字段 '{id_field_name}' (ID field '{id_field_name}' modification via update method is prohibited)." + ) + raise ValueError( + f"不允许通过此 update 方法修改ID字段 '{id_field_name}'。" + ) + + actual_item_reference.update(update_data) + await self._persist_data_to_file(entity_type) + return copy.deepcopy(actual_item_reference) _json_repo_logger.warning( f"尝试更新实体,但在实体类型 '{entity_type}' 中未找到ID为 '{entity_id}' 的实体。" @@ -240,34 +382,72 @@ async def update( return None async def delete(self, entity_type: str, entity_id: str) -> bool: - """根据ID从内存中删除实体,并异步持久化更改。""" + """根据ID从内存中删除实体,从ID索引中移除,并异步持久化更改。""" if entity_type not in self.in_memory_data: _json_repo_logger.warning( f"尝试删除实体,但实体类型 '{entity_type}' 不存在于内存中。" ) return False - id_fields_to_check = ["id", f"{entity_type}_id", "uid", "paper_id"] - # initial_len = len(self.in_memory_data[entity_type]) # 未使用 (Unused) + entity_id_str = str(entity_id) + item_to_delete = None + item_index_in_list = -1 - # 构建一个不包含待删除项的新列表 - items_to_keep = [] - item_deleted = False - for item in self.in_memory_data[entity_type]: - is_match = False - for id_field in id_fields_to_check: - if id_field in item and str(item[id_field]) == str(entity_id): - is_match = True + for id_field_name, id_map in self.id_indexes.get(entity_type, {}).items(): + if id_field_name in COMMON_ID_FIELDS: + item_to_delete = id_map.get(entity_id_str) + if item_to_delete is not None: break - if not is_match: - items_to_keep.append(item) - else: - item_deleted = True - if item_deleted: - self.in_memory_data[entity_type] = items_to_keep - await self._persist_data_to_file(entity_type) # 异步持久化 - return item_deleted + item_deleted_from_list = False + if item_to_delete: + try: + self.in_memory_data[entity_type].remove(item_to_delete) + item_deleted_from_list = True + except ValueError: + _json_repo_logger.error( + f"删除 '{entity_type}/{entity_id_str}': 索引找到但对象不在主数据列表中。索引可能已损坏。" + ) + item_deleted_from_list = False + + if not item_to_delete or not item_deleted_from_list: + _json_repo_logger.debug( + f"删除 '{entity_type}/{entity_id_str}': 索引未命中或列表移除失败,尝试线性扫描。" + ) + for i, item_in_list in enumerate(self.in_memory_data.get(entity_type, [])): + for id_field in COMMON_ID_FIELDS: + if ( + id_field in item_in_list + and str(item_in_list[id_field]) == entity_id_str + ): + item_to_delete = item_in_list + item_index_in_list = i + break + if ( + item_to_delete and item_index_in_list != -1 + ): # Check if found in this scan pass + break + if item_index_in_list != -1: # If found via linear scan + self.in_memory_data[entity_type].pop(item_index_in_list) + item_deleted_from_list = True # Now it's deleted from list + + if item_to_delete and item_deleted_from_list: + for id_field_name, id_map in self.id_indexes.get(entity_type, {}).items(): + if id_field_name in item_to_delete: + id_val_of_deleted = str(item_to_delete[id_field_name]) + if id_val_of_deleted in id_map: + del id_map[id_val_of_deleted] + + await self._persist_data_to_file(entity_type) + _json_repo_logger.info( + f"成功删除并持久化实体 '{entity_type}/{entity_id_str}'。" + ) + return True + + _json_repo_logger.warning( + f"尝试删除实体,但在实体类型 '{entity_type}' 中未找到ID为 '{entity_id_str}' 的实体。" + ) + return False async def query( self, @@ -276,25 +456,28 @@ async def query( skip: int = 0, limit: int = 100, ) -> List[Dict[str, Any]]: - """根据一组条件从内存中查询实体。""" + """ + 根据一组条件从内存中查询实体。 + 性能提示:此查询为线性扫描,对于大型数据集可能较慢。 + (Queries entities from memory based on a set of conditions. + Performance note: This query is a linear scan and may be slow for large datasets.) + """ if entity_type not in self.in_memory_data: _json_repo_logger.warning( - f"尝试查询实体,但实体类型 '{entity_type}' 不存在于内存中。" + f"尝试查询实体,但实体类型 '{entity_type}' 不在内存数据中。" ) return [] results: List[Dict[str, Any]] = [] - # 在内存中进行简单过滤 for item in self.in_memory_data[entity_type]: match = True for key, value in conditions.items(): - if item.get(key) != value: # 精确匹配 + if item.get(key) != value: match = False break if match: results.append(item) - # 对过滤后的结果应用分页 return copy.deepcopy(results[skip : skip + limit]) async def _ensure_file_exists( @@ -304,20 +487,17 @@ async def _ensure_file_exists( initial_data: Optional[List[Dict[str, Any]]] = None, ) -> None: """确保JSON文件存在,如果不存在则创建并用空列表或提供的初始数据初始化。""" - lock = self.file_locks.get(entity_type) # 获取对应实体类型的锁 + lock = self.file_locks.get(entity_type) if not lock: - # 对于动态添加的实体类型,可能需要动态创建锁 _json_repo_logger.warning( f"为实体类型 '{entity_type}' 获取文件锁失败,可能未正确初始化。" ) - return # 或者抛出错误 + return async with lock: - if not file_path.exists(): # 检查文件是否存在 - file_path.parent.mkdir(parents=True, exist_ok=True) # 确保父目录存在 - data_to_write = ( - initial_data if initial_data is not None else [] - ) # 确定写入内容 + if not file_path.exists(): + file_path.parent.mkdir(parents=True, exist_ok=True) + data_to_write = initial_data if initial_data is not None else [] try: with open(file_path, "w", encoding="utf-8") as f: json.dump(data_to_write, f, indent=4, ensure_ascii=False) @@ -337,33 +517,23 @@ async def init_storage_if_needed( 对于JSON存储库,这意味着对应的JSON文件已创建。 """ if entity_type not in self.file_paths: - # 如果实体类型未在初始配置中定义,则为其创建一个默认文件路径和锁 default_path = self.base_data_dir / f"{entity_type}_default_db.json" self.file_paths[entity_type] = default_path - self.file_locks[entity_type] = asyncio.Lock() # 为新实体类型创建锁 + self.file_locks[entity_type] = asyncio.Lock() _json_repo_logger.warning( f"实体类型 '{entity_type}' 无预设文件路径,已默认设置为 '{default_path}'。" ) file_path = self.file_paths[entity_type] - # 确保内存中存在该实体类型的列表 if entity_type not in self.in_memory_data: self.in_memory_data[entity_type] = [] - # 如果文件不存在,则创建并用初始数据(或空列表)填充 if not file_path.exists(): await self._ensure_file_exists(entity_type, file_path, initial_data or []) - # 如果提供了初始数据且内存中为空,则用初始数据填充内存 - # (_load_all_data_on_startup 应该在之后或之前处理了加载逻辑,这里确保创建时一致性) if initial_data and not self.in_memory_data[entity_type]: self.in_memory_data[entity_type] = copy.deepcopy(initial_data) - elif ( - initial_data and not self.in_memory_data[entity_type] - ): # 文件存在,但内存为空,且提供了初始数据 - # 此逻辑分支可能需要根据具体需求调整。 - # 当前:如果文件存在,数据在启动时已加载。此方法主要确保文件创建。 - # 如果希望用 initial_data 覆盖或填充空文件后的内存,需要更复杂的逻辑。 + elif initial_data and not self.in_memory_data[entity_type]: _json_repo_logger.debug( f"实体类型 '{entity_type}' 的文件已存在,内存为空但提供了初始数据。依赖启动时加载。" ) @@ -376,9 +546,7 @@ async def get_all_entity_types(self) -> List[str]: async def persist_all_data(self) -> None: """将所有实体类型的内存数据异步持久化到各自的JSON文件。""" _json_repo_logger.info("尝试持久化所有实体类型的数据...") - for entity_type in list( - self.in_memory_data.keys() - ): # 使用 list() 避免在迭代时修改字典 + for entity_type in list(self.in_memory_data.keys()): await self._persist_data_to_file(entity_type) _json_repo_logger.info("所有数据持久化完成。") diff --git a/app/crud/mysql_repository.py b/app/crud/mysql_repository.py index 822bf9a..a57e13a 100644 --- a/app/crud/mysql_repository.py +++ b/app/crud/mysql_repository.py @@ -115,7 +115,9 @@ async def disconnect(self) -> None: """关闭 MySQL 连接池。(Closes the MySQL connection pool.)""" if self.pool: self.pool.close() - await self.pool.wait_closed() # 等待所有连接关闭 (Wait for all connections to close) + await ( + self.pool.wait_closed() + ) # 等待所有连接关闭 (Wait for all connections to close) self.pool = None _mysql_repo_logger.info( "MySQL 连接池已关闭。 (MySQL connection pool closed.)" @@ -148,9 +150,9 @@ async def init_storage_if_needed( "连接池未初始化,尝试在 init_storage_if_needed 中连接。 (Connection pool not initialized, attempting to connect in init_storage_if_needed.)" ) await self.connect() - assert ( - self.pool is not None - ), "数据库连接池在init_storage_if_needed时必须可用。 (Database connection pool must be available in init_storage_if_needed.)" + assert self.pool is not None, ( + "数据库连接池在init_storage_if_needed时必须可用。 (Database connection pool must be available in init_storage_if_needed.)" + ) async with self.pool.acquire() as conn: async with conn.cursor() as cur: diff --git a/app/crud/paper.py b/app/crud/paper.py index 8e0716c..f852420 100644 --- a/app/crud/paper.py +++ b/app/crud/paper.py @@ -16,7 +16,7 @@ """ # region 模块导入 (Module Imports) -import asyncio # Added for asyncio.create_task +import asyncio # Added for asyncio.create_task import datetime import logging import random @@ -28,8 +28,6 @@ from fastapi import Request from ..core.config import ( # 应用配置及常量 (App config and constants) - CODE_INFO_OR_SPECIFIC_CONDITION, - CODE_SUCCESS, DifficultyLevel, settings, ) @@ -82,9 +80,7 @@ def __init__( self.qb_crud: Any = qb_crud_instance async def initialize_storage(self) -> None: - await self.repository.init_storage_if_needed( - PAPER_ENTITY_TYPE, initial_data=[] - ) + await self.repository.init_storage_if_needed(PAPER_ENTITY_TYPE, initial_data=[]) _paper_crud_logger.info( f"实体类型 '{PAPER_ENTITY_TYPE}' 的存储已初始化(如果需要)。" ) @@ -107,9 +103,7 @@ async def create_new_paper( _paper_crud_logger.error( f"请求了难度 '{difficulty.value}' 但其题库内容为空或元数据未加载。" ) - raise ValueError( - f"难度 '{difficulty.value}' 的题库不可用或为空。" - ) + raise ValueError(f"难度 '{difficulty.value}' 的题库不可用或为空。") current_question_bank_content = [ q.model_dump() for q in full_question_bank.questions @@ -141,13 +135,13 @@ async def create_new_paper( "creation_ip": get_client_ip_from_request(request=request), "difficulty": difficulty.value, "paper_questions": [], - "score": None, # 将代表客观题得分,或在最终批改后代表总分 - "total_score": None, # 新增:用于存储客观题+主观题的总分 + "score": None, # 将代表客观题得分,或在最终批改后代表总分 + "total_score": None, # 新增:用于存储客观题+主观题的总分 "score_percentage": None, "submitted_answers_card": None, "submission_time_utc": None, "submission_ip": None, - "pass_status": PaperPassStatusEnum.PENDING.value, # 初始状态为待处理 + "pass_status": PaperPassStatusEnum.PENDING.value, # 初始状态为待处理 "passcode": None, "last_update_time_utc": None, "last_update_ip": None, @@ -162,7 +156,9 @@ async def create_new_paper( subjective_questions_count = 0 for item_data in selected_question_samples: - question_type_str = item_data.get("question_type", QuestionTypeEnum.SINGLE_CHOICE.value) + question_type_str = item_data.get( + "question_type", QuestionTypeEnum.SINGLE_CHOICE.value + ) if question_type_str == QuestionTypeEnum.ESSAY_QUESTION.value: subjective_questions_count += 1 @@ -182,9 +178,14 @@ async def create_new_paper( } if question_type_str == QuestionTypeEnum.SINGLE_CHOICE.value: - correct_choice_text = random.sample( - item_data.get("correct_choices", ["默认正确答案"]), settings.num_correct_choices_to_select - )[0] if item_data.get("correct_choices") else "默认正确答案" + correct_choice_text = ( + random.sample( + item_data.get("correct_choices", ["默认正确答案"]), + settings.num_correct_choices_to_select, + )[0] + if item_data.get("correct_choices") + else "默认正确答案" + ) correct_choice_id = generate_random_hex_string_of_bytes( settings.generated_code_length_bytes ) @@ -201,7 +202,9 @@ async def create_new_paper( ): text for text in incorrect_choices_texts } - question_entry["correct_choices_map"] = {correct_choice_id: correct_choice_text} + question_entry["correct_choices_map"] = { + correct_choice_id: correct_choice_text + } question_entry["incorrect_choices_map"] = incorrect_choices_with_ids new_paper_data["paper_questions"].append(question_entry) @@ -224,9 +227,16 @@ async def create_new_paper( } client_paper_response_paper_field.append( { - "internal_question_id": q_data.get("internal_question_id"), # Pass internal_question_id to client + "internal_question_id": q_data.get( + "internal_question_id" + ), # Pass internal_question_id to client "body": q_data.get("body", "题目内容缺失"), - "choices": shuffle_dictionary_items(all_choices) if q_data.get("question_type") == QuestionTypeEnum.SINGLE_CHOICE.value else None, + "choices": ( + shuffle_dictionary_items(all_choices) + if q_data.get("question_type") + == QuestionTypeEnum.SINGLE_CHOICE.value + else None + ), "question_type": q_data.get("question_type"), } ) @@ -240,7 +250,7 @@ async def update_paper_progress( self, paper_id: UUID, user_uid: str, - submitted_answers: List[Optional[str]], # For choice questions + submitted_answers: List[Optional[str]], # For choice questions # subjective_answers: Dict[str, str], # For subjective questions, keyed by internal_question_id request: Request, ) -> Dict[str, Any]: @@ -257,11 +267,13 @@ async def update_paper_progress( _paper_crud_logger.warning( f"试卷 '{paper_id}' 未找到或用户 '{user_uid}' 无权访问。" ) - return { "status_code": "NOT_FOUND", "message": "试卷未找到或无权访问。"} + return {"status_code": "NOT_FOUND", "message": "试卷未找到或无权访问。"} current_pass_status = target_paper_record.get("pass_status") - if current_pass_status and \ - current_pass_status not in [PaperPassStatusEnum.PENDING.value, PaperPassStatusEnum.PENDING_REVIEW.value]: + if current_pass_status and current_pass_status not in [ + PaperPassStatusEnum.PENDING.value, + PaperPassStatusEnum.PENDING_REVIEW.value, + ]: _paper_crud_logger.info( f"试卷 '{paper_id}' 状态为 {current_pass_status},无法更新进度。" ) @@ -279,14 +291,16 @@ async def update_paper_progress( # This part primarily handles objective question answers. Subjective answers are handled differently. processed_answers = [None] * num_questions_in_paper for i, q_data in enumerate(paper_questions): - if q_data.get("question_type") == QuestionTypeEnum.SINGLE_CHOICE.value: # Only process for choice questions here - if i < len(submitted_answers): + if ( + q_data.get("question_type") == QuestionTypeEnum.SINGLE_CHOICE.value + ): # Only process for choice questions here + if i < len(submitted_answers): processed_answers[i] = submitted_answers[i] # For subjective questions, student_subjective_answer is updated elsewhere (e.g. during grading or a dedicated save) update_time = datetime.now(timezone.utc).isoformat() update_fields = { - "submitted_answers_card": processed_answers, # Save processed answers for all questions + "submitted_answers_card": processed_answers, # Save processed answers for all questions "last_update_time_utc": update_time, "last_update_ip": get_client_ip_from_request(request=request), } @@ -297,10 +311,12 @@ async def update_paper_progress( ) if not updated_record: - _paper_crud_logger.error( f"在存储库中更新试卷 '{paper_id}' 失败。") - return { "status_code": "UPDATE_FAILED", "message": "保存试卷更新失败。" } + _paper_crud_logger.error(f"在存储库中更新试卷 '{paper_id}' 失败。") + return {"status_code": "UPDATE_FAILED", "message": "保存试卷更新失败。"} - _paper_crud_logger.info( f"用户 '{user_uid}' 的试卷 '{paper_id}' 进度已更新并存储。") + _paper_crud_logger.info( + f"用户 '{user_uid}' 的试卷 '{paper_id}' 进度已更新并存储。" + ) return { "status_code": "PROGRESS_SAVED", "message": "试卷进度已成功保存。", @@ -312,58 +328,90 @@ async def grade_paper_submission( self, paper_id: UUID, user_uid: str, - submitted_answers: List[Optional[str]], # Contains answers for objective questions - # And text for subjective questions, keyed by internal_question_id + submitted_answers: List[ + Optional[str] + ], # Contains answers for objective questions + # And text for subjective questions, keyed by internal_question_id request: Request, ) -> Dict[str, Any]: - _paper_crud_logger.info( f"用户 '{user_uid}' 提交试卷 '{paper_id}' 进行批改。") - target_paper_record = await self.repository.get_by_id( PAPER_ENTITY_TYPE, str(paper_id)) + _paper_crud_logger.info(f"用户 '{user_uid}' 提交试卷 '{paper_id}' 进行批改。") + target_paper_record = await self.repository.get_by_id( + PAPER_ENTITY_TYPE, str(paper_id) + ) if not target_paper_record or target_paper_record.get("user_uid") != user_uid: - _paper_crud_logger.warning(f"试卷 '{paper_id}' 未找到或用户 '{user_uid}' 无权访问。") + _paper_crud_logger.warning( + f"试卷 '{paper_id}' 未找到或用户 '{user_uid}' 无权访问。" + ) return {"status_code": "NOT_FOUND", "message": "试卷未找到或无权访问。"} current_pass_status = target_paper_record.get("pass_status") - if current_pass_status and \ - current_pass_status not in [PaperPassStatusEnum.PENDING.value, PaperPassStatusEnum.PENDING_REVIEW.value]: - _paper_crud_logger.info(f"试卷 '{paper_id}' 已被批改过或处于非可提交状态 ({current_pass_status})。") + if current_pass_status and current_pass_status not in [ + PaperPassStatusEnum.PENDING.value, + PaperPassStatusEnum.PENDING_REVIEW.value, + ]: + _paper_crud_logger.info( + f"试卷 '{paper_id}' 已被批改过或处于非可提交状态 ({current_pass_status})。" + ) return { "status_code": "ALREADY_GRADED_OR_INVALID_STATE", "message": "此试卷已被批改或当前状态无法提交。", "previous_result": current_pass_status, "score": target_paper_record.get("score"), "passcode": target_paper_record.get("passcode"), - "pending_manual_grading_count": target_paper_record.get("pending_manual_grading_count",0) + "pending_manual_grading_count": target_paper_record.get( + "pending_manual_grading_count", 0 + ), } paper_questions = target_paper_record.get("paper_questions", []) if not isinstance(paper_questions, list) or not paper_questions: - _paper_crud_logger.error(f"试卷 '{paper_id}' 缺少 'paper_questions' 或为空,无法批改。") - return {"status_code": "INVALID_PAPER_STRUCTURE", "message": "试卷结构无效,无法批改。"} + _paper_crud_logger.error( + f"试卷 '{paper_id}' 缺少 'paper_questions' 或为空,无法批改。" + ) + return { + "status_code": "INVALID_PAPER_STRUCTURE", + "message": "试卷结构无效,无法批改。", + } if len(submitted_answers) != len(paper_questions): - _paper_crud_logger.warning(f"试卷 '{paper_id}' 提交答案数 ({len(submitted_answers)}) 与题目数 ({len(paper_questions)}) 不符。") - return {"status_code": "INVALID_SUBMISSION", "message": "提交的答案数量与题目总数不匹配。"} + _paper_crud_logger.warning( + f"试卷 '{paper_id}' 提交答案数 ({len(submitted_answers)}) 与题目数 ({len(paper_questions)}) 不符。" + ) + return { + "status_code": "INVALID_SUBMISSION", + "message": "提交的答案数量与题目总数不匹配。", + } objective_questions_total = 0 correct_objective_answers_count = 0 internal_paper_questions = target_paper_record.get("paper_questions", []) for i, q_data in enumerate(internal_paper_questions): - if not isinstance(q_data, dict): continue + if not isinstance(q_data, dict): + continue q_type = q_data.get("question_type") if q_type == QuestionTypeEnum.SINGLE_CHOICE.value: objective_questions_total += 1 correct_map = q_data.get("correct_choices_map") - if correct_map and isinstance(correct_map, dict) and len(correct_map) == 1: + if ( + correct_map + and isinstance(correct_map, dict) + and len(correct_map) == 1 + ): correct_choice_id = list(correct_map.keys())[0] - if i < len(submitted_answers) and submitted_answers[i] == correct_choice_id: + if ( + i < len(submitted_answers) + and submitted_answers[i] == correct_choice_id + ): correct_objective_answers_count += 1 elif q_type == QuestionTypeEnum.ESSAY_QUESTION.value: if i < len(submitted_answers) and submitted_answers[i] is not None: # Store student's subjective answer text - internal_paper_questions[i]["student_subjective_answer"] = str(submitted_answers[i]) + internal_paper_questions[i]["student_subjective_answer"] = str( + submitted_answers[i] + ) # Other types like MULTIPLE_CHOICE, FILL_IN_BLANK would need their own grading logic here current_time_utc_iso = datetime.now(timezone.utc).isoformat() @@ -374,10 +422,10 @@ async def grade_paper_submission( ) update_fields = { - "score": correct_objective_answers_count, # Represents objective score at this stage + "score": correct_objective_answers_count, # Represents objective score at this stage "score_percentage": round(objective_score_percentage, 2), "submitted_answers_card": submitted_answers, - "paper_questions": internal_paper_questions, # Persist updated subjective answers + "paper_questions": internal_paper_questions, # Persist updated subjective answers "submission_time_utc": current_time_utc_iso, "submission_ip": get_client_ip_from_request(request=request), "last_update_time_utc": current_time_utc_iso, @@ -389,25 +437,37 @@ async def grade_paper_submission( # Re-fetch the record to get the latest pending_manual_grading_count (which was set at creation) # and subjective_questions_count - updated_target_paper_record = await self.repository.get_by_id(PAPER_ENTITY_TYPE, str(paper_id)) - if not updated_target_paper_record: # Should not happen if previous update succeeded - _paper_crud_logger.error(f"提交后无法重新获取试卷 '{paper_id}'。") - return {"status_code": "INTERNAL_ERROR", "message": "处理提交时发生内部错误。"} - + updated_target_paper_record = await self.repository.get_by_id( + PAPER_ENTITY_TYPE, str(paper_id) + ) + if ( + not updated_target_paper_record + ): # Should not happen if previous update succeeded + _paper_crud_logger.error(f"提交后无法重新获取试卷 '{paper_id}'。") + return { + "status_code": "INTERNAL_ERROR", + "message": "处理提交时发生内部错误。", + } result_payload: Dict[str, Any] = { "score": correct_objective_answers_count, "score_percentage": round(objective_score_percentage, 2), - "pending_manual_grading_count": updated_target_paper_record.get("pending_manual_grading_count", 0) + "pending_manual_grading_count": updated_target_paper_record.get( + "pending_manual_grading_count", 0 + ), } - has_pending_subjective = updated_target_paper_record.get("pending_manual_grading_count", 0) > 0 + has_pending_subjective = ( + updated_target_paper_record.get("pending_manual_grading_count", 0) > 0 + ) final_pass_status_for_update = "" if has_pending_subjective: final_pass_status_for_update = PaperPassStatusEnum.PENDING_REVIEW.value result_payload["status_code"] = PaperPassStatusEnum.PENDING_REVIEW.value - result_payload["message"] = "客观题已自动批改。试卷包含主观题,请等待人工批阅完成获取最终结果。" + result_payload["message"] = ( + "客观题已自动批改。试卷包含主观题,请等待人工批阅完成获取最终结果。" + ) _paper_crud_logger.info( f"用户 '{user_uid}' 试卷 '{paper_id}' 客观题得分 {correct_objective_answers_count}/{objective_questions_total} ({objective_score_percentage:.2f}%),包含 {updated_target_paper_record.get('pending_manual_grading_count')} 道主观题待批阅。" ) @@ -416,25 +476,51 @@ async def grade_paper_submission( # then this objective score is the final score. if objective_score_percentage >= settings.passing_score_percentage: final_pass_status_for_update = PaperPassStatusEnum.PASSED.value - passcode = generate_random_hex_string_of_bytes(settings.generated_code_length_bytes) - update_fields["passcode"] = passcode # Add passcode to update_fields + passcode = generate_random_hex_string_of_bytes( + settings.generated_code_length_bytes + ) + update_fields["passcode"] = passcode # Add passcode to update_fields result_payload.update( - {"status_code": PaperPassStatusEnum.PASSED.value, "passcode": passcode, "message": "恭喜,您已通过本次考试!"}) - _paper_crud_logger.info( f"用户 '{user_uid}' 试卷 '{paper_id}' 通过,得分 {correct_objective_answers_count}/{objective_questions_total} ({objective_score_percentage:.2f}%)。") + { + "status_code": PaperPassStatusEnum.PASSED.value, + "passcode": passcode, + "message": "恭喜,您已通过本次考试!", + } + ) + _paper_crud_logger.info( + f"用户 '{user_uid}' 试卷 '{paper_id}' 通过,得分 {correct_objective_answers_count}/{objective_questions_total} ({objective_score_percentage:.2f}%)。" + ) else: final_pass_status_for_update = PaperPassStatusEnum.FAILED.value - result_payload.update({"status_code": PaperPassStatusEnum.FAILED.value, "message": "很遗憾,您未能通过本次考试。"}) - _paper_crud_logger.info( f"用户 '{user_uid}' 试卷 '{paper_id}' 未通过,得分 {correct_objective_answers_count}/{objective_questions_total} ({objective_score_percentage:.2f}%)。") + result_payload.update( + { + "status_code": PaperPassStatusEnum.FAILED.value, + "message": "很遗憾,您未能通过本次考试。", + } + ) + _paper_crud_logger.info( + f"用户 '{user_uid}' 试卷 '{paper_id}' 未通过,得分 {correct_objective_answers_count}/{objective_questions_total} ({objective_score_percentage:.2f}%)。" + ) update_fields["pass_status"] = final_pass_status_for_update # Final update with pass_status final_updated_record = await self.repository.update( - PAPER_ENTITY_TYPE, str(paper_id), {"pass_status": final_pass_status_for_update, "passcode": update_fields.get("passcode")} + PAPER_ENTITY_TYPE, + str(paper_id), + { + "pass_status": final_pass_status_for_update, + "passcode": update_fields.get("passcode"), + }, ) if not final_updated_record: - _paper_crud_logger.error(f"在存储库中更新试卷 '{paper_id}' 的最终状态失败。") - return {"status_code": "GRADING_PERSISTENCE_FAILED", "message": "批改完成但保存最终状态失败。"} + _paper_crud_logger.error( + f"在存储库中更新试卷 '{paper_id}' 的最终状态失败。" + ) + return { + "status_code": "GRADING_PERSISTENCE_FAILED", + "message": "批改完成但保存最终状态失败。", + } return result_payload @@ -451,26 +537,39 @@ async def get_user_history(self, user_uid: str) -> List[Dict[str, Any]]: history.append( { "paper_id": paper_data.get("paper_id"), - "difficulty": DifficultyLevel(paper_data.get("difficulty", DifficultyLevel.hybrid.value)), - "score": paper_data.get("score"), # This is objective score or final total if finalized - "total_score": paper_data.get("total_score"), # Show total_score if available + "difficulty": DifficultyLevel( + paper_data.get("difficulty", DifficultyLevel.hybrid.value) + ), + "score": paper_data.get( + "score" + ), # This is objective score or final total if finalized + "total_score": paper_data.get( + "total_score" + ), # Show total_score if available "score_percentage": paper_data.get("score_percentage"), "pass_status": paper_data.get("pass_status"), "submission_time_utc": paper_data.get("submission_time_utc"), - "subjective_questions_count": paper_data.get("subjective_questions_count"), - "pending_manual_grading_count": paper_data.get("pending_manual_grading_count"), + "subjective_questions_count": paper_data.get( + "subjective_questions_count" + ), + "pending_manual_grading_count": paper_data.get( + "pending_manual_grading_count" + ), } ) return sorted( history, - key=lambda x: x.get("submission_time_utc") or x.get("creation_time_utc", ""), + key=lambda x: x.get("submission_time_utc") + or x.get("creation_time_utc", ""), reverse=True, ) async def get_user_paper_detail_for_history( self, paper_id_str: str, user_uid: str ) -> Optional[Dict[str, Any]]: - _paper_crud_logger.debug(f"用户 '{user_uid}' 请求历史试卷 '{paper_id_str}' 的详情。") + _paper_crud_logger.debug( + f"用户 '{user_uid}' 请求历史试卷 '{paper_id_str}' 的详情。" + ) paper_data = await self.repository.get_by_id(PAPER_ENTITY_TYPE, paper_id_str) if paper_data and paper_data.get("user_uid") == user_uid: @@ -479,40 +578,61 @@ async def get_user_paper_detail_for_history( paper_questions_internal = paper_data.get("paper_questions", []) if isinstance(paper_questions_internal, list): for idx, q_internal in enumerate(paper_questions_internal): - if not isinstance(q_internal, dict): continue + if not isinstance(q_internal, dict): + continue all_choices_for_client = { **q_internal.get("correct_choices_map", {}), **q_internal.get("incorrect_choices_map", {}), } - submitted_ans_for_this_q = submitted_answers[idx] if idx < len(submitted_answers) else None + submitted_ans_for_this_q = ( + submitted_answers[idx] if idx < len(submitted_answers) else None + ) q_type_val = q_internal.get("question_type") client_question = { "internal_question_id": q_internal.get("internal_question_id"), "body": q_internal.get("body", "N/A"), "question_type": q_type_val, - "choices": shuffle_dictionary_items(all_choices_for_client) if q_type_val == QuestionTypeEnum.SINGLE_CHOICE.value else None, - "submitted_answer": None, # Will be populated based on type + "choices": ( + shuffle_dictionary_items(all_choices_for_client) + if q_type_val == QuestionTypeEnum.SINGLE_CHOICE.value + else None + ), + "submitted_answer": None, # Will be populated based on type "student_subjective_answer": None, "standard_answer_text": None, "manual_score": q_internal.get("manual_score"), "teacher_comment": q_internal.get("teacher_comment"), - "is_graded_manually": q_internal.get("is_graded_manually", False) + "is_graded_manually": q_internal.get( + "is_graded_manually", False + ), } if q_type_val == QuestionTypeEnum.ESSAY_QUESTION.value: - client_question["student_subjective_answer"] = q_internal.get("student_subjective_answer") - client_question["submitted_answer"] = q_internal.get("student_subjective_answer") # For consistency if client uses submitted_answer - client_question["standard_answer_text"] = q_internal.get("standard_answer_text") - else: # For single_choice etc. + client_question["student_subjective_answer"] = q_internal.get( + "student_subjective_answer" + ) + client_question["submitted_answer"] = q_internal.get( + "student_subjective_answer" + ) # For consistency if client uses submitted_answer + client_question["standard_answer_text"] = q_internal.get( + "standard_answer_text" + ) + else: # For single_choice etc. client_question["submitted_answer"] = submitted_ans_for_this_q - history_questions.append(HistoryPaperQuestionClientView(**client_question).model_dump(exclude_none=True)) + history_questions.append( + HistoryPaperQuestionClientView(**client_question).model_dump( + exclude_none=True + ) + ) # Prepare final response dict response_dict = { "paper_id": paper_data["paper_id"], - "difficulty": DifficultyLevel(paper_data.get("difficulty", DifficultyLevel.hybrid.value)), + "difficulty": DifficultyLevel( + paper_data.get("difficulty", DifficultyLevel.hybrid.value) + ), "user_uid": user_uid, "paper_questions": history_questions, "score": paper_data.get("score"), @@ -522,18 +642,28 @@ async def get_user_paper_detail_for_history( "pass_status": paper_data.get("pass_status"), "passcode": paper_data.get("passcode"), "submission_time_utc": paper_data.get("submission_time_utc"), - "subjective_questions_count": paper_data.get("subjective_questions_count"), - "graded_subjective_questions_count": paper_data.get("graded_subjective_questions_count"), - "pending_manual_grading_count": paper_data.get("pending_manual_grading_count"), + "subjective_questions_count": paper_data.get( + "subjective_questions_count" + ), + "graded_subjective_questions_count": paper_data.get( + "graded_subjective_questions_count" + ), + "pending_manual_grading_count": paper_data.get( + "pending_manual_grading_count" + ), } return response_dict - _paper_crud_logger.warning(f"用户 '{user_uid}' 尝试访问不属于自己的试卷 '{paper_id_str}' 或试卷不存在。") + _paper_crud_logger.warning( + f"用户 '{user_uid}' 尝试访问不属于自己的试卷 '{paper_id_str}' 或试卷不存在。" + ) return None async def admin_get_all_papers_summary( self, skip: int = 0, limit: int = 100 ) -> List[Dict[str, Any]]: - _paper_crud_logger.debug(f"管理员请求所有试卷摘要,skip={skip}, limit={limit}。") + _paper_crud_logger.debug( + f"管理员请求所有试卷摘要,skip={skip}, limit={limit}。" + ) all_papers = await self.repository.get_all( PAPER_ENTITY_TYPE, skip=skip, limit=limit ) @@ -554,8 +684,12 @@ async def admin_get_paper_detail( async def admin_delete_paper(self, paper_id_str: str) -> bool: _paper_crud_logger.info(f"管理员尝试删除试卷 '{paper_id_str}'。") deleted = await self.repository.delete(PAPER_ENTITY_TYPE, paper_id_str) - if deleted: _paper_crud_logger.info(f"[Admin] 试卷 '{paper_id_str}' 已从存储库删除。") - else: _paper_crud_logger.warning(f"[Admin] 删除试卷 '{paper_id_str}' 失败(可能未找到)。") + if deleted: + _paper_crud_logger.info(f"[Admin] 试卷 '{paper_id_str}' 已从存储库删除。") + else: + _paper_crud_logger.warning( + f"[Admin] 删除试卷 '{paper_id_str}' 失败(可能未找到)。" + ) return deleted async def grade_subjective_question( @@ -565,7 +699,9 @@ async def grade_subjective_question( manual_score: float, teacher_comment: Optional[str] = None, ) -> bool: - _paper_crud_logger.info(f"开始人工批改试卷 '{paper_id}' 中的题目 '{question_internal_id}'。") + _paper_crud_logger.info( + f"开始人工批改试卷 '{paper_id}' 中的题目 '{question_internal_id}'。" + ) paper_data = await self.repository.get_by_id(PAPER_ENTITY_TYPE, str(paper_id)) if not paper_data: @@ -582,11 +718,18 @@ async def grade_subjective_question( previously_graded = False for q_idx, q_data in enumerate(paper_questions): - if isinstance(q_data, dict) and q_data.get("internal_question_id") == question_internal_id: + if ( + isinstance(q_data, dict) + and q_data.get("internal_question_id") == question_internal_id + ): question_found = True if q_data.get("question_type") != QuestionTypeEnum.ESSAY_QUESTION.value: - _paper_crud_logger.warning(f"尝试批改的题目 '{question_internal_id}' (试卷 '{paper_id}') 不是主观题。") - raise ValueError(f"题目 '{question_internal_id}' 不是主观题,无法人工批改。") + _paper_crud_logger.warning( + f"尝试批改的题目 '{question_internal_id}' (试卷 '{paper_id}') 不是主观题。" + ) + raise ValueError( + f"题目 '{question_internal_id}' 不是主观题,无法人工批改。" + ) previously_graded = q_data.get("is_graded_manually", False) paper_questions[q_idx]["manual_score"] = manual_score @@ -596,34 +739,52 @@ async def grade_subjective_question( break if not question_found: - _paper_crud_logger.warning(f"批改主观题失败:在试卷 '{paper_id}' 中未找到题目ID '{question_internal_id}'。") - raise ValueError(f"在试卷 '{paper_id}' 中未找到题目ID '{question_internal_id}'。") + _paper_crud_logger.warning( + f"批改主观题失败:在试卷 '{paper_id}' 中未找到题目ID '{question_internal_id}'。" + ) + raise ValueError( + f"在试卷 '{paper_id}' 中未找到题目ID '{question_internal_id}'。" + ) if question_updated: update_payload_for_repo = { - "paper_questions": paper_questions, # 更新后的题目列表 + "paper_questions": paper_questions, # 更新后的题目列表 "last_update_time_utc": datetime.now(timezone.utc).isoformat(), } if not previously_graded: - current_graded_count = paper_data.get("graded_subjective_questions_count", 0) - current_pending_count = paper_data.get("pending_manual_grading_count", 0) - update_payload_for_repo["graded_subjective_questions_count"] = current_graded_count + 1 - update_payload_for_repo["pending_manual_grading_count"] = max(0, current_pending_count - 1) + current_graded_count = paper_data.get( + "graded_subjective_questions_count", 0 + ) + current_pending_count = paper_data.get( + "pending_manual_grading_count", 0 + ) + update_payload_for_repo["graded_subjective_questions_count"] = ( + current_graded_count + 1 + ) + update_payload_for_repo["pending_manual_grading_count"] = max( + 0, current_pending_count - 1 + ) updated_record_partial = await self.repository.update( PAPER_ENTITY_TYPE, str(paper_id), update_payload_for_repo ) if updated_record_partial: - _paper_crud_logger.info(f"试卷 '{paper_id}' 中题目 '{question_internal_id}' 已成功人工批改。") + _paper_crud_logger.info( + f"试卷 '{paper_id}' 中题目 '{question_internal_id}' 已成功人工批改。" + ) # Trigger finalization check (can run in background, or be awaited by API layer if needed) asyncio.create_task(self.finalize_paper_grading_if_ready(paper_id)) return True else: - _paper_crud_logger.error(f"更新试卷 '{paper_id}' 的主观题批改信息失败(存储库操作返回None)。") + _paper_crud_logger.error( + f"更新试卷 '{paper_id}' 的主观题批改信息失败(存储库操作返回None)。" + ) return False return False - async def get_papers_pending_manual_grading(self, skip: int = 0, limit: int = 100) -> List[Dict[str, Any]]: + async def get_papers_pending_manual_grading( + self, skip: int = 0, limit: int = 100 + ) -> List[Dict[str, Any]]: _paper_crud_logger.info(f"获取待人工批阅试卷列表,skip={skip}, limit={limit}。") # This query needs to be supported by the repository or done in multiple steps/filtered here. # Assuming repository query can handle "field > value" or we fetch and filter. @@ -633,21 +794,27 @@ async def get_papers_pending_manual_grading(self, skip: int = 0, limit: int = 10 conditions={"pass_status": PaperPassStatusEnum.PENDING_REVIEW.value}, # Limit might need to be higher if filtering significantly reduces results # Or implement proper DB-level filtering for pending_manual_grading_count > 0 - limit=limit * 5, # Fetch more to allow filtering, adjust as needed - skip=0 # Initial skip is 0, pagination handled after filtering + limit=limit * 5, # Fetch more to allow filtering, adjust as needed + skip=0, # Initial skip is 0, pagination handled after filtering ) papers_needing_grading = [ - p for p in all_pending_review_papers - if p.get("pending_manual_grading_count", 0) > 0 and p.get("subjective_questions_count", 0) > 0 + p + for p in all_pending_review_papers + if p.get("pending_manual_grading_count", 0) > 0 + and p.get("subjective_questions_count", 0) > 0 ] # Manual pagination on the filtered list paginated_list = papers_needing_grading[skip : skip + limit] - _paper_crud_logger.info(f"发现 {len(papers_needing_grading)} 份试卷待批阅,返回 {len(paginated_list)} 份。") + _paper_crud_logger.info( + f"发现 {len(papers_needing_grading)} 份试卷待批阅,返回 {len(paginated_list)} 份。" + ) return paginated_list - async def finalize_paper_grading_if_ready(self, paper_id: UUID) -> Optional[Dict[str, Any]]: + async def finalize_paper_grading_if_ready( + self, paper_id: UUID + ) -> Optional[Dict[str, Any]]: _paper_crud_logger.info(f"检查试卷 '{paper_id}' 是否可以最终定版批改。") paper_data = await self.repository.get_by_id(PAPER_ENTITY_TYPE, str(paper_id)) @@ -655,12 +822,18 @@ async def finalize_paper_grading_if_ready(self, paper_id: UUID) -> Optional[Dict _paper_crud_logger.warning(f"最终定版检查失败:试卷 '{paper_id}' 未找到。") return None - if paper_data.get("pending_manual_grading_count", 0) == 0 and \ - paper_data.get("pass_status") == PaperPassStatusEnum.PENDING_REVIEW.value: - - _paper_crud_logger.info(f"试卷 '{paper_id}' 所有主观题已批改,开始最终计分和状态更新。") + if ( + paper_data.get("pending_manual_grading_count", 0) == 0 + and paper_data.get("pass_status") + == PaperPassStatusEnum.PENDING_REVIEW.value + ): + _paper_crud_logger.info( + f"试卷 '{paper_id}' 所有主观题已批改,开始最终计分和状态更新。" + ) - objective_score = paper_data.get("score", 0) # This is current objective score + objective_score = paper_data.get( + "score", 0 + ) # This is current objective score total_manual_score = 0.0 paper_questions = paper_data.get("paper_questions", []) @@ -673,7 +846,12 @@ async def finalize_paper_grading_if_ready(self, paper_id: UUID) -> Optional[Dict # This part might need refinement based on how max scores for subjective Qs are defined. for q_data in paper_questions: - if isinstance(q_data, dict) and q_data.get("question_type") == QuestionTypeEnum.ESSAY_QUESTION.value and q_data.get("is_graded_manually"): + if ( + isinstance(q_data, dict) + and q_data.get("question_type") + == QuestionTypeEnum.ESSAY_QUESTION.value + and q_data.get("is_graded_manually") + ): total_manual_score += q_data.get("manual_score", 0.0) final_total_score = objective_score + total_manual_score @@ -683,33 +861,50 @@ async def finalize_paper_grading_if_ready(self, paper_id: UUID) -> Optional[Dict # If subjective questions have different max scores, this logic needs to be more complex. # For now, let's assume each question is 1 point for simplicity of pass/fail. total_possible_points = len(paper_questions) if paper_questions else 0 - final_score_percentage = (final_total_score / total_possible_points) * 100 if total_possible_points > 0 else 0.0 + final_score_percentage = ( + (final_total_score / total_possible_points) * 100 + if total_possible_points > 0 + else 0.0 + ) update_fields = { "score": round(final_total_score), - "total_score": round(final_total_score, 2), # Store the combined score explicitly + "total_score": round( + final_total_score, 2 + ), # Store the combined score explicitly "score_percentage": round(final_score_percentage, 2), "last_update_time_utc": datetime.now(timezone.utc).isoformat(), - "pass_status": "", # To be set below + "pass_status": "", # To be set below } if final_score_percentage >= settings.passing_score_percentage: update_fields["pass_status"] = PaperPassStatusEnum.PASSED.value - update_fields["passcode"] = generate_random_hex_string_of_bytes(settings.generated_code_length_bytes) - _paper_crud_logger.info(f"试卷 '{paper_id}' 最终状态:通过。总分: {final_total_score}, 百分比: {final_score_percentage:.2f}%") + update_fields["passcode"] = generate_random_hex_string_of_bytes( + settings.generated_code_length_bytes + ) + _paper_crud_logger.info( + f"试卷 '{paper_id}' 最终状态:通过。总分: {final_total_score}, 百分比: {final_score_percentage:.2f}%" + ) else: update_fields["pass_status"] = PaperPassStatusEnum.FAILED.value - _paper_crud_logger.info(f"试卷 '{paper_id}' 最终状态:未通过。总分: {final_total_score}, 百分比: {final_score_percentage:.2f}%") + _paper_crud_logger.info( + f"试卷 '{paper_id}' 最终状态:未通过。总分: {final_total_score}, 百分比: {final_score_percentage:.2f}%" + ) - updated_paper = await self.repository.update(PAPER_ENTITY_TYPE, str(paper_id), update_fields) + updated_paper = await self.repository.update( + PAPER_ENTITY_TYPE, str(paper_id), update_fields + ) if not updated_paper: - _paper_crud_logger.error(f"更新试卷 '{paper_id}' 的最终批改状态失败。") - return None + _paper_crud_logger.error(f"更新试卷 '{paper_id}' 的最终批改状态失败。") + return None return updated_paper - _paper_crud_logger.info(f"试卷 '{paper_id}' 尚不满足最终定版条件 (待批改主观题: {paper_data.get('pending_manual_grading_count')}, 状态: {paper_data.get('pass_status')})。") + _paper_crud_logger.info( + f"试卷 '{paper_id}' 尚不满足最终定版条件 (待批改主观题: {paper_data.get('pending_manual_grading_count')}, 状态: {paper_data.get('pass_status')})。" + ) return None + # endregion __all__ = [ @@ -721,6 +916,4 @@ async def finalize_paper_grading_if_ready(self, paper_id: UUID) -> Optional[Dict _paper_crud_logger.info( f"模块 {__name__} 提供了试卷数据的CRUD操作类,不应直接执行。" ) - print( - f"模块 {__name__} 提供了试卷数据的CRUD操作类,不应直接执行。" - ) + print(f"模块 {__name__} 提供了试卷数据的CRUD操作类,不应直接执行。") diff --git a/app/crud/postgres_repository.py b/app/crud/postgres_repository.py index f2667db..49ced71 100644 --- a/app/crud/postgres_repository.py +++ b/app/crud/postgres_repository.py @@ -101,9 +101,7 @@ def __init__( "password": password, "database": database, } - if ( - not self.dsn - ): # 如果不使用DSN,则过滤掉值为None的参数 (If not using DSN, filter out None parameters) + if not self.dsn: # 如果不使用DSN,则过滤掉值为None的参数 (If not using DSN, filter out None parameters) self.conn_params = { k: v for k, v in self.conn_params.items() if v is not None } @@ -181,11 +179,13 @@ async def init_storage_if_needed( "连接池未初始化,尝试在 init_storage_if_needed 中连接。 (Connection pool not initialized, attempting to connect in init_storage_if_needed.)" ) await self.connect() - assert ( - self.pool is not None - ), "数据库连接池在init_storage_if_needed时必须可用。 (DB pool must be available.)" + assert self.pool is not None, ( + "数据库连接池在init_storage_if_needed时必须可用。 (DB pool must be available.)" + ) - async with self.pool.acquire() as conn: # 从连接池获取一个连接 (Acquire a connection from the pool) + async with ( + self.pool.acquire() as conn + ): # 从连接池获取一个连接 (Acquire a connection from the pool) # 根据实体类型定义表结构并创建 (Define and create table structure based on entity type) if entity_type == "user": # 用户表 await conn.execute( diff --git a/app/crud/redis_repository.py b/app/crud/redis_repository.py index d9c10f1..bf008ed 100644 --- a/app/crud/redis_repository.py +++ b/app/crud/redis_repository.py @@ -159,9 +159,9 @@ async def get_by_id( """通过ID从Redis检索单个实体(存储为JSON字符串)。(Retrieves a single entity by ID from Redis (stored as JSON string).""" if not self.redis: await self.connect() - assert ( - self.redis is not None - ), "Redis连接未初始化 (Redis connection not initialized)" + assert self.redis is not None, ( + "Redis连接未初始化 (Redis connection not initialized)" + ) key_name = self._get_entity_key(entity_type, entity_id) json_string = await self.redis.get(key_name) @@ -190,9 +190,9 @@ async def get_all( """ if not self.redis: await self.connect() - assert ( - self.redis is not None - ), "Redis连接未初始化 (Redis connection not initialized)" + assert self.redis is not None, ( + "Redis连接未初始化 (Redis connection not initialized)" + ) ids_set_key = self._get_entity_ids_set_key(entity_type) entity_ids = list( @@ -240,9 +240,9 @@ async def create( """在Redis中创建新实体(存储为JSON字符串)。(Creates a new entity in Redis (stored as JSON string).""" if not self.redis: await self.connect() - assert ( - self.redis is not None - ), "Redis连接未初始化 (Redis connection not initialized)" + assert self.redis is not None, ( + "Redis连接未初始化 (Redis connection not initialized)" + ) entity_id: str # 从 entity_data 中确定主键ID (Determine primary key ID from entity_data) @@ -301,9 +301,9 @@ async def update( """通过ID在Redis中更新现有实体。(Updates an existing entity by ID in Redis.)""" if not self.redis: await self.connect() - assert ( - self.redis is not None - ), "Redis连接未初始化 (Redis connection not initialized)" + assert self.redis is not None, ( + "Redis连接未初始化 (Redis connection not initialized)" + ) key_name = self._get_entity_key(entity_type, entity_id) current_json_string = await self.redis.get(key_name) @@ -329,9 +329,9 @@ async def delete(self, entity_type: str, entity_id: str) -> bool: """通过ID从Redis中删除实体及其在ID集合中的引用。(Deletes an entity by ID from Redis and its reference in the ID set.)""" if not self.redis: await self.connect() - assert ( - self.redis is not None - ), "Redis连接未初始化 (Redis connection not initialized)" + assert self.redis is not None, ( + "Redis连接未初始化 (Redis connection not initialized)" + ) key_name = self._get_entity_key(entity_type, entity_id) ids_set_key = self._get_entity_ids_set_key(entity_type) @@ -360,9 +360,9 @@ async def query( """ if not self.redis: await self.connect() - assert ( - self.redis is not None - ), "Redis连接未初始化 (Redis connection not initialized)" + assert self.redis is not None, ( + "Redis连接未初始化 (Redis connection not initialized)" + ) _redis_repo_logger.warning( "正在Redis上执行低效查询(获取所有后过滤)。对于大数据集,请优化。 (Performing inefficient query on Redis (get all then filter). Please optimize for large datasets.)" ) @@ -427,9 +427,9 @@ async def get_all_entity_types(self) -> List[str]: """ if not self.redis: await self.connect() - assert ( - self.redis is not None - ), "Redis连接未初始化 (Redis connection not initialized)" + assert self.redis is not None, ( + "Redis连接未初始化 (Redis connection not initialized)" + ) cursor = b"0" # aioredis scan cursor starts as bytes found_types = set() @@ -438,9 +438,7 @@ async def get_all_entity_types(self) -> List[str]: cursor, keys = await self.redis.scan( cursor=cursor, match=prefix_to_scan, count=100 ) - for ( - key_str - ) in ( + for key_str in ( keys ): # Keys are already decoded if decode_responses=True for Redis client entity_type = key_str.split(":", 1)[ diff --git a/app/crud/settings.py b/app/crud/settings.py index 02d083c..40be721 100644 --- a/app/crud/settings.py +++ b/app/crud/settings.py @@ -14,6 +14,7 @@ `app.core.config` and its associated configuration handling functions, such as loading, validation, and persistence of configurations.) """ + # region 模块导入 (Module Imports) import json import logging diff --git a/app/main.py b/app/main.py index 1074482..bbde7a8 100644 --- a/app/main.py +++ b/app/main.py @@ -4,14 +4,20 @@ FastAPI 应用主入口文件。 负责初始化FastAPI应用实例,加载配置,设置中间件, -挂载各个功能模块的API路由 (用户认证、核心答题、管理员接口等), +挂载各个功能模块的API路由 (用户认证、核心答题、WebSocket 通信、管理员接口等), 并定义应用的生命周期事件 (启动和关闭时的任务)。 +(This is the main entry point file for the FastAPI application. + It is responsible for initializing the FastAPI app instance, loading configurations, + setting up middleware, mounting API routers for various functional modules + (user authentication, core exam-taking, WebSocket communication, admin interfaces, etc.), + and defining application lifecycle events (tasks for startup and shutdown).) """ import asyncio import logging # 用于配置应用级日志 import os -from typing import List, Optional # 确保导入 Dict +from datetime import datetime # For export filename timestamp +from typing import Any, Dict, List, Optional # 确保导入 Dict, Any from uuid import UUID import uvicorn @@ -72,11 +78,17 @@ UserProfileUpdate, UserPublicProfile, ) +from .services.audit_logger import audit_logger_service # Audit logger +from .services.websocket_manager import websocket_manager # WebSocket Manager + +# --- 工具函数导入 --- +from .utils.export_utils import data_to_csv, data_to_xlsx # Export utilities from .utils.helpers import ( # 工具函数 format_short_uuid, get_client_ip_from_request, get_current_timestamp_str, ) +from .websocket_routes import ws_router # WebSocket 接口路由 # endregion @@ -260,6 +272,13 @@ async def sign_up_new_user(payload: UserCreate, request: Request): client_ip = get_client_ip_from_request(request) if is_rate_limited(client_ip, "auth_attempts"): app_logger.warning(f"用户注册请求过于频繁 (IP: {client_ip})。") + # Audit log for rate limit + await audit_logger_service.log_event( + action_type="USER_REGISTER", + status="FAILURE", + actor_ip=client_ip, + details={"message": "注册请求过于频繁", "target_resource_id": payload.uid}, + ) raise HTTPException( status_code=http_status.HTTP_429_TOO_MANY_REQUESTS, detail="注册请求过于频繁,请稍后再试。", @@ -270,6 +289,14 @@ async def sign_up_new_user(payload: UserCreate, request: Request): app_logger.warning( f"用户注册失败:用户名 '{payload.uid}' 已存在 (IP: {client_ip})。" ) + # Audit log for registration failure (user exists) + await audit_logger_service.log_event( + action_type="USER_REGISTER", + status="FAILURE", + actor_uid=payload.uid, + actor_ip=client_ip, + details={"message": "用户名已存在"}, + ) raise HTTPException( status_code=http_status.HTTP_409_CONFLICT, detail=f"用户名 '{payload.uid}' 已被注册。", @@ -277,6 +304,14 @@ async def sign_up_new_user(payload: UserCreate, request: Request): token_str = await create_access_token(user.uid, user.tags) app_logger.info(f"新用户 '{payload.uid}' 注册成功并登录 (IP: {client_ip})。") + # Audit log for successful registration + await audit_logger_service.log_event( + action_type="USER_REGISTER", + status="SUCCESS", + actor_uid=user.uid, + actor_ip=client_ip, + details={"message": "新用户注册成功"}, + ) return Token(access_token=token_str) # 返回Token @@ -306,6 +341,16 @@ async def login_for_access_token(payload: UserCreate, request: Request): client_ip = get_client_ip_from_request(request) if is_rate_limited(client_ip, "auth_attempts"): app_logger.warning(f"用户登录请求过于频繁 (IP: {client_ip})。") + # Audit log for rate limit + await audit_logger_service.log_event( + action_type="USER_LOGIN", + status="FAILURE", + actor_ip=client_ip, + details={ + "message": "登录请求过于频繁", + "target_resource_id": payload.uid if payload else "N/A", + }, + ) raise HTTPException( status_code=http_status.HTTP_429_TOO_MANY_REQUESTS, detail="登录请求过于频繁,请稍后再试。", @@ -318,6 +363,14 @@ async def login_for_access_token(payload: UserCreate, request: Request): app_logger.warning( f"用户 '{payload.uid}' 登录失败:用户名或密码错误 (IP: {client_ip})。" ) + # Audit log for login failure + await audit_logger_service.log_event( + action_type="USER_LOGIN", + status="FAILURE", + actor_uid=payload.uid, + actor_ip=client_ip, + details={"message": "用户名或密码错误"}, + ) raise HTTPException( status_code=http_status.HTTP_401_UNAUTHORIZED, detail="用户名或密码不正确。", @@ -325,6 +378,14 @@ async def login_for_access_token(payload: UserCreate, request: Request): token_str = await create_access_token(user.uid, user.tags) app_logger.info(f"用户 '{payload.uid}' 登录成功 (IP: {client_ip})。") + # Audit log for successful login + await audit_logger_service.log_event( + action_type="USER_LOGIN", + status="SUCCESS", + actor_uid=user.uid, + actor_ip=client_ip, + details={"message": "用户登录成功"}, + ) return Token(access_token=token_str) @@ -623,11 +684,22 @@ async def request_new_exam_paper( f"[{timestamp_str}] 用户 '{current_user_uid}' (IP {client_ip}) 成功创建新试卷 [{difficulty.value}]:{short_id}" ) # 构造并返回响应 - return ExamPaperResponse( + response = ExamPaperResponse( paper_id=new_paper_client_data["paper_id"], difficulty=new_paper_client_data["difficulty"], paper=new_paper_client_data["paper"], ) + # WebSocket 广播: 考试开始 + ws_message_started = { + "event_type": "EXAM_STARTED", + "user_uid": current_user_uid, + "paper_id": str(new_paper_client_data["paper_id"]), + "difficulty": new_paper_client_data["difficulty"].value, + "num_questions": len(new_paper_client_data["paper"]), + "message": f"用户 {current_user_uid} 开始了新试卷 {str(new_paper_client_data['paper_id'])} (难度: {new_paper_client_data['difficulty'].value})。", + } + await websocket_manager.broadcast_message(ws_message_started) + return response except ValueError as ve: # 例如题库题目不足或难度无效 app_logger.warning( f"[{timestamp_str}] 用户 '{current_user_uid}' (IP {client_ip}) 创建新试卷失败 (业务逻辑错误): {ve}" @@ -697,6 +769,22 @@ async def update_exam_progress( app_logger.info( f"[{timestamp_str}] 用户 '{current_user_uid}' (IP {client_ip}) 成功保存试卷 {short_paper_id} 进度。" ) + # WebSocket 广播: 考试进度更新 + ws_message_progress = { + "event_type": "EXAM_PROGRESS_UPDATE", + "user_uid": current_user_uid, + "paper_id": str(payload.paper_id), + "num_answered": len( + payload.result + ), # 注意: payload.result 是当前提交的答案,不一定是总答题数 + # update_result 可能包含更准确的总答题数字段 + "message": f"用户 {current_user_uid} 更新了试卷 {short_paper_id} 的进度。", + } + # 如果 update_result 包含更准确的已回答问题数, 例如: + # if "answered_count" in update_result: + # ws_message_progress["num_answered"] = update_result["answered_count"] + await websocket_manager.broadcast_message(ws_message_progress) + # 移除旧的自定义 'code' 字段,因为HTTP状态码现在是主要指标 update_result.pop("code", None) return UpdateProgressResponse(**update_result) @@ -785,9 +873,22 @@ async def submit_exam_paper( f"{log_msg_prefix},结果: {status_text}, 详情: {outcome}" ) # 返回包含批改结果的JSON响应 - return JSONResponse( + json_response = JSONResponse( content=response_data.model_dump(exclude_none=True), status_code=200 ) + # WebSocket 广播: 考试提交 + ws_message_submitted = { + "event_type": "EXAM_SUBMITTED", + "user_uid": current_user_uid, + "paper_id": str(payload.paper_id), + "score": response_data.score, + "score_percentage": response_data.score_percentage, + "pass_status": outcome.get("status_code") + == "PASSED", # 使用原始 outcome 的 status_code + "message": f"用户 {current_user_uid} 提交了试卷 {short_paper_id},得分: {response_data.score if response_data.score is not None else 'N/A'}", + } + await websocket_manager.broadcast_message(ws_message_submitted) + return json_response except HTTPException as http_exc: raise http_exc except Exception as e: @@ -805,21 +906,115 @@ async def submit_exam_paper( @exam_router.get( "/history", - response_model=List[HistoryItem], - summary="获取用户答题历史", - description="获取当前认证用户的简要答题历史记录列表,包含每次答题的试卷ID、难度、得分等信息。列表按提交时间倒序排列。", + # response_model=List[HistoryItem], # Conditional return + summary="获取用户答题历史 (支持CSV/XLSX导出)", + description="获取当前认证用户的简要答题历史记录列表。可通过 'format' 查询参数导出为 CSV 或 XLSX 文件。", responses={ - http_status.HTTP_200_OK: {"description": "成功获取答题历史"}, + http_status.HTTP_200_OK: { + "description": "成功获取答题历史 (JSON, CSV, or XLSX)" + }, http_status.HTTP_401_UNAUTHORIZED: {"description": "令牌无效或已过期"}, }, ) async def get_user_exam_history( current_user_uid: str = Depends(get_current_active_user_uid), + export_format: Optional[str] = Query( + None, description="导出格式 (csv 或 xlsx)", alias="format", regex="^(csv|xlsx)$" + ), ): - """获取当前认证用户的简要答题历史记录列表 (试卷ID, 难度, 得分等)。""" + """获取当前认证用户的简要答题历史记录列表 (试卷ID, 难度, 得分等)。支持导出。""" timestamp_str = get_current_timestamp_str() - app_logger.info(f"[{timestamp_str}] 用户 '{current_user_uid}' 请求答题历史记录。") - history_data = await paper_crud_instance.get_user_history(current_user_uid) + app_logger.info( + f"[{timestamp_str}] 用户 '{current_user_uid}' 请求答题历史记录 (格式: {export_format or 'json'})。" + ) + + history_data = await paper_crud_instance.get_user_history( + current_user_uid + ) # This returns List[Dict] + + if export_format: + if not history_data: + app_logger.info( + f"[{timestamp_str}] 用户 '{current_user_uid}' 没有答题历史数据可导出。" + ) + # Return empty file for export + + data_to_export: List[Dict[str, Any]] = [] + for item in history_data: # item is a dict + pass_status_str = ( + "未完成" # Default if no pass_status or status is not 'completed' + ) + if ( + item.get("status") == "completed" + ): # Assuming 'status' field indicates completion + if item.get("pass_status") is True: + pass_status_str = "通过" + elif item.get("pass_status") is False: + pass_status_str = "未通过" + + difficulty_val = item.get("difficulty", "") + difficulty_str = ( + difficulty_val.value + if isinstance(difficulty_val, DifficultyLevel) + else str(difficulty_val) + ) + + data_to_export.append( + { + "试卷ID": str(item.get("paper_id", "")), + "难度": difficulty_str, + "状态": str( + item.get("status", "") + ), # Assuming status is an enum or string + "总得分": item.get("total_score_obtained", ""), + "百分制得分": ( + f"{item.get('score_percentage'):.2f}" + if item.get("score_percentage") is not None + else "" + ), + "通过状态": pass_status_str, + "提交时间": ( + item.get("submission_time").strftime("%Y-%m-%d %H:%M:%S") + if item.get("submission_time") + else "" + ), + } + ) + + headers = [ + "试卷ID", + "难度", + "状态", + "总得分", + "百分制得分", + "通过状态", + "提交时间", + ] + current_time_for_file = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = ( + f"答题历史_{current_user_uid}_{current_time_for_file}.{export_format}" + ) + + if export_format == "csv": + app_logger.info( + f"[{timestamp_str}] 用户 '{current_user_uid}' 准备导出答题历史到 CSV 文件: {filename}" + ) + return data_to_csv( + data_list=data_to_export, headers=headers, filename=filename + ) + elif export_format == "xlsx": + app_logger.info( + f"[{timestamp_str}] 用户 '{current_user_uid}' 准备导出答题历史到 XLSX 文件: {filename}" + ) + return data_to_xlsx( + data_list=data_to_export, headers=headers, filename=filename + ) + + # Default JSON response + if not history_data: + app_logger.info(f"[{timestamp_str}] 用户 '{current_user_uid}' 答题历史为空。") + # Return empty list, which is fine. + return [HistoryItem(**item) for item in history_data] @@ -976,6 +1171,7 @@ async def get_users_directory(): # region Admin API 路由挂载 # 管理员相关API路由在 admin_routes.py 中定义,并在此处挂载到主应用 app.include_router(admin_router, prefix="/admin") # 所有管理员接口统一前缀 /admin +app.include_router(ws_router) # 挂载 WebSocket 路由 # endregion # region 主执行块 (用于直接运行此文件进行开发) diff --git a/app/models/audit_log_models.py b/app/models/audit_log_models.py new file mode 100644 index 0000000..3fafdd6 --- /dev/null +++ b/app/models/audit_log_models.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +""" +审计日志相关的Pydantic模型模块。 +(Pydantic Models Module for Audit Logs.) + +此模块定义了用于表示审计日志条目的数据模型。 +这些模型用于数据验证、序列化以及在应用内部传递审计日志信息。 +(This module defines data models for representing audit log entries. +These models are used for data validation, serialization, and for passing +audit log information within the application.) +""" + +import uuid +from datetime import datetime +from typing import Any, Dict, Optional + +from pydantic import BaseModel, Field + + +class AuditLogEntry(BaseModel): + """ + 审计日志条目的Pydantic模型。 + (Pydantic model for an audit log entry.) + """ + + event_id: str = Field( + default_factory=lambda: uuid.uuid4().hex, + description="事件的唯一ID (Unique ID for the event)", + ) + timestamp: datetime = Field( + default_factory=datetime.utcnow, + description="事件发生的时间戳 (UTC) (Timestamp of when the event occurred (UTC))", + ) + actor_uid: Optional[str] = Field( + None, description="执行操作的用户ID (UID of the user performing the action)" + ) + actor_ip: Optional[str] = Field( + None, + description="执行操作用户的IP地址 (IP address of the user performing the action)", + ) + action_type: str = Field( + ..., + description="操作类型 (例如: USER_LOGIN, ITEM_CREATE, CONFIG_UPDATE) (Type of action performed)", + ) + target_resource_type: Optional[str] = Field( + None, + description="操作目标资源的类型 (例如: USER, PAPER, QUESTION_BANK) (Type of the target resource)", + ) + target_resource_id: Optional[str] = Field( + None, description="操作目标资源的ID (ID of the target resource)" + ) + status: str = Field( + ..., + description="操作结果状态 (例如: SUCCESS, FAILURE) (Status of the action outcome)", + ) + details: Optional[Dict[str, Any]] = Field( + None, + description="与事件相关的其他详细信息 (Additional details related to the event)", + ) + + model_config = { + "json_encoders": { + datetime: lambda v: v.isoformat() # Ensure datetime is serialized to ISO format + } + } + + +__all__ = ["AuditLogEntry"] diff --git a/app/models/enums.py b/app/models/enums.py index 92247e3..2277f62 100644 --- a/app/models/enums.py +++ b/app/models/enums.py @@ -7,6 +7,7 @@ (This module defines enumeration types that may be reused by multiple modules across the application.) """ + from enum import Enum @@ -48,7 +49,7 @@ class PaperPassStatusEnum(str, Enum): "GRADING" # 批改中 (Grading in progress - if async/manual grading is needed) ) PENDING = "PENDING" # 待提交或待批改 (Pending submission or grading) - PENDING_REVIEW = "PENDING_REVIEW" # 客观题已批改,主观题待阅 (Objective part graded, subjective pending review) + PENDING_REVIEW = "PENDING_REVIEW" # 客观题已批改,主观题待阅 (Objective part graded, subjective pending review) # 可以根据需要添加更多状态,例如 CANCELED, ERROR_IN_GRADING 等 # (More statuses like CANCELED, ERROR_IN_GRADING can be added as needed) diff --git a/app/models/paper_models.py b/app/models/paper_models.py index 235d5c5..a22da14 100644 --- a/app/models/paper_models.py +++ b/app/models/paper_models.py @@ -9,10 +9,13 @@ paper creation, submission, grading, history viewing, etc. These models are extensively used in API request/response bodies and for internal data transfer.) """ + # region 模块导入 (Module Imports) +import uuid # Fixed F821: For PaperQuestionInternalDetail.internal_question_id default_factory from typing import Dict, List, Optional, Union -from uuid import UUID # 用于处理UUID类型 (For handling UUID type) +# UUID from typing is also available, but direct import of uuid module is common for uuid.uuid4() +# from uuid import UUID # 用于处理UUID类型 (For handling UUID type) -> This is fine, but default_factory needs the module from pydantic import BaseModel, Field from ..core.config import ( @@ -46,7 +49,7 @@ class PaperSubmissionPayload(BaseModel): (Request body model for user submitting a paper or updating progress.) """ - paper_id: UUID = Field( + paper_id: uuid.UUID = Field( ..., description="试卷的唯一标识符。(Unique identifier for the paper.)" ) result: List[Optional[str]] = Field( # 允许答案列表中的元素为None,表示未作答 @@ -71,17 +74,12 @@ class ExamPaperResponse(BaseModel): difficulty: DifficultyLevel = Field( description="试卷的难度级别。(Difficulty level of the paper.)" ) - paper: List[ExamQuestionClientView] = ( - Field( # 使用新定义的模型 (Use the newly defined model) - description="试卷题目列表。(List of paper questions.)" - ) + paper: List[ExamQuestionClientView] = Field( + description="试卷题目列表。(List of paper questions.)" ) - # `submitted_answers_for_resume` 字段已移除,因为恢复逻辑通常在客户端处理或通过专用接口 - # (The `submitted_answers_for_resume` field has been removed, as resume logic is typically - # handled client-side or via a dedicated endpoint.) - model_config = { # Pydantic v2 配置 (Pydantic v2 config) - "populate_by_name": True, # 允许使用别名填充 (Allow population by alias) + model_config = { + "populate_by_name": True, } @@ -91,9 +89,7 @@ class GradingResultResponse(BaseModel): (Response model for the POST /finish endpoint, representing the paper grading result.) """ - # The 'code: int' field is removed as HTTP status codes will be the primary indicator. - # The 'status_code: str' (now PaperPassStatusEnum) remains to indicate business outcome like PASSED/FAILED. - status_code: PaperPassStatusEnum = Field( # Now uses PaperPassStatusEnum + status_code: PaperPassStatusEnum = Field( description="文本状态描述 (例如 'PASSED', 'FAILED', 'ALREADY_GRADED')。(Textual status description (e.g., 'PASSED', 'FAILED', 'ALREADY_GRADED').)" ) message: Optional[str] = Field( @@ -116,7 +112,8 @@ class GradingResultResponse(BaseModel): description="如果试卷之前已被批改,此字段表示之前的状态。(If the paper was previously graded, this field indicates the prior status.)", ) pending_manual_grading_count: Optional[int] = Field( - None, description="等待人工批阅的主观题数量。若为0或None,则表示所有题目已自动或人工批改完毕。 (Number of subjective questions pending manual grading. If 0 or None, all questions are graded.)" + None, + description="等待人工批阅的主观题数量。若为0或None,则表示所有题目已自动或人工批改完毕。 (Number of subjective questions pending manual grading. If 0 or None, all questions are graded.)", ) @@ -162,7 +159,7 @@ class HistoryItem(BaseModel): score_percentage: Optional[float] = Field( None, description="百分制得分 (如果已批改)。(Percentage score (if graded).)" ) - pass_status: Optional[PaperPassStatusEnum] = Field( # 使用枚举 (Use enum) + pass_status: Optional[PaperPassStatusEnum] = Field( None, description="通过状态 ('PASSED', 'FAILED', 或 null)。(Pass status ('PASSED', 'FAILED', or null).)", ) @@ -179,9 +176,7 @@ class HistoryPaperQuestionClientView(BaseModel): """ body: str = Field(description="问题题干。(Question body.)") - question_type: QuestionTypeEnum = Field( - description="题目类型。(Question type.)" - ) # 使用枚举 (Use enum) + question_type: QuestionTypeEnum = Field(description="题目类型。(Question type.)") choices: Optional[Dict[str, str]] = Field( None, description="选择题的选项 (ID到文本的映射,已打乱)。(Options for multiple-choice questions (map of ID to text, shuffled).)", @@ -190,18 +185,21 @@ class HistoryPaperQuestionClientView(BaseModel): None, description="用户对此题提交的答案 (选择题为选项ID或ID列表,填空题为文本列表,主观题为文本)。(User's submitted answer: option ID(s) for choice, list of texts for fill-in-blank, text for subjective.)", ) - # 主观题相关字段,供学生回顾查看 (Subjective question related fields for student review) - student_subjective_answer: Optional[str] = Field( # 重复了 submitted_answer 的部分功能,但更明确针对主观题文本 - None, description="【主观题】学生提交的答案文本(如果题目是主观题)。(Student's submitted text answer if it's a subjective question.)" + student_subjective_answer: Optional[str] = Field( + None, + description="【主观题】学生提交的答案文本(如果题目是主观题)。(Student's submitted text answer if it's a subjective question.)", ) standard_answer_text: Optional[str] = Field( - None, description="【主观题】参考答案或要点(如果允许学生查看)。(Standard answer/key points for subjective questions, if student viewing is allowed.)" + None, + description="【主观题】参考答案或要点(如果允许学生查看)。(Standard answer/key points for subjective questions, if student viewing is allowed.)", ) manual_score: Optional[float] = Field( - None, description="【主观题】此题目的人工批阅得分。(Manual score for this subjective question.)" + None, + description="【主观题】此题目的人工批阅得分。(Manual score for this subjective question.)", ) teacher_comment: Optional[str] = Field( - None, description="【主观题】教师对此题的评语。(Teacher's comment on this subjective question.)" + None, + description="【主观题】教师对此题的评语。(Teacher's comment on this subjective question.)", ) @@ -235,7 +233,7 @@ class HistoryPaperDetailResponse(BaseModel): None, description="用户提交的完整原始答案卡 (选项ID列表,未答为null)。(User's complete original answer card (list of option IDs, null for unanswered).)", ) - pass_status: Optional[PaperPassStatusEnum] = Field( # 使用枚举 (Use enum) + pass_status: Optional[PaperPassStatusEnum] = Field( None, description="最终通过状态。(Final pass status.)" ) passcode: Optional[str] = Field( @@ -245,14 +243,16 @@ class HistoryPaperDetailResponse(BaseModel): None, description="试卷提交时间。(Paper submission time.)" ) pending_manual_grading_count: Optional[int] = Field( - None, description="等待人工批阅的主观题数量。若为0或None,则表示所有题目已自动或人工批改完毕。 (Number of subjective questions pending manual grading. If 0 or None, all questions are graded.)" + None, + description="等待人工批阅的主观题数量。若为0或None,则表示所有题目已自动或人工批改完毕。 (Number of subjective questions pending manual grading. If 0 or None, all questions are graded.)", ) - # 也添加主观题统计信息到历史详情 subjective_questions_count: Optional[int] = Field( - None, description="试卷中主观题的总数量。(Total number of subjective questions in the paper.)" + None, + description="试卷中主观题的总数量。(Total number of subjective questions in the paper.)", ) graded_subjective_questions_count: Optional[int] = Field( - None, description="已人工批阅的主观题数量。(Number of manually graded subjective questions.)" + None, + description="已人工批阅的主观题数量。(Number of manually graded subjective questions.)", ) @@ -294,7 +294,7 @@ class PaperAdminView(BaseModel): ) pass_status: Optional[PaperPassStatusEnum] = Field( None, description="通过状态。(Pass status.)" - ) # 使用枚举 (Use enum) + ) passcode: Optional[str] = Field(None, description="通行码。(Passcode.)") last_update_time_utc: Optional[str] = Field( None, description="最后更新时间 (UTC)。(Last update time (UTC).)" @@ -303,10 +303,12 @@ class PaperAdminView(BaseModel): None, description="最后更新IP地址。(Last update IP address.)" ) subjective_questions_count: Optional[int] = Field( - 0, description="试卷中主观题的总数量。(Total number of subjective questions in the paper.)" + 0, + description="试卷中主观题的总数量。(Total number of subjective questions in the paper.)", ) graded_subjective_questions_count: Optional[int] = Field( - 0, description="已人工批阅的主观题数量。(Number of manually graded subjective questions.)" + 0, + description="已人工批阅的主观题数量。(Number of manually graded subjective questions.)", ) @@ -319,44 +321,50 @@ class PaperQuestionInternalDetail(BaseModel): Includes question content, choice answer mappings, and subjective question answering/grading info.) """ - internal_question_id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="试卷中此题目的唯一内部ID。(Unique internal ID for this question within the paper.)") + internal_question_id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + description="试卷中此题目的唯一内部ID。(Unique internal ID for this question within the paper.)", + ) body: str = Field(description="问题题干。(Question body.)") - # --- 选择题相关字段 (Choice-based question fields) --- correct_choices_map: Optional[Dict[str, str]] = Field( - None, description="【选择题】正确选项 (ID -> 文本)。(Correct choices (ID -> text) for choice questions.)" + None, + description="【选择题】正确选项 (ID -> 文本)。(Correct choices (ID -> text) for choice questions.)", ) incorrect_choices_map: Optional[Dict[str, str]] = Field( - None, description="【选择题】错误选项 (ID -> 文本)。(Incorrect choices (ID -> text) for choice questions.)" + None, + description="【选择题】错误选项 (ID -> 文本)。(Incorrect choices (ID -> text) for choice questions.)", ) - # --- 通用题目信息 (General question information) --- question_type: Optional[QuestionTypeEnum] = Field( None, description="题目类型 (例如 'single_choice', 'essay_question')。(Question type (e.g., 'single_choice', 'essay_question').)", ) - # 从 QuestionModel 复制的基础字段,用于批阅或展示 (Fields copied from QuestionModel for grading/display) standard_answer_text: Optional[str] = Field( - None, description="【主观题】参考答案或答案要点。(Reference answer for subjective questions.)" + None, + description="【主观题】参考答案或答案要点。(Reference answer for subjective questions.)", ) scoring_criteria: Optional[str] = Field( - None, description="【主观题】评分标准。(Scoring criteria for subjective questions.)" + None, + description="【主观题】评分标准。(Scoring criteria for subjective questions.)", ) ref: Optional[str] = Field( None, description="通用答案解析或参考信息。(General answer explanation or reference information.)", ) - - # --- 主观题作答与批阅信息 (Subjective question answer and grading information) --- student_subjective_answer: Optional[str] = Field( - None, description="学生提交的主观题答案文本。(Student's submitted text answer for subjective questions.)" + None, + description="学生提交的主观题答案文本。(Student's submitted text answer for subjective questions.)", ) manual_score: Optional[float] = Field( - None, description="人工批阅得分(针对单个主观题)。(Manual score for this subjective question.)" + None, + description="人工批阅得分(针对单个主观题)。(Manual score for this subjective question.)", ) teacher_comment: Optional[str] = Field( - None, description="教师对学生此题作答的评语。(Teacher's comment on this subjective question.)" + None, + description="教师对学生此题作答的评语。(Teacher's comment on this subjective question.)", ) is_graded_manually: Optional[bool] = Field( - False, description="此主观题是否已被人工批阅。(Whether this subjective question has been manually graded.)" + False, + description="此主观题是否已被人工批阅。(Whether this subjective question has been manually graded.)", ) @@ -382,7 +390,7 @@ class PaperFullDetailModel(BaseModel): ) submitted_answers_card: Optional[List[Optional[str]]] = Field( None, - description="用户提交的答案卡 (选项ID列表,未答为null)。(User's submitted answer card (list of option IDs, null for unanswered).)", + description="用户提交的答案卡 (选项ID列表,未答为null)。(User's complete original answer card (list of option IDs, null for unanswered).)", ) submission_time_utc: Optional[str] = Field( None, description="提交时间 (UTC)。(Submission time (UTC).)" @@ -392,7 +400,7 @@ class PaperFullDetailModel(BaseModel): ) pass_status: Optional[PaperPassStatusEnum] = Field( None, description="通过状态。(Pass status.)" - ) # 使用枚举 (Use enum) + ) passcode: Optional[str] = Field(None, description="通行码。(Passcode.)") last_update_time_utc: Optional[str] = Field( None, description="最后更新时间 (UTC)。(Last update time (UTC).)" @@ -401,17 +409,19 @@ class PaperFullDetailModel(BaseModel): None, description="最后更新IP地址。(Last update IP address.)" ) subjective_questions_count: Optional[int] = Field( - None, description="试卷中主观题的总数量。(Total number of subjective questions in the paper.)" + None, + description="试卷中主观题的总数量。(Total number of subjective questions in the paper.)", ) graded_subjective_questions_count: Optional[int] = Field( - None, description="已人工批阅的主观题数量。(Number of manually graded subjective questions.)" + None, + description="已人工批阅的主观题数量。(Number of manually graded subjective questions.)", ) # endregion __all__ = [ - "ExamQuestionClientView", # 新增模型 (Added new model) + "ExamQuestionClientView", "PaperSubmissionPayload", "ExamPaperResponse", "GradingResultResponse", @@ -422,7 +432,6 @@ class PaperFullDetailModel(BaseModel): "PaperAdminView", "PaperQuestionInternalDetail", "PaperFullDetailModel", - # Models for Grading Subjective Questions "PendingGradingPaperItem", "SubjectiveQuestionForGrading", "GradeSubmissionPayload", @@ -430,16 +439,24 @@ class PaperFullDetailModel(BaseModel): # region Models for Grading Subjective Questions + class PendingGradingPaperItem(BaseModel): """ 待批阅试卷列表中的项目模型。 (Item model for the list of papers pending manual grading.) """ + paper_id: str = Field(description="试卷ID。(Paper ID.)") user_uid: Optional[str] = Field(None, description="用户UID。(User UID.)") - submission_time_utc: Optional[str] = Field(None, description="提交时间 (UTC)。(Submission time (UTC).)") - subjective_questions_count: Optional[int] = Field(0, description="主观题总数。(Total subjective questions.)") - pending_manual_grading_count: Optional[int] = Field(0, description="待批改主观题数量。(Pending subjective questions.)") + submission_time_utc: Optional[str] = Field( + None, description="提交时间 (UTC)。(Submission time (UTC).)" + ) + subjective_questions_count: Optional[int] = Field( + 0, description="主观题总数。(Total subjective questions.)" + ) + pending_manual_grading_count: Optional[int] = Field( + 0, description="待批改主观题数量。(Pending subjective questions.)" + ) difficulty: Optional[str] = Field(None, description="试卷难度。(Paper difficulty.)") @@ -448,16 +465,32 @@ class SubjectiveQuestionForGrading(BaseModel): 获取待批阅主观题详情时,单个题目的数据模型。 (Data model for a single question when fetching subjective questions for grading.) """ - internal_question_id: str = Field(description="题目在试卷中的唯一内部ID。(Internal unique ID of the question in the paper.)") + + internal_question_id: str = Field( + description="题目在试卷中的唯一内部ID。(Internal unique ID of the question in the paper.)" + ) body: str = Field(description="问题题干。(Question body.)") - question_type: QuestionTypeEnum = Field(description="题目类型 (应为 essay_question)。(Question type (should be essay_question).)") - student_subjective_answer: Optional[str] = Field(None, description="学生提交的答案文本。(Student's submitted text answer.)") - standard_answer_text: Optional[str] = Field(None, description="参考答案或答案要点。(Standard answer or key points.)") - scoring_criteria: Optional[str] = Field(None, description="评分标准。(Scoring criteria.)") - # 当前已保存的批阅信息 (Current saved grading info, if any) - manual_score: Optional[float] = Field(None, description="当前已保存的人工得分。(Current saved manual score.)") - teacher_comment: Optional[str] = Field(None, description="当前已保存的教师评语。(Current saved teacher comment.)") - is_graded_manually: Optional[bool] = Field(False, description="此题是否已批阅。(Whether this question has been graded.)") + question_type: QuestionTypeEnum = Field( + description="题目类型 (应为 essay_question)。(Question type (should be essay_question).)" + ) + student_subjective_answer: Optional[str] = Field( + None, description="学生提交的答案文本。(Student's submitted text answer.)" + ) + standard_answer_text: Optional[str] = Field( + None, description="参考答案或答案要点。(Standard answer or key points.)" + ) + scoring_criteria: Optional[str] = Field( + None, description="评分标准。(Scoring criteria.)" + ) + manual_score: Optional[float] = Field( + None, description="当前已保存的人工得分。(Current saved manual score.)" + ) + teacher_comment: Optional[str] = Field( + None, description="当前已保存的教师评语。(Current saved teacher comment.)" + ) + is_graded_manually: Optional[bool] = Field( + False, description="此题是否已批阅。(Whether this question has been graded.)" + ) class GradeSubmissionPayload(BaseModel): @@ -465,8 +498,18 @@ class GradeSubmissionPayload(BaseModel): 提交单个主观题批阅结果的请求体模型。 (Request body model for submitting the grading result of a single subjective question.) """ - manual_score: float = Field(..., ge=0, description="人工给出的分数 (非负)。(Manually assigned score (non-negative).)") - teacher_comment: Optional[str] = Field(None, max_length=1000, description="教师评语 (可选, 最长1000字符)。(Teacher's comment (optional, max 1000 chars).)") + + manual_score: float = Field( + ..., + ge=0, + description="人工给出的分数 (非负)。(Manually assigned score (non-negative).)", + ) + teacher_comment: Optional[str] = Field( + None, + max_length=1000, + description="教师评语 (可选, 最长1000字符)。(Teacher's comment (optional, max 1000 chars).)", + ) + # endregion diff --git a/app/models/qb_models.py b/app/models/qb_models.py index 6ec522d..79cf4b2 100644 --- a/app/models/qb_models.py +++ b/app/models/qb_models.py @@ -10,6 +10,7 @@ These models are used for data validation, serialization, and for passing question bank information within the application and through API interfaces.) """ + # region 模块导入 (Module Imports) import logging from typing import List, Optional @@ -73,11 +74,11 @@ class QuestionModel(BaseModel): # --- 主观题相关字段 (Subjective/Essay Question related fields) --- standard_answer_text: Optional[str] = Field( None, - description="【主观题】参考答案或答案要点。用于教师批阅时参考,或在回顾时展示给学生。对于非主观题,此字段应为None。 (Reference answer or key points for subjective questions. For teacher grading reference or student review. Should be None for non-subjective questions.)" + description="【主观题】参考答案或答案要点。用于教师批阅时参考,或在回顾时展示给学生。对于非主观题,此字段应为None。 (Reference answer or key points for subjective questions. For teacher grading reference or student review. Should be None for non-subjective questions.)", ) scoring_criteria: Optional[str] = Field( None, - description="【主观题】评分标准或详细评分细则。供教师批阅时参考。对于非主观题,此字段应为None。 (Scoring criteria or detailed rubrics for subjective questions. For teacher grading reference. Should be None for non-subjective questions.)" + description="【主观题】评分标准或详细评分细则。供教师批阅时参考。对于非主观题,此字段应为None。 (Scoring criteria or detailed rubrics for subjective questions. For teacher grading reference. Should be None for non-subjective questions.)", ) # --- 通用参考/解释字段 (General Reference/Explanation field) --- diff --git a/app/models/token_models.py b/app/models/token_models.py index 67fab23..6912c6f 100644 --- a/app/models/token_models.py +++ b/app/models/token_models.py @@ -14,6 +14,7 @@ - `AuthStatusResponse`: Generic response model for representing authentication failures or specific authentication statuses.) """ + # region 模块导入 (Module Imports) from typing import List, Optional diff --git a/app/models/user_models.py b/app/models/user_models.py index 7ba07ef..8d90a15 100644 --- a/app/models/user_models.py +++ b/app/models/user_models.py @@ -10,6 +10,7 @@ extensively used in API request/response bodies, database storage, and for internal data transfer.) """ + # region 模块导入 (Module Imports) import re # 用于正则表达式验证 (For regular expression validation) from enum import Enum diff --git a/app/services/__init__.py b/app/services/__init__.py index 2f2880d..ffe71f8 100644 --- a/app/services/__init__.py +++ b/app/services/__init__.py @@ -8,7 +8,18 @@ 目前此包为空,未来可以根据需求添加具体服务模块。 """ -__all__ = [] +# from . import some_service # Example placeholder +from .websocket_manager import ( + WebSocketManager, + websocket_manager, +) # 导入新的WebSocket管理器 + +__all__ = [ + # "some_service", # Example placeholder + # "SomeServiceClass", # Example placeholder + "WebSocketManager", + "websocket_manager", +] # Example of how to structure when services are added: # diff --git a/app/services/audit_logger.py b/app/services/audit_logger.py new file mode 100644 index 0000000..fe0a674 --- /dev/null +++ b/app/services/audit_logger.py @@ -0,0 +1,157 @@ +# -*- coding: utf-8 -*- +""" +审计日志服务模块。 +(Audit Logging Service Module.) + +此模块提供了一个服务类,用于记录应用中的重要操作和事件到审计日志文件。 +审计日志以JSON格式记录,包含事件ID、时间戳、执行者、操作类型、目标资源、状态和详细信息。 +(This module provides a service class for logging important actions and events +within the application to an audit log file. Audit logs are recorded in JSON format, +including event ID, timestamp, actor, action type, target resource, status, and details.) +""" + +import asyncio +import logging +import os +from datetime import datetime # Ensure datetime is imported for AuditLogEntry +from typing import Any, Dict, Optional + +from app.core.config import settings # Application settings +from app.models.audit_log_models import AuditLogEntry # Audit log Pydantic model + +# 审计日志文件的路径,从配置中读取 +# (Path to the audit log file, read from configuration) +# Assuming settings.audit_log_file_path will be "data/logs/audit.log" +AUDIT_LOG_FILE_PATH = settings.audit_log_file_path + + +class AuditLoggerService: + """ + 审计日志服务类。 + (Audit Logging Service class.) + + 负责初始化专用的审计日志记录器,并提供一个方法来记录结构化的审计事件。 + (Responsible for initializing a dedicated audit logger and providing a method + to log structured audit events.) + """ + + _instance = None + _lock = asyncio.Lock() + + def __new__(cls, *args, **kwargs): + # Singleton pattern might be useful here if multiple instantiations are a concern, + # but a global instance is also fine for this project structure. + # For simplicity, we'll use a global instance approach rather than enforcing singleton here. + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__(self): + """ + 初始化审计日志记录器。 + (Initializes the audit logger.) + + - 确保审计日志目录存在。 + - 设置一个名为 "audit_log" 的Python日志记录器。 + - 为此记录器配置一个文件处理器,指向 AUDIT_LOG_FILE_PATH。 + - 文件处理器的格式化器配置为直接输出原始JSON字符串。 + - 日志级别设置为 INFO。 + """ + # Ensure this runs only once for the global instance + if hasattr(self, "_initialized") and self._initialized: + return + + self.logger_name = "audit_log" + self.logger = logging.getLogger(self.logger_name) + + if not self.logger.handlers: # Avoid adding multiple handlers if instantiated multiple times (though global instance should prevent this) + # 确保日志目录存在 (Ensure log directory exists) + log_dir = os.path.dirname(AUDIT_LOG_FILE_PATH) + if log_dir and not os.path.exists(log_dir): + try: + os.makedirs(log_dir, exist_ok=True) + except OSError as e: + # Use a fallback logger or print if this critical setup fails + fallback_logger = logging.getLogger(__name__ + ".AuditLoggerSetup") + fallback_logger.error( + f"创建审计日志目录 '{log_dir}' 失败: {e}", exc_info=True + ) + # Depending on policy, could raise error or continue without file logging for audit + # For now, it will try to add handler anyway, which might fail if dir doesn't exist + + handler = logging.FileHandler(AUDIT_LOG_FILE_PATH, encoding="utf-8") + + # 自定义格式化器,直接输出消息 (Custom formatter to output the message directly) + # The message passed to logger will be the pre-formatted JSON string. + class JsonFormatter(logging.Formatter): + def format(self, record): + return record.getMessage() # record.msg should be the JSON string + + handler.setFormatter(JsonFormatter()) + self.logger.addHandler(handler) + self.logger.setLevel(logging.INFO) + # Prevent audit logs from propagating to the root logger if it has other handlers (e.g. console) + self.logger.propagate = False + + self._initialized = True + + async def log_event( + self, + action_type: str, + status: str, + actor_uid: Optional[str] = None, + actor_ip: Optional[str] = None, + target_resource_type: Optional[str] = None, + target_resource_id: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + ) -> None: + """ + 记录一个审计事件。 + (Logs an audit event.) + + 参数 (Args): + actor_uid (Optional[str]): 执行操作的用户ID。 + actor_ip (Optional[str]): 执行操作用户的IP地址。 + action_type (str): 操作类型。 + status (str): 操作结果状态 (例如:"SUCCESS", "FAILURE")。 + target_resource_type (Optional[str]): 操作目标资源的类型。 + target_resource_id (Optional[str]): 操作目标资源的ID。 + details (Optional[Dict[str, Any]]): 与事件相关的其他详细信息。 + """ + try: + log_entry = AuditLogEntry( + timestamp=datetime.utcnow(), # Generate timestamp at the moment of logging + actor_uid=actor_uid, + actor_ip=actor_ip, + action_type=action_type, + target_resource_type=target_resource_type, + target_resource_id=target_resource_id, + status=status, + details=details, + ) + + # 使用 model_dump_json() 将Pydantic模型转换为JSON字符串 + # (Convert Pydantic model to JSON string using model_dump_json()) + log_json_string = log_entry.model_dump_json() + + # 使用配置好的审计日志记录器记录JSON字符串 + # (Log the JSON string using the configured audit logger) + self.logger.info(log_json_string) + + except Exception as e: + # 如果审计日志本身失败,记录到应用主日志或标准错误输出 + # (If audit logging itself fails, log to the main app logger or stderr) + app_fallback_logger = logging.getLogger(__name__ + ".AuditLoggingError") + app_fallback_logger.error( + f"记录审计事件失败 (Failed to log audit event): {e}", exc_info=True + ) + app_fallback_logger.error( + f"失败的审计事件数据 (Failed audit event data): action_type={action_type}, status={status}, actor_uid={actor_uid}" + ) + + +# 创建审计日志服务的全局实例 +# (Create a global instance of the audit logging service) +audit_logger_service = AuditLoggerService() + +__all__ = ["audit_logger_service", "AuditLoggerService"] diff --git a/app/services/websocket_manager.py b/app/services/websocket_manager.py new file mode 100644 index 0000000..975ac4b --- /dev/null +++ b/app/services/websocket_manager.py @@ -0,0 +1,172 @@ +# -*- coding: utf-8 -*- +""" +WebSocket 连接管理模块。 +(WebSocket Connection Management Module.) + +此模块提供了一个 `WebSocketManager` 类,用于管理活跃的 WebSocket 连接, +并支持向所有连接的客户端广播消息。主要用于实时通知功能,例如通知管理员。 +(This module provides a `WebSocketManager` class for managing active WebSocket connections +and supports broadcasting messages to all connected clients. It is primarily intended for +real-time notification features, such as notifying administrators.) +""" + +import asyncio +import logging +from typing import ( + Any, + Dict, + Set, +) # List for potential future use with multiple rooms + +from fastapi import WebSocket + +# 获取本模块的日志记录器实例 +# (Get logger instance for this module) +_websocket_manager_logger = logging.getLogger(__name__) + + +class WebSocketManager: + """ + 管理 WebSocket 连接的类。 + (Class for managing WebSocket connections.) + + 提供连接、断开连接以及广播消息的功能。 + (Provides functionalities for connecting, disconnecting, and broadcasting messages.) + """ + + def __init__(self): + """ + 初始化 WebSocketManager。 + (Initializes the WebSocketManager.) + + `active_connections`: 一个集合,存储所有当前活跃的 WebSocket 连接。 + (A set storing all currently active WebSocket connections.) + `lock`: 一个异步锁,用于在并发操作中保护 `active_connections`。 + (An asyncio.Lock to protect `active_connections` during concurrent operations.) + """ + self.active_connections: Set[WebSocket] = set() + self.lock = asyncio.Lock() + _websocket_manager_logger.info( + "WebSocket 管理器已初始化。 (WebSocketManager initialized.)" + ) + + async def connect(self, websocket: WebSocket) -> None: + """ + 处理新的 WebSocket 连接。 + (Handles a new WebSocket connection.) + + 将新的 WebSocket 对象添加到活跃连接集合中。 + (Adds the new WebSocket object to the set of active connections.) + + 参数 (Args): + websocket (WebSocket): 要添加的 FastAPI WebSocket 对象。 + (The FastAPI WebSocket object to add.) + """ + await ( + websocket.accept() + ) # 接受 WebSocket 连接 (Accept the WebSocket connection) + async with self.lock: + self.active_connections.add(websocket) + # 获取客户端信息用于日志记录 (Get client info for logging) + client_host = websocket.client.host if websocket.client else "未知主机" + client_port = websocket.client.port if websocket.client else "未知端口" + _websocket_manager_logger.info( + f"WebSocket 已连接: {client_host}:{client_port}。当前总连接数: {len(self.active_connections)}。" + f"(WebSocket connected: {client_host}:{client_port}. Total connections: {len(self.active_connections)}.)" + ) + + async def disconnect(self, websocket: WebSocket) -> None: + """ + 处理 WebSocket 断开连接。 + (Handles a WebSocket disconnection.) + + 从活跃连接集合中移除指定的 WebSocket 对象。 + (Removes the specified WebSocket object from the set of active connections.) + + 参数 (Args): + websocket (WebSocket): 要移除的 FastAPI WebSocket 对象。 + (The FastAPI WebSocket object to remove.) + """ + async with self.lock: + if websocket in self.active_connections: + self.active_connections.remove(websocket) + # 获取客户端信息用于日志记录 (Get client info for logging) + client_host = websocket.client.host if websocket.client else "未知主机" + client_port = websocket.client.port if websocket.client else "未知端口" + _websocket_manager_logger.info( + f"WebSocket 已断开: {client_host}:{client_port}。剩余连接数: {len(self.active_connections)}。" + f"(WebSocket disconnected: {client_host}:{client_port}. Remaining connections: {len(self.active_connections)}.)" + ) + # WebSocket 对象通常由 FastAPI 在断开后自行关闭,此处无需显式调用 websocket.close() + # (The WebSocket object is typically closed by FastAPI itself after disconnection, + # no explicit call to websocket.close() is needed here.) + + async def broadcast_message(self, message: Dict[str, Any]) -> None: + """ + 向所有当前连接的 WebSocket 客户端广播一条JSON消息。 + (Broadcasts a JSON message to all currently connected WebSocket clients.) + + 如果发送消息给某个客户端时发生异常(例如连接已关闭),则会安全地移除该客户端。 + (If an exception occurs while sending a message to a client (e.g., connection closed), + that client will be safely removed.) + + 参数 (Args): + message (Dict[str, Any]): 要广播的JSON可序列化字典消息。 + (The JSON-serializable dictionary message to broadcast.) + """ + # 创建一个当前连接的副本进行迭代,以允许在广播过程中安全地修改原始集合 + # (Create a copy of current connections for iteration to allow safe modification + # of the original set during broadcasting.) + + # 使用锁来确保在复制和迭代期间连接列表的完整性 + # (Use lock to ensure integrity of connection list during copy and iteration) + disconnected_websockets: Set[WebSocket] = set() + + async with self.lock: + # 收集所有仍然活跃的连接进行广播 + # (Collect all still active connections for broadcasting) + # This is important because a connection might have been closed right before acquiring the lock + connections_to_broadcast = list(self.active_connections) + + if not connections_to_broadcast: + _websocket_manager_logger.info( + "广播消息:无活跃连接,消息未发送。 (Broadcast message: No active connections, message not sent.)" + ) + return + + _websocket_manager_logger.debug( + f"准备向 {len(connections_to_broadcast)} 个连接广播消息: {message}" + ) + + for websocket in connections_to_broadcast: + try: + await websocket.send_json(message) + except Exception as e: # WebSocketException, ConnectionClosed, etc. + # 客户端可能已断开连接 (Client might have disconnected) + client_host = websocket.client.host if websocket.client else "未知主机" + client_port = websocket.client.port if websocket.client else "未知端口" + _websocket_manager_logger.warning( + f"广播消息给 {client_host}:{client_port} 失败: {e}。将标记此连接为待移除。" + f"(Failed to broadcast message to {client_host}:{client_port}: {e}. Marking connection for removal.)" + ) + disconnected_websockets.add(websocket) + + # 如果在广播过程中有连接失败,则从主列表中移除它们 + # (If any connections failed during broadcast, remove them from the main list) + if disconnected_websockets: + async with self.lock: + for ws_to_remove in disconnected_websockets: + if ( + ws_to_remove in self.active_connections + ): # 再次检查,以防在两次获取锁之间状态改变 + self.active_connections.remove(ws_to_remove) + _websocket_manager_logger.info( + f"已从活跃连接中移除 {len(disconnected_websockets)} 个失败的WebSocket连接。" + ) + + +# 创建 WebSocketManager 的全局实例 +# (Create a global instance of WebSocketManager) +websocket_manager = WebSocketManager() + +__all__ = ["websocket_manager", "WebSocketManager"] diff --git a/app/utils/export_utils.py b/app/utils/export_utils.py new file mode 100644 index 0000000..47a8417 --- /dev/null +++ b/app/utils/export_utils.py @@ -0,0 +1,125 @@ +# -*- coding: utf-8 -*- +""" +数据导出工具模块 (Data Export Utilities Module) + +此模块提供了将数据导出为CSV和XLSX格式的通用函数。 +主要用于API端点,以流式响应的形式提供文件下载。 +(This module provides utility functions for exporting data to CSV and XLSX formats. + It's primarily intended for use in API endpoints to offer file downloads + as streaming responses.) +""" + +import csv +import io +from typing import Any, Dict, List + +import openpyxl # For XLSX export +from fastapi.responses import StreamingResponse + + +def data_to_csv( + data_list: List[Dict[str, Any]], headers: List[str], filename: str = "export.csv" +) -> StreamingResponse: + """ + 将字典列表数据转换为CSV格式并通过StreamingResponse提供下载。 + (Converts a list of dictionaries to CSV format and provides it for download via StreamingResponse.) + + 参数 (Args): + data_list (List[Dict[str, Any]]): 要导出的数据,每个字典代表一行,键应与headers对应。 + (Data to export, each dict represents a row, keys should match headers.) + headers (List[str]): CSV文件的表头列表。 + (List of headers for the CSV file.) + filename (str): 下载时建议的文件名。 + (Suggested filename for the download.) + + 返回 (Returns): + StreamingResponse: FastAPI流式响应对象,包含CSV数据。 + (FastAPI StreamingResponse object containing the CSV data.) + """ + output = io.StringIO() + # 使用 utf-8-sig 编码以确保Excel正确显示中文字符 (Use utf-8-sig for Excel to correctly display Chinese chars) + # The StreamingResponse will handle encoding, but csv.writer needs unicode. + + writer = csv.writer(output) + + # 写入表头 (Write headers) + writer.writerow(headers) + + # 写入数据行 (Write data rows) + if data_list: + for item in data_list: + writer.writerow( + [item.get(header, "") for header in headers] + ) # Safely get values + + # StreamingResponse需要字节流,所以我们将StringIO的内容编码为UTF-8 (with BOM for Excel) + # The content must be bytes for StreamingResponse if we specify charset in media_type or headers + # However, StreamingResponse can also take an iterator of strings and encode it. + # For simplicity with csv.writer producing strings, we'll let StreamingResponse handle it. + + # Reset stream position + output.seek(0) + + # Create a string iterator for StreamingResponse + # This avoids loading the whole CSV into memory as one giant string if data_list is huge. + # However, csv.writer already wrote to an in-memory StringIO buffer. + # For very large datasets, a different approach might be needed (e.g. generating CSV row by row as an iterator). + # Given the current structure with StringIO, we'll read its content. + + response_content = output.getvalue() + # output.close() # StringIO doesn't need explicit close for getvalue() + + return StreamingResponse( + iter([response_content.encode("utf-8-sig")]), # Encode to utf-8-sig for BOM + media_type="text/csv", + headers={ + "Content-Disposition": f'attachment; filename="{filename}"', + "Content-Encoding": "utf-8-sig", # Explicitly state encoding for clarity, though BOM handles it + }, + ) + + +def data_to_xlsx( + data_list: List[Dict[str, Any]], headers: List[str], filename: str = "export.xlsx" +) -> StreamingResponse: + """ + 将字典列表数据转换为XLSX格式并通过StreamingResponse提供下载。 + (Converts a list of dictionaries to XLSX format and provides it for download via StreamingResponse.) + + 参数 (Args): + data_list (List[Dict[str, Any]]): 要导出的数据,每个字典代表一行。 + (Data to export, each dict represents a row.) + headers (List[str]): XLSX文件的表头列表。 + (List of headers for the XLSX file.) + filename (str): 下载时建议的文件名。 + (Suggested filename for the download.) + + 返回 (Returns): + StreamingResponse: FastAPI流式响应对象,包含XLSX数据。 + (FastAPI StreamingResponse object containing the XLSX data.) + """ + workbook = openpyxl.Workbook() + sheet = workbook.active + + # 写入表头 (Write headers) + sheet.append(headers) + + # 写入数据行 (Write data rows) + if data_list: + for item in data_list: + row_values = [item.get(header) for header in headers] # Safely get values + sheet.append(row_values) + + # 将工作簿保存到内存中的字节流 (Save workbook to an in-memory byte stream) + output = io.BytesIO() + workbook.save(output) + output.seek(0) # Reset stream position to the beginning + + return StreamingResponse( + output, # BytesIO is directly iterable by StreamingResponse + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +__all__ = ["data_to_csv", "data_to_xlsx"] diff --git a/app/utils/helpers.py b/app/utils/helpers.py index 3723039..bc690e8 100644 --- a/app/utils/helpers.py +++ b/app/utils/helpers.py @@ -8,6 +8,7 @@ in the project, such as time formatting, UUID abbreviation, client IP address acquisition, data structure processing, and random string generation.) """ + # region 模块导入 (Module Imports) import datetime import ipaddress # 用于处理和验证IP地址 (For processing and validating IP addresses) diff --git a/app/websocket_routes.py b/app/websocket_routes.py new file mode 100644 index 0000000..082e023 --- /dev/null +++ b/app/websocket_routes.py @@ -0,0 +1,131 @@ +# -*- coding: utf-8 -*- +""" +WebSocket API 路由模块。 +(WebSocket API Routing Module.) + +此模块定义了所有与 WebSocket 通信相关的API端点。 +例如,用于实时监控、通知等功能。 +(This module defines all API endpoints related to WebSocket communication, +for example, for real-time monitoring, notifications, etc.) +""" + +import logging + +from fastapi import ( + APIRouter, + Depends, + WebSocket, + WebSocketDisconnect, +) + +from .core.security import require_admin # 用于 WebSocket 端点的管理权限验证 +from .services.websocket_manager import websocket_manager + +# (For admin permission verification on WebSocket endpoints) + +# 获取本模块的日志记录器实例 +# (Get logger instance for this module) +_ws_logger = logging.getLogger(__name__) + +# 创建 WebSocket 路由实例 +# (Create WebSocket router instance) +# 注意: FastAPI 对 APIRouter 上的 `dependencies` 用于 WebSocket 的行为可能有限。 +# 通常,WebSocket的认证在连接建立时通过查询参数或头部信息在端点函数内部处理。 +# 此处添加 `Depends(require_admin)` 是为了声明意图,实际执行可能需要调整。 +# (Note: FastAPI's behavior for `dependencies` on APIRouter with WebSockets might be limited. +# Typically, WebSocket authentication is handled within the endpoint function during connection +# establishment using query parameters or headers. Adding `Depends(require_admin)` here +# declares intent; actual execution might require adjustments.) +ws_router = APIRouter( + tags=["WebSocket接口 (WebSocket Interface)"], + dependencies=[Depends(require_admin)], # 尝试对整个路由应用管理员认证 + # (Attempt to apply admin authentication to the entire router) +) + + +@ws_router.websocket("/ws/exam_monitor") +async def websocket_exam_monitor(websocket: WebSocket): + """ + 考试监控 WebSocket 端点。 + (Exam Monitoring WebSocket Endpoint.) + + 管理员客户端可以通过此端点连接,以接收实时的考试监控信息(例如,考生提交试卷事件)。 + (Administrator clients can connect via this endpoint to receive real-time exam monitoring + information (e.g., examinee submission events).) + + 认证 (Authentication): + 连接时需要在查询参数中提供有效的管理员Token (例如: `/ws/exam_monitor?token=YOUR_ADMIN_TOKEN`)。 + (A valid admin token must be provided as a query parameter upon connection + (e.g., `/ws/exam_monitor?token=YOUR_ADMIN_TOKEN`).) + """ + # 实际的管理员身份验证已由 `Depends(require_admin)` 在 APIRouter 级别(尝试)处理。 + # 如果该机制对 WebSocket 不完全适用,认证逻辑需要移到此处, + # 例如通过 `token: Optional[str] = Query(None)` 获取token,然后调用 `validate_token_and_get_user_info`。 + # (Actual admin authentication is (attempted) by `Depends(require_admin)` at the APIRouter level. + # If this mechanism is not fully applicable to WebSockets, authentication logic needs to be moved here, + # e.g., by getting the token via `token: Optional[str] = Query(None)` and then calling + # `validate_token_and_get_user_info`.) + + # 假设 `require_admin` 依赖项如果失败会直接拒绝连接或 `websocket.scope['user']` 会被填充。 + # (Assuming the `require_admin` dependency would reject the connection if failed, + # or `websocket.scope['user']` would be populated.) + + # 从 scope 中获取认证信息 (这是 FastAPI 处理依赖注入的一种方式,但对 WebSocket 可能不同) + # (Getting auth info from scope - this is one way FastAPI handles DI, but might differ for WebSockets) + # admin_user_info = websocket.scope.get("user_info_from_token", None) # 假设依赖注入会填充这个 + # actor_uid = admin_user_info.get("user_uid", "unknown_ws_admin") if admin_user_info else "unknown_ws_admin" + # ^^^ 上述方法依赖于 `require_admin` 如何将信息传递给 WebSocket scope, + # 这通常不直接发生。`require_admin` 会在 HTTP 升级请求阶段起作用。 + + client_host = websocket.client.host if websocket.client else "未知主机" + client_port = websocket.client.port if websocket.client else "未知端口" + + # 由于 `require_admin` 会在连接尝试时验证,若失败则不会执行到这里。 + # 若成功,我们可以认为连接的是已认证的管理员。 + # (Since `require_admin` validates upon connection attempt, if it fails, execution won't reach here. + # If successful, we can assume the connected client is an authenticated admin.) + _ws_logger.info( + f"管理员客户端 {client_host}:{client_port} 已连接到考试监控 WebSocket。" + f"(Admin client {client_host}:{client_port} connected to exam monitoring WebSocket.)" + ) + + await websocket_manager.connect(websocket) + try: + while True: + # 管理员客户端通常只接收由服务器推送的消息。 + # (Admin clients typically only receive messages pushed by the server.) + # 此处 `receive_text` / `receive_json` 主要用于保持连接活性或处理客户端发来的控制指令(如果设计有)。 + # (Here, `receive_text` / `receive_json` is mainly for keeping the connection alive + # or processing control commands from the client (if designed).) + data = await websocket.receive_text() + _ws_logger.debug( + f"从管理员客户端 {client_host}:{client_port} 收到监控 WebSocket 文本消息: {data}" + f"(Received text message from admin client {client_host}:{client_port} on monitoring WebSocket: {data})" + ) + # 示例:如果客户端发送特定指令 + # (Example: if client sends a specific command) + # if data == "PING": + # await websocket.send_text("PONG") + # 一般管理员监控端点不需要处理来自客户端的太多常规消息。 + # (Generally, admin monitoring endpoints don't need to process many regular messages from clients.) + + except WebSocketDisconnect: + _ws_logger.info( + f"管理员客户端 {client_host}:{client_port} 已从考试监控 WebSocket 断开。" + f"(Admin client {client_host}:{client_port} disconnected from exam monitoring WebSocket.)" + ) + except Exception as e: + # 记录任何其他在 WebSocket 通信期间发生的异常 + # (Log any other exceptions occurring during WebSocket communication) + _ws_logger.error( + f"考试监控 WebSocket ({client_host}:{client_port}) 发生错误: {e}. " + f"英文详情 (English details): (Exam monitoring WebSocket ({client_host}:{client_port}) encountered an error: {e})", + exc_info=True, + ) + finally: + # 确保无论因何原因循环结束,连接都会从管理器中断开 + # (Ensure the connection is removed from the manager regardless of why the loop ended) + await websocket_manager.disconnect(websocket) + + +__all__ = ["ws_router"] diff --git a/app_legacy.py b/app_legacy.py index 8c45355..2a66b7e 100644 --- a/app_legacy.py +++ b/app_legacy.py @@ -964,7 +964,10 @@ def admin_delete_paper_from_memory( # region Cloudflare IP范围获取与更新任务 async def fetch_and_update_cloudflare_ips_once(): """获取一次Cloudflare IP范围并更新全局变量。""" - global cloudflare_ipv4_ranges, cloudflare_ipv6_ranges, cloudflare_ranges_last_updated + global \ + cloudflare_ipv4_ranges, \ + cloudflare_ipv6_ranges, \ + cloudflare_ranges_last_updated try: app_logger.info("尝试获取Cloudflare IP地址范围...") async with httpx.AsyncClient() as client: diff --git a/examctl.py b/examctl.py index 12e9169..ec0c3a1 100644 --- a/examctl.py +++ b/examctl.py @@ -18,8 +18,10 @@ python examctl.py update-user --uid existinguser --email "new_email@example.com" --tags "user,limited" python examctl.py change-password --uid someuser --new-password "AnotherSecurePassword" """ + import argparse import asyncio +import csv import sys from pathlib import Path @@ -27,6 +29,10 @@ # (Adjust Python search path to allow importing modules from the 'app' package) sys.path.insert(0, str(Path(__file__).resolve().parent)) +import json # For parsing JSON arguments + +from pydantic import BaseModel # F821: BaseModel used in get_nested_value + from app.core.config import ( settings, ) # 导入应用全局配置 (Import global application settings) @@ -36,18 +42,76 @@ from app.crud import ( # 从CRUD包导入所需实例和初始化函数 # (Import required instances and initialization function from CRUD package) initialize_crud_instances, # CRUD及存储库异步初始化函数 (Async init function for CRUD & repo) + paper_crud_instance, # 试卷CRUD实例 (Paper CRUD instance) + qb_crud_instance, # 题库CRUD操作实例 (Question Bank CRUD operations instance) + settings_crud_instance, # 配置CRUD实例 (Settings CRUD instance) user_crud_instance, # 用户CRUD操作实例 (User CRUD operations instance) - # paper_crud_instance, # (可选) 试卷CRUD实例 (Optional: Paper CRUD instance) - # qb_crud_instance, # (可选) 题库CRUD实例 (Optional: Question Bank CRUD instance) - # settings_crud_instance, # (可选) 配置CRUD实例 (Optional: Settings CRUD instance) ) +from app.models.enums import QuestionTypeEnum # 题目类型枚举 +from app.models.qb_models import ( + QuestionModel, + # QuestionBank, # May not be needed directly for commands + # LibraryIndexItem, # May not be needed directly for commands +) # 题库相关Pydantic模型 from app.models.user_models import ( AdminUserUpdate, UserCreate, + UserInDB, # For retrieving user data UserTag, ) # 用户相关Pydantic模型 (User-related Pydantic models) +async def list_users_command(args: argparse.Namespace): + """ + 处理 'list-users' / '导出用户' 命令:检索所有用户并将其数据导出到CSV文件或打印到标准输出。 + """ + if not user_crud_instance: + print("错误:用户数据操作模块 (UserCRUD) 未初始化。") + return + + print("正在检索所有用户信息...") + try: + users: list[UserInDB] = await user_crud_instance.admin_get_all_users() + if not users: + print("未找到任何用户。") + return + + print(f"共检索到 {len(users)} 位用户。") + + header = ["用户ID", "昵称", "邮箱", "QQ", "标签"] + data = [ + [ + user.uid, + user.nickname, + user.email, + user.qq, + ",".join([tag.value for tag in user.tags]), + ] + for user in users + ] + + if args.output_file: + output_path = Path(args.output_file) + try: + with output_path.open( + "w", newline="", encoding="utf-8-sig" + ) as csvfile: # utf-8-sig for Excel compatibility + writer = csv.writer(csvfile) + writer.writerow(header) + writer.writerows(data) + print(f"用户信息已成功导出到: {output_path}") + except IOError as e: + print(f"写入文件 '{output_path}' 时发生错误: {e}") + else: + # 打印到 stdout + writer = csv.writer(sys.stdout) + writer.writerow(header) + writer.writerows(data) + + except Exception as e: + print(f"检索或导出用户时发生错误: {e}") + + async def add_user_command(args: argparse.Namespace): """ 处理 'add-user' 命令:添加一个新用户到系统。 @@ -62,12 +126,10 @@ async def add_user_command(args: argparse.Namespace): print( "错误:用户数据操作模块 (UserCRUD) 未初始化。请确保异步初始化已成功调用。" ) - print( - "Error: User data operations module (UserCRUD) is not initialized. Ensure async initialization was called." - ) + # "Error: User data operations module (UserCRUD) is not initialized. Ensure async initialization was called." return - print(f"正在尝试添加用户 (Attempting to add user): {args.uid}...") + print(f"正在尝试添加用户: {args.uid}...") # 从命令行参数构造用户创建数据模型 user_create_payload = UserCreate( uid=args.uid, @@ -81,18 +143,16 @@ async def add_user_command(args: argparse.Namespace): created_user = await user_crud_instance.create_user(user_create_payload) if created_user: print( - f"成功添加用户 (Successfully added user) '{created_user.uid}' " - f"标签 (Tags): {[tag.value for tag in created_user.tags]}." + f"成功添加用户 '{created_user.uid}' " + f"标签: {[tag.value for tag in created_user.tags]}。" ) else: # 此路径理论上在 create_user 抛出异常前不应到达 # (This path should ideally not be reached if create_user raises an exception on failure) - print( - f"添加用户 (Failed to add user) '{args.uid}' 失败。用户可能已存在或提供的数据无效。" - ) - print("User might already exist or provided data is invalid.") + print(f"添加用户 '{args.uid}' 失败。用户可能已存在或提供的数据无效。") + # "User might already exist or provided data is invalid." except Exception as e: # 捕获创建过程中可能发生的任何异常 - print(f"添加用户 (Error adding user) '{args.uid}' 时发生错误: {e}") + print(f"添加用户 '{args.uid}' 时发生错误: {e}") async def update_user_command(args: argparse.Namespace): @@ -107,10 +167,10 @@ async def update_user_command(args: argparse.Namespace): """ if not user_crud_instance: print("错误:用户数据操作模块 (UserCRUD) 未初始化。") - print("Error: User data operations module (UserCRUD) is not initialized.") + # "Error: User data operations module (UserCRUD) is not initialized." return - print(f"正在尝试更新用户 (Attempting to update user): {args.uid}...") + print(f"正在尝试更新用户: {args.uid}...") update_data = {} # 存储需要更新的字段 if args.nickname is not None: update_data["nickname"] = args.nickname @@ -124,13 +184,13 @@ async def update_user_command(args: argparse.Namespace): update_data["tags"] = [UserTag(tag.strip()) for tag in args.tags.split(",")] except ValueError as e: # 如果提供的标签无效 print( - f"错误: --tags 中提供了无效标签 (Invalid tag provided in --tags): {e}。" - f"允许的标签 (Allowed tags): {[tag.value for tag in UserTag]}" + f"错误: --tags 中提供了无效标签: {e}。" + f"允许的标签: {[tag.value for tag in UserTag]}" ) return if not update_data: # 如果没有提供任何更新参数 - print("未提供更新参数。正在退出。 (No update parameters provided. Exiting.)") + print("未提供更新参数。正在退出。") return # 使用 AdminUserUpdate 模型构造更新数据 @@ -141,18 +201,16 @@ async def update_user_command(args: argparse.Namespace): args.uid, admin_update_payload ) if updated_user: - print(f"成功更新用户 (Successfully updated user) '{updated_user.uid}'.") - print(f" 昵称 (Nickname): {updated_user.nickname}") - print(f" 邮箱 (Email): {updated_user.email}") + print(f"成功更新用户 '{updated_user.uid}'.") + print(f" 昵称: {updated_user.nickname}") + print(f" 邮箱: {updated_user.email}") print(f" QQ: {updated_user.qq}") - print(f" 标签 (Tags): {[tag.value for tag in updated_user.tags]}") + print(f" 标签: {[tag.value for tag in updated_user.tags]}") else: - print( - f"更新用户 (Failed to update user) '{args.uid}' 失败。用户可能不存在或提供的数据无效。" - ) - print("User might not exist or provided data is invalid.") + print(f"更新用户 '{args.uid}' 失败。用户可能不存在或提供的数据无效。") + # "User might not exist or provided data is invalid." except Exception as e: - print(f"更新用户 (Error updating user) '{args.uid}' 时发生错误: {e}") + print(f"更新用户 '{args.uid}' 时发生错误: {e}") async def change_password_command(args: argparse.Namespace): @@ -166,12 +224,10 @@ async def change_password_command(args: argparse.Namespace): """ if not user_crud_instance: print("错误:用户数据操作模块 (UserCRUD) 未初始化。") - print("Error: User data operations module (UserCRUD) is not initialized.") + # "Error: User data operations module (UserCRUD) is not initialized." return - print( - f"正在尝试为用户 (Attempting to change password for user) '{args.uid}' 修改密码..." - ) + print(f"正在尝试为用户 '{args.uid}' 修改密码...") # 检查新密码长度是否符合配置要求 pw_config = settings.user_config @@ -182,7 +238,6 @@ async def change_password_command(args: argparse.Namespace): ): print( f"错误:新密码长度必须在 {pw_config.password_min_len} 和 {pw_config.password_max_len} 字符之间。" - f"(Error: New password length must be between {pw_config.password_min_len} and {pw_config.password_max_len} characters.)" ) return @@ -190,7 +245,7 @@ async def change_password_command(args: argparse.Namespace): # 获取用户以确认存在性 user = await user_crud_instance.get_user_by_uid(args.uid) if not user: - print(f"错误:用户 (Error: User) '{args.uid}' 未找到。") + print(f"错误:用户 '{args.uid}' 未找到。") return # 对新密码进行哈希处理 @@ -201,18 +256,12 @@ async def change_password_command(args: argparse.Namespace): ) if success: - print( - f"用户 (User) '{args.uid}' 的密码已成功修改。 (Password changed successfully.)" - ) + print(f"用户 '{args.uid}' 的密码已成功修改。") else: # 此情况理论上不应发生(如果用户已找到),除非存储库的更新操作失败 - print( - f"为用户 (Failed to change password for user) '{args.uid}' 修改密码失败。" - ) + print(f"为用户 '{args.uid}' 修改密码失败。") except Exception as e: - print( - f"为用户 (Error changing password for user) '{args.uid}' 修改密码时发生错误: {e}" - ) + print(f"为用户 '{args.uid}' 修改密码时发生错误: {e}") async def main_async(): @@ -228,30 +277,27 @@ async def main_async(): """ # 初始化 CRUD 实例和存储库 # (Initialize CRUD instances and repository) - print("正在初始化应用和数据存储... (Initializing application and data storage...)") + print("正在初始化应用和数据存储...") try: await initialize_crud_instances() # 关键的异步初始化步骤 - print("初始化完成。 (Initialization complete.)") + print("初始化完成。") except Exception as e: print( - f"初始化过程中发生严重错误 (Critical error during initialization): {e}", + f"初始化过程中发生严重错误: {e}", file=sys.stderr, ) print( "请检查您的 .env 文件和数据存储配置 (如 JSON 文件路径、数据库连接字符串等)。", file=sys.stderr, ) - print( - "Please check your .env file and data storage configuration (e.g., JSON file paths, database connection strings).", - file=sys.stderr, - ) + # "Please check your .env file and data storage configuration (e.g., JSON file paths, database connection strings)." sys.exit(1) # 初始化失败则退出 # 确保 settings 已加载 (通常 initialize_crud_instances 会间接触发) # (Ensure settings are loaded (usually triggered indirectly by initialize_crud_instances)) if not settings.app_name: # 检查一个已知的配置项是否存在 print( - "错误:未能加载应用配置。 (Error: Could not load application settings.)", + "错误:未能加载应用配置。", file=sys.stderr, ) sys.exit(1) @@ -259,79 +305,188 @@ async def main_async(): # 创建命令行参数解析器 # (Create command-line argument parser) parser = argparse.ArgumentParser( - description="在线考试系统命令行管理工具 - 用于用户管理和应用设置等。\n(Online Examination System CLI Management Tool - For user management, application settings, etc.)" - ) - subparsers = parser.add_subparsers( - dest="command", required=True, help="可用的命令 (Available commands)" + description="在线考试系统命令行管理工具 - 用于用户管理和应用设置等。" ) + subparsers = parser.add_subparsers(dest="command", required=True, help="可用的命令") # 添加 'add-user' 子命令解析器 # (Add 'add-user' subcommand parser) - add_parser = subparsers.add_parser( - "add-user", help="添加一个新用户到系统。 (Add a new user to the system.)" - ) - add_parser.add_argument( - "--uid", required=True, help="用户ID (用户名)。 (User ID (username))" - ) - add_parser.add_argument( - "--password", required=True, help="用户密码。 (User password)" - ) - add_parser.add_argument( - "--nickname", help="可选的用户昵称。 (Optional: User nickname)" - ) + add_parser = subparsers.add_parser("add-user", help="添加一个新用户到系统。") + add_parser.add_argument("--uid", required=True, help="用户ID (用户名)。") + add_parser.add_argument("--password", required=True, help="用户密码。") + add_parser.add_argument("--nickname", help="可选的用户昵称。") add_parser.add_argument( "--email", - help="可选的用户邮箱 (例如: user@example.com)。 (Optional: User email (e.g., user@example.com))", - ) - add_parser.add_argument( - "--qq", help="可选的用户QQ号码。 (Optional: User QQ number)" + help="可选的用户邮箱 (例如: user@example.com)。", ) + add_parser.add_argument("--qq", help="可选的用户QQ号码。") add_parser.set_defaults(func=add_user_command) # 设置此子命令对应的处理函数 # 添加 'update-user' 子命令解析器 # (Add 'update-user' subcommand parser) update_parser = subparsers.add_parser( "update-user", - help="更新现有用户的属性。 (Update attributes of an existing user.)", + help="更新现有用户的属性。", ) update_parser.add_argument( "--uid", required=True, - help="需要更新的用户的用户ID (用户名)。 (User ID (username) of the user to update.)", - ) - update_parser.add_argument( - "--nickname", help="用户的新昵称。 (New nickname for the user.)" - ) - update_parser.add_argument( - "--email", help="用户的新邮箱。 (New email for the user.)" - ) - update_parser.add_argument( - "--qq", help="用户的新QQ号码。 (New QQ number for the user.)" + help="需要更新的用户的用户ID (用户名)。", ) + update_parser.add_argument("--nickname", help="用户的新昵称。") + update_parser.add_argument("--email", help="用户的新邮箱。") + update_parser.add_argument("--qq", help="用户的新QQ号码。") update_parser.add_argument( "--tags", - help=f"逗号分隔的新标签列表 (例如: user,admin)。允许的标签: {[t.value for t in UserTag]}\n" - f"(Comma-separated list of new tags (e.g., user,admin). Allowed: {[t.value for t in UserTag]})", + help=f"逗号分隔的新标签列表 (例如: user,admin)。允许的标签: {[t.value for t in UserTag]}", ) update_parser.set_defaults(func=update_user_command) # 添加 'change-password' 子命令解析器 # (Add 'change-password' subcommand parser) - pw_parser = subparsers.add_parser( - "change-password", help="修改用户的密码。 (Change a user's password.)" - ) + pw_parser = subparsers.add_parser("change-password", help="修改用户的密码。") pw_parser.add_argument( "--uid", required=True, - help="需要修改密码的用户的用户ID (用户名)。 (User ID (username) whose password to change.)", + help="需要修改密码的用户的用户ID (用户名)。", ) pw_parser.add_argument( "--new-password", required=True, - help="用户的新密码。 (The new password for the user.)", + help="用户的新密码。", ) pw_parser.set_defaults(func=change_password_command) + # 添加 'list-users' / '导出用户' 子命令解析器 + list_users_parser = subparsers.add_parser( + "list-users", + help="导出所有用户的列表到CSV文件或标准输出。", + aliases=["导出用户"], + ) + list_users_parser.add_argument( + "--output-file", + "--输出文件", + type=str, + help="导出CSV文件的路径。如果未提供,则输出到标准输出。", + ) + list_users_parser.set_defaults(func=list_users_command) + + # --- Question Bank Subcommands --- + + # 'add-question' / '添加题目' 子命令 + add_q_parser = subparsers.add_parser( + "add-question", + help="添加一个新题目到指定的题库。", + aliases=["添加题目"], + ) + add_q_parser.add_argument( + "--library-id", required=True, help="题库ID (例如: 'easy', 'hard')" + ) + add_q_parser.add_argument("--content", required=True, help="题目内容 (题干)") + add_q_parser.add_argument( + "--options", + help='选择题选项的JSON字符串列表 (例如: \'["选项A", "选项B"]\')', + default="[]", + ) + add_q_parser.add_argument( + "--answer", required=True, help="正确答案 (对于选择题,应为选项之一)" + ) + add_q_parser.add_argument("--answer-detail", help="答案解析 (可选)") + add_q_parser.add_argument("--tags", help="逗号分隔的标签列表 (可选)") + add_q_parser.add_argument( + "--type", + choices=[qt.value for qt in QuestionTypeEnum], + default=QuestionTypeEnum.SINGLE_CHOICE.value, + help="题目类型 (默认为单选题)", + ) + # TODO: Add more specific fields based on QuestionModel for different types if needed + add_q_parser.set_defaults(func=add_question_command) + + # 'view-question' / '查看题目' 子命令 + view_q_parser = subparsers.add_parser( + "view-question", + help="查看指定ID的题目详情。", + aliases=["查看题目"], + ) + view_q_parser.add_argument("--question-id", required=True, help="要查看的题目ID") + view_q_parser.set_defaults(func=view_question_command) + + # 'update-question' / '更新题目' 子命令 + update_q_parser = subparsers.add_parser( + "update-question", + help="更新现有题目的信息。", + aliases=["更新题目"], + ) + update_q_parser.add_argument("--question-id", required=True, help="要更新的题目ID") + update_q_parser.add_argument("--content", help="新的题目内容 (题干)") + update_q_parser.add_argument("--options", help="新的选择题选项JSON字符串列表") + update_q_parser.add_argument("--answer", help="新的正确答案") + update_q_parser.add_argument("--answer-detail", help="新的答案解析") + update_q_parser.add_argument("--tags", help="新的逗号分隔的标签列表") + update_q_parser.add_argument( + "--confirm-rename", action="store_true", help="如果题目内容改变,需确认重命名" + ) + update_q_parser.set_defaults(func=update_question_command) + + # 'delete-question' / '删除题目' 子命令 + delete_q_parser = subparsers.add_parser( + "delete-question", + help="从题库中删除一个题目。", + aliases=["删除题目"], + ) + delete_q_parser.add_argument("--question-id", required=True, help="要删除的题目ID") + delete_q_parser.add_argument( + "--confirm", action="store_true", help="必须提供此参数以确认删除" + ) + delete_q_parser.set_defaults(func=delete_question_command) + + # 'list-questions' / '列出题目' 子命令 + list_q_parser = subparsers.add_parser( + "list-questions", + help="列出指定题库中的题目 (支持分页)。", + aliases=["列出题目"], + ) + list_q_parser.add_argument("--library-id", required=True, help="要列出题目的题库ID") + list_q_parser.add_argument("--page", type=int, default=1, help="页码 (从1开始)") + list_q_parser.add_argument("--per-page", type=int, default=10, help="每页数量") + list_q_parser.set_defaults(func=list_questions_command) + + # --- Application Configuration Subcommands --- + + # 'view-config' / '查看配置' 子命令 + view_cfg_parser = subparsers.add_parser( + "view-config", + help="查看当前应用配置信息。", + aliases=["查看配置"], + ) + view_cfg_parser.add_argument( + "--key", + help="可选,只显示指定配置项的值。使用点表示法访问嵌套键 (例如: 'rate_limits.default.get_exam.limit')", + ) + view_cfg_parser.set_defaults(func=view_config_command) + + # 'update-config' / '更新配置' 子命令 + update_cfg_parser = subparsers.add_parser( + "update-config", + help="更新应用配置项。请谨慎使用。", + aliases=["更新配置"], + ) + update_cfg_parser.add_argument( + "--key-value-pairs", + required=True, + help='包含待更新配置项及其新值的JSON字符串 (例如: \'{"app_name": "新名称", "log_level": "DEBUG"}\')', + ) + # Consider adding --confirm for critical changes later if needed + update_cfg_parser.set_defaults(func=update_config_command) + + # --- Statistics Viewing Subcommand --- + view_stats_parser = subparsers.add_parser( + "view-stats", + help="查看应用相关的统计信息。", + aliases=["查看统计"], + ) + view_stats_parser.set_defaults(func=view_stats_command) + # 解析命令行参数 # (Parse command-line arguments) args = parser.parse_args() @@ -349,6 +504,868 @@ async def main_async(): # 定义模块对外暴露的接口 (Define the module's public interface) __all__ = ["main_async"] + +# Stubs for new async command functions +async def add_question_command(args: argparse.Namespace): + if not qb_crud_instance: + print("错误: 题库操作模块 (QuestionBankCRUD) 未初始化。") + return + + print(f"正在尝试向题库 '{args.library_id}' 添加题目...") + + try: + question_type = QuestionTypeEnum(args.type) + options = json.loads(args.options) if args.options else [] + + # Validate options format + if not isinstance(options, list): + print( + '错误: --options 参数必须是一个有效的JSON列表字符串。例如: \'["选项A", "选项B"]\'' + ) + return + for opt in options: + if not isinstance(opt, str): + print(f"错误: 选项列表中的每个选项都必须是字符串。找到: {type(opt)}") + return + + tags_list = [tag.strip() for tag in args.tags.split(",")] if args.tags else [] + + question_data = { + "body": args.content, + "question_type": question_type, + "ref": args.answer_detail, + # Tags are not directly in QuestionModel based on qb_models.py, + # Assuming CRUD handles tags separately or it's a custom field. + # For now, we'll pass it if CRUD expects it, otherwise it might be ignored or cause error. + # "tags": tags_list, # This line might be needed if CRUD supports it. + } + + if question_type == QuestionTypeEnum.SINGLE_CHOICE: + if not args.answer: + print("错误: 单选题必须提供 --answer 参数。") + return + if args.answer not in options: + print(f"错误: 答案 '{args.answer}' 必须是提供的选项之一: {options}") + return + question_data["correct_choices"] = [args.answer] + question_data["incorrect_choices"] = [ + opt for opt in options if opt != args.answer + ] + question_data["num_correct_to_select"] = 1 + elif question_type == QuestionTypeEnum.MULTIPLE_CHOICE: + # For MULTIPLE_CHOICE, assuming args.answer is a comma-separated string of correct options + if not args.answer: + print("错误: 多选题必须提供 --answer 参数 (逗号分隔的正确选项)。") + return + correct_answers = [ans.strip() for ans in args.answer.split(",")] + for ans in correct_answers: + if ans not in options: + print(f"错误: 答案 '{ans}' 必须是提供的选项之一: {options}") + return + question_data["correct_choices"] = correct_answers + question_data["incorrect_choices"] = [ + opt for opt in options if opt not in correct_answers + ] + question_data["num_correct_to_select"] = len(correct_answers) + # Add handling for other question types (FILL_IN_THE_BLANK, ESSAY_QUESTION) if needed based on QuestionModel + # For now, QuestionModel seems to primarily support choice-based questions via correct_choices/incorrect_choices + # and other types via standard_answer_text etc. which are not fully mapped in CLI args yet. + + # Create QuestionModel instance + # We need to handle potential Pydantic validation errors here + try: + question_to_create = QuestionModel(**question_data) + except Exception as e: # Catch Pydantic validation error + print(f"创建题目数据时发生验证错误: {e}") + return + + # Assuming qb_crud_instance.create_question returns the created question with an ID + # The actual method signature might be different (e.g. create_question_in_library) + # Also, qb_crud_instance might not have a 'tags' field in its QuestionModel + # This will likely need adjustment based on the actual CRUD interface for questions + + # Placeholder for actual CRUD call, which needs library_id and question model + # created_question = await qb_crud_instance.create_question( + # library_id=args.library_id, question_data=question_to_create + # ) + + # Simulating a call to a hypothetical extended CRUD method that handles tags: + # created_question = await qb_crud_instance.create_question_with_tags( + # library_id=args.library_id, question_data=question_to_create, tags=tags_list + # ) + + # Based on the provided `QuestionModel`, 'tags' is not a field. + # The CRUD method `create_question` likely takes `QuestionModel` and `library_id`. + # If tags need to be stored, the CRUD method itself must handle it, + # or the QuestionModel needs a `tags` field. + # For now, we'll assume tags are not directly part of the QuestionModel in `qb_crud_instance.create_question`. + # The `tags` argument in the CLI will be acknowledged but might not be persisted unless CRUD supports it. + + # Let's assume a method signature like: create_question(self, library_id: str, question: QuestionModel, tags: Optional[List[str]] = None) + # This is a guess; the actual CRUD method signature is unknown. + # For the purpose of this exercise, I will assume the CRUD method is: + # `qb_crud_instance.add_question_to_library(library_id: str, question_data: QuestionModel, tags: List[str])` + # And it returns a model that includes an `id` attribute. + + # This is a placeholder. The actual method might be different. + # For example, it might be `qb_crud_instance.create_question_in_library(library_id=args.library_id, question_obj=question_to_create, tags=tags_list)` + # I will use a plausible name based on common CRUD patterns. + created_question_response = await qb_crud_instance.create_question_in_library( + library_id=args.library_id, + question_data=question_to_create, + tags=tags_list, # Assuming the CRUD method can take tags + ) + + if created_question_response and hasattr(created_question_response, "id"): + print(f"题目已成功添加到题库 '{args.library_id}'。") + print(f"新题目ID: {created_question_response.id}") + if tags_list: + print(f"标签: {', '.join(tags_list)}") + else: + print(f"添加题目到题库 '{args.library_id}' 失败。未返回题目ID。") + + except json.JSONDecodeError: + print("错误: --options 参数不是有效的JSON字符串。") + except ValueError as e: # Catches issues like invalid enum values + print(f"输入值错误: {e}") + except Exception as e: + print(f"添加题目时发生未预料的错误: {e}") + + +async def view_question_command(args: argparse.Namespace): + if not qb_crud_instance: + print("错误: 题库操作模块 (QuestionBankCRUD) 未初始化。") + return + + print(f"正在尝试查看题目ID: {args.question_id}...") + try: + # Assume get_question_by_id returns a model that includes an 'id' field, + # and potentially 'tags' if the CRUD joins them or they are part of the stored model. + # Let's call it `QuestionDetailsModel` for this example, which might be QuestionModel itself + # or an augmented version. + question = await qb_crud_instance.get_question_by_id( + question_id=args.question_id + ) + + if question: + print("\n--- 题目详情 ---") + if hasattr(question, "id"): # If the returned object has an ID + print(f"题目ID: {question.id}") + else: # Fallback to the requested ID if not part of the response model + print(f"题目ID: {args.question_id}") + + print( + f"题库ID: {question.library_id if hasattr(question, 'library_id') else '未知'}" + ) # Assuming library_id is part of fetched model + print( + f"类型: {question.question_type.value if hasattr(question, 'question_type') and question.question_type else '未知'}" + ) + print( + f"题目内容:\n{question.body if hasattr(question, 'body') else '未知'}" + ) + + if hasattr(question, "question_type") and question.question_type in [ + QuestionTypeEnum.SINGLE_CHOICE, + QuestionTypeEnum.MULTIPLE_CHOICE, + ]: + print("\n选项:") + options = [] + if hasattr(question, "correct_choices") and question.correct_choices: + options.extend(question.correct_choices) + if ( + hasattr(question, "incorrect_choices") + and question.incorrect_choices + ): + options.extend(question.incorrect_choices) + + # The original QuestionModel stores correct and incorrect choices separately. + # For display, we might want to show all choices. + # This part needs to be careful not to assume a specific structure for "options" during display + # if the model only provides correct_choices and incorrect_choices. + + all_options_display = [] + if hasattr(question, "correct_choices") and question.correct_choices: + for opt in question.correct_choices: + all_options_display.append(f" - {opt} (正确答案)") + if ( + hasattr(question, "incorrect_choices") + and question.incorrect_choices + ): + for opt in question.incorrect_choices: + all_options_display.append(f" - {opt}") + + if all_options_display: + for opt_display in all_options_display: + print(opt_display) + else: + print(" (未提供选项信息)") + + print("\n正确答案:") + if hasattr(question, "correct_choices") and question.correct_choices: + for ans in question.correct_choices: + print(f" - {ans}") + else: + print(" (未设置正确答案)") + + if ( + hasattr(question, "standard_answer_text") + and question.standard_answer_text + ): # For essay questions + print(f"\n参考答案 (主观题):\n{question.standard_answer_text}") + + if hasattr(question, "ref") and question.ref: + print(f"\n答案解析:\n{question.ref}") + + # Assuming tags are fetched as part of the question object by the CRUD method + if hasattr(question, "tags") and question.tags: + print(f"\n标签: {', '.join(question.tags)}") + + print("--- 详情结束 ---") + else: + print(f"错误: 未找到题目ID为 '{args.question_id}' 的题目。") + + except Exception as e: + # Catch specific exceptions like 'QuestionNotFoundException' if defined by CRUD layer + # For now, a generic catch. + print(f"查看题目时发生错误: {e}") + + +async def update_question_command(args: argparse.Namespace): + if not qb_crud_instance: + print("错误: 题库操作模块 (QuestionBankCRUD) 未初始化。") + return + + print(f"正在尝试更新题目ID: {args.question_id}...") + + try: + existing_question = await qb_crud_instance.get_question_by_id( + question_id=args.question_id + ) + if not existing_question: + print(f"错误: 未找到题目ID为 '{args.question_id}' 的题目,无法更新。") + return + + update_payload = {} # Using a dict for partial updates + + if args.content: + if args.content != existing_question.body and not args.confirm_rename: + print( + "错误: 题目内容 (content) 已更改,但未提供 --confirm-rename 标志。操作已取消。" + ) + print("如果您确定要修改题目内容,请同时使用 --confirm-rename 参数。") + return + update_payload["body"] = args.content + + if args.answer_detail: + update_payload["ref"] = args.answer_detail + + new_tags_list = None + if ( + args.tags is not None + ): # Check if tags argument was provided (even if empty string) + new_tags_list = ( + [tag.strip() for tag in args.tags.split(",") if tag.strip()] + if args.tags + else [] + ) + # This assumes the CRUD update method can handle tags. + # If QuestionModel had a 'tags' field, this would be `update_payload["tags"] = new_tags_list` + + # Handling options and answer is complex as it depends on question type + # and how they are stored in QuestionModel (correct_choices, incorrect_choices) + if args.options or args.answer: + if not hasattr(existing_question, "question_type"): + print("错误: 无法确定现有题目的类型,无法更新选项或答案。") + return + + current_q_type = existing_question.question_type + options = ( + json.loads(args.options) if args.options else None + ) # Parse new options if provided + + if options is not None and not isinstance(options, list): + print( + '错误: --options 参数必须是一个有效的JSON列表字符串。例如: \'["选项A", "选项B"]\'' + ) + return + for opt_idx, opt_val in enumerate(options or []): + if not isinstance(opt_val, str): + print( + f"错误: 新选项列表索引 {opt_idx} 处的值必须是字符串。找到: {type(opt_val)}" + ) + return + + current_options = [] + if ( + hasattr(existing_question, "correct_choices") + and existing_question.correct_choices + ): + current_options.extend(existing_question.correct_choices) + if ( + hasattr(existing_question, "incorrect_choices") + and existing_question.incorrect_choices + ): + current_options.extend(existing_question.incorrect_choices) + + final_options = options if options is not None else current_options + new_answer = ( + args.answer + if args.answer is not None + else ( + existing_question.correct_choices[0] + if hasattr(existing_question, "correct_choices") + and existing_question.correct_choices + and current_q_type == QuestionTypeEnum.SINGLE_CHOICE + else None + ) + ) + # For multi-choice, new_answer might need to be a list from comma-separated string + + if current_q_type == QuestionTypeEnum.SINGLE_CHOICE: + if ( + new_answer is None + ): # If answer is being cleared, or was not set and not provided now + # This case might need clarification: can a choice question exist without an answer? + # For now, if no new answer is given, and no old one, then error or keep as is. + # If new_answer is explicitly empty, it might mean to clear it. + # Let's assume for now an answer is required if options are present. + if ( + final_options and not new_answer + ): # If there are options but no answer + print( + "错误: 单选题更新时,如果提供了选项,则必须有明确的答案。" + ) + return + + if new_answer and new_answer not in final_options: + print( + f"错误: 新答案 '{new_answer}' 必须是最终选项列表之一: {final_options}" + ) + return + update_payload["correct_choices"] = [new_answer] if new_answer else [] + update_payload["incorrect_choices"] = [ + opt for opt in final_options if opt != new_answer + ] + update_payload["num_correct_to_select"] = ( + 1 if new_answer and final_options else 0 + ) + + elif current_q_type == QuestionTypeEnum.MULTIPLE_CHOICE: + # Assuming new_answer for multiple choice is comma-separated if provided via args.answer + # If args.answer is not provided, we rely on existing_question.correct_choices + new_correct_answers_list = [] + if args.answer is not None: # New answer string is provided + new_correct_answers_list = [ + ans.strip() for ans in args.answer.split(",") + ] + elif hasattr( + existing_question, "correct_choices" + ): # No new answer string, use existing + new_correct_answers_list = existing_question.correct_choices + + for ans in new_correct_answers_list: + if ans not in final_options: + print( + f"错误: 更新后的答案 '{ans}' 必须是最终选项列表之一: {final_options}" + ) + return + update_payload["correct_choices"] = new_correct_answers_list + update_payload["incorrect_choices"] = [ + opt for opt in final_options if opt not in new_correct_answers_list + ] + update_payload["num_correct_to_select"] = len(new_correct_answers_list) + + # If only options are provided, but not answer, and type is choice: + # Need to ensure existing answer is still valid or require new answer. + if ( + options is not None + and args.answer is None + and current_q_type + in [QuestionTypeEnum.SINGLE_CHOICE, QuestionTypeEnum.MULTIPLE_CHOICE] + ): + if not update_payload.get( + "correct_choices" + ): # if correct_choices wasn't set (e.g. existing answer was not in new options) + print( + "警告: 选项已更新,但未提供新的答案,或旧答案已失效。请使用 --answer 更新答案。" + ) + + if ( + not update_payload and new_tags_list is None + ): # Check if any actual update data is present + print("未提供任何需要更新的字段。操作已取消。") + return + + # The qb_crud_instance.update_question method needs to be defined. + # It should accept question_id and a dictionary (or Pydantic model) for updates. + # And potentially tags as a separate argument. + # e.g., updated_question = await qb_crud_instance.update_question(question_id, update_data=QuestionUpdateModel(**update_payload), tags=new_tags_list) + # For now, using a dict for update_payload. + # Let's assume a method like: + # `qb_crud_instance.update_question_by_id(question_id: str, update_doc: dict, tags: Optional[List[str]])` + + # This is a placeholder for the actual CRUD call. + # The update_payload should ideally be validated by a Pydantic model for update. + # For example, `QuestionUpdate(**update_payload)` + updated_q_response = await qb_crud_instance.update_question_fields( + question_id=args.question_id, + update_data=update_payload, + tags=new_tags_list, # Pass tags if CRUD supports it + ) + + if updated_q_response: # Assuming CRUD returns the updated question or True + print(f"题目ID '{args.question_id}' 已成功更新。") + # Optionally, print which fields were updated if the response indicates this. + else: + # This 'else' might not be reachable if CRUD raises an exception on failure. + print(f"更新题目ID '{args.question_id}' 失败。") + + except json.JSONDecodeError: + print("错误: --options 参数不是有效的JSON字符串。") + except ValueError as e: + print(f"输入值错误或转换失败: {e}") + except Exception as e: + # Consider specific exceptions like QuestionNotFoundException if defined by CRUD + print(f"更新题目时发生未预料的错误: {e}") + import traceback + + traceback.print_exc() + + +async def delete_question_command(args: argparse.Namespace): + if not qb_crud_instance: + print("错误: 题库操作模块 (QuestionBankCRUD) 未初始化。") + return + + if not args.confirm: + print("错误: 删除操作需要 --confirm 标志进行确认。操作已取消。") + print( + f"如果您确定要删除题目ID '{args.question_id}',请再次运行命令并添加 --confirm 参数。" + ) + return + + print(f"正在尝试删除题目ID: {args.question_id}...") + try: + # Assume a CRUD method like delete_question_by_id(question_id: str) + # This method should ideally return True if deletion was successful, + # or raise a specific exception (e.g., QuestionNotFound) if it matters, + # or return False if deletion failed for other reasons. + # Some delete operations are idempotent (deleting a non-existent item is success). + # Let's assume it returns True on success, False on failure (e.g. lock error), + # and handles QuestionNotFound internally or by not raising an error for it. + + deleted_successfully = await qb_crud_instance.delete_question_by_id( + question_id=args.question_id + ) + + if deleted_successfully: + print(f"题目ID '{args.question_id}' 已成功删除。") + else: + # This path might be taken if the question didn't exist AND the CRUD method returns False for that, + # or if there was another failure preventing deletion. + # If QuestionNotFound is not an error for delete, this message might need adjustment. + print( + f"删除题目ID '{args.question_id}' 失败。可能题目不存在或删除过程中发生错误。" + ) + # To provide better feedback, CRUD could return a more specific status or raise specific exceptions. + + except Exception as e: + # Example: Catch a specific 'QuestionNotFoundException' if CRUD defines it and + # we want to treat "not found" as a specific case (e.g., not an error for delete). + # if isinstance(e, QuestionNotFoundException): + # print(f"题目ID '{args.question_id}' 未找到,无需删除。") + # else: + print(f"删除题目ID '{args.question_id}' 时发生未预料的错误: {e}") + + +async def list_questions_command(args: argparse.Namespace): + if not qb_crud_instance: + print("错误: 题库操作模块 (QuestionBankCRUD) 未初始化。") + return + + print( + f"正在尝试列出题库 '{args.library_id}' 中的题目 (页码: {args.page}, 每页: {args.per_page})..." + ) + try: + # Assume a CRUD method like: + # get_questions_from_library(library_id: str, page: int, per_page: int) -> dict + # The returned dict might look like: + # { + # "items": [QuestionModelSubset, ...], + # "total_items": int, + # "total_pages": int, + # "current_page": int + # } + # Or it might just return a list of questions if pagination is simpler. + # Let's assume a method that returns a list of questions directly for now, + # and pagination info might be part of those question objects or inferred. + # A more robust CRUD would return total counts for proper pagination display. + + # Placeholder: Actual signature might be `get_questions_in_library` or similar + # and might return a more complex object with pagination data. + # For this example, let's assume it returns a list of question objects (e.g., QuestionModel or a summary model) + # and we don't have total pages/items info from this call directly, unless the CRUD provides it. + + # Let's refine the assumed CRUD call to return a structure that includes pagination details: + # result = await qb_crud_instance.list_questions_in_library_paginated( + # library_id=args.library_id, page=args.page, limit=args.per_page + # ) + # Assuming `result` is an object or dict with `items` (list of questions), + # `total_count`, `page`, `per_page`. + + # Simpler assumption for now: returns a list of QuestionModel like objects + # And we don't have total count from this call. + questions_page = await qb_crud_instance.get_questions_from_library_paginated( + library_id=args.library_id, page=args.page, per_page=args.per_page + ) + # questions_page should be a list of objects, each having at least 'id' and 'body' + # Ideally, the response would also include total_items and total_pages. + # Let's say questions_page = { "questions": [...], "total_count": N, "page": P, "per_page": PP } + + if ( + questions_page + and isinstance(questions_page, dict) + and "questions" in questions_page + ): + items = questions_page["questions"] + total_items = questions_page.get( + "total_count", len(items) + ) # Fallback if total_count not provided + total_pages = questions_page.get( + "total_pages", None + ) # If CRUD provides total_pages + + if not items: + print( + f"题库 '{args.library_id}' 中未找到任何题目 (第 {args.page} 页)。" + ) + if total_items > 0 and args.page > 1: + print(f"总共有 {total_items} 个题目。可能您请求的页码超出了范围。") + elif total_items == 0: + print("该题库当前为空。") + return + + print( + f"\n--- 题库 '{args.library_id}' - 第 {args.page} 页 (共 {total_items} 个题目) ---" + ) + if total_pages: + print(f" (总页数: {total_pages})") + + for idx, q_item in enumerate(items): + # Assuming q_item has 'id' and 'body' attributes. + # It might be a full QuestionModel or a summary. + content_snippet = ( + (q_item.body[:70] + "...") + if hasattr(q_item, "body") and q_item.body and len(q_item.body) > 70 + else (q_item.body if hasattr(q_item, "body") else "无内容") + ) + q_id = q_item.id if hasattr(q_item, "id") else "未知ID" + print( + f" {((args.page - 1) * args.per_page) + idx + 1}. ID: {q_id} - 内容: {content_snippet}" + ) + + print(f"--- 共显示 {len(items)} 个题目 ---") + if total_pages and args.page < total_pages: + print(f"要查看下一页,请使用 --page {args.page + 1}") + + elif ( + isinstance(questions_page, list) and not questions_page + ): # Simpler list return, empty + print(f"题库 '{args.library_id}' 中未找到任何题目 (第 {args.page} 页)。") + elif ( + isinstance(questions_page, list) and questions_page + ): # Simpler list return, with items + print(f"\n--- 题库 '{args.library_id}' - 第 {args.page} 页 ---") + for idx, q_item in enumerate(questions_page): + content_snippet = ( + (q_item.body[:70] + "...") + if hasattr(q_item, "body") and q_item.body and len(q_item.body) > 70 + else (q_item.body if hasattr(q_item, "body") else "无内容") + ) + q_id = q_item.id if hasattr(q_item, "id") else "未知ID" + print( + f" {((args.page - 1) * args.per_page) + idx + 1}. ID: {q_id} - 内容: {content_snippet}" + ) + print(f"--- 共显示 {len(questions_page)} 个题目 ---") + if len(questions_page) == args.per_page: + print( + f"可能还有更多题目,请尝试使用 --page {args.page + 1} 查看下一页。" + ) + else: + # This case handles if questions_page is None or an unexpected structure + print(f"未能从题库 '{args.library_id}' 获取题目列表,或题库为空。") + + except Exception as e: + # Example: Catch a specific 'LibraryNotFoundException' if CRUD defines it. + # if isinstance(e, LibraryNotFoundException): + # print(f"错误: 未找到ID为 '{args.library_id}' 的题库。") + # else: + print(f"列出题库 '{args.library_id}' 中的题目时发生错误: {e}") + import traceback + + traceback.print_exc() + + +# Helper for accessing nested dictionary keys using dot notation +def get_nested_value(data_dict, key_path): + keys = key_path.split(".") + value = data_dict + for key in keys: + if isinstance(value, dict) and key in value: + value = value[key] + elif isinstance(value, BaseModel) and hasattr( + value, key + ): # Handle Pydantic models + value = getattr(value, key) + else: + return None # Key not found or path invalid + return value + + +async def view_config_command(args: argparse.Namespace): + if not settings_crud_instance: + print("错误: 配置操作模块 (SettingsCRUD) 未初始化。") + return + + try: + # Assuming get_all_settings() returns a Pydantic model instance (e.g., SettingsResponseModel) + # or a dict that can be parsed into one. + all_settings_model = await settings_crud_instance.get_all_settings() + + if not all_settings_model: + print("错误: 未能获取到任何配置信息。") + return + + # Convert Pydantic model to dict for easier processing if it's not already a dict + # The SettingsResponseModel has `model_config = {"extra": "ignore"}` + # and fields are Optional, so it should handle settings.json not having all keys. + # We should use model_dump to get a dict from the Pydantic model. + if isinstance(all_settings_model, BaseModel): + settings_dict = all_settings_model.model_dump( + exclude_unset=True + ) # exclude_unset for cleaner output + else: # Assuming it's already a dict (less ideal, CRUD should return model) + settings_dict = all_settings_model + + if args.key: + print(f"正在查找配置项: '{args.key}'...") + value = get_nested_value(settings_dict, args.key) + if value is not None: + print(f"\n--- 配置项 '{args.key}' ---") + # Pretty print if value is a dict or list + if isinstance(value, (dict, list)): + print(json.dumps(value, indent=2, ensure_ascii=False)) + else: + print(str(value)) + print("--- 结束 ---") + else: + print(f"错误: 未找到配置项键 '{args.key}'。") + else: + print("\n--- 当前应用配置 ---") + if settings_dict: + # Using json.dumps for pretty printing the whole dict + print(json.dumps(settings_dict, indent=2, ensure_ascii=False)) + else: + print("未找到任何配置项。") + print("--- 配置结束 ---") + + except Exception as e: + print(f"查看配置时发生错误: {e}") + import traceback + + traceback.print_exc() + + +async def update_config_command(args: argparse.Namespace): + if not settings_crud_instance: + print("错误: 配置操作模块 (SettingsCRUD) 未初始化。") + return + + print("警告: 更新应用配置是一项敏感操作,请确保您了解所做更改的影响。") + + try: + update_data_dict = json.loads(args.key_value_pairs) + if not isinstance(update_data_dict, dict): + print( + "错误: --key-value-pairs 参数必须是一个有效的JSON对象 (字典) 字符串。" + ) + return + if not update_data_dict: + print("错误: 提供的键值对为空,没有可更新的配置项。") + return + + print("\n正在尝试更新以下配置项:") + for key, value in update_data_dict.items(): + # Truncate long values for display confirmation + display_value = str(value) + if len(display_value) > 70: + display_value = display_value[:67] + "..." + print(f" - {key}: {display_value}") + + # It's good practice to ask for confirmation here, especially for `update-config`. + # However, the prompt doesn't explicitly ask for a --confirm flag for this command, + # but mentions "Consider adding a --confirm flag". For now, proceeding without it. + # confirm = input("\n您确定要应用这些更改吗? (yes/no): ") + # if confirm.lower() != 'yes': + # print("操作已取消。") + # return + + # Assumption: settings_crud_instance.update_settings(dict) + # This method should perform validation against SettingsUpdatePayload internally. + # It should return the updated settings or a status. + # Let's assume it returns a model of the *actually updated* settings or True/False. + + # To ensure validation against SettingsUpdatePayload, we could do: + # validated_payload = SettingsUpdatePayload(**update_data_dict) + # updated_settings_response = await settings_crud_instance.update_settings(validated_payload.model_dump(exclude_unset=True)) + # This would raise Pydantic validation error before calling CRUD if input is bad. + # For now, let's assume CRUD handles validation of the raw dict. + + updated_result = await settings_crud_instance.update_settings(update_data_dict) + + if updated_result: # If CRUD returns True or the updated settings model + print("\n配置已成功更新。") + if isinstance( + updated_result, (dict, BaseModel) + ): # If it returns the updated data + print("更新后的值为:") + # If updated_result is a Pydantic model, convert to dict for printing + if isinstance(updated_result, BaseModel): + updated_result_dict = updated_result.model_dump(exclude_unset=True) + else: + updated_result_dict = updated_result + + # Print only the keys that were part of the update request + for key in update_data_dict.keys(): + if key in updated_result_dict: + print(f" - {key}: {updated_result_dict[key]}") + else: + print(f" - {key}: (未从更新结果中返回)") + else: # If it just returns True + print("请使用 'view-config' 命令查看更改后的配置。") + else: + # This path might be taken if CRUD returns False or None on failure, + # without raising an exception. + print("\n更新配置失败。请检查日志或输入数据。") + print("可能的原因包括: 无效的配置键、值不符合类型要求或验证规则。") + + except json.JSONDecodeError: + print("错误: --key-value-pairs 参数不是有效的JSON字符串。") + except ( + Exception + ) as e: # This could catch Pydantic validation errors if CRUD raises them + print(f"更新配置时发生错误: {e}") + # If e is a Pydantic ValidationError, it can be printed more nicely. + # from pydantic import ValidationError + # if isinstance(e, ValidationError): + # print("详细验证错误:") + # for error in e.errors(): + # print(f" 字段: {'.'.join(str(loc) for loc in error['loc'])}") + # print(f" 错误: {error['msg']}") + # else: + import traceback + + traceback.print_exc() + + +# Stats viewing command +async def view_stats_command(args: argparse.Namespace): + print("正在收集应用统计信息...") + + stats = {} + errors = [] + + # User stats + if user_crud_instance: + try: + total_users = await user_crud_instance.get_total_users_count() + stats["总用户数"] = total_users + except AttributeError: + errors.append("用户CRUD实例缺少 'get_total_users_count' 方法。") + except Exception as e: + errors.append(f"获取总用户数时出错: {e}") + else: + errors.append("用户CRUD实例未初始化。") + + # Question bank stats + if qb_crud_instance: + try: + total_questions = await qb_crud_instance.get_total_questions_count() + stats["总题目数 (所有题库)"] = total_questions + except AttributeError: + errors.append("题库CRUD实例缺少 'get_total_questions_count' 方法。") + except Exception as e: + errors.append(f"获取总题目数时出错: {e}") + + try: + counts_per_lib = await qb_crud_instance.get_questions_count_per_library() + if counts_per_lib: # Assuming it returns a dict like {'lib_id': count} + stats["各题库题目数"] = counts_per_lib + else: + stats["各题库题目数"] = "暂无题库或题目信息。" + except AttributeError: + errors.append("题库CRUD实例缺少 'get_questions_count_per_library' 方法。") + except Exception as e: + errors.append(f"获取各题库题目数时出错: {e}") + else: + errors.append("题库CRUD实例未初始化。") + + # Paper/Exam stats + if paper_crud_instance: + try: + completed_exams = ( + await paper_crud_instance.get_total_completed_exams_count() + ) + stats["已完成考试总数"] = completed_exams + except AttributeError: + errors.append("试卷CRUD实例缺少 'get_total_completed_exams_count' 方法。") + except Exception as e: + errors.append(f"获取已完成考试数时出错: {e}") + + try: + avg_score_data = ( + await paper_crud_instance.get_average_score() + ) # Assuming this returns a float or a dict with score + if isinstance(avg_score_data, (float, int)): + stats["平均考试得分"] = ( + f"{avg_score_data:.2f}%" if avg_score_data is not None else "N/A" + ) + elif isinstance(avg_score_data, dict) and "average_score" in avg_score_data: + stats["平均考试得分"] = ( + f"{avg_score_data['average_score']:.2f}%" + if avg_score_data["average_score"] is not None + else "N/A" + ) + else: + stats["平均考试得分"] = "数据不可用或格式不正确。" + + except AttributeError: + errors.append("试卷CRUD实例缺少 'get_average_score' 方法。") + except Exception as e: + errors.append(f"获取平均分时出错: {e}") + else: + errors.append("试卷CRUD实例未初始化。") + + print("\n--- 应用统计 ---") + if stats: + for key, value in stats.items(): + if isinstance(value, dict): + print(f"{key}:") + for sub_key, sub_value in value.items(): + print(f" - {sub_key}: {sub_value}") + else: + print(f"{key}: {value}") + else: + print("未能收集到任何统计信息。") + + if errors: + print("\n--- 收集统计信息时遇到的错误 ---") + for err in errors: + print(f"- {err}") + + print("--- 统计结束 ---") + + if __name__ == "__main__": # 这个检查确保脚本是直接运行的 (`python examctl.py ...`),而不是被导入的。 # (This check ensures the script is run directly (`python examctl.py ...`), not imported.) diff --git a/migrate_question_data.py b/migrate_question_data.py index 43d49f2..0c686c3 100644 --- a/migrate_question_data.py +++ b/migrate_question_data.py @@ -11,9 +11,10 @@ - 保留可选的 ref 字段(如果存在)。 - 移除 QuestionModel 定义之外的其他字段。 """ + import json -from pathlib import Path import sys +from pathlib import Path # 定义项目根目录,确保脚本可以从任何位置正确地找到数据文件 # Path(__file__) 是当前脚本的路径 @@ -27,8 +28,18 @@ # QuestionModel 定义中预期的核心字段 (用于筛选,确保不遗漏或添加多余字段) # 注意:'correct_fillings' 对于单选题通常为 None 或不存在,脚本逻辑会确保这一点。 -EXPECTED_FIELDS_BASE = {"body", "question_type", "correct_choices", "incorrect_choices", "num_correct_to_select"} -EXPECTED_FIELDS_OPTIONAL = {"ref", "correct_fillings"} # correct_fillings 将被明确设置为None或移除 +EXPECTED_FIELDS_BASE = { + "body", + "question_type", + "correct_choices", + "incorrect_choices", + "num_correct_to_select", +} +EXPECTED_FIELDS_OPTIONAL = { + "ref", + "correct_fillings", +} # correct_fillings 将被明确设置为None或移除 + def migrate_file(file_path: Path): """ @@ -62,7 +73,9 @@ def migrate_file(file_path: Path): for i, q_orig in enumerate(questions_data): if not isinstance(q_orig, dict): - print(f"警告:在 {file_path.name} 中发现非字典类型的题目数据(条目 {i+1}),已跳过。数据:{q_orig}") + print( + f"警告:在 {file_path.name} 中发现非字典类型的题目数据(条目 {i + 1}),已跳过。数据:{q_orig}" + ) skipped_count += 1 continue @@ -72,19 +85,31 @@ def migrate_file(file_path: Path): # 如果源文件中这些字段缺失,则使用默认值(空字符串/列表)以保证结构完整性 new_q["body"] = q_orig.get("body", "") if not new_q["body"]: - print(f"警告:在 {file_path.name} 的题目 {i+1} 中 'body' 字段为空或缺失。") + print( + f"警告:在 {file_path.name} 的题目 {i + 1} 中 'body' 字段为空或缺失。" + ) correct_choices = q_orig.get("correct_choices") - if correct_choices is None or not isinstance(correct_choices, list) or not correct_choices: - print(f"警告:在 {file_path.name} 的题目 {i+1} 中 'correct_choices' 字段为空、缺失或格式不正确。将使用空列表。") + if ( + correct_choices is None + or not isinstance(correct_choices, list) + or not correct_choices + ): + print( + f"警告:在 {file_path.name} 的题目 {i + 1} 中 'correct_choices' 字段为空、缺失或格式不正确。将使用空列表。" + ) new_q["correct_choices"] = [] else: new_q["correct_choices"] = correct_choices incorrect_choices = q_orig.get("incorrect_choices") - if incorrect_choices is None or not isinstance(incorrect_choices, list): # 允许空列表 - print(f"警告:在 {file_path.name} 的题目 {i+1} 中 'incorrect_choices' 字段缺失或格式不正确。将使用空列表。") - new_q["incorrect_choices"] = [] + if incorrect_choices is None or not isinstance( + incorrect_choices, list + ): # 允许空列表 + print( + f"警告:在 {file_path.name} 的题目 {i + 1} 中 'incorrect_choices' 字段缺失或格式不正确。将使用空列表。" + ) + new_q["incorrect_choices"] = [] else: new_q["incorrect_choices"] = incorrect_choices @@ -95,7 +120,9 @@ def migrate_file(file_path: Path): # 对于单选题,此值应为1。 # 如果原始数据中有此字段且值不为1,则打印警告,但仍强制设为1。 if "num_correct_to_select" in q_orig and q_orig["num_correct_to_select"] != 1: - print(f"警告:在 {file_path.name} 的题目 {i+1} 中 'num_correct_to_select' 字段值为 {q_orig['num_correct_to_select']},将强制修改为 1。") + print( + f"警告:在 {file_path.name} 的题目 {i + 1} 中 'num_correct_to_select' 字段值为 {q_orig['num_correct_to_select']},将强制修改为 1。" + ) new_q["num_correct_to_select"] = 1 # 4. 保留可选的 ref 字段 (如果存在且非空) @@ -122,19 +149,22 @@ def migrate_file(file_path: Path): try: with open(file_path, "w", encoding="utf-8") as f: json.dump(migrated_questions, f, ensure_ascii=False, indent=4) - print(f"文件 {file_path.name} 已成功迁移并写回。共处理 {processed_count} 道题目。") + print( + f"文件 {file_path.name} 已成功迁移并写回。共处理 {processed_count} 道题目。" + ) except IOError as e: print(f"错误:无法写回文件 {file_path.name}。错误详情: {e}") except Exception as e: print(f"错误:写回文件 {file_path.name} 时发生未知错误: {e}") + if __name__ == "__main__": # 检查脚本是否从项目根目录运行,以便LIBRARY_DIR正确 expected_data_path = BASE_DIR / "data" if not expected_data_path.exists() or not expected_data_path.is_dir(): - print(f"错误:脚本似乎没有在预期的项目根目录下运行。") + print("错误:脚本似乎没有在预期的项目根目录下运行。") print(f"预期的 'data' 文件夹路径: {expected_data_path}") - print(f"请确保从项目根目录执行此脚本,例如:python migrate_question_data.py") + print("请确保从项目根目录执行此脚本,例如:python migrate_question_data.py") sys.exit(1) print("开始执行题库数据迁移脚本...") @@ -157,7 +187,9 @@ def migrate_file(file_path: Path): print("题库数据迁移脚本执行完毕。") print("请注意:") print("1. 此脚本主要负责数据结构迁移,确保符合 QuestionModel 的基本要求。") - print("2. 脚本假设题目内容(题干、选项、解释)已为中文。如果仍存在非中文内容,需要人工复核和翻译。") + print( + "2. 脚本假设题目内容(题干、选项、解释)已为中文。如果仍存在非中文内容,需要人工复核和翻译。" + ) print(f"3. 已处理的文件位于: {LIBRARY_DIR}") print("4. 强烈建议在执行此脚本前备份您的 data/library 目录。") print("5. 如果之前执行覆写题库文件失败,此脚本的执行结果才是实际的迁移结果。") diff --git a/requirements.txt b/requirements.txt index 8447e62..c4dd3c1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,6 +10,7 @@ asyncpg>=0.29.0 # For PostgreSQL adapter aiomysql>=0.2.0 # For MySQL adapter (also needs PyMySQL) aioredis>=2.0.0 # For Redis adapter aiosqlite>=0.19.0 # For SQLite adapter +openpyxl>=3.1.0 # For XLSX export/import # Documentation tools mkdocs>=1.5.0 diff --git a/run.py b/run.py index bc2f8b6..8ad7e68 100644 --- a/run.py +++ b/run.py @@ -22,6 +22,7 @@ process managers (like Gunicorn + Uvicorn workers or Supervisor), this script demonstrates how Uvicorn can be configured and run.) """ + # run.py (位于项目根目录 / Located in the project root directory) import uvicorn diff --git a/site/404.html b/site/404.html deleted file mode 100644 index 1327718..0000000 --- a/site/404.html +++ /dev/null @@ -1,529 +0,0 @@ - - - - - - - - - - - - - - - - - - - 在线考试系统 文档 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- -
-
- -
- - - - -
- - -
- -
- - - - - - - - - -
-
- - - -
-
-
- - - - - - - -
-
-
- - - -
-
-
- - - -
-
-
- - - -
-
- -

404 - Not found

- -
-
- - - -
- - - -
- - - -
-
-
-
- - - - - - - - - - - - \ No newline at end of file diff --git a/site/CONTRIBUTING/index.html b/site/CONTRIBUTING/index.html deleted file mode 100644 index 5614b5d..0000000 --- a/site/CONTRIBUTING/index.html +++ /dev/null @@ -1,782 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - 贡献指南 - 在线考试系统 文档 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - - - 跳转至 - - -
-
- -
- - - - -
- - -
- -
- - - - - - - - - -
-
- - - -
-
-
- - - - - - - -
-
-
- - - - - - - -
-
- - - - - -

贡献指南

-

我们非常欢迎您为本项目做出贡献!无论是报告错误、提出改进建议,还是直接贡献代码,您的参与都对项目至关重要。

-

如何贡献

-

报告问题 (Issues)

-
    -
  • 如果您在项目中发现了错误 (Bug)、有功能建议或任何疑问,请通过提交 Issue 来告诉我们。
  • -
  • 在提交 Issue 前,请先搜索现有的 Issues,看是否已有类似内容。
  • -
  • 提交 Issue 时,请尽可能详细地描述问题或建议,包括:
      -
    • 错误报告: 复现步骤、期望行为、实际行为、错误信息、相关截图、您的环境信息(操作系统、Python版本等)。
    • -
    • 功能建议: 清晰描述建议的功能、它能解决什么问题、以及可能的实现思路。
    • -
    -
  • -
-

贡献代码 (Pull Requests)

-
    -
  1. Fork 本仓库: 点击仓库右上角的 "Fork" 按钮,将项目复刻到您自己的 GitHub 账户下。
  2. -
  3. 克隆您的 Fork: git clone https://github.com/YOUR_USERNAME/YOUR_REPOSITORY_NAME.git
  4. -
  5. 创建新分支: git checkout -b feature/your-feature-namebugfix/issue-number。请为您的分支选择一个描述性的名称。
  6. -
  7. 进行修改:
      -
    • 确保您的代码风格与项目现有代码保持一致 (遵循 PEP8,使用 Black 和 Ruff 进行格式化与检查)。
    • -
    • 为新增的功能或重要的代码段添加清晰的中文文档字符串和注释。
    • -
    • 如果您添加了新功能,请考虑添加相应的单元测试。
    • -
    -
  8. -
  9. 代码格式化与检查: 在提交前,请运行: -
    python -m black .
    -python -m ruff format .
    -python -m ruff check . --fix 
    -
  10. -
  11. 提交您的更改: git commit -m "feat: 添加了 XXX 功能"fix: 修复了 YYY 问题 (#issue_number)。请遵循 Conventional Commits 规范编写提交信息。
  12. -
  13. 推送代码到您的 Fork: git push origin feature/your-feature-name
  14. -
  15. 创建 Pull Request: 返回原始仓库页面,点击 "New pull request" 按钮,选择您的分支与目标分支 (通常是 maindevelop)。
      -
    • 在 Pull Request 描述中,清晰说明您所做的更改、解决的问题等。如果关联到某个 Issue,请使用 Closes #issue_number
    • -
    -
  16. -
-

开发环境设置

-

请参考项目主 README.md 中的“快速开始”部分,使用 install.sh 脚本来设置您的本地开发环境。

-

文档预览与构建 (Previewing and Building Documentation)

-

本项目使用 MkDocs 和 Material for MkDocs 主题来生成文档网站。

-
    -
  • -

    安装依赖: - 确保您已安装项目依赖,特别是 mkdocsmkdocs-material。可以运行 pip install -r requirements.txt 来安装或更新。

    -
  • -
  • -

    本地预览文档: - 在项目根目录下运行以下命令,可以在本地启动一个实时预览服务器,通常访问 http://127.0.0.1:8000 即可查看: -

    python -m mkdocs serve
    -
    - 当您修改 docs/ 目录下的 Markdown 文件或 mkdocs.yml 配置文件时,网页会自动刷新。

    -
  • -
  • -

    构建静态文档: - 要生成静态的 HTML 文档网站(通常用于部署),请在项目根目录下运行: -

    python -m mkdocs build --clean
    -
    - 构建后的文件将默认输出到项目根目录下的 site/ 文件夹中。--clean 参数表示在构建前清除旧的构建文件。

    -
  • -
-

行为准则

-

我们期望所有贡献者都能遵守友好和互相尊重的社区行为准则。

-

感谢您的贡献!

- - - - - - - - - - - - - -
-
- - - -
- - - -
- - - -
-
-
-
- - - - - - - - - - - - \ No newline at end of file diff --git a/site/api/admin/index.html b/site/api/admin/index.html deleted file mode 100644 index aadf2ca..0000000 --- a/site/api/admin/index.html +++ /dev/null @@ -1,1187 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - 管理员API - 在线考试系统 文档 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - - - 跳转至 - - -
-
- -
- - - - -
- - -
- -
- - - - - - - - - -
-
- - - -
-
-
- - - - - - - -
-
-
- - - - - - - -
-
- - - - - -

管理员接口 (Admin API)

-

本文档描述了仅供管理员使用的API端点。所有这些端点都需要有效的管理员Token进行认证(通过请求参数 ?token={ADMIN_ACCESS_TOKEN} 传递),并且通常以 /admin 作为路径前缀。

-

1. 系统配置管理 API (System Configuration Management API)

-

基础路径: /admin/settings

-

这些端点允许管理员查看和修改应用的核心配置。

-

1.1 获取当前系统配置 (GET /settings)

-
    -
  • 摘要: 获取当前系统配置
  • -
  • 描述: 管理员获取当前应用的主要配置项信息。注意:此接口返回的配置主要反映 settings.json 文件的内容,可能不完全包含通过环境变量最终生效的配置值。敏感信息(如数据库密码)不会在此接口返回。
  • -
  • 认证: 需要管理员权限。
  • -
  • 响应:
      -
    • 200 OK: 成功获取配置信息。返回 SettingsResponseModel。 -
      // SettingsResponseModel 示例 (部分字段)
      -{
      -    "app_name": "在线考试系统",
      -    "token_expiry_hours": 24,
      -    "log_level": "INFO",
      -    // ... 其他配置项
      -}
      -
    • -
    • 401 Unauthorized: Token缺失或无效。
    • -
    • 403 Forbidden: 当前用户非管理员或无权访问。
    • -
    • 500 Internal Server Error: 服务器内部错误导致无法获取配置。
    • -
    -
  • -
-

1.2 更新系统配置 (POST /settings)

-
    -
  • 摘要: 更新系统配置
  • -
  • 描述: 管理员更新应用的部分或全部可配置项。请求体中仅需包含需要修改的字段及其新值。更新操作会写入 settings.json 文件并尝试动态重新加载配置到应用内存。注意:通过环境变量设置的配置项具有最高优先级,其在内存中的值不会被此API调用修改,但 settings.json 文件中的对应值会被更新。
  • -
  • 认证: 需要管理员权限。
  • -
  • 请求体 (application/json): SettingsUpdatePayload 模型 -
    // SettingsUpdatePayload 示例
    -{
    -    "app_name": "新版在线考试平台",
    -    "token_expiry_hours": 48
    -}
    -
  • -
  • 响应:
      -
    • 200 OK: 配置成功更新并已重新加载。返回更新后的 SettingsResponseModel
    • -
    • 400 Bad Request: 提供的配置数据无效或不符合约束。
    • -
    • 401 Unauthorized: Token缺失或无效。
    • -
    • 403 Forbidden: 当前用户非管理员。
    • -
    • 422 Unprocessable Entity: 请求体验证失败。
    • -
    • 500 Internal Server Error: 配置文件写入失败或更新时发生未知服务器错误。
    • -
    -
  • -
-
-

2. 用户账户管理 API (User Account Management API)

-

基础路径: /admin/users

-

这些端点允许管理员管理用户账户。

-

2.1 管理员获取用户列表 (GET /users)

-
    -
  • 摘要: 管理员获取用户列表
  • -
  • 描述: 获取系统中的用户账户列表,支持分页查询。返回的用户信息不包含敏感数据(如哈希密码)。
  • -
  • 认证: 需要管理员权限。
  • -
  • 请求参数 (Query Parameters):
      -
    • skip (integer, 可选, 默认: 0): 跳过的记录数,用于分页 (最小值为0)。
    • -
    • limit (integer, 可选, 默认: 100): 返回的最大记录数 (最小值为1,最大值为200)。
    • -
    -
  • -
  • 响应:
      -
    • 200 OK: 成功获取用户列表。返回 List[UserPublicProfile]
    • -
    • 401 Unauthorized: Token缺失或无效。
    • -
    • 403 Forbidden: 当前用户非管理员。
    • -
    • 500 Internal Server Error: 获取用户列表时发生服务器内部错误。
    • -
    -
  • -
-

2.2 管理员获取特定用户信息 (GET /users/{user_uid})

-
    -
  • 摘要: 管理员获取特定用户信息
  • -
  • 描述: 根据用户UID(用户名)获取其公开的详细信息,不包括密码等敏感内容。
  • -
  • 认证: 需要管理员权限。
  • -
  • 路径参数 (Path Parameters):
      -
    • user_uid (string, 必需): 要获取详情的用户的UID。
    • -
    -
  • -
  • 响应:
      -
    • 200 OK: 成功获取用户信息。返回 UserPublicProfile 模型。
    • -
    • 401 Unauthorized: Token缺失或无效。
    • -
    • 403 Forbidden: 当前用户非管理员。
    • -
    • 404 Not Found: 指定UID的用户未找到。
    • -
    -
  • -
-

2.3 管理员更新特定用户信息 (PUT /users/{user_uid})

-
    -
  • 摘要: 管理员更新特定用户信息
  • -
  • 描述: 管理员修改用户的昵称、邮箱、QQ、用户标签,或为其重置密码。请求体中仅需包含需要修改的字段。
  • -
  • 认证: 需要管理员权限。
  • -
  • 路径参数 (Path Parameters):
      -
    • user_uid (string, 必需): 要更新信息的用户的UID。
    • -
    -
  • -
  • 请求体 (application/json): AdminUserUpdate 模型
  • -
  • 响应:
      -
    • 200 OK: 用户信息成功更新。返回更新后的 UserPublicProfile 模型。
    • -
    • 400 Bad Request: 提供的更新数据无效(例如,无效的标签值)。
    • -
    • 401 Unauthorized: Token缺失或无效。
    • -
    • 403 Forbidden: 当前用户非管理员。
    • -
    • 404 Not Found: 指定UID的用户未找到。
    • -
    • 422 Unprocessable Entity: 请求体验证失败。
    • -
    -
  • -
-
-

3. 试卷管理 API (Paper Management API)

-

基础路径: /admin/papers

-

这些端点允许管理员管理用户生成的试卷。

-

3.1 管理员获取所有试卷摘要列表 (GET /papers)

-
    -
  • 摘要: 管理员获取所有试卷摘要列表
  • -
  • 描述: 获取系统生成的所有试卷的摘要信息列表,支持分页。
  • -
  • 认证: 需要管理员权限。
  • -
  • 请求参数 (Query Parameters):
      -
    • skip (integer, 可选, 默认: 0): 跳过的记录数。
    • -
    • limit (integer, 可选, 默认: 100): 返回的最大记录数。
    • -
    -
  • -
  • 响应:
      -
    • 200 OK: 成功获取试卷摘要列表。返回 List[PaperAdminView]
    • -
    • 401 Unauthorized: Token缺失或无效。
    • -
    • 403 Forbidden: 当前用户非管理员。
    • -
    • 500 Internal Server Error: 获取试卷列表时发生服务器内部错误。
    • -
    -
  • -
-

3.2 管理员获取特定试卷的完整信息 (GET /papers/{paper_id})

-
    -
  • 摘要: 管理员获取特定试卷的完整信息
  • -
  • 描述: 根据试卷ID获取其完整详细信息。
  • -
  • 认证: 需要管理员权限。
  • -
  • 路径参数 (Path Parameters):
      -
    • paper_id (string, 必需, UUID格式): 要获取详情的试卷ID。
    • -
    -
  • -
  • 响应:
      -
    • 200 OK: 成功获取试卷详细信息。返回 PaperFullDetailModel
    • -
    • 401 Unauthorized: Token缺失或无效。
    • -
    • 403 Forbidden: 当前用户非管理员。
    • -
    • 404 Not Found: 指定ID的试卷未找到。
    • -
    • 500 Internal Server Error: 获取试卷详情时发生服务器内部错误。
    • -
    -
  • -
-

3.3 管理员删除特定试卷 (DELETE /papers/{paper_id})

-
    -
  • 摘要: 管理员删除特定试卷
  • -
  • 描述: 根据试卷ID永久删除一份试卷。此操作需谨慎。
  • -
  • 认证: 需要管理员权限。
  • -
  • 路径参数 (Path Parameters):
      -
    • paper_id (string, 必需, UUID格式): 要删除的试卷ID。
    • -
    -
  • -
  • 响应:
      -
    • 204 No Content: 试卷成功删除。
    • -
    • 401 Unauthorized: Token缺失或无效。
    • -
    • 403 Forbidden: 当前用户非管理员。
    • -
    • 404 Not Found: 指定ID的试卷未找到。
    • -
    • 500 Internal Server Error: 删除试卷时发生服务器内部错误。
    • -
    -
  • -
-
-

4. 题库管理 API (Question Bank Management API)

-

基础路径: /admin/question-banks

-

这些端点允许管理员管理题库的元数据和题目内容。

-

4.1 管理员获取所有题库的元数据列表 (GET /question-banks)

-
    -
  • 摘要: 管理员获取所有题库的元数据列表
  • -
  • 描述: 获取系统中所有题库的元数据信息列表。
  • -
  • 认证: 需要管理员权限。
  • -
  • 响应:
      -
    • 200 OK: 成功获取题库元数据列表。返回 List[LibraryIndexItem]
    • -
    • 401 Unauthorized: Token缺失或无效。
    • -
    • 403 Forbidden: 当前用户非管理员。
    • -
    • 500 Internal Server Error: 获取题库元数据时发生服务器内部错误。
    • -
    -
  • -
-

4.2 管理员获取特定难度题库的完整内容 (GET /question-banks/{difficulty_id}/content)

-
    -
  • 摘要: 管理员获取特定难度题库的完整内容
  • -
  • 描述: 根据难度ID获取指定题库的元数据及其包含的所有题目详情。
  • -
  • 认证: 需要管理员权限。
  • -
  • 路径参数 (Path Parameters):
      -
    • difficulty_id (string, 必需): 要获取内容的题库难度ID (例如: "easy", "hybrid")。
    • -
    -
  • -
  • 响应:
      -
    • 200 OK: 成功获取题库内容。返回 QuestionBank 模型。
    • -
    • 401 Unauthorized: Token缺失或无效。
    • -
    • 403 Forbidden: 当前用户非管理员。
    • -
    • 404 Not Found: 指定难度的题库未找到。
    • -
    • 500 Internal Server Error: 获取题库内容时发生服务器内部错误。
    • -
    -
  • -
-

4.3 管理员向特定题库添加新题目 (POST /question-banks/{difficulty_id}/questions)

-
    -
  • 摘要: 管理员向特定题库添加新题目
  • -
  • 描述: 向指定难度的题库中添加一道新的题目。
  • -
  • 认证: 需要管理员权限。
  • -
  • 路径参数 (Path Parameters):
      -
    • difficulty_id (string, 必需): 要添加题目的题库难度ID。
    • -
    -
  • -
  • 请求体 (application/json): QuestionModel 模型
  • -
  • 响应:
      -
    • 201 Created: 题目成功添加到题库。返回已添加的 QuestionModel
    • -
    • 400 Bad Request: 提供的题目数据无效。
    • -
    • 401 Unauthorized: Token缺失或无效。
    • -
    • 403 Forbidden: 当前用户非管理员。
    • -
    • 404 Not Found: 指定难度的题库未找到。
    • -
    • 422 Unprocessable Entity: 请求体验证失败。
    • -
    • 500 Internal Server Error: 添加题目到题库时发生服务器内部错误。
    • -
    -
  • -
-

4.4 管理员从特定题库删除题目 (DELETE /question-banks/{difficulty_id}/questions)

-
    -
  • 摘要: 管理员从特定题库删除题目
  • -
  • 描述: 根据题目在题库列表中的索引,从指定难度的题库中删除一道题目。
  • -
  • 认证: 需要管理员权限。
  • -
  • 路径参数 (Path Parameters):
      -
    • difficulty_id (string, 必需): 要删除题目的题库难度ID。
    • -
    -
  • -
  • 请求参数 (Query Parameters):
      -
    • index (integer, 必需, ge=0): 要删除的题目在列表中的索引 (从0开始)。
    • -
    -
  • -
  • 响应:
      -
    • 204 No Content: 题目成功删除。
    • -
    • 400 Bad Request: 提供的索引无效。
    • -
    • 401 Unauthorized: Token缺失或无效。
    • -
    • 403 Forbidden: 当前用户非管理员。
    • -
    • 404 Not Found: 指定难度的题库或指定索引的题目未找到。
    • -
    • 500 Internal Server Error: 删除题目时发生服务器内部错误。
    • -
    -
  • -
-
- - - - - - - - - - - - - -
-
- - - -
- - - -
- - - -
-
-
-
- - - - - - - - - - - - \ No newline at end of file diff --git a/site/api/index.html b/site/api/index.html deleted file mode 100644 index 2df5f69..0000000 --- a/site/api/index.html +++ /dev/null @@ -1,756 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - 总览 - 在线考试系统 文档 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - - - 跳转至 - - -
-
- -
- - - - -
- - -
- -
- - - - - - - - - -
-
- - - -
-
-
- - - - - - - -
-
-
- - - - - - - -
-
- - - - - -

API 文档 - 在线考试系统

-

版本: 3.0.0

-

简介

-

本文档详细描述了在线考试系统的 API 接口。系统功能包括用户认证、试卷获取、答题、进度保存、历史记录查看以及管理员后台管理等。

-

本 API 文档分为以下几个主要部分:

- -

认证机制

-

大部分核心 API 端点需要通过 Token 进行认证。Token 在用户登录或注册成功后返回。

-
    -
  • 用户 Token: 通过在请求的 Query 参数中附加 token={USER_ACCESS_TOKEN} 来传递。
  • -
  • 管理员 Token: 管理员接口同样通过 Query 参数中的 token 进行认证,并且服务器端会校验该 Token 对应的用户是否拥有 admin 标签。
  • -
-

当前系统使用的是自定义的简单Token机制,并非基于JWT。

-

数据模型速查

-

为方便理解,以下列出一些关键的 Pydantic 模型(详细字段请参考各 API 说明):

-
    -
  • 用户认证与管理相关:
      -
    • UserCreationPayload: 用户注册请求体。
    • -
    • UserCredentials: 用户登录请求体 (FastAPI的OAuth2PasswordRequestForm通常在后台使用,但概念上用户提供用户名密码)。
    • -
    • TokenResponse: 认证成功后的 Token 响应。
    • -
    • UserPublicProfile: 用户公开信息响应。
    • -
    • UserProfileUpdatePayload: 用户更新个人资料请求。
    • -
    • UserPasswordUpdatePayload: 用户更新密码请求。
    • -
    -
  • -
  • 考试与答题相关:
      -
    • PaperDetailModel: (通常作为获取新试卷或历史试卷详情的)试卷详情响应。
    • -
    • PaperSubmissionPayload: 提交/更新试卷答案的请求。
    • -
    • ProgressUpdateResponse: 更新试卷进度的响应。
    • -
    • GradingResultResponse: 试卷批改结果的响应。
    • -
    • UserPaperHistoryItem: 用户历史记录条目。
    • -
    -
  • -
  • 题库与配置相关 (管理端):
      -
    • LibraryIndexItem: 题库元数据。
    • -
    • QuestionModel: 题库题目模型。
    • -
    • SettingsResponseModel: 管理员获取配置响应。
    • -
    • SettingsUpdatePayload: 管理员更新配置请求。
    • -
    • PaperAdminView: 管理员查看试卷摘要。
    • -
    • PaperFullDetailAdminView: 管理员查看试卷完整详情。
    • -
    -
  • -
  • 通用响应与枚举:
      -
    • MessageResponse: 通用的消息响应体 (例如,操作成功但无特定数据返回)。
    • -
    • ErrorResponse: 通用的错误响应体 (通常由 HTTPException 自动处理)。
    • -
    • QuestionTypeEnum, PaperPassStatusEnum, AuthStatusCodeEnum: 常用枚举类型,定义在 app/models/enums.py
    • -
    -
  • -
-
-

错误处理与HTTP状态码

-

本API力求遵循标准的HTTP状态码来指示请求的结果。

-
    -
  • 200 OK: 请求成功执行,响应体中通常包含所请求的数据。
  • -
  • 201 Created: 资源成功创建(例如,用户注册成功后),响应体中可能包含新创建的资源或相关信息(如Token)。
  • -
  • 204 No Content: 请求成功执行,但响应体中无内容返回(例如,用户成功修改密码后,或部分删除操作成功后)。
  • -
  • 400 Bad Request: 客户端请求无效。这可能因为参数错误、业务逻辑不满足(如请求的题目数量不足以出题)、或提交的数据格式不正确但不符合特定验证错误类型。响应体的 detail 字段通常包含具体的错误描述。
  • -
  • 401 Unauthorized: 未认证或认证失败。通常由于Token无效、过期、缺失,或凭证不正确。响应头可能包含 WWW-Authenticate
  • -
  • 403 Forbidden: 用户已认证,但无权访问所请求的资源。例如,用户账户被封禁,或普通用户尝试访问管理员专属接口。
  • -
  • 404 Not Found: 请求的资源不存在。例如,查询一个不存在的试卷ID或用户UID。
  • -
  • 409 Conflict: 请求与服务器当前状态冲突,无法完成。例如,尝试创建已存在的用户 (UID冲突)。
  • -
  • 422 Unprocessable Entity: 请求体数据虽然格式正确(例如是合法的JSON),但无法通过Pydantic模型的验证规则(如类型错误、必填字段缺失、值不符合约束等)。响应体通常包含详细的字段级验证错误信息。
  • -
  • 429 Too Many Requests: 客户端在给定时间内发送的请求过多,已超出速率限制。
  • -
  • 500 Internal Server Error: 服务器内部发生未预期的错误,导致无法完成请求。
  • -
-

关于响应体中的业务状态字段: -在部分成功响应(如 200 OK)的场景下,响应体内的特定字段(例如 GradingResultResponse 中的 status_code 字段,其值为 PaperPassStatusEnum 枚举的成员如 "PASSED" 或 "FAILED")会提供更细致的业务处理结果。这些字段用于区分业务逻辑上的不同成功状态,而非HTTP层面的错误。对于API错误,应优先参考HTTP状态码和 HTTPException 返回的 detail 信息。

-
-

附录: 枚举类型

-

DifficultyLevel (难度级别)

-

系统通过读取 data/library/index.json 文件动态生成此枚举。通常可能包含:

-
    -
  • easy
  • -
  • hybrid
  • -
  • hard
  • -
  • ... (其他在 index.json 中定义的 id)
  • -
-

UserTag (用户标签)

-

定义在 app/models/user_models.py 中的 UserTag 枚举,主要包括:

-
    -
  • admin: 管理员
  • -
  • user: 普通用户
  • -
  • banned: 禁用用户
  • -
  • limited: 受限用户
  • -
  • grader: 批阅者 (规划中)
  • -
  • examiner: 出题者/题库管理员 (规划中)
  • -
  • manager: 运营管理员 (规划中)
  • -
-

QuestionTypeEnum (题目类型)

-

定义在 app/models/enums.py,目前主要使用: -- single_choice: 单选题

-

PaperPassStatusEnum (试卷通过状态)

-

定义在 app/models/enums.py,例如: -- PASSED: 已通过 -- FAILED: 未通过 -- PENDING: 待处理/待批改

-
-

[end of docs/api/index.md]

- - - - - - - - - - - - - -
-
- - - -
- - - -
- - - -
-
-
-
- - - - - - - - - - - - \ No newline at end of file diff --git a/site/api/user_exam/index.html b/site/api/user_exam/index.html deleted file mode 100644 index 9e6c358..0000000 --- a/site/api/user_exam/index.html +++ /dev/null @@ -1,1247 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - 用户、认证、核心考试及公共API - 在线考试系统 文档 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - - - 跳转至 - - -
-
- -
- - - - -
- - -
- -
- - - - - - - - - -
-
- - - -
-
-
- - - - - - - -
-
-
- - - - - - - -
-
- - - - - -

用户与考试API

- -

本文档详细描述了与普通用户操作相关的API端点,包括用户认证、个人资料管理、核心的考试答题流程以及一些公开查询的接口。所有路径相对于应用根路径。

-

1. 用户认证 API (User Authentication API)

-

基础路径: /auth

-

1.1 用户注册 (POST /signin)

-
    -
  • 摘要: 用户注册
  • -
  • 描述: 新用户通过提供用户名、密码等信息进行注册。成功后返回访问令牌。
  • -
  • 认证: 无需。
  • -
  • 速率限制: 应用标准认证尝试速率限制。
  • -
  • 请求体 (application/json): UserCreationPayload 模型 -
    // UserCreationPayload 模型示例
    -{
    -    "uid": "yonghu001",
    -    "password": "Mima123!@#",
    -    "nickname": "入门新手",
    -    "email": "user@example.com",
    -    "qq": "10000"
    -}
    -
  • -
  • 响应:
      -
    • 201 Created: 注册成功。返回 TokenResponse 模型。 -
      // TokenResponse 模型示例
      -{
      -    "access_token": "eyJhbGciOiJIUzI1NiIs...", 
      -    "token_type": "bearer"
      -}
      -
    • -
    • 409 Conflict: 用户名已存在。响应体: {"detail": "用户名 'yonghu001' 已被注册。"}
    • -
    • 422 Unprocessable Entity: 请求数据验证失败 (例如,uid 或 password 不符合要求)。
    • -
    • 429 Too Many Requests: 请求过于频繁。响应体: {"detail": "注册请求过于频繁,请稍后再试。"}
    • -
    -
  • -
-

1.2 用户登录 (POST /login)

-
    -
  • 摘要: 用户登录
  • -
  • 描述: 用户通过提供用户名和密码进行登录。服务器期望的是标准的 application/x-www-form-urlencoded 表单数据(由FastAPI的 OAuth2PasswordRequestForm 处理),而非JSON。成功后返回访问令牌。
  • -
  • 认证: 无需。
  • -
  • 速率限制: 应用标准认证尝试速率限制。
  • -
  • 请求体 (application/x-www-form-urlencoded):
      -
    • username: (string, 必需) 用户名 (对应 uid)
    • -
    • password: (string, 必需) 密码
    • -
    -
  • -
  • 响应:
      -
    • 200 OK: 登录成功。返回 TokenResponse 模型。
    • -
    • 401 Unauthorized: 用户名或密码错误。响应体: {"detail": "用户名或密码不正确。"}
    • -
    • 422 Unprocessable Entity: 请求数据验证失败 (例如,表单字段缺失)。
    • -
    • 429 Too Many Requests: 请求过于频繁。响应体: {"detail": "登录请求过于频繁,请稍后再试。"}
    • -
    -
  • -
-

1.3 刷新访问令牌 (GET /login)

-
    -
  • 摘要: 刷新访问令牌
  • -
  • 描述: 使用一个有效的旧访问令牌(通过查询参数 token 提供)获取一个新的访问令牌。成功后,旧令牌将失效。
  • -
  • 认证: 无需(但旧Token本身需有效)。
  • -
  • 请求参数 (Query Parameters):
      -
    • token (string, 必需): 待刷新的有效旧访问令牌。
    • -
    -
  • -
  • 响应:
      -
    • 200 OK: 令牌刷新成功。返回 TokenResponse 模型。
    • -
    • 401 Unauthorized: 提供的旧令牌无效或已过期。响应体: {"detail": "提供的令牌无效或已过期,无法刷新。"}
    • -
    -
  • -
-
-

2. 用户个人信息管理 API (User Profile Management API)

-

基础路径: /users/me -认证: 所有此部分接口都需要用户Token认证 (?token={USER_ACCESS_TOKEN} 作为查询参数)

-

2.1 获取当前用户信息 (GET /)

-
    -
  • 摘要: 获取当前用户信息
  • -
  • 描述: 获取当前认证用户的公开个人资料,包括UID、昵称、邮箱、QQ以及用户标签等信息。
  • -
  • 响应:
      -
    • 200 OK: 成功获取用户信息。返回 UserPublicProfile 模型。
    • -
    • 401 Unauthorized: 令牌无效或已过期。
    • -
    • 403 Forbidden: 用户账户已被封禁。
    • -
    • 404 Not Found: 用户未找到(理论上在Token有效时此错误不应发生)。
    • -
    -
  • -
-

2.2 更新当前用户个人资料 (PUT /)

-
    -
  • 摘要: 更新当前用户个人资料
  • -
  • 描述: 允许当前认证用户更新其个人资料,如昵称、邮箱或QQ号码。请求体中应包含待更新的字段及其新值。
  • -
  • 请求体 (application/json): UserProfileUpdatePayload 模型 (所有字段可选)
  • -
  • 响应:
      -
    • 200 OK: 更新成功。返回更新后的 UserPublicProfile 模型。
    • -
    • 401 Unauthorized: 令牌无效或已过期。
    • -
    • 403 Forbidden: 用户账户已被封禁。
    • -
    • 404 Not Found: 用户未找到。
    • -
    • 422 Unprocessable Entity: 请求体验证失败。
    • -
    -
  • -
-

2.3 修改当前用户密码 (PUT /password)

-
    -
  • 摘要: 修改当前用户密码
  • -
  • 描述: 允许当前认证用户修改自己的密码。请求体中必须提供当前密码和新密码。
  • -
  • 请求体 (application/json): UserPasswordUpdatePayload 模型
  • -
  • 响应:
      -
    • 204 No Content: 密码修改成功。无响应体。
    • -
    • 400 Bad Request: 当前密码不正确。响应体: {"detail": "当前密码不正确。"}
    • -
    • 401 Unauthorized: 令牌无效或已过期。
    • -
    • 403 Forbidden: 用户账户已被封禁。
    • -
    • 404 Not Found: 用户未找到。
    • -
    • 422 Unprocessable Entity: 新密码不符合要求。
    • -
    • 500 Internal Server Error: 更新密码时发生未知错误。
    • -
    -
  • -
-
-

3. 核心答题接口 (Core Exam Taking API)

-

认证: 所有此部分接口都需要用户Token认证 (?token={USER_ACCESS_TOKEN} 作为查询参数)

-

3.1 请求新试卷 (GET /get_exam)

-
    -
  • 摘要: 请求新试卷
  • -
  • 描述: 为当前认证用户创建一份指定难度(可选题目数量)的新试卷。返回试卷的详细信息,包括题目列表。题目内容会经过处理,对用户隐藏正确答案和答案解析。非管理员用户受速率限制。
  • -
  • 请求参数 (Query Parameters):
      -
    • token (string, 必需): 用户访问令牌。
    • -
    • difficulty (string, 可选, 默认: "hybrid"): 新试卷的难度级别 (来自 DifficultyLevel 枚举)。
    • -
    • num_questions (integer, 可选, 1-200): 请求的题目数量。
    • -
    -
  • -
  • 响应:
      -
    • 200 OK: 成功获取新试卷。返回 PaperDetailModel 模型,其中 questions 列表中的题目为 ExamQuestionClientView 类型。 -
      // PaperDetailModel (部分) 结合 ExamQuestionClientView 示例
      -{
      -    "paper_id": "uuid-string-paper-id",
      -    "user_uid": "yonghu001",
      -    "difficulty": "hybrid",
      -    "num_questions": 2, // 实际生成的题目数
      -    "questions": [
      -        { 
      -            "question_id": "uuid-string-q1", // 题目在试卷中的唯一ID
      -            "body": "题目1的题干内容...", 
      -            "choices": [ // 所有选项合并打乱后呈现给用户
      -                "选项A文本", 
      -                "选项B文本", 
      -                "选项C文本",
      -                "选项D文本"
      -            ], 
      -            "question_type": "single_choice" // 题目类型
      -        },
      -        { 
      -            "question_id": "uuid-string-q2",
      -            "body": "题目2的题干内容...", 
      -            "choices": [
      -                "选项X文本", 
      -                "选项Y文本", 
      -                "选项Z文本"
      -            ], 
      -            "question_type": "single_choice"
      -        }
      -    ],
      -    "created_at": "2024-01-01T10:00:00Z",
      -    "pass_status": "PENDING" // 初始为待处理
      -    // ... 其他试卷元数据
      -}
      -
    • -
    • 400 Bad Request: 请求参数无效或业务逻辑错误(如题库题目不足)。
    • -
    • 401 Unauthorized: 令牌无效或已过期。
    • -
    • 403 Forbidden: 用户账户已被封禁。
    • -
    • 429 Too Many Requests: 获取新试卷请求过于频繁。
    • -
    • 500 Internal Server Error: 创建新试卷时发生意外服务器错误。
    • -
    -
  • -
-

3.2 更新答题进度 (POST /update)

-
    -
  • 摘要: 更新答题进度
  • -
  • 描述: 用户提交一部分答案以保存当前答题进度。此接口不进行批改,仅保存用户答案。
  • -
  • 请求参数 (Query Parameters):
      -
    • token (string, 必需): 用户访问令牌。
    • -
    -
  • -
  • 请求体 (application/json): PaperSubmissionPayload 模型
  • -
  • 响应:
      -
    • 200 OK: 进度已成功保存。返回 ProgressUpdateResponse 模型。 -
      // ProgressUpdateResponse 示例
      -{
      -    "status_code": "PROGRESS_SAVED", 
      -    "message": "试卷进度已成功保存。", 
      -    "paper_id": "uuid-string-paper-id",
      -    "last_update_time_utc": "2024-01-01T12:00:00Z"
      -}
      -
    • -
    • 400 Bad Request: 请求数据无效(如答案数量错误)。
    • -
    • 401 Unauthorized: 令牌无效或已过期。
    • -
    • 403 Forbidden: 试卷已完成,无法更新进度。
    • -
    • 404 Not Found: 试卷未找到或用户无权访问。
    • -
    • 500 Internal Server Error: 更新进度时发生意外服务器错误。
    • -
    -
  • -
-

3.3 提交试卷答案以供批改 (POST /finish)

-
    -
  • 摘要: 提交试卷答案以供批改
  • -
  • 描述: 用户提交已完成作答的试卷。系统将对答案进行批改,并返回详细的批改结果,包括得分、通过状态以及可能的通行码(如果通过考试)。此操作会记录提交时间及用户IP。 - (注意:此端点的部分错误处理逻辑的后台实现仍在优化中,当前文档反映的是其OpenAPI装饰器中定义的理想行为。实际调用时,部分业务错误细节可能仍通过响应体内的字段传递。)
  • -
  • 请求参数 (Query Parameters):
      -
    • token (string, 必需): 用户访问令牌。
    • -
    -
  • -
  • 请求体 (application/json): PaperSubmissionPayload 模型
  • -
  • 响应:
      -
    • 200 OK: 试卷已成功接收并完成批改。返回 GradingResultResponse 模型。 -
      // GradingResultResponse 示例
      -{
      -    "status_code": "PASSED", // PaperPassStatusEnum 值: "PASSED" 或 "FAILED"
      -    "passcode": "PASSCODE_EXAMPLE", // (如果通过)
      -    "score": 90,
      -    "score_percentage": 90.0
      -}
      -
    • -
    • 400 Bad Request: 无效的提交数据(例如,提交的答案数量与试卷题目总数不匹配)。
    • -
    • 401 Unauthorized: 用户未认证(Token无效或缺失)。
    • -
    • 403 Forbidden: 用户无权进行此操作(非预期,但为完备性保留)。
    • -
    • 404 Not Found: 要提交的试卷ID不存在,或不属于当前用户。
    • -
    • 409 Conflict: 操作冲突(例如,该试卷已被最终批改且系统配置为不允许重复提交)。
    • -
    • 422 Unprocessable Entity: 请求体数据校验失败。
    • -
    • 500 Internal Server Error: 服务器内部错误(例如,因试卷数据结构问题导致无法批改,或在批改过程中发生其他意外)。
    • -
    -
  • -
-

3.4 获取用户答题历史 (GET /history)

-
    -
  • 摘要: 获取用户答题历史
  • -
  • 描述: 获取当前认证用户的简要答题历史记录列表,包含每次答题的试卷ID、难度、得分等信息。列表按提交时间倒序排列。
  • -
  • 请求参数 (Query Parameters):
      -
    • token (string, 必需): 用户访问令牌。
    • -
    -
  • -
  • 响应:
      -
    • 200 OK: 成功获取答题历史。返回 List[UserPaperHistoryItem]
    • -
    • 401 Unauthorized: 令牌无效或已过期。
    • -
    -
  • -
-

3.5 获取指定历史试卷详情 (GET /history_paper)

-
    -
  • 摘要: 获取指定历史试卷详情
  • -
  • 描述: 用户获取自己答题历史中某一份特定试卷的详细题目、作答情况和批改结果(如果已批改)。
  • -
  • 请求参数 (Query Parameters):
      -
    • token (string, 必需): 用户访问令牌。
    • -
    • paper_id (string, 必需, UUID格式): 要获取详情的历史试卷ID。
    • -
    -
  • -
  • 响应:
      -
    • 200 OK: 成功获取历史试卷详情。返回 PaperDetailModel (或类似的包含完整题目、用户答案和正确答案的详细模型)。
    • -
    • 401 Unauthorized: 令牌无效或已过期。
    • -
    • 404 Not Found: 指定的历史试卷未找到或用户无权查看。响应体: {"detail": "指定的历史试卷未找到或您无权查看。"}
    • -
    -
  • -
-
-

4. 公共接口 (Public APIs)

-

此部分包含所有公开访问的API端点,无需用户认证。

-

4.1 获取可用题库难度列表 (GET /difficulties)

-
    -
  • 摘要: 获取可用题库难度列表
  • -
  • 描述: 公开接口,返回系统中所有已定义的题库难度级别及其元数据(如名称、描述、默认题量等)。
  • -
  • 响应:
      -
    • 200 OK: 成功获取题库难度列表。返回 List[LibraryIndexItem]
    • -
    • 500 Internal Server Error: 获取题库元数据时发生服务器内部错误。
    • -
    -
  • -
-

4.2 获取公开用户目录 (GET /users/directory)

-
    -
  • 摘要: 获取公开用户目录
  • -
  • 描述: 公开接口,返回系统中拥有特定公开角色标签(例如:管理员、出题人等)的用户子集。
  • -
  • 响应:
      -
    • 200 OK: 成功获取用户目录列表。返回 List[UserDirectoryEntry]
    • -
    • 500 Internal Server Error: 获取用户目录时发生服务器内部错误。
    • -
    -
  • -
-
-

[end of docs/api/user_exam.md]

- - - - - - - - - - - - - - - - -
-
- - - -
- - - -
- - - -
-
-
-
- - - - - - - - - - - - \ No newline at end of file diff --git a/site/assets/images/favicon.png b/site/assets/images/favicon.png deleted file mode 100644 index 1cf13b9..0000000 Binary files a/site/assets/images/favicon.png and /dev/null differ diff --git a/site/assets/javascripts/bundle.13a4f30d.min.js b/site/assets/javascripts/bundle.13a4f30d.min.js deleted file mode 100644 index e5a5437..0000000 --- a/site/assets/javascripts/bundle.13a4f30d.min.js +++ /dev/null @@ -1,15 +0,0 @@ -"use strict";(()=>{var Wi=Object.create;var gr=Object.defineProperty;var Vi=Object.getOwnPropertyDescriptor;var Di=Object.getOwnPropertyNames,Vt=Object.getOwnPropertySymbols,zi=Object.getPrototypeOf,yr=Object.prototype.hasOwnProperty,ao=Object.prototype.propertyIsEnumerable;var io=(e,t,r)=>t in e?gr(e,t,{enumerable:!0,configurable:!0,writable:!0,value:r}):e[t]=r,$=(e,t)=>{for(var r in t||(t={}))yr.call(t,r)&&io(e,r,t[r]);if(Vt)for(var r of Vt(t))ao.call(t,r)&&io(e,r,t[r]);return e};var so=(e,t)=>{var r={};for(var o in e)yr.call(e,o)&&t.indexOf(o)<0&&(r[o]=e[o]);if(e!=null&&Vt)for(var o of Vt(e))t.indexOf(o)<0&&ao.call(e,o)&&(r[o]=e[o]);return r};var xr=(e,t)=>()=>(t||e((t={exports:{}}).exports,t),t.exports);var Ni=(e,t,r,o)=>{if(t&&typeof t=="object"||typeof t=="function")for(let n of Di(t))!yr.call(e,n)&&n!==r&&gr(e,n,{get:()=>t[n],enumerable:!(o=Vi(t,n))||o.enumerable});return e};var Lt=(e,t,r)=>(r=e!=null?Wi(zi(e)):{},Ni(t||!e||!e.__esModule?gr(r,"default",{value:e,enumerable:!0}):r,e));var co=(e,t,r)=>new Promise((o,n)=>{var i=p=>{try{s(r.next(p))}catch(c){n(c)}},a=p=>{try{s(r.throw(p))}catch(c){n(c)}},s=p=>p.done?o(p.value):Promise.resolve(p.value).then(i,a);s((r=r.apply(e,t)).next())});var lo=xr((Er,po)=>{(function(e,t){typeof Er=="object"&&typeof po!="undefined"?t():typeof define=="function"&&define.amd?define(t):t()})(Er,function(){"use strict";function e(r){var o=!0,n=!1,i=null,a={text:!0,search:!0,url:!0,tel:!0,email:!0,password:!0,number:!0,date:!0,month:!0,week:!0,time:!0,datetime:!0,"datetime-local":!0};function s(k){return!!(k&&k!==document&&k.nodeName!=="HTML"&&k.nodeName!=="BODY"&&"classList"in k&&"contains"in k.classList)}function p(k){var ft=k.type,qe=k.tagName;return!!(qe==="INPUT"&&a[ft]&&!k.readOnly||qe==="TEXTAREA"&&!k.readOnly||k.isContentEditable)}function c(k){k.classList.contains("focus-visible")||(k.classList.add("focus-visible"),k.setAttribute("data-focus-visible-added",""))}function l(k){k.hasAttribute("data-focus-visible-added")&&(k.classList.remove("focus-visible"),k.removeAttribute("data-focus-visible-added"))}function f(k){k.metaKey||k.altKey||k.ctrlKey||(s(r.activeElement)&&c(r.activeElement),o=!0)}function u(k){o=!1}function d(k){s(k.target)&&(o||p(k.target))&&c(k.target)}function y(k){s(k.target)&&(k.target.classList.contains("focus-visible")||k.target.hasAttribute("data-focus-visible-added"))&&(n=!0,window.clearTimeout(i),i=window.setTimeout(function(){n=!1},100),l(k.target))}function L(k){document.visibilityState==="hidden"&&(n&&(o=!0),X())}function X(){document.addEventListener("mousemove",J),document.addEventListener("mousedown",J),document.addEventListener("mouseup",J),document.addEventListener("pointermove",J),document.addEventListener("pointerdown",J),document.addEventListener("pointerup",J),document.addEventListener("touchmove",J),document.addEventListener("touchstart",J),document.addEventListener("touchend",J)}function ee(){document.removeEventListener("mousemove",J),document.removeEventListener("mousedown",J),document.removeEventListener("mouseup",J),document.removeEventListener("pointermove",J),document.removeEventListener("pointerdown",J),document.removeEventListener("pointerup",J),document.removeEventListener("touchmove",J),document.removeEventListener("touchstart",J),document.removeEventListener("touchend",J)}function J(k){k.target.nodeName&&k.target.nodeName.toLowerCase()==="html"||(o=!1,ee())}document.addEventListener("keydown",f,!0),document.addEventListener("mousedown",u,!0),document.addEventListener("pointerdown",u,!0),document.addEventListener("touchstart",u,!0),document.addEventListener("visibilitychange",L,!0),X(),r.addEventListener("focus",d,!0),r.addEventListener("blur",y,!0),r.nodeType===Node.DOCUMENT_FRAGMENT_NODE&&r.host?r.host.setAttribute("data-js-focus-visible",""):r.nodeType===Node.DOCUMENT_NODE&&(document.documentElement.classList.add("js-focus-visible"),document.documentElement.setAttribute("data-js-focus-visible",""))}if(typeof window!="undefined"&&typeof document!="undefined"){window.applyFocusVisiblePolyfill=e;var t;try{t=new CustomEvent("focus-visible-polyfill-ready")}catch(r){t=document.createEvent("CustomEvent"),t.initCustomEvent("focus-visible-polyfill-ready",!1,!1,{})}window.dispatchEvent(t)}typeof document!="undefined"&&e(document)})});var qr=xr((dy,On)=>{"use strict";/*! - * escape-html - * Copyright(c) 2012-2013 TJ Holowaychuk - * Copyright(c) 2015 Andreas Lubbe - * Copyright(c) 2015 Tiancheng "Timothy" Gu - * MIT Licensed - */var $a=/["'&<>]/;On.exports=Pa;function Pa(e){var t=""+e,r=$a.exec(t);if(!r)return t;var o,n="",i=0,a=0;for(i=r.index;i{/*! - * clipboard.js v2.0.11 - * https://clipboardjs.com/ - * - * Licensed MIT © Zeno Rocha - */(function(t,r){typeof Rt=="object"&&typeof Yr=="object"?Yr.exports=r():typeof define=="function"&&define.amd?define([],r):typeof Rt=="object"?Rt.ClipboardJS=r():t.ClipboardJS=r()})(Rt,function(){return function(){var e={686:function(o,n,i){"use strict";i.d(n,{default:function(){return Ui}});var a=i(279),s=i.n(a),p=i(370),c=i.n(p),l=i(817),f=i.n(l);function u(D){try{return document.execCommand(D)}catch(A){return!1}}var d=function(A){var M=f()(A);return u("cut"),M},y=d;function L(D){var A=document.documentElement.getAttribute("dir")==="rtl",M=document.createElement("textarea");M.style.fontSize="12pt",M.style.border="0",M.style.padding="0",M.style.margin="0",M.style.position="absolute",M.style[A?"right":"left"]="-9999px";var F=window.pageYOffset||document.documentElement.scrollTop;return M.style.top="".concat(F,"px"),M.setAttribute("readonly",""),M.value=D,M}var X=function(A,M){var F=L(A);M.container.appendChild(F);var V=f()(F);return u("copy"),F.remove(),V},ee=function(A){var M=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{container:document.body},F="";return typeof A=="string"?F=X(A,M):A instanceof HTMLInputElement&&!["text","search","url","tel","password"].includes(A==null?void 0:A.type)?F=X(A.value,M):(F=f()(A),u("copy")),F},J=ee;function k(D){"@babel/helpers - typeof";return typeof Symbol=="function"&&typeof Symbol.iterator=="symbol"?k=function(M){return typeof M}:k=function(M){return M&&typeof Symbol=="function"&&M.constructor===Symbol&&M!==Symbol.prototype?"symbol":typeof M},k(D)}var ft=function(){var A=arguments.length>0&&arguments[0]!==void 0?arguments[0]:{},M=A.action,F=M===void 0?"copy":M,V=A.container,Y=A.target,$e=A.text;if(F!=="copy"&&F!=="cut")throw new Error('Invalid "action" value, use either "copy" or "cut"');if(Y!==void 0)if(Y&&k(Y)==="object"&&Y.nodeType===1){if(F==="copy"&&Y.hasAttribute("disabled"))throw new Error('Invalid "target" attribute. Please use "readonly" instead of "disabled" attribute');if(F==="cut"&&(Y.hasAttribute("readonly")||Y.hasAttribute("disabled")))throw new Error(`Invalid "target" attribute. You can't cut text from elements with "readonly" or "disabled" attributes`)}else throw new Error('Invalid "target" value, use a valid Element');if($e)return J($e,{container:V});if(Y)return F==="cut"?y(Y):J(Y,{container:V})},qe=ft;function Fe(D){"@babel/helpers - typeof";return typeof Symbol=="function"&&typeof Symbol.iterator=="symbol"?Fe=function(M){return typeof M}:Fe=function(M){return M&&typeof Symbol=="function"&&M.constructor===Symbol&&M!==Symbol.prototype?"symbol":typeof M},Fe(D)}function ki(D,A){if(!(D instanceof A))throw new TypeError("Cannot call a class as a function")}function no(D,A){for(var M=0;M0&&arguments[0]!==void 0?arguments[0]:{};this.action=typeof V.action=="function"?V.action:this.defaultAction,this.target=typeof V.target=="function"?V.target:this.defaultTarget,this.text=typeof V.text=="function"?V.text:this.defaultText,this.container=Fe(V.container)==="object"?V.container:document.body}},{key:"listenClick",value:function(V){var Y=this;this.listener=c()(V,"click",function($e){return Y.onClick($e)})}},{key:"onClick",value:function(V){var Y=V.delegateTarget||V.currentTarget,$e=this.action(Y)||"copy",Wt=qe({action:$e,container:this.container,target:this.target(Y),text:this.text(Y)});this.emit(Wt?"success":"error",{action:$e,text:Wt,trigger:Y,clearSelection:function(){Y&&Y.focus(),window.getSelection().removeAllRanges()}})}},{key:"defaultAction",value:function(V){return vr("action",V)}},{key:"defaultTarget",value:function(V){var Y=vr("target",V);if(Y)return document.querySelector(Y)}},{key:"defaultText",value:function(V){return vr("text",V)}},{key:"destroy",value:function(){this.listener.destroy()}}],[{key:"copy",value:function(V){var Y=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{container:document.body};return J(V,Y)}},{key:"cut",value:function(V){return y(V)}},{key:"isSupported",value:function(){var V=arguments.length>0&&arguments[0]!==void 0?arguments[0]:["copy","cut"],Y=typeof V=="string"?[V]:V,$e=!!document.queryCommandSupported;return Y.forEach(function(Wt){$e=$e&&!!document.queryCommandSupported(Wt)}),$e}}]),M}(s()),Ui=Fi},828:function(o){var n=9;if(typeof Element!="undefined"&&!Element.prototype.matches){var i=Element.prototype;i.matches=i.matchesSelector||i.mozMatchesSelector||i.msMatchesSelector||i.oMatchesSelector||i.webkitMatchesSelector}function a(s,p){for(;s&&s.nodeType!==n;){if(typeof s.matches=="function"&&s.matches(p))return s;s=s.parentNode}}o.exports=a},438:function(o,n,i){var a=i(828);function s(l,f,u,d,y){var L=c.apply(this,arguments);return l.addEventListener(u,L,y),{destroy:function(){l.removeEventListener(u,L,y)}}}function p(l,f,u,d,y){return typeof l.addEventListener=="function"?s.apply(null,arguments):typeof u=="function"?s.bind(null,document).apply(null,arguments):(typeof l=="string"&&(l=document.querySelectorAll(l)),Array.prototype.map.call(l,function(L){return s(L,f,u,d,y)}))}function c(l,f,u,d){return function(y){y.delegateTarget=a(y.target,f),y.delegateTarget&&d.call(l,y)}}o.exports=p},879:function(o,n){n.node=function(i){return i!==void 0&&i instanceof HTMLElement&&i.nodeType===1},n.nodeList=function(i){var a=Object.prototype.toString.call(i);return i!==void 0&&(a==="[object NodeList]"||a==="[object HTMLCollection]")&&"length"in i&&(i.length===0||n.node(i[0]))},n.string=function(i){return typeof i=="string"||i instanceof String},n.fn=function(i){var a=Object.prototype.toString.call(i);return a==="[object Function]"}},370:function(o,n,i){var a=i(879),s=i(438);function p(u,d,y){if(!u&&!d&&!y)throw new Error("Missing required arguments");if(!a.string(d))throw new TypeError("Second argument must be a String");if(!a.fn(y))throw new TypeError("Third argument must be a Function");if(a.node(u))return c(u,d,y);if(a.nodeList(u))return l(u,d,y);if(a.string(u))return f(u,d,y);throw new TypeError("First argument must be a String, HTMLElement, HTMLCollection, or NodeList")}function c(u,d,y){return u.addEventListener(d,y),{destroy:function(){u.removeEventListener(d,y)}}}function l(u,d,y){return Array.prototype.forEach.call(u,function(L){L.addEventListener(d,y)}),{destroy:function(){Array.prototype.forEach.call(u,function(L){L.removeEventListener(d,y)})}}}function f(u,d,y){return s(document.body,u,d,y)}o.exports=p},817:function(o){function n(i){var a;if(i.nodeName==="SELECT")i.focus(),a=i.value;else if(i.nodeName==="INPUT"||i.nodeName==="TEXTAREA"){var s=i.hasAttribute("readonly");s||i.setAttribute("readonly",""),i.select(),i.setSelectionRange(0,i.value.length),s||i.removeAttribute("readonly"),a=i.value}else{i.hasAttribute("contenteditable")&&i.focus();var p=window.getSelection(),c=document.createRange();c.selectNodeContents(i),p.removeAllRanges(),p.addRange(c),a=p.toString()}return a}o.exports=n},279:function(o){function n(){}n.prototype={on:function(i,a,s){var p=this.e||(this.e={});return(p[i]||(p[i]=[])).push({fn:a,ctx:s}),this},once:function(i,a,s){var p=this;function c(){p.off(i,c),a.apply(s,arguments)}return c._=a,this.on(i,c,s)},emit:function(i){var a=[].slice.call(arguments,1),s=((this.e||(this.e={}))[i]||[]).slice(),p=0,c=s.length;for(p;p0&&i[i.length-1])&&(c[0]===6||c[0]===2)){r=0;continue}if(c[0]===3&&(!i||c[1]>i[0]&&c[1]=e.length&&(e=void 0),{value:e&&e[o++],done:!e}}};throw new TypeError(t?"Object is not iterable.":"Symbol.iterator is not defined.")}function z(e,t){var r=typeof Symbol=="function"&&e[Symbol.iterator];if(!r)return e;var o=r.call(e),n,i=[],a;try{for(;(t===void 0||t-- >0)&&!(n=o.next()).done;)i.push(n.value)}catch(s){a={error:s}}finally{try{n&&!n.done&&(r=o.return)&&r.call(o)}finally{if(a)throw a.error}}return i}function q(e,t,r){if(r||arguments.length===2)for(var o=0,n=t.length,i;o1||p(d,L)})},y&&(n[d]=y(n[d])))}function p(d,y){try{c(o[d](y))}catch(L){u(i[0][3],L)}}function c(d){d.value instanceof nt?Promise.resolve(d.value.v).then(l,f):u(i[0][2],d)}function l(d){p("next",d)}function f(d){p("throw",d)}function u(d,y){d(y),i.shift(),i.length&&p(i[0][0],i[0][1])}}function uo(e){if(!Symbol.asyncIterator)throw new TypeError("Symbol.asyncIterator is not defined.");var t=e[Symbol.asyncIterator],r;return t?t.call(e):(e=typeof he=="function"?he(e):e[Symbol.iterator](),r={},o("next"),o("throw"),o("return"),r[Symbol.asyncIterator]=function(){return this},r);function o(i){r[i]=e[i]&&function(a){return new Promise(function(s,p){a=e[i](a),n(s,p,a.done,a.value)})}}function n(i,a,s,p){Promise.resolve(p).then(function(c){i({value:c,done:s})},a)}}function H(e){return typeof e=="function"}function ut(e){var t=function(o){Error.call(o),o.stack=new Error().stack},r=e(t);return r.prototype=Object.create(Error.prototype),r.prototype.constructor=r,r}var zt=ut(function(e){return function(r){e(this),this.message=r?r.length+` errors occurred during unsubscription: -`+r.map(function(o,n){return n+1+") "+o.toString()}).join(` - `):"",this.name="UnsubscriptionError",this.errors=r}});function Qe(e,t){if(e){var r=e.indexOf(t);0<=r&&e.splice(r,1)}}var Ue=function(){function e(t){this.initialTeardown=t,this.closed=!1,this._parentage=null,this._finalizers=null}return e.prototype.unsubscribe=function(){var t,r,o,n,i;if(!this.closed){this.closed=!0;var a=this._parentage;if(a)if(this._parentage=null,Array.isArray(a))try{for(var s=he(a),p=s.next();!p.done;p=s.next()){var c=p.value;c.remove(this)}}catch(L){t={error:L}}finally{try{p&&!p.done&&(r=s.return)&&r.call(s)}finally{if(t)throw t.error}}else a.remove(this);var l=this.initialTeardown;if(H(l))try{l()}catch(L){i=L instanceof zt?L.errors:[L]}var f=this._finalizers;if(f){this._finalizers=null;try{for(var u=he(f),d=u.next();!d.done;d=u.next()){var y=d.value;try{ho(y)}catch(L){i=i!=null?i:[],L instanceof zt?i=q(q([],z(i)),z(L.errors)):i.push(L)}}}catch(L){o={error:L}}finally{try{d&&!d.done&&(n=u.return)&&n.call(u)}finally{if(o)throw o.error}}}if(i)throw new zt(i)}},e.prototype.add=function(t){var r;if(t&&t!==this)if(this.closed)ho(t);else{if(t instanceof e){if(t.closed||t._hasParent(this))return;t._addParent(this)}(this._finalizers=(r=this._finalizers)!==null&&r!==void 0?r:[]).push(t)}},e.prototype._hasParent=function(t){var r=this._parentage;return r===t||Array.isArray(r)&&r.includes(t)},e.prototype._addParent=function(t){var r=this._parentage;this._parentage=Array.isArray(r)?(r.push(t),r):r?[r,t]:t},e.prototype._removeParent=function(t){var r=this._parentage;r===t?this._parentage=null:Array.isArray(r)&&Qe(r,t)},e.prototype.remove=function(t){var r=this._finalizers;r&&Qe(r,t),t instanceof e&&t._removeParent(this)},e.EMPTY=function(){var t=new e;return t.closed=!0,t}(),e}();var Tr=Ue.EMPTY;function Nt(e){return e instanceof Ue||e&&"closed"in e&&H(e.remove)&&H(e.add)&&H(e.unsubscribe)}function ho(e){H(e)?e():e.unsubscribe()}var Pe={onUnhandledError:null,onStoppedNotification:null,Promise:void 0,useDeprecatedSynchronousErrorHandling:!1,useDeprecatedNextContext:!1};var dt={setTimeout:function(e,t){for(var r=[],o=2;o0},enumerable:!1,configurable:!0}),t.prototype._trySubscribe=function(r){return this._throwIfClosed(),e.prototype._trySubscribe.call(this,r)},t.prototype._subscribe=function(r){return this._throwIfClosed(),this._checkFinalizedStatuses(r),this._innerSubscribe(r)},t.prototype._innerSubscribe=function(r){var o=this,n=this,i=n.hasError,a=n.isStopped,s=n.observers;return i||a?Tr:(this.currentObservers=null,s.push(r),new Ue(function(){o.currentObservers=null,Qe(s,r)}))},t.prototype._checkFinalizedStatuses=function(r){var o=this,n=o.hasError,i=o.thrownError,a=o.isStopped;n?r.error(i):a&&r.complete()},t.prototype.asObservable=function(){var r=new j;return r.source=this,r},t.create=function(r,o){return new To(r,o)},t}(j);var To=function(e){oe(t,e);function t(r,o){var n=e.call(this)||this;return n.destination=r,n.source=o,n}return t.prototype.next=function(r){var o,n;(n=(o=this.destination)===null||o===void 0?void 0:o.next)===null||n===void 0||n.call(o,r)},t.prototype.error=function(r){var o,n;(n=(o=this.destination)===null||o===void 0?void 0:o.error)===null||n===void 0||n.call(o,r)},t.prototype.complete=function(){var r,o;(o=(r=this.destination)===null||r===void 0?void 0:r.complete)===null||o===void 0||o.call(r)},t.prototype._subscribe=function(r){var o,n;return(n=(o=this.source)===null||o===void 0?void 0:o.subscribe(r))!==null&&n!==void 0?n:Tr},t}(g);var _r=function(e){oe(t,e);function t(r){var o=e.call(this)||this;return o._value=r,o}return Object.defineProperty(t.prototype,"value",{get:function(){return this.getValue()},enumerable:!1,configurable:!0}),t.prototype._subscribe=function(r){var o=e.prototype._subscribe.call(this,r);return!o.closed&&r.next(this._value),o},t.prototype.getValue=function(){var r=this,o=r.hasError,n=r.thrownError,i=r._value;if(o)throw n;return this._throwIfClosed(),i},t.prototype.next=function(r){e.prototype.next.call(this,this._value=r)},t}(g);var _t={now:function(){return(_t.delegate||Date).now()},delegate:void 0};var At=function(e){oe(t,e);function t(r,o,n){r===void 0&&(r=1/0),o===void 0&&(o=1/0),n===void 0&&(n=_t);var i=e.call(this)||this;return i._bufferSize=r,i._windowTime=o,i._timestampProvider=n,i._buffer=[],i._infiniteTimeWindow=!0,i._infiniteTimeWindow=o===1/0,i._bufferSize=Math.max(1,r),i._windowTime=Math.max(1,o),i}return t.prototype.next=function(r){var o=this,n=o.isStopped,i=o._buffer,a=o._infiniteTimeWindow,s=o._timestampProvider,p=o._windowTime;n||(i.push(r),!a&&i.push(s.now()+p)),this._trimBuffer(),e.prototype.next.call(this,r)},t.prototype._subscribe=function(r){this._throwIfClosed(),this._trimBuffer();for(var o=this._innerSubscribe(r),n=this,i=n._infiniteTimeWindow,a=n._buffer,s=a.slice(),p=0;p0?e.prototype.schedule.call(this,r,o):(this.delay=o,this.state=r,this.scheduler.flush(this),this)},t.prototype.execute=function(r,o){return o>0||this.closed?e.prototype.execute.call(this,r,o):this._execute(r,o)},t.prototype.requestAsyncId=function(r,o,n){return n===void 0&&(n=0),n!=null&&n>0||n==null&&this.delay>0?e.prototype.requestAsyncId.call(this,r,o,n):(r.flush(this),0)},t}(gt);var Lo=function(e){oe(t,e);function t(){return e!==null&&e.apply(this,arguments)||this}return t}(yt);var kr=new Lo(Oo);var Mo=function(e){oe(t,e);function t(r,o){var n=e.call(this,r,o)||this;return n.scheduler=r,n.work=o,n}return t.prototype.requestAsyncId=function(r,o,n){return n===void 0&&(n=0),n!==null&&n>0?e.prototype.requestAsyncId.call(this,r,o,n):(r.actions.push(this),r._scheduled||(r._scheduled=vt.requestAnimationFrame(function(){return r.flush(void 0)})))},t.prototype.recycleAsyncId=function(r,o,n){var i;if(n===void 0&&(n=0),n!=null?n>0:this.delay>0)return e.prototype.recycleAsyncId.call(this,r,o,n);var a=r.actions;o!=null&&o===r._scheduled&&((i=a[a.length-1])===null||i===void 0?void 0:i.id)!==o&&(vt.cancelAnimationFrame(o),r._scheduled=void 0)},t}(gt);var _o=function(e){oe(t,e);function t(){return e!==null&&e.apply(this,arguments)||this}return t.prototype.flush=function(r){this._active=!0;var o;r?o=r.id:(o=this._scheduled,this._scheduled=void 0);var n=this.actions,i;r=r||n.shift();do if(i=r.execute(r.state,r.delay))break;while((r=n[0])&&r.id===o&&n.shift());if(this._active=!1,i){for(;(r=n[0])&&r.id===o&&n.shift();)r.unsubscribe();throw i}},t}(yt);var me=new _o(Mo);var S=new j(function(e){return e.complete()});function Kt(e){return e&&H(e.schedule)}function Hr(e){return e[e.length-1]}function Xe(e){return H(Hr(e))?e.pop():void 0}function ke(e){return Kt(Hr(e))?e.pop():void 0}function Yt(e,t){return typeof Hr(e)=="number"?e.pop():t}var xt=function(e){return e&&typeof e.length=="number"&&typeof e!="function"};function Bt(e){return H(e==null?void 0:e.then)}function Gt(e){return H(e[bt])}function Jt(e){return Symbol.asyncIterator&&H(e==null?void 0:e[Symbol.asyncIterator])}function Xt(e){return new TypeError("You provided "+(e!==null&&typeof e=="object"?"an invalid object":"'"+e+"'")+" where a stream was expected. You can provide an Observable, Promise, ReadableStream, Array, AsyncIterable, or Iterable.")}function Zi(){return typeof Symbol!="function"||!Symbol.iterator?"@@iterator":Symbol.iterator}var Zt=Zi();function er(e){return H(e==null?void 0:e[Zt])}function tr(e){return fo(this,arguments,function(){var r,o,n,i;return Dt(this,function(a){switch(a.label){case 0:r=e.getReader(),a.label=1;case 1:a.trys.push([1,,9,10]),a.label=2;case 2:return[4,nt(r.read())];case 3:return o=a.sent(),n=o.value,i=o.done,i?[4,nt(void 0)]:[3,5];case 4:return[2,a.sent()];case 5:return[4,nt(n)];case 6:return[4,a.sent()];case 7:return a.sent(),[3,2];case 8:return[3,10];case 9:return r.releaseLock(),[7];case 10:return[2]}})})}function rr(e){return H(e==null?void 0:e.getReader)}function U(e){if(e instanceof j)return e;if(e!=null){if(Gt(e))return ea(e);if(xt(e))return ta(e);if(Bt(e))return ra(e);if(Jt(e))return Ao(e);if(er(e))return oa(e);if(rr(e))return na(e)}throw Xt(e)}function ea(e){return new j(function(t){var r=e[bt]();if(H(r.subscribe))return r.subscribe(t);throw new TypeError("Provided object does not correctly implement Symbol.observable")})}function ta(e){return new j(function(t){for(var r=0;r=2;return function(o){return o.pipe(e?b(function(n,i){return e(n,i,o)}):le,Te(1),r?Ve(t):Qo(function(){return new nr}))}}function jr(e){return e<=0?function(){return S}:E(function(t,r){var o=[];t.subscribe(T(r,function(n){o.push(n),e=2,!0))}function pe(e){e===void 0&&(e={});var t=e.connector,r=t===void 0?function(){return new g}:t,o=e.resetOnError,n=o===void 0?!0:o,i=e.resetOnComplete,a=i===void 0?!0:i,s=e.resetOnRefCountZero,p=s===void 0?!0:s;return function(c){var l,f,u,d=0,y=!1,L=!1,X=function(){f==null||f.unsubscribe(),f=void 0},ee=function(){X(),l=u=void 0,y=L=!1},J=function(){var k=l;ee(),k==null||k.unsubscribe()};return E(function(k,ft){d++,!L&&!y&&X();var qe=u=u!=null?u:r();ft.add(function(){d--,d===0&&!L&&!y&&(f=Ur(J,p))}),qe.subscribe(ft),!l&&d>0&&(l=new at({next:function(Fe){return qe.next(Fe)},error:function(Fe){L=!0,X(),f=Ur(ee,n,Fe),qe.error(Fe)},complete:function(){y=!0,X(),f=Ur(ee,a),qe.complete()}}),U(k).subscribe(l))})(c)}}function Ur(e,t){for(var r=[],o=2;oe.next(document)),e}function P(e,t=document){return Array.from(t.querySelectorAll(e))}function R(e,t=document){let r=fe(e,t);if(typeof r=="undefined")throw new ReferenceError(`Missing element: expected "${e}" to be present`);return r}function fe(e,t=document){return t.querySelector(e)||void 0}function Ie(){var e,t,r,o;return(o=(r=(t=(e=document.activeElement)==null?void 0:e.shadowRoot)==null?void 0:t.activeElement)!=null?r:document.activeElement)!=null?o:void 0}var wa=O(h(document.body,"focusin"),h(document.body,"focusout")).pipe(_e(1),Q(void 0),m(()=>Ie()||document.body),G(1));function et(e){return wa.pipe(m(t=>e.contains(t)),K())}function Ht(e,t){return C(()=>O(h(e,"mouseenter").pipe(m(()=>!0)),h(e,"mouseleave").pipe(m(()=>!1))).pipe(t?kt(r=>Le(+!r*t)):le,Q(e.matches(":hover"))))}function Jo(e,t){if(typeof t=="string"||typeof t=="number")e.innerHTML+=t.toString();else if(t instanceof Node)e.appendChild(t);else if(Array.isArray(t))for(let r of t)Jo(e,r)}function x(e,t,...r){let o=document.createElement(e);if(t)for(let n of Object.keys(t))typeof t[n]!="undefined"&&(typeof t[n]!="boolean"?o.setAttribute(n,t[n]):o.setAttribute(n,""));for(let n of r)Jo(o,n);return o}function sr(e){if(e>999){let t=+((e-950)%1e3>99);return`${((e+1e-6)/1e3).toFixed(t)}k`}else return e.toString()}function wt(e){let t=x("script",{src:e});return C(()=>(document.head.appendChild(t),O(h(t,"load"),h(t,"error").pipe(v(()=>$r(()=>new ReferenceError(`Invalid script: ${e}`))))).pipe(m(()=>{}),_(()=>document.head.removeChild(t)),Te(1))))}var Xo=new g,Ta=C(()=>typeof ResizeObserver=="undefined"?wt("https://unpkg.com/resize-observer-polyfill"):I(void 0)).pipe(m(()=>new ResizeObserver(e=>e.forEach(t=>Xo.next(t)))),v(e=>O(Ye,I(e)).pipe(_(()=>e.disconnect()))),G(1));function ce(e){return{width:e.offsetWidth,height:e.offsetHeight}}function ge(e){let t=e;for(;t.clientWidth===0&&t.parentElement;)t=t.parentElement;return Ta.pipe(w(r=>r.observe(t)),v(r=>Xo.pipe(b(o=>o.target===t),_(()=>r.unobserve(t)))),m(()=>ce(e)),Q(ce(e)))}function Tt(e){return{width:e.scrollWidth,height:e.scrollHeight}}function cr(e){let t=e.parentElement;for(;t&&(e.scrollWidth<=t.scrollWidth&&e.scrollHeight<=t.scrollHeight);)t=(e=t).parentElement;return t?e:void 0}function Zo(e){let t=[],r=e.parentElement;for(;r;)(e.clientWidth>r.clientWidth||e.clientHeight>r.clientHeight)&&t.push(r),r=(e=r).parentElement;return t.length===0&&t.push(document.documentElement),t}function De(e){return{x:e.offsetLeft,y:e.offsetTop}}function en(e){let t=e.getBoundingClientRect();return{x:t.x+window.scrollX,y:t.y+window.scrollY}}function tn(e){return O(h(window,"load"),h(window,"resize")).pipe(Me(0,me),m(()=>De(e)),Q(De(e)))}function pr(e){return{x:e.scrollLeft,y:e.scrollTop}}function ze(e){return O(h(e,"scroll"),h(window,"scroll"),h(window,"resize")).pipe(Me(0,me),m(()=>pr(e)),Q(pr(e)))}var rn=new g,Sa=C(()=>I(new IntersectionObserver(e=>{for(let t of e)rn.next(t)},{threshold:0}))).pipe(v(e=>O(Ye,I(e)).pipe(_(()=>e.disconnect()))),G(1));function tt(e){return Sa.pipe(w(t=>t.observe(e)),v(t=>rn.pipe(b(({target:r})=>r===e),_(()=>t.unobserve(e)),m(({isIntersecting:r})=>r))))}function on(e,t=16){return ze(e).pipe(m(({y:r})=>{let o=ce(e),n=Tt(e);return r>=n.height-o.height-t}),K())}var lr={drawer:R("[data-md-toggle=drawer]"),search:R("[data-md-toggle=search]")};function nn(e){return lr[e].checked}function Je(e,t){lr[e].checked!==t&&lr[e].click()}function Ne(e){let t=lr[e];return h(t,"change").pipe(m(()=>t.checked),Q(t.checked))}function Oa(e,t){switch(e.constructor){case HTMLInputElement:return e.type==="radio"?/^Arrow/.test(t):!0;case HTMLSelectElement:case HTMLTextAreaElement:return!0;default:return e.isContentEditable}}function La(){return O(h(window,"compositionstart").pipe(m(()=>!0)),h(window,"compositionend").pipe(m(()=>!1))).pipe(Q(!1))}function an(){let e=h(window,"keydown").pipe(b(t=>!(t.metaKey||t.ctrlKey)),m(t=>({mode:nn("search")?"search":"global",type:t.key,claim(){t.preventDefault(),t.stopPropagation()}})),b(({mode:t,type:r})=>{if(t==="global"){let o=Ie();if(typeof o!="undefined")return!Oa(o,r)}return!0}),pe());return La().pipe(v(t=>t?S:e))}function ye(){return new URL(location.href)}function lt(e,t=!1){if(B("navigation.instant")&&!t){let r=x("a",{href:e.href});document.body.appendChild(r),r.click(),r.remove()}else location.href=e.href}function sn(){return new g}function cn(){return location.hash.slice(1)}function pn(e){let t=x("a",{href:e});t.addEventListener("click",r=>r.stopPropagation()),t.click()}function Ma(e){return O(h(window,"hashchange"),e).pipe(m(cn),Q(cn()),b(t=>t.length>0),G(1))}function ln(e){return Ma(e).pipe(m(t=>fe(`[id="${t}"]`)),b(t=>typeof t!="undefined"))}function $t(e){let t=matchMedia(e);return ir(r=>t.addListener(()=>r(t.matches))).pipe(Q(t.matches))}function mn(){let e=matchMedia("print");return O(h(window,"beforeprint").pipe(m(()=>!0)),h(window,"afterprint").pipe(m(()=>!1))).pipe(Q(e.matches))}function zr(e,t){return e.pipe(v(r=>r?t():S))}function Nr(e,t){return new j(r=>{let o=new XMLHttpRequest;return o.open("GET",`${e}`),o.responseType="blob",o.addEventListener("load",()=>{o.status>=200&&o.status<300?(r.next(o.response),r.complete()):r.error(new Error(o.statusText))}),o.addEventListener("error",()=>{r.error(new Error("Network error"))}),o.addEventListener("abort",()=>{r.complete()}),typeof(t==null?void 0:t.progress$)!="undefined"&&(o.addEventListener("progress",n=>{var i;if(n.lengthComputable)t.progress$.next(n.loaded/n.total*100);else{let a=(i=o.getResponseHeader("Content-Length"))!=null?i:0;t.progress$.next(n.loaded/+a*100)}}),t.progress$.next(5)),o.send(),()=>o.abort()})}function je(e,t){return Nr(e,t).pipe(v(r=>r.text()),m(r=>JSON.parse(r)),G(1))}function fn(e,t){let r=new DOMParser;return Nr(e,t).pipe(v(o=>o.text()),m(o=>r.parseFromString(o,"text/html")),G(1))}function un(e,t){let r=new DOMParser;return Nr(e,t).pipe(v(o=>o.text()),m(o=>r.parseFromString(o,"text/xml")),G(1))}function dn(){return{x:Math.max(0,scrollX),y:Math.max(0,scrollY)}}function hn(){return O(h(window,"scroll",{passive:!0}),h(window,"resize",{passive:!0})).pipe(m(dn),Q(dn()))}function bn(){return{width:innerWidth,height:innerHeight}}function vn(){return h(window,"resize",{passive:!0}).pipe(m(bn),Q(bn()))}function gn(){return N([hn(),vn()]).pipe(m(([e,t])=>({offset:e,size:t})),G(1))}function mr(e,{viewport$:t,header$:r}){let o=t.pipe(te("size")),n=N([o,r]).pipe(m(()=>De(e)));return N([r,t,n]).pipe(m(([{height:i},{offset:a,size:s},{x:p,y:c}])=>({offset:{x:a.x-p,y:a.y-c+i},size:s})))}function _a(e){return h(e,"message",t=>t.data)}function Aa(e){let t=new g;return t.subscribe(r=>e.postMessage(r)),t}function yn(e,t=new Worker(e)){let r=_a(t),o=Aa(t),n=new g;n.subscribe(o);let i=o.pipe(Z(),ie(!0));return n.pipe(Z(),Re(r.pipe(W(i))),pe())}var Ca=R("#__config"),St=JSON.parse(Ca.textContent);St.base=`${new URL(St.base,ye())}`;function xe(){return St}function B(e){return St.features.includes(e)}function Ee(e,t){return typeof t!="undefined"?St.translations[e].replace("#",t.toString()):St.translations[e]}function Se(e,t=document){return R(`[data-md-component=${e}]`,t)}function ae(e,t=document){return P(`[data-md-component=${e}]`,t)}function ka(e){let t=R(".md-typeset > :first-child",e);return h(t,"click",{once:!0}).pipe(m(()=>R(".md-typeset",e)),m(r=>({hash:__md_hash(r.innerHTML)})))}function xn(e){if(!B("announce.dismiss")||!e.childElementCount)return S;if(!e.hidden){let t=R(".md-typeset",e);__md_hash(t.innerHTML)===__md_get("__announce")&&(e.hidden=!0)}return C(()=>{let t=new g;return t.subscribe(({hash:r})=>{e.hidden=!0,__md_set("__announce",r)}),ka(e).pipe(w(r=>t.next(r)),_(()=>t.complete()),m(r=>$({ref:e},r)))})}function Ha(e,{target$:t}){return t.pipe(m(r=>({hidden:r!==e})))}function En(e,t){let r=new g;return r.subscribe(({hidden:o})=>{e.hidden=o}),Ha(e,t).pipe(w(o=>r.next(o)),_(()=>r.complete()),m(o=>$({ref:e},o)))}function Pt(e,t){return t==="inline"?x("div",{class:"md-tooltip md-tooltip--inline",id:e,role:"tooltip"},x("div",{class:"md-tooltip__inner md-typeset"})):x("div",{class:"md-tooltip",id:e,role:"tooltip"},x("div",{class:"md-tooltip__inner md-typeset"}))}function wn(...e){return x("div",{class:"md-tooltip2",role:"tooltip"},x("div",{class:"md-tooltip2__inner md-typeset"},e))}function Tn(e,t){if(t=t?`${t}_annotation_${e}`:void 0,t){let r=t?`#${t}`:void 0;return x("aside",{class:"md-annotation",tabIndex:0},Pt(t),x("a",{href:r,class:"md-annotation__index",tabIndex:-1},x("span",{"data-md-annotation-id":e})))}else return x("aside",{class:"md-annotation",tabIndex:0},Pt(t),x("span",{class:"md-annotation__index",tabIndex:-1},x("span",{"data-md-annotation-id":e})))}function Sn(e){return x("button",{class:"md-clipboard md-icon",title:Ee("clipboard.copy"),"data-clipboard-target":`#${e} > code`})}var Ln=Lt(qr());function Qr(e,t){let r=t&2,o=t&1,n=Object.keys(e.terms).filter(p=>!e.terms[p]).reduce((p,c)=>[...p,x("del",null,(0,Ln.default)(c))," "],[]).slice(0,-1),i=xe(),a=new URL(e.location,i.base);B("search.highlight")&&a.searchParams.set("h",Object.entries(e.terms).filter(([,p])=>p).reduce((p,[c])=>`${p} ${c}`.trim(),""));let{tags:s}=xe();return x("a",{href:`${a}`,class:"md-search-result__link",tabIndex:-1},x("article",{class:"md-search-result__article md-typeset","data-md-score":e.score.toFixed(2)},r>0&&x("div",{class:"md-search-result__icon md-icon"}),r>0&&x("h1",null,e.title),r<=0&&x("h2",null,e.title),o>0&&e.text.length>0&&e.text,e.tags&&x("nav",{class:"md-tags"},e.tags.map(p=>{let c=s?p in s?`md-tag-icon md-tag--${s[p]}`:"md-tag-icon":"";return x("span",{class:`md-tag ${c}`},p)})),o>0&&n.length>0&&x("p",{class:"md-search-result__terms"},Ee("search.result.term.missing"),": ",...n)))}function Mn(e){let t=e[0].score,r=[...e],o=xe(),n=r.findIndex(l=>!`${new URL(l.location,o.base)}`.includes("#")),[i]=r.splice(n,1),a=r.findIndex(l=>l.scoreQr(l,1)),...p.length?[x("details",{class:"md-search-result__more"},x("summary",{tabIndex:-1},x("div",null,p.length>0&&p.length===1?Ee("search.result.more.one"):Ee("search.result.more.other",p.length))),...p.map(l=>Qr(l,1)))]:[]];return x("li",{class:"md-search-result__item"},c)}function _n(e){return x("ul",{class:"md-source__facts"},Object.entries(e).map(([t,r])=>x("li",{class:`md-source__fact md-source__fact--${t}`},typeof r=="number"?sr(r):r)))}function Kr(e){let t=`tabbed-control tabbed-control--${e}`;return x("div",{class:t,hidden:!0},x("button",{class:"tabbed-button",tabIndex:-1,"aria-hidden":"true"}))}function An(e){return x("div",{class:"md-typeset__scrollwrap"},x("div",{class:"md-typeset__table"},e))}function Ra(e){var o;let t=xe(),r=new URL(`../${e.version}/`,t.base);return x("li",{class:"md-version__item"},x("a",{href:`${r}`,class:"md-version__link"},e.title,((o=t.version)==null?void 0:o.alias)&&e.aliases.length>0&&x("span",{class:"md-version__alias"},e.aliases[0])))}function Cn(e,t){var o;let r=xe();return e=e.filter(n=>{var i;return!((i=n.properties)!=null&&i.hidden)}),x("div",{class:"md-version"},x("button",{class:"md-version__current","aria-label":Ee("select.version")},t.title,((o=r.version)==null?void 0:o.alias)&&t.aliases.length>0&&x("span",{class:"md-version__alias"},t.aliases[0])),x("ul",{class:"md-version__list"},e.map(Ra)))}var Ia=0;function ja(e){let t=N([et(e),Ht(e)]).pipe(m(([o,n])=>o||n),K()),r=C(()=>Zo(e)).pipe(ne(ze),pt(1),He(t),m(()=>en(e)));return t.pipe(Ae(o=>o),v(()=>N([t,r])),m(([o,n])=>({active:o,offset:n})),pe())}function Fa(e,t){let{content$:r,viewport$:o}=t,n=`__tooltip2_${Ia++}`;return C(()=>{let i=new g,a=new _r(!1);i.pipe(Z(),ie(!1)).subscribe(a);let s=a.pipe(kt(c=>Le(+!c*250,kr)),K(),v(c=>c?r:S),w(c=>c.id=n),pe());N([i.pipe(m(({active:c})=>c)),s.pipe(v(c=>Ht(c,250)),Q(!1))]).pipe(m(c=>c.some(l=>l))).subscribe(a);let p=a.pipe(b(c=>c),re(s,o),m(([c,l,{size:f}])=>{let u=e.getBoundingClientRect(),d=u.width/2;if(l.role==="tooltip")return{x:d,y:8+u.height};if(u.y>=f.height/2){let{height:y}=ce(l);return{x:d,y:-16-y}}else return{x:d,y:16+u.height}}));return N([s,i,p]).subscribe(([c,{offset:l},f])=>{c.style.setProperty("--md-tooltip-host-x",`${l.x}px`),c.style.setProperty("--md-tooltip-host-y",`${l.y}px`),c.style.setProperty("--md-tooltip-x",`${f.x}px`),c.style.setProperty("--md-tooltip-y",`${f.y}px`),c.classList.toggle("md-tooltip2--top",f.y<0),c.classList.toggle("md-tooltip2--bottom",f.y>=0)}),a.pipe(b(c=>c),re(s,(c,l)=>l),b(c=>c.role==="tooltip")).subscribe(c=>{let l=ce(R(":scope > *",c));c.style.setProperty("--md-tooltip-width",`${l.width}px`),c.style.setProperty("--md-tooltip-tail","0px")}),a.pipe(K(),ve(me),re(s)).subscribe(([c,l])=>{l.classList.toggle("md-tooltip2--active",c)}),N([a.pipe(b(c=>c)),s]).subscribe(([c,l])=>{l.role==="dialog"?(e.setAttribute("aria-controls",n),e.setAttribute("aria-haspopup","dialog")):e.setAttribute("aria-describedby",n)}),a.pipe(b(c=>!c)).subscribe(()=>{e.removeAttribute("aria-controls"),e.removeAttribute("aria-describedby"),e.removeAttribute("aria-haspopup")}),ja(e).pipe(w(c=>i.next(c)),_(()=>i.complete()),m(c=>$({ref:e},c)))})}function mt(e,{viewport$:t},r=document.body){return Fa(e,{content$:new j(o=>{let n=e.title,i=wn(n);return o.next(i),e.removeAttribute("title"),r.append(i),()=>{i.remove(),e.setAttribute("title",n)}}),viewport$:t})}function Ua(e,t){let r=C(()=>N([tn(e),ze(t)])).pipe(m(([{x:o,y:n},i])=>{let{width:a,height:s}=ce(e);return{x:o-i.x+a/2,y:n-i.y+s/2}}));return et(e).pipe(v(o=>r.pipe(m(n=>({active:o,offset:n})),Te(+!o||1/0))))}function kn(e,t,{target$:r}){let[o,n]=Array.from(e.children);return C(()=>{let i=new g,a=i.pipe(Z(),ie(!0));return i.subscribe({next({offset:s}){e.style.setProperty("--md-tooltip-x",`${s.x}px`),e.style.setProperty("--md-tooltip-y",`${s.y}px`)},complete(){e.style.removeProperty("--md-tooltip-x"),e.style.removeProperty("--md-tooltip-y")}}),tt(e).pipe(W(a)).subscribe(s=>{e.toggleAttribute("data-md-visible",s)}),O(i.pipe(b(({active:s})=>s)),i.pipe(_e(250),b(({active:s})=>!s))).subscribe({next({active:s}){s?e.prepend(o):o.remove()},complete(){e.prepend(o)}}),i.pipe(Me(16,me)).subscribe(({active:s})=>{o.classList.toggle("md-tooltip--active",s)}),i.pipe(pt(125,me),b(()=>!!e.offsetParent),m(()=>e.offsetParent.getBoundingClientRect()),m(({x:s})=>s)).subscribe({next(s){s?e.style.setProperty("--md-tooltip-0",`${-s}px`):e.style.removeProperty("--md-tooltip-0")},complete(){e.style.removeProperty("--md-tooltip-0")}}),h(n,"click").pipe(W(a),b(s=>!(s.metaKey||s.ctrlKey))).subscribe(s=>{s.stopPropagation(),s.preventDefault()}),h(n,"mousedown").pipe(W(a),re(i)).subscribe(([s,{active:p}])=>{var c;if(s.button!==0||s.metaKey||s.ctrlKey)s.preventDefault();else if(p){s.preventDefault();let l=e.parentElement.closest(".md-annotation");l instanceof HTMLElement?l.focus():(c=Ie())==null||c.blur()}}),r.pipe(W(a),b(s=>s===o),Ge(125)).subscribe(()=>e.focus()),Ua(e,t).pipe(w(s=>i.next(s)),_(()=>i.complete()),m(s=>$({ref:e},s)))})}function Wa(e){return e.tagName==="CODE"?P(".c, .c1, .cm",e):[e]}function Va(e){let t=[];for(let r of Wa(e)){let o=[],n=document.createNodeIterator(r,NodeFilter.SHOW_TEXT);for(let i=n.nextNode();i;i=n.nextNode())o.push(i);for(let i of o){let a;for(;a=/(\(\d+\))(!)?/.exec(i.textContent);){let[,s,p]=a;if(typeof p=="undefined"){let c=i.splitText(a.index);i=c.splitText(s.length),t.push(c)}else{i.textContent=s,t.push(i);break}}}}return t}function Hn(e,t){t.append(...Array.from(e.childNodes))}function fr(e,t,{target$:r,print$:o}){let n=t.closest("[id]"),i=n==null?void 0:n.id,a=new Map;for(let s of Va(t)){let[,p]=s.textContent.match(/\((\d+)\)/);fe(`:scope > li:nth-child(${p})`,e)&&(a.set(p,Tn(p,i)),s.replaceWith(a.get(p)))}return a.size===0?S:C(()=>{let s=new g,p=s.pipe(Z(),ie(!0)),c=[];for(let[l,f]of a)c.push([R(".md-typeset",f),R(`:scope > li:nth-child(${l})`,e)]);return o.pipe(W(p)).subscribe(l=>{e.hidden=!l,e.classList.toggle("md-annotation-list",l);for(let[f,u]of c)l?Hn(f,u):Hn(u,f)}),O(...[...a].map(([,l])=>kn(l,t,{target$:r}))).pipe(_(()=>s.complete()),pe())})}function $n(e){if(e.nextElementSibling){let t=e.nextElementSibling;if(t.tagName==="OL")return t;if(t.tagName==="P"&&!t.children.length)return $n(t)}}function Pn(e,t){return C(()=>{let r=$n(e);return typeof r!="undefined"?fr(r,e,t):S})}var Rn=Lt(Br());var Da=0;function In(e){if(e.nextElementSibling){let t=e.nextElementSibling;if(t.tagName==="OL")return t;if(t.tagName==="P"&&!t.children.length)return In(t)}}function za(e){return ge(e).pipe(m(({width:t})=>({scrollable:Tt(e).width>t})),te("scrollable"))}function jn(e,t){let{matches:r}=matchMedia("(hover)"),o=C(()=>{let n=new g,i=n.pipe(jr(1));n.subscribe(({scrollable:c})=>{c&&r?e.setAttribute("tabindex","0"):e.removeAttribute("tabindex")});let a=[];if(Rn.default.isSupported()&&(e.closest(".copy")||B("content.code.copy")&&!e.closest(".no-copy"))){let c=e.closest("pre");c.id=`__code_${Da++}`;let l=Sn(c.id);c.insertBefore(l,e),B("content.tooltips")&&a.push(mt(l,{viewport$}))}let s=e.closest(".highlight");if(s instanceof HTMLElement){let c=In(s);if(typeof c!="undefined"&&(s.classList.contains("annotate")||B("content.code.annotate"))){let l=fr(c,e,t);a.push(ge(s).pipe(W(i),m(({width:f,height:u})=>f&&u),K(),v(f=>f?l:S)))}}return P(":scope > span[id]",e).length&&e.classList.add("md-code__content"),za(e).pipe(w(c=>n.next(c)),_(()=>n.complete()),m(c=>$({ref:e},c)),Re(...a))});return B("content.lazy")?tt(e).pipe(b(n=>n),Te(1),v(()=>o)):o}function Na(e,{target$:t,print$:r}){let o=!0;return O(t.pipe(m(n=>n.closest("details:not([open])")),b(n=>e===n),m(()=>({action:"open",reveal:!0}))),r.pipe(b(n=>n||!o),w(()=>o=e.open),m(n=>({action:n?"open":"close"}))))}function Fn(e,t){return C(()=>{let r=new g;return r.subscribe(({action:o,reveal:n})=>{e.toggleAttribute("open",o==="open"),n&&e.scrollIntoView()}),Na(e,t).pipe(w(o=>r.next(o)),_(()=>r.complete()),m(o=>$({ref:e},o)))})}var Un=".node circle,.node ellipse,.node path,.node polygon,.node rect{fill:var(--md-mermaid-node-bg-color);stroke:var(--md-mermaid-node-fg-color)}marker{fill:var(--md-mermaid-edge-color)!important}.edgeLabel .label rect{fill:#0000}.flowchartTitleText{fill:var(--md-mermaid-label-fg-color)}.label{color:var(--md-mermaid-label-fg-color);font-family:var(--md-mermaid-font-family)}.label foreignObject{line-height:normal;overflow:visible}.label div .edgeLabel{color:var(--md-mermaid-label-fg-color)}.edgeLabel,.edgeLabel p,.label div .edgeLabel{background-color:var(--md-mermaid-label-bg-color)}.edgeLabel,.edgeLabel p{fill:var(--md-mermaid-label-bg-color);color:var(--md-mermaid-edge-color)}.edgePath .path,.flowchart-link{stroke:var(--md-mermaid-edge-color);stroke-width:.05rem}.edgePath .arrowheadPath{fill:var(--md-mermaid-edge-color);stroke:none}.cluster rect{fill:var(--md-default-fg-color--lightest);stroke:var(--md-default-fg-color--lighter)}.cluster span{color:var(--md-mermaid-label-fg-color);font-family:var(--md-mermaid-font-family)}g #flowchart-circleEnd,g #flowchart-circleStart,g #flowchart-crossEnd,g #flowchart-crossStart,g #flowchart-pointEnd,g #flowchart-pointStart{stroke:none}.classDiagramTitleText{fill:var(--md-mermaid-label-fg-color)}g.classGroup line,g.classGroup rect{fill:var(--md-mermaid-node-bg-color);stroke:var(--md-mermaid-node-fg-color)}g.classGroup text{fill:var(--md-mermaid-label-fg-color);font-family:var(--md-mermaid-font-family)}.classLabel .box{fill:var(--md-mermaid-label-bg-color);background-color:var(--md-mermaid-label-bg-color);opacity:1}.classLabel .label{fill:var(--md-mermaid-label-fg-color);font-family:var(--md-mermaid-font-family)}.node .divider{stroke:var(--md-mermaid-node-fg-color)}.relation{stroke:var(--md-mermaid-edge-color)}.cardinality{fill:var(--md-mermaid-label-fg-color);font-family:var(--md-mermaid-font-family)}.cardinality text{fill:inherit!important}defs marker.marker.composition.class path,defs marker.marker.dependency.class path,defs marker.marker.extension.class path{fill:var(--md-mermaid-edge-color)!important;stroke:var(--md-mermaid-edge-color)!important}defs marker.marker.aggregation.class path{fill:var(--md-mermaid-label-bg-color)!important;stroke:var(--md-mermaid-edge-color)!important}.statediagramTitleText{fill:var(--md-mermaid-label-fg-color)}g.stateGroup rect{fill:var(--md-mermaid-node-bg-color);stroke:var(--md-mermaid-node-fg-color)}g.stateGroup .state-title{fill:var(--md-mermaid-label-fg-color)!important;font-family:var(--md-mermaid-font-family)}g.stateGroup .composit{fill:var(--md-mermaid-label-bg-color)}.nodeLabel,.nodeLabel p{color:var(--md-mermaid-label-fg-color);font-family:var(--md-mermaid-font-family)}a .nodeLabel{text-decoration:underline}.node circle.state-end,.node circle.state-start,.start-state{fill:var(--md-mermaid-edge-color);stroke:none}.end-state-inner,.end-state-outer{fill:var(--md-mermaid-edge-color)}.end-state-inner,.node circle.state-end{stroke:var(--md-mermaid-label-bg-color)}.transition{stroke:var(--md-mermaid-edge-color)}[id^=state-fork] rect,[id^=state-join] rect{fill:var(--md-mermaid-edge-color)!important;stroke:none!important}.statediagram-cluster.statediagram-cluster .inner{fill:var(--md-default-bg-color)}.statediagram-cluster rect{fill:var(--md-mermaid-node-bg-color);stroke:var(--md-mermaid-node-fg-color)}.statediagram-state rect.divider{fill:var(--md-default-fg-color--lightest);stroke:var(--md-default-fg-color--lighter)}defs #statediagram-barbEnd{stroke:var(--md-mermaid-edge-color)}[id^=entity] path,[id^=entity] rect{fill:var(--md-default-bg-color)}.relationshipLine{stroke:var(--md-mermaid-edge-color)}defs .marker.oneOrMore.er *,defs .marker.onlyOne.er *,defs .marker.zeroOrMore.er *,defs .marker.zeroOrOne.er *{stroke:var(--md-mermaid-edge-color)!important}text:not([class]):last-child{fill:var(--md-mermaid-label-fg-color)}.actor{fill:var(--md-mermaid-sequence-actor-bg-color);stroke:var(--md-mermaid-sequence-actor-border-color)}text.actor>tspan{fill:var(--md-mermaid-sequence-actor-fg-color);font-family:var(--md-mermaid-font-family)}line{stroke:var(--md-mermaid-sequence-actor-line-color)}.actor-man circle,.actor-man line{fill:var(--md-mermaid-sequence-actorman-bg-color);stroke:var(--md-mermaid-sequence-actorman-line-color)}.messageLine0,.messageLine1{stroke:var(--md-mermaid-sequence-message-line-color)}.note{fill:var(--md-mermaid-sequence-note-bg-color);stroke:var(--md-mermaid-sequence-note-border-color)}.loopText,.loopText>tspan,.messageText,.noteText>tspan{stroke:none;font-family:var(--md-mermaid-font-family)!important}.messageText{fill:var(--md-mermaid-sequence-message-fg-color)}.loopText,.loopText>tspan{fill:var(--md-mermaid-sequence-loop-fg-color)}.noteText>tspan{fill:var(--md-mermaid-sequence-note-fg-color)}#arrowhead path{fill:var(--md-mermaid-sequence-message-line-color);stroke:none}.loopLine{fill:var(--md-mermaid-sequence-loop-bg-color);stroke:var(--md-mermaid-sequence-loop-border-color)}.labelBox{fill:var(--md-mermaid-sequence-label-bg-color);stroke:none}.labelText,.labelText>span{fill:var(--md-mermaid-sequence-label-fg-color);font-family:var(--md-mermaid-font-family)}.sequenceNumber{fill:var(--md-mermaid-sequence-number-fg-color)}rect.rect{fill:var(--md-mermaid-sequence-box-bg-color);stroke:none}rect.rect+text.text{fill:var(--md-mermaid-sequence-box-fg-color)}defs #sequencenumber{fill:var(--md-mermaid-sequence-number-bg-color)!important}";var Gr,Qa=0;function Ka(){return typeof mermaid=="undefined"||mermaid instanceof Element?wt("https://unpkg.com/mermaid@11/dist/mermaid.min.js"):I(void 0)}function Wn(e){return e.classList.remove("mermaid"),Gr||(Gr=Ka().pipe(w(()=>mermaid.initialize({startOnLoad:!1,themeCSS:Un,sequence:{actorFontSize:"16px",messageFontSize:"16px",noteFontSize:"16px"}})),m(()=>{}),G(1))),Gr.subscribe(()=>co(null,null,function*(){e.classList.add("mermaid");let t=`__mermaid_${Qa++}`,r=x("div",{class:"mermaid"}),o=e.textContent,{svg:n,fn:i}=yield mermaid.render(t,o),a=r.attachShadow({mode:"closed"});a.innerHTML=n,e.replaceWith(r),i==null||i(a)})),Gr.pipe(m(()=>({ref:e})))}var Vn=x("table");function Dn(e){return e.replaceWith(Vn),Vn.replaceWith(An(e)),I({ref:e})}function Ya(e){let t=e.find(r=>r.checked)||e[0];return O(...e.map(r=>h(r,"change").pipe(m(()=>R(`label[for="${r.id}"]`))))).pipe(Q(R(`label[for="${t.id}"]`)),m(r=>({active:r})))}function zn(e,{viewport$:t,target$:r}){let o=R(".tabbed-labels",e),n=P(":scope > input",e),i=Kr("prev");e.append(i);let a=Kr("next");return e.append(a),C(()=>{let s=new g,p=s.pipe(Z(),ie(!0));N([s,ge(e),tt(e)]).pipe(W(p),Me(1,me)).subscribe({next([{active:c},l]){let f=De(c),{width:u}=ce(c);e.style.setProperty("--md-indicator-x",`${f.x}px`),e.style.setProperty("--md-indicator-width",`${u}px`);let d=pr(o);(f.xd.x+l.width)&&o.scrollTo({left:Math.max(0,f.x-16),behavior:"smooth"})},complete(){e.style.removeProperty("--md-indicator-x"),e.style.removeProperty("--md-indicator-width")}}),N([ze(o),ge(o)]).pipe(W(p)).subscribe(([c,l])=>{let f=Tt(o);i.hidden=c.x<16,a.hidden=c.x>f.width-l.width-16}),O(h(i,"click").pipe(m(()=>-1)),h(a,"click").pipe(m(()=>1))).pipe(W(p)).subscribe(c=>{let{width:l}=ce(o);o.scrollBy({left:l*c,behavior:"smooth"})}),r.pipe(W(p),b(c=>n.includes(c))).subscribe(c=>c.click()),o.classList.add("tabbed-labels--linked");for(let c of n){let l=R(`label[for="${c.id}"]`);l.replaceChildren(x("a",{href:`#${l.htmlFor}`,tabIndex:-1},...Array.from(l.childNodes))),h(l.firstElementChild,"click").pipe(W(p),b(f=>!(f.metaKey||f.ctrlKey)),w(f=>{f.preventDefault(),f.stopPropagation()})).subscribe(()=>{history.replaceState({},"",`#${l.htmlFor}`),l.click()})}return B("content.tabs.link")&&s.pipe(Ce(1),re(t)).subscribe(([{active:c},{offset:l}])=>{let f=c.innerText.trim();if(c.hasAttribute("data-md-switching"))c.removeAttribute("data-md-switching");else{let u=e.offsetTop-l.y;for(let y of P("[data-tabs]"))for(let L of P(":scope > input",y)){let X=R(`label[for="${L.id}"]`);if(X!==c&&X.innerText.trim()===f){X.setAttribute("data-md-switching",""),L.click();break}}window.scrollTo({top:e.offsetTop-u});let d=__md_get("__tabs")||[];__md_set("__tabs",[...new Set([f,...d])])}}),s.pipe(W(p)).subscribe(()=>{for(let c of P("audio, video",e))c.pause()}),Ya(n).pipe(w(c=>s.next(c)),_(()=>s.complete()),m(c=>$({ref:e},c)))}).pipe(Ke(se))}function Nn(e,{viewport$:t,target$:r,print$:o}){return O(...P(".annotate:not(.highlight)",e).map(n=>Pn(n,{target$:r,print$:o})),...P("pre:not(.mermaid) > code",e).map(n=>jn(n,{target$:r,print$:o})),...P("pre.mermaid",e).map(n=>Wn(n)),...P("table:not([class])",e).map(n=>Dn(n)),...P("details",e).map(n=>Fn(n,{target$:r,print$:o})),...P("[data-tabs]",e).map(n=>zn(n,{viewport$:t,target$:r})),...P("[title]",e).filter(()=>B("content.tooltips")).map(n=>mt(n,{viewport$:t})))}function Ba(e,{alert$:t}){return t.pipe(v(r=>O(I(!0),I(!1).pipe(Ge(2e3))).pipe(m(o=>({message:r,active:o})))))}function qn(e,t){let r=R(".md-typeset",e);return C(()=>{let o=new g;return o.subscribe(({message:n,active:i})=>{e.classList.toggle("md-dialog--active",i),r.textContent=n}),Ba(e,t).pipe(w(n=>o.next(n)),_(()=>o.complete()),m(n=>$({ref:e},n)))})}var Ga=0;function Ja(e,t){document.body.append(e);let{width:r}=ce(e);e.style.setProperty("--md-tooltip-width",`${r}px`),e.remove();let o=cr(t),n=typeof o!="undefined"?ze(o):I({x:0,y:0}),i=O(et(t),Ht(t)).pipe(K());return N([i,n]).pipe(m(([a,s])=>{let{x:p,y:c}=De(t),l=ce(t),f=t.closest("table");return f&&t.parentElement&&(p+=f.offsetLeft+t.parentElement.offsetLeft,c+=f.offsetTop+t.parentElement.offsetTop),{active:a,offset:{x:p-s.x+l.width/2-r/2,y:c-s.y+l.height+8}}}))}function Qn(e){let t=e.title;if(!t.length)return S;let r=`__tooltip_${Ga++}`,o=Pt(r,"inline"),n=R(".md-typeset",o);return n.innerHTML=t,C(()=>{let i=new g;return i.subscribe({next({offset:a}){o.style.setProperty("--md-tooltip-x",`${a.x}px`),o.style.setProperty("--md-tooltip-y",`${a.y}px`)},complete(){o.style.removeProperty("--md-tooltip-x"),o.style.removeProperty("--md-tooltip-y")}}),O(i.pipe(b(({active:a})=>a)),i.pipe(_e(250),b(({active:a})=>!a))).subscribe({next({active:a}){a?(e.insertAdjacentElement("afterend",o),e.setAttribute("aria-describedby",r),e.removeAttribute("title")):(o.remove(),e.removeAttribute("aria-describedby"),e.setAttribute("title",t))},complete(){o.remove(),e.removeAttribute("aria-describedby"),e.setAttribute("title",t)}}),i.pipe(Me(16,me)).subscribe(({active:a})=>{o.classList.toggle("md-tooltip--active",a)}),i.pipe(pt(125,me),b(()=>!!e.offsetParent),m(()=>e.offsetParent.getBoundingClientRect()),m(({x:a})=>a)).subscribe({next(a){a?o.style.setProperty("--md-tooltip-0",`${-a}px`):o.style.removeProperty("--md-tooltip-0")},complete(){o.style.removeProperty("--md-tooltip-0")}}),Ja(o,e).pipe(w(a=>i.next(a)),_(()=>i.complete()),m(a=>$({ref:e},a)))}).pipe(Ke(se))}function Xa({viewport$:e}){if(!B("header.autohide"))return I(!1);let t=e.pipe(m(({offset:{y:n}})=>n),Be(2,1),m(([n,i])=>[nMath.abs(i-n.y)>100),m(([,[n]])=>n),K()),o=Ne("search");return N([e,o]).pipe(m(([{offset:n},i])=>n.y>400&&!i),K(),v(n=>n?r:I(!1)),Q(!1))}function Kn(e,t){return C(()=>N([ge(e),Xa(t)])).pipe(m(([{height:r},o])=>({height:r,hidden:o})),K((r,o)=>r.height===o.height&&r.hidden===o.hidden),G(1))}function Yn(e,{header$:t,main$:r}){return C(()=>{let o=new g,n=o.pipe(Z(),ie(!0));o.pipe(te("active"),He(t)).subscribe(([{active:a},{hidden:s}])=>{e.classList.toggle("md-header--shadow",a&&!s),e.hidden=s});let i=ue(P("[title]",e)).pipe(b(()=>B("content.tooltips")),ne(a=>Qn(a)));return r.subscribe(o),t.pipe(W(n),m(a=>$({ref:e},a)),Re(i.pipe(W(n))))})}function Za(e,{viewport$:t,header$:r}){return mr(e,{viewport$:t,header$:r}).pipe(m(({offset:{y:o}})=>{let{height:n}=ce(e);return{active:o>=n}}),te("active"))}function Bn(e,t){return C(()=>{let r=new g;r.subscribe({next({active:n}){e.classList.toggle("md-header__title--active",n)},complete(){e.classList.remove("md-header__title--active")}});let o=fe(".md-content h1");return typeof o=="undefined"?S:Za(o,t).pipe(w(n=>r.next(n)),_(()=>r.complete()),m(n=>$({ref:e},n)))})}function Gn(e,{viewport$:t,header$:r}){let o=r.pipe(m(({height:i})=>i),K()),n=o.pipe(v(()=>ge(e).pipe(m(({height:i})=>({top:e.offsetTop,bottom:e.offsetTop+i})),te("bottom"))));return N([o,n,t]).pipe(m(([i,{top:a,bottom:s},{offset:{y:p},size:{height:c}}])=>(c=Math.max(0,c-Math.max(0,a-p,i)-Math.max(0,c+p-s)),{offset:a-i,height:c,active:a-i<=p})),K((i,a)=>i.offset===a.offset&&i.height===a.height&&i.active===a.active))}function es(e){let t=__md_get("__palette")||{index:e.findIndex(o=>matchMedia(o.getAttribute("data-md-color-media")).matches)},r=Math.max(0,Math.min(t.index,e.length-1));return I(...e).pipe(ne(o=>h(o,"change").pipe(m(()=>o))),Q(e[r]),m(o=>({index:e.indexOf(o),color:{media:o.getAttribute("data-md-color-media"),scheme:o.getAttribute("data-md-color-scheme"),primary:o.getAttribute("data-md-color-primary"),accent:o.getAttribute("data-md-color-accent")}})),G(1))}function Jn(e){let t=P("input",e),r=x("meta",{name:"theme-color"});document.head.appendChild(r);let o=x("meta",{name:"color-scheme"});document.head.appendChild(o);let n=$t("(prefers-color-scheme: light)");return C(()=>{let i=new g;return i.subscribe(a=>{if(document.body.setAttribute("data-md-color-switching",""),a.color.media==="(prefers-color-scheme)"){let s=matchMedia("(prefers-color-scheme: light)"),p=document.querySelector(s.matches?"[data-md-color-media='(prefers-color-scheme: light)']":"[data-md-color-media='(prefers-color-scheme: dark)']");a.color.scheme=p.getAttribute("data-md-color-scheme"),a.color.primary=p.getAttribute("data-md-color-primary"),a.color.accent=p.getAttribute("data-md-color-accent")}for(let[s,p]of Object.entries(a.color))document.body.setAttribute(`data-md-color-${s}`,p);for(let s=0;sa.key==="Enter"),re(i,(a,s)=>s)).subscribe(({index:a})=>{a=(a+1)%t.length,t[a].click(),t[a].focus()}),i.pipe(m(()=>{let a=Se("header"),s=window.getComputedStyle(a);return o.content=s.colorScheme,s.backgroundColor.match(/\d+/g).map(p=>(+p).toString(16).padStart(2,"0")).join("")})).subscribe(a=>r.content=`#${a}`),i.pipe(ve(se)).subscribe(()=>{document.body.removeAttribute("data-md-color-switching")}),es(t).pipe(W(n.pipe(Ce(1))),ct(),w(a=>i.next(a)),_(()=>i.complete()),m(a=>$({ref:e},a)))})}function Xn(e,{progress$:t}){return C(()=>{let r=new g;return r.subscribe(({value:o})=>{e.style.setProperty("--md-progress-value",`${o}`)}),t.pipe(w(o=>r.next({value:o})),_(()=>r.complete()),m(o=>({ref:e,value:o})))})}var Jr=Lt(Br());function ts(e){e.setAttribute("data-md-copying","");let t=e.closest("[data-copy]"),r=t?t.getAttribute("data-copy"):e.innerText;return e.removeAttribute("data-md-copying"),r.trimEnd()}function Zn({alert$:e}){Jr.default.isSupported()&&new j(t=>{new Jr.default("[data-clipboard-target], [data-clipboard-text]",{text:r=>r.getAttribute("data-clipboard-text")||ts(R(r.getAttribute("data-clipboard-target")))}).on("success",r=>t.next(r))}).pipe(w(t=>{t.trigger.focus()}),m(()=>Ee("clipboard.copied"))).subscribe(e)}function ei(e,t){return e.protocol=t.protocol,e.hostname=t.hostname,e}function rs(e,t){let r=new Map;for(let o of P("url",e)){let n=R("loc",o),i=[ei(new URL(n.textContent),t)];r.set(`${i[0]}`,i);for(let a of P("[rel=alternate]",o)){let s=a.getAttribute("href");s!=null&&i.push(ei(new URL(s),t))}}return r}function ur(e){return un(new URL("sitemap.xml",e)).pipe(m(t=>rs(t,new URL(e))),de(()=>I(new Map)))}function os(e,t){if(!(e.target instanceof Element))return S;let r=e.target.closest("a");if(r===null)return S;if(r.target||e.metaKey||e.ctrlKey)return S;let o=new URL(r.href);return o.search=o.hash="",t.has(`${o}`)?(e.preventDefault(),I(new URL(r.href))):S}function ti(e){let t=new Map;for(let r of P(":scope > *",e.head))t.set(r.outerHTML,r);return t}function ri(e){for(let t of P("[href], [src]",e))for(let r of["href","src"]){let o=t.getAttribute(r);if(o&&!/^(?:[a-z]+:)?\/\//i.test(o)){t[r]=t[r];break}}return I(e)}function ns(e){for(let o of["[data-md-component=announce]","[data-md-component=container]","[data-md-component=header-topic]","[data-md-component=outdated]","[data-md-component=logo]","[data-md-component=skip]",...B("navigation.tabs.sticky")?["[data-md-component=tabs]"]:[]]){let n=fe(o),i=fe(o,e);typeof n!="undefined"&&typeof i!="undefined"&&n.replaceWith(i)}let t=ti(document);for(let[o,n]of ti(e))t.has(o)?t.delete(o):document.head.appendChild(n);for(let o of t.values()){let n=o.getAttribute("name");n!=="theme-color"&&n!=="color-scheme"&&o.remove()}let r=Se("container");return We(P("script",r)).pipe(v(o=>{let n=e.createElement("script");if(o.src){for(let i of o.getAttributeNames())n.setAttribute(i,o.getAttribute(i));return o.replaceWith(n),new j(i=>{n.onload=()=>i.complete()})}else return n.textContent=o.textContent,o.replaceWith(n),S}),Z(),ie(document))}function oi({location$:e,viewport$:t,progress$:r}){let o=xe();if(location.protocol==="file:")return S;let n=ur(o.base);I(document).subscribe(ri);let i=h(document.body,"click").pipe(He(n),v(([p,c])=>os(p,c)),pe()),a=h(window,"popstate").pipe(m(ye),pe());i.pipe(re(t)).subscribe(([p,{offset:c}])=>{history.replaceState(c,""),history.pushState(null,"",p)}),O(i,a).subscribe(e);let s=e.pipe(te("pathname"),v(p=>fn(p,{progress$:r}).pipe(de(()=>(lt(p,!0),S)))),v(ri),v(ns),pe());return O(s.pipe(re(e,(p,c)=>c)),s.pipe(v(()=>e),te("hash")),e.pipe(K((p,c)=>p.pathname===c.pathname&&p.hash===c.hash),v(()=>i),w(()=>history.back()))).subscribe(p=>{var c,l;history.state!==null||!p.hash?window.scrollTo(0,(l=(c=history.state)==null?void 0:c.y)!=null?l:0):(history.scrollRestoration="auto",pn(p.hash),history.scrollRestoration="manual")}),e.subscribe(()=>{history.scrollRestoration="manual"}),h(window,"beforeunload").subscribe(()=>{history.scrollRestoration="auto"}),t.pipe(te("offset"),_e(100)).subscribe(({offset:p})=>{history.replaceState(p,"")}),s}var ni=Lt(qr());function ii(e){let t=e.separator.split("|").map(n=>n.replace(/(\(\?[!=<][^)]+\))/g,"").length===0?"\uFFFD":n).join("|"),r=new RegExp(t,"img"),o=(n,i,a)=>`${i}${a}`;return n=>{n=n.replace(/[\s*+\-:~^]+/g," ").trim();let i=new RegExp(`(^|${e.separator}|)(${n.replace(/[|\\{}()[\]^$+*?.-]/g,"\\$&").replace(r,"|")})`,"img");return a=>(0,ni.default)(a).replace(i,o).replace(/<\/mark>(\s+)]*>/img,"$1")}}function It(e){return e.type===1}function dr(e){return e.type===3}function ai(e,t){let r=yn(e);return O(I(location.protocol!=="file:"),Ne("search")).pipe(Ae(o=>o),v(()=>t)).subscribe(({config:o,docs:n})=>r.next({type:0,data:{config:o,docs:n,options:{suggest:B("search.suggest")}}})),r}function si(e){var l;let{selectedVersionSitemap:t,selectedVersionBaseURL:r,currentLocation:o,currentBaseURL:n}=e,i=(l=Xr(n))==null?void 0:l.pathname;if(i===void 0)return;let a=ss(o.pathname,i);if(a===void 0)return;let s=ps(t.keys());if(!t.has(s))return;let p=Xr(a,s);if(!p||!t.has(p.href))return;let c=Xr(a,r);if(c)return c.hash=o.hash,c.search=o.search,c}function Xr(e,t){try{return new URL(e,t)}catch(r){return}}function ss(e,t){if(e.startsWith(t))return e.slice(t.length)}function cs(e,t){let r=Math.min(e.length,t.length),o;for(o=0;oS)),o=r.pipe(m(n=>{let[,i]=t.base.match(/([^/]+)\/?$/);return n.find(({version:a,aliases:s})=>a===i||s.includes(i))||n[0]}));r.pipe(m(n=>new Map(n.map(i=>[`${new URL(`../${i.version}/`,t.base)}`,i]))),v(n=>h(document.body,"click").pipe(b(i=>!i.metaKey&&!i.ctrlKey),re(o),v(([i,a])=>{if(i.target instanceof Element){let s=i.target.closest("a");if(s&&!s.target&&n.has(s.href)){let p=s.href;return!i.target.closest(".md-version")&&n.get(p)===a?S:(i.preventDefault(),I(new URL(p)))}}return S}),v(i=>ur(i).pipe(m(a=>{var s;return(s=si({selectedVersionSitemap:a,selectedVersionBaseURL:i,currentLocation:ye(),currentBaseURL:t.base}))!=null?s:i})))))).subscribe(n=>lt(n,!0)),N([r,o]).subscribe(([n,i])=>{R(".md-header__topic").appendChild(Cn(n,i))}),e.pipe(v(()=>o)).subscribe(n=>{var s;let i=new URL(t.base),a=__md_get("__outdated",sessionStorage,i);if(a===null){a=!0;let p=((s=t.version)==null?void 0:s.default)||"latest";Array.isArray(p)||(p=[p]);e:for(let c of p)for(let l of n.aliases.concat(n.version))if(new RegExp(c,"i").test(l)){a=!1;break e}__md_set("__outdated",a,sessionStorage,i)}if(a)for(let p of ae("outdated"))p.hidden=!1})}function ls(e,{worker$:t}){let{searchParams:r}=ye();r.has("q")&&(Je("search",!0),e.value=r.get("q"),e.focus(),Ne("search").pipe(Ae(i=>!i)).subscribe(()=>{let i=ye();i.searchParams.delete("q"),history.replaceState({},"",`${i}`)}));let o=et(e),n=O(t.pipe(Ae(It)),h(e,"keyup"),o).pipe(m(()=>e.value),K());return N([n,o]).pipe(m(([i,a])=>({value:i,focus:a})),G(1))}function pi(e,{worker$:t}){let r=new g,o=r.pipe(Z(),ie(!0));N([t.pipe(Ae(It)),r],(i,a)=>a).pipe(te("value")).subscribe(({value:i})=>t.next({type:2,data:i})),r.pipe(te("focus")).subscribe(({focus:i})=>{i&&Je("search",i)}),h(e.form,"reset").pipe(W(o)).subscribe(()=>e.focus());let n=R("header [for=__search]");return h(n,"click").subscribe(()=>e.focus()),ls(e,{worker$:t}).pipe(w(i=>r.next(i)),_(()=>r.complete()),m(i=>$({ref:e},i)),G(1))}function li(e,{worker$:t,query$:r}){let o=new g,n=on(e.parentElement).pipe(b(Boolean)),i=e.parentElement,a=R(":scope > :first-child",e),s=R(":scope > :last-child",e);Ne("search").subscribe(l=>s.setAttribute("role",l?"list":"presentation")),o.pipe(re(r),Wr(t.pipe(Ae(It)))).subscribe(([{items:l},{value:f}])=>{switch(l.length){case 0:a.textContent=f.length?Ee("search.result.none"):Ee("search.result.placeholder");break;case 1:a.textContent=Ee("search.result.one");break;default:let u=sr(l.length);a.textContent=Ee("search.result.other",u)}});let p=o.pipe(w(()=>s.innerHTML=""),v(({items:l})=>O(I(...l.slice(0,10)),I(...l.slice(10)).pipe(Be(4),Dr(n),v(([f])=>f)))),m(Mn),pe());return p.subscribe(l=>s.appendChild(l)),p.pipe(ne(l=>{let f=fe("details",l);return typeof f=="undefined"?S:h(f,"toggle").pipe(W(o),m(()=>f))})).subscribe(l=>{l.open===!1&&l.offsetTop<=i.scrollTop&&i.scrollTo({top:l.offsetTop})}),t.pipe(b(dr),m(({data:l})=>l)).pipe(w(l=>o.next(l)),_(()=>o.complete()),m(l=>$({ref:e},l)))}function ms(e,{query$:t}){return t.pipe(m(({value:r})=>{let o=ye();return o.hash="",r=r.replace(/\s+/g,"+").replace(/&/g,"%26").replace(/=/g,"%3D"),o.search=`q=${r}`,{url:o}}))}function mi(e,t){let r=new g,o=r.pipe(Z(),ie(!0));return r.subscribe(({url:n})=>{e.setAttribute("data-clipboard-text",e.href),e.href=`${n}`}),h(e,"click").pipe(W(o)).subscribe(n=>n.preventDefault()),ms(e,t).pipe(w(n=>r.next(n)),_(()=>r.complete()),m(n=>$({ref:e},n)))}function fi(e,{worker$:t,keyboard$:r}){let o=new g,n=Se("search-query"),i=O(h(n,"keydown"),h(n,"focus")).pipe(ve(se),m(()=>n.value),K());return o.pipe(He(i),m(([{suggest:s},p])=>{let c=p.split(/([\s-]+)/);if(s!=null&&s.length&&c[c.length-1]){let l=s[s.length-1];l.startsWith(c[c.length-1])&&(c[c.length-1]=l)}else c.length=0;return c})).subscribe(s=>e.innerHTML=s.join("").replace(/\s/g," ")),r.pipe(b(({mode:s})=>s==="search")).subscribe(s=>{switch(s.type){case"ArrowRight":e.innerText.length&&n.selectionStart===n.value.length&&(n.value=e.innerText);break}}),t.pipe(b(dr),m(({data:s})=>s)).pipe(w(s=>o.next(s)),_(()=>o.complete()),m(()=>({ref:e})))}function ui(e,{index$:t,keyboard$:r}){let o=xe();try{let n=ai(o.search,t),i=Se("search-query",e),a=Se("search-result",e);h(e,"click").pipe(b(({target:p})=>p instanceof Element&&!!p.closest("a"))).subscribe(()=>Je("search",!1)),r.pipe(b(({mode:p})=>p==="search")).subscribe(p=>{let c=Ie();switch(p.type){case"Enter":if(c===i){let l=new Map;for(let f of P(":first-child [href]",a)){let u=f.firstElementChild;l.set(f,parseFloat(u.getAttribute("data-md-score")))}if(l.size){let[[f]]=[...l].sort(([,u],[,d])=>d-u);f.click()}p.claim()}break;case"Escape":case"Tab":Je("search",!1),i.blur();break;case"ArrowUp":case"ArrowDown":if(typeof c=="undefined")i.focus();else{let l=[i,...P(":not(details) > [href], summary, details[open] [href]",a)],f=Math.max(0,(Math.max(0,l.indexOf(c))+l.length+(p.type==="ArrowUp"?-1:1))%l.length);l[f].focus()}p.claim();break;default:i!==Ie()&&i.focus()}}),r.pipe(b(({mode:p})=>p==="global")).subscribe(p=>{switch(p.type){case"f":case"s":case"/":i.focus(),i.select(),p.claim();break}});let s=pi(i,{worker$:n});return O(s,li(a,{worker$:n,query$:s})).pipe(Re(...ae("search-share",e).map(p=>mi(p,{query$:s})),...ae("search-suggest",e).map(p=>fi(p,{worker$:n,keyboard$:r}))))}catch(n){return e.hidden=!0,Ye}}function di(e,{index$:t,location$:r}){return N([t,r.pipe(Q(ye()),b(o=>!!o.searchParams.get("h")))]).pipe(m(([o,n])=>ii(o.config)(n.searchParams.get("h"))),m(o=>{var a;let n=new Map,i=document.createNodeIterator(e,NodeFilter.SHOW_TEXT);for(let s=i.nextNode();s;s=i.nextNode())if((a=s.parentElement)!=null&&a.offsetHeight){let p=s.textContent,c=o(p);c.length>p.length&&n.set(s,c)}for(let[s,p]of n){let{childNodes:c}=x("span",null,p);s.replaceWith(...Array.from(c))}return{ref:e,nodes:n}}))}function fs(e,{viewport$:t,main$:r}){let o=e.closest(".md-grid"),n=o.offsetTop-o.parentElement.offsetTop;return N([r,t]).pipe(m(([{offset:i,height:a},{offset:{y:s}}])=>(a=a+Math.min(n,Math.max(0,s-i))-n,{height:a,locked:s>=i+n})),K((i,a)=>i.height===a.height&&i.locked===a.locked))}function Zr(e,o){var n=o,{header$:t}=n,r=so(n,["header$"]);let i=R(".md-sidebar__scrollwrap",e),{y:a}=De(i);return C(()=>{let s=new g,p=s.pipe(Z(),ie(!0)),c=s.pipe(Me(0,me));return c.pipe(re(t)).subscribe({next([{height:l},{height:f}]){i.style.height=`${l-2*a}px`,e.style.top=`${f}px`},complete(){i.style.height="",e.style.top=""}}),c.pipe(Ae()).subscribe(()=>{for(let l of P(".md-nav__link--active[href]",e)){if(!l.clientHeight)continue;let f=l.closest(".md-sidebar__scrollwrap");if(typeof f!="undefined"){let u=l.offsetTop-f.offsetTop,{height:d}=ce(f);f.scrollTo({top:u-d/2})}}}),ue(P("label[tabindex]",e)).pipe(ne(l=>h(l,"click").pipe(ve(se),m(()=>l),W(p)))).subscribe(l=>{let f=R(`[id="${l.htmlFor}"]`);R(`[aria-labelledby="${l.id}"]`).setAttribute("aria-expanded",`${f.checked}`)}),fs(e,r).pipe(w(l=>s.next(l)),_(()=>s.complete()),m(l=>$({ref:e},l)))})}function hi(e,t){if(typeof t!="undefined"){let r=`https://api.github.com/repos/${e}/${t}`;return st(je(`${r}/releases/latest`).pipe(de(()=>S),m(o=>({version:o.tag_name})),Ve({})),je(r).pipe(de(()=>S),m(o=>({stars:o.stargazers_count,forks:o.forks_count})),Ve({}))).pipe(m(([o,n])=>$($({},o),n)))}else{let r=`https://api.github.com/users/${e}`;return je(r).pipe(m(o=>({repositories:o.public_repos})),Ve({}))}}function bi(e,t){let r=`https://${e}/api/v4/projects/${encodeURIComponent(t)}`;return st(je(`${r}/releases/permalink/latest`).pipe(de(()=>S),m(({tag_name:o})=>({version:o})),Ve({})),je(r).pipe(de(()=>S),m(({star_count:o,forks_count:n})=>({stars:o,forks:n})),Ve({}))).pipe(m(([o,n])=>$($({},o),n)))}function vi(e){let t=e.match(/^.+github\.com\/([^/]+)\/?([^/]+)?/i);if(t){let[,r,o]=t;return hi(r,o)}if(t=e.match(/^.+?([^/]*gitlab[^/]+)\/(.+?)\/?$/i),t){let[,r,o]=t;return bi(r,o)}return S}var us;function ds(e){return us||(us=C(()=>{let t=__md_get("__source",sessionStorage);if(t)return I(t);if(ae("consent").length){let o=__md_get("__consent");if(!(o&&o.github))return S}return vi(e.href).pipe(w(o=>__md_set("__source",o,sessionStorage)))}).pipe(de(()=>S),b(t=>Object.keys(t).length>0),m(t=>({facts:t})),G(1)))}function gi(e){let t=R(":scope > :last-child",e);return C(()=>{let r=new g;return r.subscribe(({facts:o})=>{t.appendChild(_n(o)),t.classList.add("md-source__repository--active")}),ds(e).pipe(w(o=>r.next(o)),_(()=>r.complete()),m(o=>$({ref:e},o)))})}function hs(e,{viewport$:t,header$:r}){return ge(document.body).pipe(v(()=>mr(e,{header$:r,viewport$:t})),m(({offset:{y:o}})=>({hidden:o>=10})),te("hidden"))}function yi(e,t){return C(()=>{let r=new g;return r.subscribe({next({hidden:o}){e.hidden=o},complete(){e.hidden=!1}}),(B("navigation.tabs.sticky")?I({hidden:!1}):hs(e,t)).pipe(w(o=>r.next(o)),_(()=>r.complete()),m(o=>$({ref:e},o)))})}function bs(e,{viewport$:t,header$:r}){let o=new Map,n=P(".md-nav__link",e);for(let s of n){let p=decodeURIComponent(s.hash.substring(1)),c=fe(`[id="${p}"]`);typeof c!="undefined"&&o.set(s,c)}let i=r.pipe(te("height"),m(({height:s})=>{let p=Se("main"),c=R(":scope > :first-child",p);return s+.8*(c.offsetTop-p.offsetTop)}),pe());return ge(document.body).pipe(te("height"),v(s=>C(()=>{let p=[];return I([...o].reduce((c,[l,f])=>{for(;p.length&&o.get(p[p.length-1]).tagName>=f.tagName;)p.pop();let u=f.offsetTop;for(;!u&&f.parentElement;)f=f.parentElement,u=f.offsetTop;let d=f.offsetParent;for(;d;d=d.offsetParent)u+=d.offsetTop;return c.set([...p=[...p,l]].reverse(),u)},new Map))}).pipe(m(p=>new Map([...p].sort(([,c],[,l])=>c-l))),He(i),v(([p,c])=>t.pipe(Fr(([l,f],{offset:{y:u},size:d})=>{let y=u+d.height>=Math.floor(s.height);for(;f.length;){let[,L]=f[0];if(L-c=u&&!y)f=[l.pop(),...f];else break}return[l,f]},[[],[...p]]),K((l,f)=>l[0]===f[0]&&l[1]===f[1])))))).pipe(m(([s,p])=>({prev:s.map(([c])=>c),next:p.map(([c])=>c)})),Q({prev:[],next:[]}),Be(2,1),m(([s,p])=>s.prev.length{let i=new g,a=i.pipe(Z(),ie(!0));if(i.subscribe(({prev:s,next:p})=>{for(let[c]of p)c.classList.remove("md-nav__link--passed"),c.classList.remove("md-nav__link--active");for(let[c,[l]]of s.entries())l.classList.add("md-nav__link--passed"),l.classList.toggle("md-nav__link--active",c===s.length-1)}),B("toc.follow")){let s=O(t.pipe(_e(1),m(()=>{})),t.pipe(_e(250),m(()=>"smooth")));i.pipe(b(({prev:p})=>p.length>0),He(o.pipe(ve(se))),re(s)).subscribe(([[{prev:p}],c])=>{let[l]=p[p.length-1];if(l.offsetHeight){let f=cr(l);if(typeof f!="undefined"){let u=l.offsetTop-f.offsetTop,{height:d}=ce(f);f.scrollTo({top:u-d/2,behavior:c})}}})}return B("navigation.tracking")&&t.pipe(W(a),te("offset"),_e(250),Ce(1),W(n.pipe(Ce(1))),ct({delay:250}),re(i)).subscribe(([,{prev:s}])=>{let p=ye(),c=s[s.length-1];if(c&&c.length){let[l]=c,{hash:f}=new URL(l.href);p.hash!==f&&(p.hash=f,history.replaceState({},"",`${p}`))}else p.hash="",history.replaceState({},"",`${p}`)}),bs(e,{viewport$:t,header$:r}).pipe(w(s=>i.next(s)),_(()=>i.complete()),m(s=>$({ref:e},s)))})}function vs(e,{viewport$:t,main$:r,target$:o}){let n=t.pipe(m(({offset:{y:a}})=>a),Be(2,1),m(([a,s])=>a>s&&s>0),K()),i=r.pipe(m(({active:a})=>a));return N([i,n]).pipe(m(([a,s])=>!(a&&s)),K(),W(o.pipe(Ce(1))),ie(!0),ct({delay:250}),m(a=>({hidden:a})))}function Ei(e,{viewport$:t,header$:r,main$:o,target$:n}){let i=new g,a=i.pipe(Z(),ie(!0));return i.subscribe({next({hidden:s}){e.hidden=s,s?(e.setAttribute("tabindex","-1"),e.blur()):e.removeAttribute("tabindex")},complete(){e.style.top="",e.hidden=!0,e.removeAttribute("tabindex")}}),r.pipe(W(a),te("height")).subscribe(({height:s})=>{e.style.top=`${s+16}px`}),h(e,"click").subscribe(s=>{s.preventDefault(),window.scrollTo({top:0})}),vs(e,{viewport$:t,main$:o,target$:n}).pipe(w(s=>i.next(s)),_(()=>i.complete()),m(s=>$({ref:e},s)))}function wi({document$:e,viewport$:t}){e.pipe(v(()=>P(".md-ellipsis")),ne(r=>tt(r).pipe(W(e.pipe(Ce(1))),b(o=>o),m(()=>r),Te(1))),b(r=>r.offsetWidth{let o=r.innerText,n=r.closest("a")||r;return n.title=o,B("content.tooltips")?mt(n,{viewport$:t}).pipe(W(e.pipe(Ce(1))),_(()=>n.removeAttribute("title"))):S})).subscribe(),B("content.tooltips")&&e.pipe(v(()=>P(".md-status")),ne(r=>mt(r,{viewport$:t}))).subscribe()}function Ti({document$:e,tablet$:t}){e.pipe(v(()=>P(".md-toggle--indeterminate")),w(r=>{r.indeterminate=!0,r.checked=!1}),ne(r=>h(r,"change").pipe(Vr(()=>r.classList.contains("md-toggle--indeterminate")),m(()=>r))),re(t)).subscribe(([r,o])=>{r.classList.remove("md-toggle--indeterminate"),o&&(r.checked=!1)})}function gs(){return/(iPad|iPhone|iPod)/.test(navigator.userAgent)}function Si({document$:e}){e.pipe(v(()=>P("[data-md-scrollfix]")),w(t=>t.removeAttribute("data-md-scrollfix")),b(gs),ne(t=>h(t,"touchstart").pipe(m(()=>t)))).subscribe(t=>{let r=t.scrollTop;r===0?t.scrollTop=1:r+t.offsetHeight===t.scrollHeight&&(t.scrollTop=r-1)})}function Oi({viewport$:e,tablet$:t}){N([Ne("search"),t]).pipe(m(([r,o])=>r&&!o),v(r=>I(r).pipe(Ge(r?400:100))),re(e)).subscribe(([r,{offset:{y:o}}])=>{if(r)document.body.setAttribute("data-md-scrolllock",""),document.body.style.top=`-${o}px`;else{let n=-1*parseInt(document.body.style.top,10);document.body.removeAttribute("data-md-scrolllock"),document.body.style.top="",n&&window.scrollTo(0,n)}})}Object.entries||(Object.entries=function(e){let t=[];for(let r of Object.keys(e))t.push([r,e[r]]);return t});Object.values||(Object.values=function(e){let t=[];for(let r of Object.keys(e))t.push(e[r]);return t});typeof Element!="undefined"&&(Element.prototype.scrollTo||(Element.prototype.scrollTo=function(e,t){typeof e=="object"?(this.scrollLeft=e.left,this.scrollTop=e.top):(this.scrollLeft=e,this.scrollTop=t)}),Element.prototype.replaceWith||(Element.prototype.replaceWith=function(...e){let t=this.parentNode;if(t){e.length===0&&t.removeChild(this);for(let r=e.length-1;r>=0;r--){let o=e[r];typeof o=="string"?o=document.createTextNode(o):o.parentNode&&o.parentNode.removeChild(o),r?t.insertBefore(this.previousSibling,o):t.replaceChild(o,this)}}}));function ys(){return location.protocol==="file:"?wt(`${new URL("search/search_index.js",eo.base)}`).pipe(m(()=>__index),G(1)):je(new URL("search/search_index.json",eo.base))}document.documentElement.classList.remove("no-js");document.documentElement.classList.add("js");var ot=Go(),Ft=sn(),Ot=ln(Ft),to=an(),Oe=gn(),hr=$t("(min-width: 960px)"),Mi=$t("(min-width: 1220px)"),_i=mn(),eo=xe(),Ai=document.forms.namedItem("search")?ys():Ye,ro=new g;Zn({alert$:ro});var oo=new g;B("navigation.instant")&&oi({location$:Ft,viewport$:Oe,progress$:oo}).subscribe(ot);var Li;((Li=eo.version)==null?void 0:Li.provider)==="mike"&&ci({document$:ot});O(Ft,Ot).pipe(Ge(125)).subscribe(()=>{Je("drawer",!1),Je("search",!1)});to.pipe(b(({mode:e})=>e==="global")).subscribe(e=>{switch(e.type){case"p":case",":let t=fe("link[rel=prev]");typeof t!="undefined"&<(t);break;case"n":case".":let r=fe("link[rel=next]");typeof r!="undefined"&<(r);break;case"Enter":let o=Ie();o instanceof HTMLLabelElement&&o.click()}});wi({viewport$:Oe,document$:ot});Ti({document$:ot,tablet$:hr});Si({document$:ot});Oi({viewport$:Oe,tablet$:hr});var rt=Kn(Se("header"),{viewport$:Oe}),jt=ot.pipe(m(()=>Se("main")),v(e=>Gn(e,{viewport$:Oe,header$:rt})),G(1)),xs=O(...ae("consent").map(e=>En(e,{target$:Ot})),...ae("dialog").map(e=>qn(e,{alert$:ro})),...ae("palette").map(e=>Jn(e)),...ae("progress").map(e=>Xn(e,{progress$:oo})),...ae("search").map(e=>ui(e,{index$:Ai,keyboard$:to})),...ae("source").map(e=>gi(e))),Es=C(()=>O(...ae("announce").map(e=>xn(e)),...ae("content").map(e=>Nn(e,{viewport$:Oe,target$:Ot,print$:_i})),...ae("content").map(e=>B("search.highlight")?di(e,{index$:Ai,location$:Ft}):S),...ae("header").map(e=>Yn(e,{viewport$:Oe,header$:rt,main$:jt})),...ae("header-title").map(e=>Bn(e,{viewport$:Oe,header$:rt})),...ae("sidebar").map(e=>e.getAttribute("data-md-type")==="navigation"?zr(Mi,()=>Zr(e,{viewport$:Oe,header$:rt,main$:jt})):zr(hr,()=>Zr(e,{viewport$:Oe,header$:rt,main$:jt}))),...ae("tabs").map(e=>yi(e,{viewport$:Oe,header$:rt})),...ae("toc").map(e=>xi(e,{viewport$:Oe,header$:rt,main$:jt,target$:Ot})),...ae("top").map(e=>Ei(e,{viewport$:Oe,header$:rt,main$:jt,target$:Ot})))),Ci=ot.pipe(v(()=>Es),Re(xs),G(1));Ci.subscribe();window.document$=ot;window.location$=Ft;window.target$=Ot;window.keyboard$=to;window.viewport$=Oe;window.tablet$=hr;window.screen$=Mi;window.print$=_i;window.alert$=ro;window.progress$=oo;window.component$=Ci;})(); -//# sourceMappingURL=bundle.13a4f30d.min.js.map diff --git a/site/assets/javascripts/bundle.13a4f30d.min.js.map b/site/assets/javascripts/bundle.13a4f30d.min.js.map deleted file mode 100644 index 8941cb8..0000000 --- a/site/assets/javascripts/bundle.13a4f30d.min.js.map +++ /dev/null @@ -1,7 +0,0 @@ -{ - "version": 3, - "sources": ["node_modules/focus-visible/dist/focus-visible.js", "node_modules/escape-html/index.js", "node_modules/clipboard/dist/clipboard.js", "src/templates/assets/javascripts/bundle.ts", "node_modules/tslib/tslib.es6.mjs", "node_modules/rxjs/src/internal/util/isFunction.ts", "node_modules/rxjs/src/internal/util/createErrorClass.ts", "node_modules/rxjs/src/internal/util/UnsubscriptionError.ts", "node_modules/rxjs/src/internal/util/arrRemove.ts", "node_modules/rxjs/src/internal/Subscription.ts", "node_modules/rxjs/src/internal/config.ts", "node_modules/rxjs/src/internal/scheduler/timeoutProvider.ts", "node_modules/rxjs/src/internal/util/reportUnhandledError.ts", "node_modules/rxjs/src/internal/util/noop.ts", "node_modules/rxjs/src/internal/NotificationFactories.ts", "node_modules/rxjs/src/internal/util/errorContext.ts", "node_modules/rxjs/src/internal/Subscriber.ts", "node_modules/rxjs/src/internal/symbol/observable.ts", "node_modules/rxjs/src/internal/util/identity.ts", "node_modules/rxjs/src/internal/util/pipe.ts", "node_modules/rxjs/src/internal/Observable.ts", "node_modules/rxjs/src/internal/util/lift.ts", "node_modules/rxjs/src/internal/operators/OperatorSubscriber.ts", "node_modules/rxjs/src/internal/scheduler/animationFrameProvider.ts", "node_modules/rxjs/src/internal/util/ObjectUnsubscribedError.ts", "node_modules/rxjs/src/internal/Subject.ts", "node_modules/rxjs/src/internal/BehaviorSubject.ts", "node_modules/rxjs/src/internal/scheduler/dateTimestampProvider.ts", "node_modules/rxjs/src/internal/ReplaySubject.ts", "node_modules/rxjs/src/internal/scheduler/Action.ts", "node_modules/rxjs/src/internal/scheduler/intervalProvider.ts", "node_modules/rxjs/src/internal/scheduler/AsyncAction.ts", "node_modules/rxjs/src/internal/Scheduler.ts", "node_modules/rxjs/src/internal/scheduler/AsyncScheduler.ts", "node_modules/rxjs/src/internal/scheduler/async.ts", "node_modules/rxjs/src/internal/scheduler/QueueAction.ts", "node_modules/rxjs/src/internal/scheduler/QueueScheduler.ts", "node_modules/rxjs/src/internal/scheduler/queue.ts", "node_modules/rxjs/src/internal/scheduler/AnimationFrameAction.ts", "node_modules/rxjs/src/internal/scheduler/AnimationFrameScheduler.ts", "node_modules/rxjs/src/internal/scheduler/animationFrame.ts", "node_modules/rxjs/src/internal/observable/empty.ts", "node_modules/rxjs/src/internal/util/isScheduler.ts", "node_modules/rxjs/src/internal/util/args.ts", "node_modules/rxjs/src/internal/util/isArrayLike.ts", "node_modules/rxjs/src/internal/util/isPromise.ts", "node_modules/rxjs/src/internal/util/isInteropObservable.ts", "node_modules/rxjs/src/internal/util/isAsyncIterable.ts", "node_modules/rxjs/src/internal/util/throwUnobservableError.ts", "node_modules/rxjs/src/internal/symbol/iterator.ts", "node_modules/rxjs/src/internal/util/isIterable.ts", "node_modules/rxjs/src/internal/util/isReadableStreamLike.ts", "node_modules/rxjs/src/internal/observable/innerFrom.ts", "node_modules/rxjs/src/internal/util/executeSchedule.ts", "node_modules/rxjs/src/internal/operators/observeOn.ts", "node_modules/rxjs/src/internal/operators/subscribeOn.ts", "node_modules/rxjs/src/internal/scheduled/scheduleObservable.ts", "node_modules/rxjs/src/internal/scheduled/schedulePromise.ts", "node_modules/rxjs/src/internal/scheduled/scheduleArray.ts", "node_modules/rxjs/src/internal/scheduled/scheduleIterable.ts", "node_modules/rxjs/src/internal/scheduled/scheduleAsyncIterable.ts", "node_modules/rxjs/src/internal/scheduled/scheduleReadableStreamLike.ts", "node_modules/rxjs/src/internal/scheduled/scheduled.ts", "node_modules/rxjs/src/internal/observable/from.ts", "node_modules/rxjs/src/internal/observable/of.ts", "node_modules/rxjs/src/internal/observable/throwError.ts", "node_modules/rxjs/src/internal/util/EmptyError.ts", "node_modules/rxjs/src/internal/util/isDate.ts", "node_modules/rxjs/src/internal/operators/map.ts", "node_modules/rxjs/src/internal/util/mapOneOrManyArgs.ts", "node_modules/rxjs/src/internal/util/argsArgArrayOrObject.ts", "node_modules/rxjs/src/internal/util/createObject.ts", "node_modules/rxjs/src/internal/observable/combineLatest.ts", "node_modules/rxjs/src/internal/operators/mergeInternals.ts", "node_modules/rxjs/src/internal/operators/mergeMap.ts", "node_modules/rxjs/src/internal/operators/mergeAll.ts", "node_modules/rxjs/src/internal/operators/concatAll.ts", "node_modules/rxjs/src/internal/observable/concat.ts", "node_modules/rxjs/src/internal/observable/defer.ts", "node_modules/rxjs/src/internal/observable/fromEvent.ts", "node_modules/rxjs/src/internal/observable/fromEventPattern.ts", "node_modules/rxjs/src/internal/observable/timer.ts", "node_modules/rxjs/src/internal/observable/merge.ts", "node_modules/rxjs/src/internal/observable/never.ts", "node_modules/rxjs/src/internal/util/argsOrArgArray.ts", "node_modules/rxjs/src/internal/operators/filter.ts", "node_modules/rxjs/src/internal/observable/zip.ts", "node_modules/rxjs/src/internal/operators/audit.ts", "node_modules/rxjs/src/internal/operators/auditTime.ts", "node_modules/rxjs/src/internal/operators/bufferCount.ts", "node_modules/rxjs/src/internal/operators/catchError.ts", "node_modules/rxjs/src/internal/operators/scanInternals.ts", "node_modules/rxjs/src/internal/operators/combineLatest.ts", "node_modules/rxjs/src/internal/operators/combineLatestWith.ts", "node_modules/rxjs/src/internal/operators/debounce.ts", "node_modules/rxjs/src/internal/operators/debounceTime.ts", "node_modules/rxjs/src/internal/operators/defaultIfEmpty.ts", "node_modules/rxjs/src/internal/operators/take.ts", "node_modules/rxjs/src/internal/operators/ignoreElements.ts", "node_modules/rxjs/src/internal/operators/mapTo.ts", "node_modules/rxjs/src/internal/operators/delayWhen.ts", "node_modules/rxjs/src/internal/operators/delay.ts", "node_modules/rxjs/src/internal/operators/distinctUntilChanged.ts", "node_modules/rxjs/src/internal/operators/distinctUntilKeyChanged.ts", "node_modules/rxjs/src/internal/operators/throwIfEmpty.ts", "node_modules/rxjs/src/internal/operators/endWith.ts", "node_modules/rxjs/src/internal/operators/finalize.ts", "node_modules/rxjs/src/internal/operators/first.ts", "node_modules/rxjs/src/internal/operators/takeLast.ts", "node_modules/rxjs/src/internal/operators/merge.ts", "node_modules/rxjs/src/internal/operators/mergeWith.ts", "node_modules/rxjs/src/internal/operators/repeat.ts", "node_modules/rxjs/src/internal/operators/scan.ts", "node_modules/rxjs/src/internal/operators/share.ts", "node_modules/rxjs/src/internal/operators/shareReplay.ts", "node_modules/rxjs/src/internal/operators/skip.ts", "node_modules/rxjs/src/internal/operators/skipUntil.ts", "node_modules/rxjs/src/internal/operators/startWith.ts", "node_modules/rxjs/src/internal/operators/switchMap.ts", "node_modules/rxjs/src/internal/operators/takeUntil.ts", "node_modules/rxjs/src/internal/operators/takeWhile.ts", "node_modules/rxjs/src/internal/operators/tap.ts", "node_modules/rxjs/src/internal/operators/throttle.ts", "node_modules/rxjs/src/internal/operators/throttleTime.ts", "node_modules/rxjs/src/internal/operators/withLatestFrom.ts", "node_modules/rxjs/src/internal/operators/zip.ts", "node_modules/rxjs/src/internal/operators/zipWith.ts", "src/templates/assets/javascripts/browser/document/index.ts", "src/templates/assets/javascripts/browser/element/_/index.ts", "src/templates/assets/javascripts/browser/element/focus/index.ts", "src/templates/assets/javascripts/browser/element/hover/index.ts", "src/templates/assets/javascripts/utilities/h/index.ts", "src/templates/assets/javascripts/utilities/round/index.ts", "src/templates/assets/javascripts/browser/script/index.ts", "src/templates/assets/javascripts/browser/element/size/_/index.ts", "src/templates/assets/javascripts/browser/element/size/content/index.ts", "src/templates/assets/javascripts/browser/element/offset/_/index.ts", "src/templates/assets/javascripts/browser/element/offset/content/index.ts", "src/templates/assets/javascripts/browser/element/visibility/index.ts", "src/templates/assets/javascripts/browser/toggle/index.ts", "src/templates/assets/javascripts/browser/keyboard/index.ts", "src/templates/assets/javascripts/browser/location/_/index.ts", "src/templates/assets/javascripts/browser/location/hash/index.ts", "src/templates/assets/javascripts/browser/media/index.ts", "src/templates/assets/javascripts/browser/request/index.ts", "src/templates/assets/javascripts/browser/viewport/offset/index.ts", "src/templates/assets/javascripts/browser/viewport/size/index.ts", "src/templates/assets/javascripts/browser/viewport/_/index.ts", "src/templates/assets/javascripts/browser/viewport/at/index.ts", "src/templates/assets/javascripts/browser/worker/index.ts", "src/templates/assets/javascripts/_/index.ts", "src/templates/assets/javascripts/components/_/index.ts", "src/templates/assets/javascripts/components/announce/index.ts", "src/templates/assets/javascripts/components/consent/index.ts", "src/templates/assets/javascripts/templates/tooltip/index.tsx", "src/templates/assets/javascripts/templates/annotation/index.tsx", "src/templates/assets/javascripts/templates/clipboard/index.tsx", "src/templates/assets/javascripts/templates/search/index.tsx", "src/templates/assets/javascripts/templates/source/index.tsx", "src/templates/assets/javascripts/templates/tabbed/index.tsx", "src/templates/assets/javascripts/templates/table/index.tsx", "src/templates/assets/javascripts/templates/version/index.tsx", "src/templates/assets/javascripts/components/tooltip2/index.ts", "src/templates/assets/javascripts/components/content/annotation/_/index.ts", "src/templates/assets/javascripts/components/content/annotation/list/index.ts", "src/templates/assets/javascripts/components/content/annotation/block/index.ts", "src/templates/assets/javascripts/components/content/code/_/index.ts", "src/templates/assets/javascripts/components/content/details/index.ts", "src/templates/assets/javascripts/components/content/mermaid/index.css", "src/templates/assets/javascripts/components/content/mermaid/index.ts", "src/templates/assets/javascripts/components/content/table/index.ts", "src/templates/assets/javascripts/components/content/tabs/index.ts", "src/templates/assets/javascripts/components/content/_/index.ts", "src/templates/assets/javascripts/components/dialog/index.ts", "src/templates/assets/javascripts/components/tooltip/index.ts", "src/templates/assets/javascripts/components/header/_/index.ts", "src/templates/assets/javascripts/components/header/title/index.ts", "src/templates/assets/javascripts/components/main/index.ts", "src/templates/assets/javascripts/components/palette/index.ts", "src/templates/assets/javascripts/components/progress/index.ts", "src/templates/assets/javascripts/integrations/clipboard/index.ts", "src/templates/assets/javascripts/integrations/sitemap/index.ts", "src/templates/assets/javascripts/integrations/instant/index.ts", "src/templates/assets/javascripts/integrations/search/highlighter/index.ts", "src/templates/assets/javascripts/integrations/search/worker/message/index.ts", "src/templates/assets/javascripts/integrations/search/worker/_/index.ts", "src/templates/assets/javascripts/integrations/version/findurl/index.ts", "src/templates/assets/javascripts/integrations/version/index.ts", "src/templates/assets/javascripts/components/search/query/index.ts", "src/templates/assets/javascripts/components/search/result/index.ts", "src/templates/assets/javascripts/components/search/share/index.ts", "src/templates/assets/javascripts/components/search/suggest/index.ts", "src/templates/assets/javascripts/components/search/_/index.ts", "src/templates/assets/javascripts/components/search/highlight/index.ts", "src/templates/assets/javascripts/components/sidebar/index.ts", "src/templates/assets/javascripts/components/source/facts/github/index.ts", "src/templates/assets/javascripts/components/source/facts/gitlab/index.ts", "src/templates/assets/javascripts/components/source/facts/_/index.ts", "src/templates/assets/javascripts/components/source/_/index.ts", "src/templates/assets/javascripts/components/tabs/index.ts", "src/templates/assets/javascripts/components/toc/index.ts", "src/templates/assets/javascripts/components/top/index.ts", "src/templates/assets/javascripts/patches/ellipsis/index.ts", "src/templates/assets/javascripts/patches/indeterminate/index.ts", "src/templates/assets/javascripts/patches/scrollfix/index.ts", "src/templates/assets/javascripts/patches/scrolllock/index.ts", "src/templates/assets/javascripts/polyfills/index.ts"], - "sourcesContent": ["(function (global, factory) {\n typeof exports === 'object' && typeof module !== 'undefined' ? factory() :\n typeof define === 'function' && define.amd ? define(factory) :\n (factory());\n}(this, (function () { 'use strict';\n\n /**\n * Applies the :focus-visible polyfill at the given scope.\n * A scope in this case is either the top-level Document or a Shadow Root.\n *\n * @param {(Document|ShadowRoot)} scope\n * @see https://github.com/WICG/focus-visible\n */\n function applyFocusVisiblePolyfill(scope) {\n var hadKeyboardEvent = true;\n var hadFocusVisibleRecently = false;\n var hadFocusVisibleRecentlyTimeout = null;\n\n var inputTypesAllowlist = {\n text: true,\n search: true,\n url: true,\n tel: true,\n email: true,\n password: true,\n number: true,\n date: true,\n month: true,\n week: true,\n time: true,\n datetime: true,\n 'datetime-local': true\n };\n\n /**\n * Helper function for legacy browsers and iframes which sometimes focus\n * elements like document, body, and non-interactive SVG.\n * @param {Element} el\n */\n function isValidFocusTarget(el) {\n if (\n el &&\n el !== document &&\n el.nodeName !== 'HTML' &&\n el.nodeName !== 'BODY' &&\n 'classList' in el &&\n 'contains' in el.classList\n ) {\n return true;\n }\n return false;\n }\n\n /**\n * Computes whether the given element should automatically trigger the\n * `focus-visible` class being added, i.e. whether it should always match\n * `:focus-visible` when focused.\n * @param {Element} el\n * @return {boolean}\n */\n function focusTriggersKeyboardModality(el) {\n var type = el.type;\n var tagName = el.tagName;\n\n if (tagName === 'INPUT' && inputTypesAllowlist[type] && !el.readOnly) {\n return true;\n }\n\n if (tagName === 'TEXTAREA' && !el.readOnly) {\n return true;\n }\n\n if (el.isContentEditable) {\n return true;\n }\n\n return false;\n }\n\n /**\n * Add the `focus-visible` class to the given element if it was not added by\n * the author.\n * @param {Element} el\n */\n function addFocusVisibleClass(el) {\n if (el.classList.contains('focus-visible')) {\n return;\n }\n el.classList.add('focus-visible');\n el.setAttribute('data-focus-visible-added', '');\n }\n\n /**\n * Remove the `focus-visible` class from the given element if it was not\n * originally added by the author.\n * @param {Element} el\n */\n function removeFocusVisibleClass(el) {\n if (!el.hasAttribute('data-focus-visible-added')) {\n return;\n }\n el.classList.remove('focus-visible');\n el.removeAttribute('data-focus-visible-added');\n }\n\n /**\n * If the most recent user interaction was via the keyboard;\n * and the key press did not include a meta, alt/option, or control key;\n * then the modality is keyboard. Otherwise, the modality is not keyboard.\n * Apply `focus-visible` to any current active element and keep track\n * of our keyboard modality state with `hadKeyboardEvent`.\n * @param {KeyboardEvent} e\n */\n function onKeyDown(e) {\n if (e.metaKey || e.altKey || e.ctrlKey) {\n return;\n }\n\n if (isValidFocusTarget(scope.activeElement)) {\n addFocusVisibleClass(scope.activeElement);\n }\n\n hadKeyboardEvent = true;\n }\n\n /**\n * If at any point a user clicks with a pointing device, ensure that we change\n * the modality away from keyboard.\n * This avoids the situation where a user presses a key on an already focused\n * element, and then clicks on a different element, focusing it with a\n * pointing device, while we still think we're in keyboard modality.\n * @param {Event} e\n */\n function onPointerDown(e) {\n hadKeyboardEvent = false;\n }\n\n /**\n * On `focus`, add the `focus-visible` class to the target if:\n * - the target received focus as a result of keyboard navigation, or\n * - the event target is an element that will likely require interaction\n * via the keyboard (e.g. a text box)\n * @param {Event} e\n */\n function onFocus(e) {\n // Prevent IE from focusing the document or HTML element.\n if (!isValidFocusTarget(e.target)) {\n return;\n }\n\n if (hadKeyboardEvent || focusTriggersKeyboardModality(e.target)) {\n addFocusVisibleClass(e.target);\n }\n }\n\n /**\n * On `blur`, remove the `focus-visible` class from the target.\n * @param {Event} e\n */\n function onBlur(e) {\n if (!isValidFocusTarget(e.target)) {\n return;\n }\n\n if (\n e.target.classList.contains('focus-visible') ||\n e.target.hasAttribute('data-focus-visible-added')\n ) {\n // To detect a tab/window switch, we look for a blur event followed\n // rapidly by a visibility change.\n // If we don't see a visibility change within 100ms, it's probably a\n // regular focus change.\n hadFocusVisibleRecently = true;\n window.clearTimeout(hadFocusVisibleRecentlyTimeout);\n hadFocusVisibleRecentlyTimeout = window.setTimeout(function() {\n hadFocusVisibleRecently = false;\n }, 100);\n removeFocusVisibleClass(e.target);\n }\n }\n\n /**\n * If the user changes tabs, keep track of whether or not the previously\n * focused element had .focus-visible.\n * @param {Event} e\n */\n function onVisibilityChange(e) {\n if (document.visibilityState === 'hidden') {\n // If the tab becomes active again, the browser will handle calling focus\n // on the element (Safari actually calls it twice).\n // If this tab change caused a blur on an element with focus-visible,\n // re-apply the class when the user switches back to the tab.\n if (hadFocusVisibleRecently) {\n hadKeyboardEvent = true;\n }\n addInitialPointerMoveListeners();\n }\n }\n\n /**\n * Add a group of listeners to detect usage of any pointing devices.\n * These listeners will be added when the polyfill first loads, and anytime\n * the window is blurred, so that they are active when the window regains\n * focus.\n */\n function addInitialPointerMoveListeners() {\n document.addEventListener('mousemove', onInitialPointerMove);\n document.addEventListener('mousedown', onInitialPointerMove);\n document.addEventListener('mouseup', onInitialPointerMove);\n document.addEventListener('pointermove', onInitialPointerMove);\n document.addEventListener('pointerdown', onInitialPointerMove);\n document.addEventListener('pointerup', onInitialPointerMove);\n document.addEventListener('touchmove', onInitialPointerMove);\n document.addEventListener('touchstart', onInitialPointerMove);\n document.addEventListener('touchend', onInitialPointerMove);\n }\n\n function removeInitialPointerMoveListeners() {\n document.removeEventListener('mousemove', onInitialPointerMove);\n document.removeEventListener('mousedown', onInitialPointerMove);\n document.removeEventListener('mouseup', onInitialPointerMove);\n document.removeEventListener('pointermove', onInitialPointerMove);\n document.removeEventListener('pointerdown', onInitialPointerMove);\n document.removeEventListener('pointerup', onInitialPointerMove);\n document.removeEventListener('touchmove', onInitialPointerMove);\n document.removeEventListener('touchstart', onInitialPointerMove);\n document.removeEventListener('touchend', onInitialPointerMove);\n }\n\n /**\n * When the polfyill first loads, assume the user is in keyboard modality.\n * If any event is received from a pointing device (e.g. mouse, pointer,\n * touch), turn off keyboard modality.\n * This accounts for situations where focus enters the page from the URL bar.\n * @param {Event} e\n */\n function onInitialPointerMove(e) {\n // Work around a Safari quirk that fires a mousemove on whenever the\n // window blurs, even if you're tabbing out of the page. \u00AF\\_(\u30C4)_/\u00AF\n if (e.target.nodeName && e.target.nodeName.toLowerCase() === 'html') {\n return;\n }\n\n hadKeyboardEvent = false;\n removeInitialPointerMoveListeners();\n }\n\n // For some kinds of state, we are interested in changes at the global scope\n // only. For example, global pointer input, global key presses and global\n // visibility change should affect the state at every scope:\n document.addEventListener('keydown', onKeyDown, true);\n document.addEventListener('mousedown', onPointerDown, true);\n document.addEventListener('pointerdown', onPointerDown, true);\n document.addEventListener('touchstart', onPointerDown, true);\n document.addEventListener('visibilitychange', onVisibilityChange, true);\n\n addInitialPointerMoveListeners();\n\n // For focus and blur, we specifically care about state changes in the local\n // scope. This is because focus / blur events that originate from within a\n // shadow root are not re-dispatched from the host element if it was already\n // the active element in its own scope:\n scope.addEventListener('focus', onFocus, true);\n scope.addEventListener('blur', onBlur, true);\n\n // We detect that a node is a ShadowRoot by ensuring that it is a\n // DocumentFragment and also has a host property. This check covers native\n // implementation and polyfill implementation transparently. If we only cared\n // about the native implementation, we could just check if the scope was\n // an instance of a ShadowRoot.\n if (scope.nodeType === Node.DOCUMENT_FRAGMENT_NODE && scope.host) {\n // Since a ShadowRoot is a special kind of DocumentFragment, it does not\n // have a root element to add a class to. So, we add this attribute to the\n // host element instead:\n scope.host.setAttribute('data-js-focus-visible', '');\n } else if (scope.nodeType === Node.DOCUMENT_NODE) {\n document.documentElement.classList.add('js-focus-visible');\n document.documentElement.setAttribute('data-js-focus-visible', '');\n }\n }\n\n // It is important to wrap all references to global window and document in\n // these checks to support server-side rendering use cases\n // @see https://github.com/WICG/focus-visible/issues/199\n if (typeof window !== 'undefined' && typeof document !== 'undefined') {\n // Make the polyfill helper globally available. This can be used as a signal\n // to interested libraries that wish to coordinate with the polyfill for e.g.,\n // applying the polyfill to a shadow root:\n window.applyFocusVisiblePolyfill = applyFocusVisiblePolyfill;\n\n // Notify interested libraries of the polyfill's presence, in case the\n // polyfill was loaded lazily:\n var event;\n\n try {\n event = new CustomEvent('focus-visible-polyfill-ready');\n } catch (error) {\n // IE11 does not support using CustomEvent as a constructor directly:\n event = document.createEvent('CustomEvent');\n event.initCustomEvent('focus-visible-polyfill-ready', false, false, {});\n }\n\n window.dispatchEvent(event);\n }\n\n if (typeof document !== 'undefined') {\n // Apply the polyfill to the global document, so that no JavaScript\n // coordination is required to use the polyfill in the top-level document:\n applyFocusVisiblePolyfill(document);\n }\n\n})));\n", "/*!\n * escape-html\n * Copyright(c) 2012-2013 TJ Holowaychuk\n * Copyright(c) 2015 Andreas Lubbe\n * Copyright(c) 2015 Tiancheng \"Timothy\" Gu\n * MIT Licensed\n */\n\n'use strict';\n\n/**\n * Module variables.\n * @private\n */\n\nvar matchHtmlRegExp = /[\"'&<>]/;\n\n/**\n * Module exports.\n * @public\n */\n\nmodule.exports = escapeHtml;\n\n/**\n * Escape special characters in the given string of html.\n *\n * @param {string} string The string to escape for inserting into HTML\n * @return {string}\n * @public\n */\n\nfunction escapeHtml(string) {\n var str = '' + string;\n var match = matchHtmlRegExp.exec(str);\n\n if (!match) {\n return str;\n }\n\n var escape;\n var html = '';\n var index = 0;\n var lastIndex = 0;\n\n for (index = match.index; index < str.length; index++) {\n switch (str.charCodeAt(index)) {\n case 34: // \"\n escape = '"';\n break;\n case 38: // &\n escape = '&';\n break;\n case 39: // '\n escape = ''';\n break;\n case 60: // <\n escape = '<';\n break;\n case 62: // >\n escape = '>';\n break;\n default:\n continue;\n }\n\n if (lastIndex !== index) {\n html += str.substring(lastIndex, index);\n }\n\n lastIndex = index + 1;\n html += escape;\n }\n\n return lastIndex !== index\n ? html + str.substring(lastIndex, index)\n : html;\n}\n", "/*!\n * clipboard.js v2.0.11\n * https://clipboardjs.com/\n *\n * Licensed MIT \u00A9 Zeno Rocha\n */\n(function webpackUniversalModuleDefinition(root, factory) {\n\tif(typeof exports === 'object' && typeof module === 'object')\n\t\tmodule.exports = factory();\n\telse if(typeof define === 'function' && define.amd)\n\t\tdefine([], factory);\n\telse if(typeof exports === 'object')\n\t\texports[\"ClipboardJS\"] = factory();\n\telse\n\t\troot[\"ClipboardJS\"] = factory();\n})(this, function() {\nreturn /******/ (function() { // webpackBootstrap\n/******/ \tvar __webpack_modules__ = ({\n\n/***/ 686:\n/***/ (function(__unused_webpack_module, __webpack_exports__, __webpack_require__) {\n\n\"use strict\";\n\n// EXPORTS\n__webpack_require__.d(__webpack_exports__, {\n \"default\": function() { return /* binding */ clipboard; }\n});\n\n// EXTERNAL MODULE: ./node_modules/tiny-emitter/index.js\nvar tiny_emitter = __webpack_require__(279);\nvar tiny_emitter_default = /*#__PURE__*/__webpack_require__.n(tiny_emitter);\n// EXTERNAL MODULE: ./node_modules/good-listener/src/listen.js\nvar listen = __webpack_require__(370);\nvar listen_default = /*#__PURE__*/__webpack_require__.n(listen);\n// EXTERNAL MODULE: ./node_modules/select/src/select.js\nvar src_select = __webpack_require__(817);\nvar select_default = /*#__PURE__*/__webpack_require__.n(src_select);\n;// CONCATENATED MODULE: ./src/common/command.js\n/**\n * Executes a given operation type.\n * @param {String} type\n * @return {Boolean}\n */\nfunction command(type) {\n try {\n return document.execCommand(type);\n } catch (err) {\n return false;\n }\n}\n;// CONCATENATED MODULE: ./src/actions/cut.js\n\n\n/**\n * Cut action wrapper.\n * @param {String|HTMLElement} target\n * @return {String}\n */\n\nvar ClipboardActionCut = function ClipboardActionCut(target) {\n var selectedText = select_default()(target);\n command('cut');\n return selectedText;\n};\n\n/* harmony default export */ var actions_cut = (ClipboardActionCut);\n;// CONCATENATED MODULE: ./src/common/create-fake-element.js\n/**\n * Creates a fake textarea element with a value.\n * @param {String} value\n * @return {HTMLElement}\n */\nfunction createFakeElement(value) {\n var isRTL = document.documentElement.getAttribute('dir') === 'rtl';\n var fakeElement = document.createElement('textarea'); // Prevent zooming on iOS\n\n fakeElement.style.fontSize = '12pt'; // Reset box model\n\n fakeElement.style.border = '0';\n fakeElement.style.padding = '0';\n fakeElement.style.margin = '0'; // Move element out of screen horizontally\n\n fakeElement.style.position = 'absolute';\n fakeElement.style[isRTL ? 'right' : 'left'] = '-9999px'; // Move element to the same position vertically\n\n var yPosition = window.pageYOffset || document.documentElement.scrollTop;\n fakeElement.style.top = \"\".concat(yPosition, \"px\");\n fakeElement.setAttribute('readonly', '');\n fakeElement.value = value;\n return fakeElement;\n}\n;// CONCATENATED MODULE: ./src/actions/copy.js\n\n\n\n/**\n * Create fake copy action wrapper using a fake element.\n * @param {String} target\n * @param {Object} options\n * @return {String}\n */\n\nvar fakeCopyAction = function fakeCopyAction(value, options) {\n var fakeElement = createFakeElement(value);\n options.container.appendChild(fakeElement);\n var selectedText = select_default()(fakeElement);\n command('copy');\n fakeElement.remove();\n return selectedText;\n};\n/**\n * Copy action wrapper.\n * @param {String|HTMLElement} target\n * @param {Object} options\n * @return {String}\n */\n\n\nvar ClipboardActionCopy = function ClipboardActionCopy(target) {\n var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {\n container: document.body\n };\n var selectedText = '';\n\n if (typeof target === 'string') {\n selectedText = fakeCopyAction(target, options);\n } else if (target instanceof HTMLInputElement && !['text', 'search', 'url', 'tel', 'password'].includes(target === null || target === void 0 ? void 0 : target.type)) {\n // If input type doesn't support `setSelectionRange`. Simulate it. https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/setSelectionRange\n selectedText = fakeCopyAction(target.value, options);\n } else {\n selectedText = select_default()(target);\n command('copy');\n }\n\n return selectedText;\n};\n\n/* harmony default export */ var actions_copy = (ClipboardActionCopy);\n;// CONCATENATED MODULE: ./src/actions/default.js\nfunction _typeof(obj) { \"@babel/helpers - typeof\"; if (typeof Symbol === \"function\" && typeof Symbol.iterator === \"symbol\") { _typeof = function _typeof(obj) { return typeof obj; }; } else { _typeof = function _typeof(obj) { return obj && typeof Symbol === \"function\" && obj.constructor === Symbol && obj !== Symbol.prototype ? \"symbol\" : typeof obj; }; } return _typeof(obj); }\n\n\n\n/**\n * Inner function which performs selection from either `text` or `target`\n * properties and then executes copy or cut operations.\n * @param {Object} options\n */\n\nvar ClipboardActionDefault = function ClipboardActionDefault() {\n var options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};\n // Defines base properties passed from constructor.\n var _options$action = options.action,\n action = _options$action === void 0 ? 'copy' : _options$action,\n container = options.container,\n target = options.target,\n text = options.text; // Sets the `action` to be performed which can be either 'copy' or 'cut'.\n\n if (action !== 'copy' && action !== 'cut') {\n throw new Error('Invalid \"action\" value, use either \"copy\" or \"cut\"');\n } // Sets the `target` property using an element that will be have its content copied.\n\n\n if (target !== undefined) {\n if (target && _typeof(target) === 'object' && target.nodeType === 1) {\n if (action === 'copy' && target.hasAttribute('disabled')) {\n throw new Error('Invalid \"target\" attribute. Please use \"readonly\" instead of \"disabled\" attribute');\n }\n\n if (action === 'cut' && (target.hasAttribute('readonly') || target.hasAttribute('disabled'))) {\n throw new Error('Invalid \"target\" attribute. You can\\'t cut text from elements with \"readonly\" or \"disabled\" attributes');\n }\n } else {\n throw new Error('Invalid \"target\" value, use a valid Element');\n }\n } // Define selection strategy based on `text` property.\n\n\n if (text) {\n return actions_copy(text, {\n container: container\n });\n } // Defines which selection strategy based on `target` property.\n\n\n if (target) {\n return action === 'cut' ? actions_cut(target) : actions_copy(target, {\n container: container\n });\n }\n};\n\n/* harmony default export */ var actions_default = (ClipboardActionDefault);\n;// CONCATENATED MODULE: ./src/clipboard.js\nfunction clipboard_typeof(obj) { \"@babel/helpers - typeof\"; if (typeof Symbol === \"function\" && typeof Symbol.iterator === \"symbol\") { clipboard_typeof = function _typeof(obj) { return typeof obj; }; } else { clipboard_typeof = function _typeof(obj) { return obj && typeof Symbol === \"function\" && obj.constructor === Symbol && obj !== Symbol.prototype ? \"symbol\" : typeof obj; }; } return clipboard_typeof(obj); }\n\nfunction _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError(\"Cannot call a class as a function\"); } }\n\nfunction _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if (\"value\" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } }\n\nfunction _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; }\n\nfunction _inherits(subClass, superClass) { if (typeof superClass !== \"function\" && superClass !== null) { throw new TypeError(\"Super expression must either be null or a function\"); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, writable: true, configurable: true } }); if (superClass) _setPrototypeOf(subClass, superClass); }\n\nfunction _setPrototypeOf(o, p) { _setPrototypeOf = Object.setPrototypeOf || function _setPrototypeOf(o, p) { o.__proto__ = p; return o; }; return _setPrototypeOf(o, p); }\n\nfunction _createSuper(Derived) { var hasNativeReflectConstruct = _isNativeReflectConstruct(); return function _createSuperInternal() { var Super = _getPrototypeOf(Derived), result; if (hasNativeReflectConstruct) { var NewTarget = _getPrototypeOf(this).constructor; result = Reflect.construct(Super, arguments, NewTarget); } else { result = Super.apply(this, arguments); } return _possibleConstructorReturn(this, result); }; }\n\nfunction _possibleConstructorReturn(self, call) { if (call && (clipboard_typeof(call) === \"object\" || typeof call === \"function\")) { return call; } return _assertThisInitialized(self); }\n\nfunction _assertThisInitialized(self) { if (self === void 0) { throw new ReferenceError(\"this hasn't been initialised - super() hasn't been called\"); } return self; }\n\nfunction _isNativeReflectConstruct() { if (typeof Reflect === \"undefined\" || !Reflect.construct) return false; if (Reflect.construct.sham) return false; if (typeof Proxy === \"function\") return true; try { Date.prototype.toString.call(Reflect.construct(Date, [], function () {})); return true; } catch (e) { return false; } }\n\nfunction _getPrototypeOf(o) { _getPrototypeOf = Object.setPrototypeOf ? Object.getPrototypeOf : function _getPrototypeOf(o) { return o.__proto__ || Object.getPrototypeOf(o); }; return _getPrototypeOf(o); }\n\n\n\n\n\n\n/**\n * Helper function to retrieve attribute value.\n * @param {String} suffix\n * @param {Element} element\n */\n\nfunction getAttributeValue(suffix, element) {\n var attribute = \"data-clipboard-\".concat(suffix);\n\n if (!element.hasAttribute(attribute)) {\n return;\n }\n\n return element.getAttribute(attribute);\n}\n/**\n * Base class which takes one or more elements, adds event listeners to them,\n * and instantiates a new `ClipboardAction` on each click.\n */\n\n\nvar Clipboard = /*#__PURE__*/function (_Emitter) {\n _inherits(Clipboard, _Emitter);\n\n var _super = _createSuper(Clipboard);\n\n /**\n * @param {String|HTMLElement|HTMLCollection|NodeList} trigger\n * @param {Object} options\n */\n function Clipboard(trigger, options) {\n var _this;\n\n _classCallCheck(this, Clipboard);\n\n _this = _super.call(this);\n\n _this.resolveOptions(options);\n\n _this.listenClick(trigger);\n\n return _this;\n }\n /**\n * Defines if attributes would be resolved using internal setter functions\n * or custom functions that were passed in the constructor.\n * @param {Object} options\n */\n\n\n _createClass(Clipboard, [{\n key: \"resolveOptions\",\n value: function resolveOptions() {\n var options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};\n this.action = typeof options.action === 'function' ? options.action : this.defaultAction;\n this.target = typeof options.target === 'function' ? options.target : this.defaultTarget;\n this.text = typeof options.text === 'function' ? options.text : this.defaultText;\n this.container = clipboard_typeof(options.container) === 'object' ? options.container : document.body;\n }\n /**\n * Adds a click event listener to the passed trigger.\n * @param {String|HTMLElement|HTMLCollection|NodeList} trigger\n */\n\n }, {\n key: \"listenClick\",\n value: function listenClick(trigger) {\n var _this2 = this;\n\n this.listener = listen_default()(trigger, 'click', function (e) {\n return _this2.onClick(e);\n });\n }\n /**\n * Defines a new `ClipboardAction` on each click event.\n * @param {Event} e\n */\n\n }, {\n key: \"onClick\",\n value: function onClick(e) {\n var trigger = e.delegateTarget || e.currentTarget;\n var action = this.action(trigger) || 'copy';\n var text = actions_default({\n action: action,\n container: this.container,\n target: this.target(trigger),\n text: this.text(trigger)\n }); // Fires an event based on the copy operation result.\n\n this.emit(text ? 'success' : 'error', {\n action: action,\n text: text,\n trigger: trigger,\n clearSelection: function clearSelection() {\n if (trigger) {\n trigger.focus();\n }\n\n window.getSelection().removeAllRanges();\n }\n });\n }\n /**\n * Default `action` lookup function.\n * @param {Element} trigger\n */\n\n }, {\n key: \"defaultAction\",\n value: function defaultAction(trigger) {\n return getAttributeValue('action', trigger);\n }\n /**\n * Default `target` lookup function.\n * @param {Element} trigger\n */\n\n }, {\n key: \"defaultTarget\",\n value: function defaultTarget(trigger) {\n var selector = getAttributeValue('target', trigger);\n\n if (selector) {\n return document.querySelector(selector);\n }\n }\n /**\n * Allow fire programmatically a copy action\n * @param {String|HTMLElement} target\n * @param {Object} options\n * @returns Text copied.\n */\n\n }, {\n key: \"defaultText\",\n\n /**\n * Default `text` lookup function.\n * @param {Element} trigger\n */\n value: function defaultText(trigger) {\n return getAttributeValue('text', trigger);\n }\n /**\n * Destroy lifecycle.\n */\n\n }, {\n key: \"destroy\",\n value: function destroy() {\n this.listener.destroy();\n }\n }], [{\n key: \"copy\",\n value: function copy(target) {\n var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {\n container: document.body\n };\n return actions_copy(target, options);\n }\n /**\n * Allow fire programmatically a cut action\n * @param {String|HTMLElement} target\n * @returns Text cutted.\n */\n\n }, {\n key: \"cut\",\n value: function cut(target) {\n return actions_cut(target);\n }\n /**\n * Returns the support of the given action, or all actions if no action is\n * given.\n * @param {String} [action]\n */\n\n }, {\n key: \"isSupported\",\n value: function isSupported() {\n var action = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : ['copy', 'cut'];\n var actions = typeof action === 'string' ? [action] : action;\n var support = !!document.queryCommandSupported;\n actions.forEach(function (action) {\n support = support && !!document.queryCommandSupported(action);\n });\n return support;\n }\n }]);\n\n return Clipboard;\n}((tiny_emitter_default()));\n\n/* harmony default export */ var clipboard = (Clipboard);\n\n/***/ }),\n\n/***/ 828:\n/***/ (function(module) {\n\nvar DOCUMENT_NODE_TYPE = 9;\n\n/**\n * A polyfill for Element.matches()\n */\nif (typeof Element !== 'undefined' && !Element.prototype.matches) {\n var proto = Element.prototype;\n\n proto.matches = proto.matchesSelector ||\n proto.mozMatchesSelector ||\n proto.msMatchesSelector ||\n proto.oMatchesSelector ||\n proto.webkitMatchesSelector;\n}\n\n/**\n * Finds the closest parent that matches a selector.\n *\n * @param {Element} element\n * @param {String} selector\n * @return {Function}\n */\nfunction closest (element, selector) {\n while (element && element.nodeType !== DOCUMENT_NODE_TYPE) {\n if (typeof element.matches === 'function' &&\n element.matches(selector)) {\n return element;\n }\n element = element.parentNode;\n }\n}\n\nmodule.exports = closest;\n\n\n/***/ }),\n\n/***/ 438:\n/***/ (function(module, __unused_webpack_exports, __webpack_require__) {\n\nvar closest = __webpack_require__(828);\n\n/**\n * Delegates event to a selector.\n *\n * @param {Element} element\n * @param {String} selector\n * @param {String} type\n * @param {Function} callback\n * @param {Boolean} useCapture\n * @return {Object}\n */\nfunction _delegate(element, selector, type, callback, useCapture) {\n var listenerFn = listener.apply(this, arguments);\n\n element.addEventListener(type, listenerFn, useCapture);\n\n return {\n destroy: function() {\n element.removeEventListener(type, listenerFn, useCapture);\n }\n }\n}\n\n/**\n * Delegates event to a selector.\n *\n * @param {Element|String|Array} [elements]\n * @param {String} selector\n * @param {String} type\n * @param {Function} callback\n * @param {Boolean} useCapture\n * @return {Object}\n */\nfunction delegate(elements, selector, type, callback, useCapture) {\n // Handle the regular Element usage\n if (typeof elements.addEventListener === 'function') {\n return _delegate.apply(null, arguments);\n }\n\n // Handle Element-less usage, it defaults to global delegation\n if (typeof type === 'function') {\n // Use `document` as the first parameter, then apply arguments\n // This is a short way to .unshift `arguments` without running into deoptimizations\n return _delegate.bind(null, document).apply(null, arguments);\n }\n\n // Handle Selector-based usage\n if (typeof elements === 'string') {\n elements = document.querySelectorAll(elements);\n }\n\n // Handle Array-like based usage\n return Array.prototype.map.call(elements, function (element) {\n return _delegate(element, selector, type, callback, useCapture);\n });\n}\n\n/**\n * Finds closest match and invokes callback.\n *\n * @param {Element} element\n * @param {String} selector\n * @param {String} type\n * @param {Function} callback\n * @return {Function}\n */\nfunction listener(element, selector, type, callback) {\n return function(e) {\n e.delegateTarget = closest(e.target, selector);\n\n if (e.delegateTarget) {\n callback.call(element, e);\n }\n }\n}\n\nmodule.exports = delegate;\n\n\n/***/ }),\n\n/***/ 879:\n/***/ (function(__unused_webpack_module, exports) {\n\n/**\n * Check if argument is a HTML element.\n *\n * @param {Object} value\n * @return {Boolean}\n */\nexports.node = function(value) {\n return value !== undefined\n && value instanceof HTMLElement\n && value.nodeType === 1;\n};\n\n/**\n * Check if argument is a list of HTML elements.\n *\n * @param {Object} value\n * @return {Boolean}\n */\nexports.nodeList = function(value) {\n var type = Object.prototype.toString.call(value);\n\n return value !== undefined\n && (type === '[object NodeList]' || type === '[object HTMLCollection]')\n && ('length' in value)\n && (value.length === 0 || exports.node(value[0]));\n};\n\n/**\n * Check if argument is a string.\n *\n * @param {Object} value\n * @return {Boolean}\n */\nexports.string = function(value) {\n return typeof value === 'string'\n || value instanceof String;\n};\n\n/**\n * Check if argument is a function.\n *\n * @param {Object} value\n * @return {Boolean}\n */\nexports.fn = function(value) {\n var type = Object.prototype.toString.call(value);\n\n return type === '[object Function]';\n};\n\n\n/***/ }),\n\n/***/ 370:\n/***/ (function(module, __unused_webpack_exports, __webpack_require__) {\n\nvar is = __webpack_require__(879);\nvar delegate = __webpack_require__(438);\n\n/**\n * Validates all params and calls the right\n * listener function based on its target type.\n *\n * @param {String|HTMLElement|HTMLCollection|NodeList} target\n * @param {String} type\n * @param {Function} callback\n * @return {Object}\n */\nfunction listen(target, type, callback) {\n if (!target && !type && !callback) {\n throw new Error('Missing required arguments');\n }\n\n if (!is.string(type)) {\n throw new TypeError('Second argument must be a String');\n }\n\n if (!is.fn(callback)) {\n throw new TypeError('Third argument must be a Function');\n }\n\n if (is.node(target)) {\n return listenNode(target, type, callback);\n }\n else if (is.nodeList(target)) {\n return listenNodeList(target, type, callback);\n }\n else if (is.string(target)) {\n return listenSelector(target, type, callback);\n }\n else {\n throw new TypeError('First argument must be a String, HTMLElement, HTMLCollection, or NodeList');\n }\n}\n\n/**\n * Adds an event listener to a HTML element\n * and returns a remove listener function.\n *\n * @param {HTMLElement} node\n * @param {String} type\n * @param {Function} callback\n * @return {Object}\n */\nfunction listenNode(node, type, callback) {\n node.addEventListener(type, callback);\n\n return {\n destroy: function() {\n node.removeEventListener(type, callback);\n }\n }\n}\n\n/**\n * Add an event listener to a list of HTML elements\n * and returns a remove listener function.\n *\n * @param {NodeList|HTMLCollection} nodeList\n * @param {String} type\n * @param {Function} callback\n * @return {Object}\n */\nfunction listenNodeList(nodeList, type, callback) {\n Array.prototype.forEach.call(nodeList, function(node) {\n node.addEventListener(type, callback);\n });\n\n return {\n destroy: function() {\n Array.prototype.forEach.call(nodeList, function(node) {\n node.removeEventListener(type, callback);\n });\n }\n }\n}\n\n/**\n * Add an event listener to a selector\n * and returns a remove listener function.\n *\n * @param {String} selector\n * @param {String} type\n * @param {Function} callback\n * @return {Object}\n */\nfunction listenSelector(selector, type, callback) {\n return delegate(document.body, selector, type, callback);\n}\n\nmodule.exports = listen;\n\n\n/***/ }),\n\n/***/ 817:\n/***/ (function(module) {\n\nfunction select(element) {\n var selectedText;\n\n if (element.nodeName === 'SELECT') {\n element.focus();\n\n selectedText = element.value;\n }\n else if (element.nodeName === 'INPUT' || element.nodeName === 'TEXTAREA') {\n var isReadOnly = element.hasAttribute('readonly');\n\n if (!isReadOnly) {\n element.setAttribute('readonly', '');\n }\n\n element.select();\n element.setSelectionRange(0, element.value.length);\n\n if (!isReadOnly) {\n element.removeAttribute('readonly');\n }\n\n selectedText = element.value;\n }\n else {\n if (element.hasAttribute('contenteditable')) {\n element.focus();\n }\n\n var selection = window.getSelection();\n var range = document.createRange();\n\n range.selectNodeContents(element);\n selection.removeAllRanges();\n selection.addRange(range);\n\n selectedText = selection.toString();\n }\n\n return selectedText;\n}\n\nmodule.exports = select;\n\n\n/***/ }),\n\n/***/ 279:\n/***/ (function(module) {\n\nfunction E () {\n // Keep this empty so it's easier to inherit from\n // (via https://github.com/lipsmack from https://github.com/scottcorgan/tiny-emitter/issues/3)\n}\n\nE.prototype = {\n on: function (name, callback, ctx) {\n var e = this.e || (this.e = {});\n\n (e[name] || (e[name] = [])).push({\n fn: callback,\n ctx: ctx\n });\n\n return this;\n },\n\n once: function (name, callback, ctx) {\n var self = this;\n function listener () {\n self.off(name, listener);\n callback.apply(ctx, arguments);\n };\n\n listener._ = callback\n return this.on(name, listener, ctx);\n },\n\n emit: function (name) {\n var data = [].slice.call(arguments, 1);\n var evtArr = ((this.e || (this.e = {}))[name] || []).slice();\n var i = 0;\n var len = evtArr.length;\n\n for (i; i < len; i++) {\n evtArr[i].fn.apply(evtArr[i].ctx, data);\n }\n\n return this;\n },\n\n off: function (name, callback) {\n var e = this.e || (this.e = {});\n var evts = e[name];\n var liveEvents = [];\n\n if (evts && callback) {\n for (var i = 0, len = evts.length; i < len; i++) {\n if (evts[i].fn !== callback && evts[i].fn._ !== callback)\n liveEvents.push(evts[i]);\n }\n }\n\n // Remove event from queue to prevent memory leak\n // Suggested by https://github.com/lazd\n // Ref: https://github.com/scottcorgan/tiny-emitter/commit/c6ebfaa9bc973b33d110a84a307742b7cf94c953#commitcomment-5024910\n\n (liveEvents.length)\n ? e[name] = liveEvents\n : delete e[name];\n\n return this;\n }\n};\n\nmodule.exports = E;\nmodule.exports.TinyEmitter = E;\n\n\n/***/ })\n\n/******/ \t});\n/************************************************************************/\n/******/ \t// The module cache\n/******/ \tvar __webpack_module_cache__ = {};\n/******/ \t\n/******/ \t// The require function\n/******/ \tfunction __webpack_require__(moduleId) {\n/******/ \t\t// Check if module is in cache\n/******/ \t\tif(__webpack_module_cache__[moduleId]) {\n/******/ \t\t\treturn __webpack_module_cache__[moduleId].exports;\n/******/ \t\t}\n/******/ \t\t// Create a new module (and put it into the cache)\n/******/ \t\tvar module = __webpack_module_cache__[moduleId] = {\n/******/ \t\t\t// no module.id needed\n/******/ \t\t\t// no module.loaded needed\n/******/ \t\t\texports: {}\n/******/ \t\t};\n/******/ \t\n/******/ \t\t// Execute the module function\n/******/ \t\t__webpack_modules__[moduleId](module, module.exports, __webpack_require__);\n/******/ \t\n/******/ \t\t// Return the exports of the module\n/******/ \t\treturn module.exports;\n/******/ \t}\n/******/ \t\n/************************************************************************/\n/******/ \t/* webpack/runtime/compat get default export */\n/******/ \t!function() {\n/******/ \t\t// getDefaultExport function for compatibility with non-harmony modules\n/******/ \t\t__webpack_require__.n = function(module) {\n/******/ \t\t\tvar getter = module && module.__esModule ?\n/******/ \t\t\t\tfunction() { return module['default']; } :\n/******/ \t\t\t\tfunction() { return module; };\n/******/ \t\t\t__webpack_require__.d(getter, { a: getter });\n/******/ \t\t\treturn getter;\n/******/ \t\t};\n/******/ \t}();\n/******/ \t\n/******/ \t/* webpack/runtime/define property getters */\n/******/ \t!function() {\n/******/ \t\t// define getter functions for harmony exports\n/******/ \t\t__webpack_require__.d = function(exports, definition) {\n/******/ \t\t\tfor(var key in definition) {\n/******/ \t\t\t\tif(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {\n/******/ \t\t\t\t\tObject.defineProperty(exports, key, { enumerable: true, get: definition[key] });\n/******/ \t\t\t\t}\n/******/ \t\t\t}\n/******/ \t\t};\n/******/ \t}();\n/******/ \t\n/******/ \t/* webpack/runtime/hasOwnProperty shorthand */\n/******/ \t!function() {\n/******/ \t\t__webpack_require__.o = function(obj, prop) { return Object.prototype.hasOwnProperty.call(obj, prop); }\n/******/ \t}();\n/******/ \t\n/************************************************************************/\n/******/ \t// module exports must be returned from runtime so entry inlining is disabled\n/******/ \t// startup\n/******/ \t// Load entry module and return exports\n/******/ \treturn __webpack_require__(686);\n/******/ })()\n.default;\n});", "/*\n * Copyright (c) 2016-2025 Martin Donath \n *\n * Permission is hereby granted, free of charge, to any person obtaining a copy\n * of this software and associated documentation files (the \"Software\"), to\n * deal in the Software without restriction, including without limitation the\n * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or\n * sell copies of the Software, and to permit persons to whom the Software is\n * furnished to do so, subject to the following conditions:\n *\n * The above copyright notice and this permission notice shall be included in\n * all copies or substantial portions of the Software.\n *\n * THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE\n * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING\n * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS\n * IN THE SOFTWARE.\n */\n\nimport \"focus-visible\"\n\nimport {\n EMPTY,\n NEVER,\n Observable,\n Subject,\n defer,\n delay,\n filter,\n map,\n merge,\n mergeWith,\n shareReplay,\n switchMap\n} from \"rxjs\"\n\nimport { configuration, feature } from \"./_\"\nimport {\n at,\n getActiveElement,\n getOptionalElement,\n requestJSON,\n setLocation,\n setToggle,\n watchDocument,\n watchKeyboard,\n watchLocation,\n watchLocationTarget,\n watchMedia,\n watchPrint,\n watchScript,\n watchViewport\n} from \"./browser\"\nimport {\n getComponentElement,\n getComponentElements,\n mountAnnounce,\n mountBackToTop,\n mountConsent,\n mountContent,\n mountDialog,\n mountHeader,\n mountHeaderTitle,\n mountPalette,\n mountProgress,\n mountSearch,\n mountSearchHiglight,\n mountSidebar,\n mountSource,\n mountTableOfContents,\n mountTabs,\n watchHeader,\n watchMain\n} from \"./components\"\nimport {\n SearchIndex,\n setupClipboardJS,\n setupInstantNavigation,\n setupVersionSelector\n} from \"./integrations\"\nimport {\n patchEllipsis,\n patchIndeterminate,\n patchScrollfix,\n patchScrolllock\n} from \"./patches\"\nimport \"./polyfills\"\n\n/* ----------------------------------------------------------------------------\n * Functions - @todo refactor\n * ------------------------------------------------------------------------- */\n\n/**\n * Fetch search index\n *\n * @returns Search index observable\n */\nfunction fetchSearchIndex(): Observable {\n if (location.protocol === \"file:\") {\n return watchScript(\n `${new URL(\"search/search_index.js\", config.base)}`\n )\n .pipe(\n // @ts-ignore - @todo fix typings\n map(() => __index),\n shareReplay(1)\n )\n } else {\n return requestJSON(\n new URL(\"search/search_index.json\", config.base)\n )\n }\n}\n\n/* ----------------------------------------------------------------------------\n * Application\n * ------------------------------------------------------------------------- */\n\n/* Yay, JavaScript is available */\ndocument.documentElement.classList.remove(\"no-js\")\ndocument.documentElement.classList.add(\"js\")\n\n/* Set up navigation observables and subjects */\nconst document$ = watchDocument()\nconst location$ = watchLocation()\nconst target$ = watchLocationTarget(location$)\nconst keyboard$ = watchKeyboard()\n\n/* Set up media observables */\nconst viewport$ = watchViewport()\nconst tablet$ = watchMedia(\"(min-width: 960px)\")\nconst screen$ = watchMedia(\"(min-width: 1220px)\")\nconst print$ = watchPrint()\n\n/* Retrieve search index, if search is enabled */\nconst config = configuration()\nconst index$ = document.forms.namedItem(\"search\")\n ? fetchSearchIndex()\n : NEVER\n\n/* Set up Clipboard.js integration */\nconst alert$ = new Subject()\nsetupClipboardJS({ alert$ })\n\n/* Set up progress indicator */\nconst progress$ = new Subject()\n\n/* Set up instant navigation, if enabled */\nif (feature(\"navigation.instant\"))\n setupInstantNavigation({ location$, viewport$, progress$ })\n .subscribe(document$)\n\n/* Set up version selector */\nif (config.version?.provider === \"mike\")\n setupVersionSelector({ document$ })\n\n/* Always close drawer and search on navigation */\nmerge(location$, target$)\n .pipe(\n delay(125)\n )\n .subscribe(() => {\n setToggle(\"drawer\", false)\n setToggle(\"search\", false)\n })\n\n/* Set up global keyboard handlers */\nkeyboard$\n .pipe(\n filter(({ mode }) => mode === \"global\")\n )\n .subscribe(key => {\n switch (key.type) {\n\n /* Go to previous page */\n case \"p\":\n case \",\":\n const prev = getOptionalElement(\"link[rel=prev]\")\n if (typeof prev !== \"undefined\")\n setLocation(prev)\n break\n\n /* Go to next page */\n case \"n\":\n case \".\":\n const next = getOptionalElement(\"link[rel=next]\")\n if (typeof next !== \"undefined\")\n setLocation(next)\n break\n\n /* Expand navigation, see https://bit.ly/3ZjG5io */\n case \"Enter\":\n const active = getActiveElement()\n if (active instanceof HTMLLabelElement)\n active.click()\n }\n })\n\n/* Set up patches */\npatchEllipsis({ viewport$, document$ })\npatchIndeterminate({ document$, tablet$ })\npatchScrollfix({ document$ })\npatchScrolllock({ viewport$, tablet$ })\n\n/* Set up header and main area observable */\nconst header$ = watchHeader(getComponentElement(\"header\"), { viewport$ })\nconst main$ = document$\n .pipe(\n map(() => getComponentElement(\"main\")),\n switchMap(el => watchMain(el, { viewport$, header$ })),\n shareReplay(1)\n )\n\n/* Set up control component observables */\nconst control$ = merge(\n\n /* Consent */\n ...getComponentElements(\"consent\")\n .map(el => mountConsent(el, { target$ })),\n\n /* Dialog */\n ...getComponentElements(\"dialog\")\n .map(el => mountDialog(el, { alert$ })),\n\n /* Color palette */\n ...getComponentElements(\"palette\")\n .map(el => mountPalette(el)),\n\n /* Progress bar */\n ...getComponentElements(\"progress\")\n .map(el => mountProgress(el, { progress$ })),\n\n /* Search */\n ...getComponentElements(\"search\")\n .map(el => mountSearch(el, { index$, keyboard$ })),\n\n /* Repository information */\n ...getComponentElements(\"source\")\n .map(el => mountSource(el))\n)\n\n/* Set up content component observables */\nconst content$ = defer(() => merge(\n\n /* Announcement bar */\n ...getComponentElements(\"announce\")\n .map(el => mountAnnounce(el)),\n\n /* Content */\n ...getComponentElements(\"content\")\n .map(el => mountContent(el, { viewport$, target$, print$ })),\n\n /* Search highlighting */\n ...getComponentElements(\"content\")\n .map(el => feature(\"search.highlight\")\n ? mountSearchHiglight(el, { index$, location$ })\n : EMPTY\n ),\n\n /* Header */\n ...getComponentElements(\"header\")\n .map(el => mountHeader(el, { viewport$, header$, main$ })),\n\n /* Header title */\n ...getComponentElements(\"header-title\")\n .map(el => mountHeaderTitle(el, { viewport$, header$ })),\n\n /* Sidebar */\n ...getComponentElements(\"sidebar\")\n .map(el => el.getAttribute(\"data-md-type\") === \"navigation\"\n ? at(screen$, () => mountSidebar(el, { viewport$, header$, main$ }))\n : at(tablet$, () => mountSidebar(el, { viewport$, header$, main$ }))\n ),\n\n /* Navigation tabs */\n ...getComponentElements(\"tabs\")\n .map(el => mountTabs(el, { viewport$, header$ })),\n\n /* Table of contents */\n ...getComponentElements(\"toc\")\n .map(el => mountTableOfContents(el, {\n viewport$, header$, main$, target$\n })),\n\n /* Back-to-top button */\n ...getComponentElements(\"top\")\n .map(el => mountBackToTop(el, { viewport$, header$, main$, target$ }))\n))\n\n/* Set up component observables */\nconst component$ = document$\n .pipe(\n switchMap(() => content$),\n mergeWith(control$),\n shareReplay(1)\n )\n\n/* Subscribe to all components */\ncomponent$.subscribe()\n\n/* ----------------------------------------------------------------------------\n * Exports\n * ------------------------------------------------------------------------- */\n\nwindow.document$ = document$ /* Document observable */\nwindow.location$ = location$ /* Location subject */\nwindow.target$ = target$ /* Location target observable */\nwindow.keyboard$ = keyboard$ /* Keyboard observable */\nwindow.viewport$ = viewport$ /* Viewport observable */\nwindow.tablet$ = tablet$ /* Media tablet observable */\nwindow.screen$ = screen$ /* Media screen observable */\nwindow.print$ = print$ /* Media print observable */\nwindow.alert$ = alert$ /* Alert subject */\nwindow.progress$ = progress$ /* Progress indicator subject */\nwindow.component$ = component$ /* Component observable */\n", "/******************************************************************************\nCopyright (c) Microsoft Corporation.\n\nPermission to use, copy, modify, and/or distribute this software for any\npurpose with or without fee is hereby granted.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH\nREGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY\nAND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,\nINDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM\nLOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR\nOTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR\nPERFORMANCE OF THIS SOFTWARE.\n***************************************************************************** */\n/* global Reflect, Promise, SuppressedError, Symbol, Iterator */\n\nvar extendStatics = function(d, b) {\n extendStatics = Object.setPrototypeOf ||\n ({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) ||\n function (d, b) { for (var p in b) if (Object.prototype.hasOwnProperty.call(b, p)) d[p] = b[p]; };\n return extendStatics(d, b);\n};\n\nexport function __extends(d, b) {\n if (typeof b !== \"function\" && b !== null)\n throw new TypeError(\"Class extends value \" + String(b) + \" is not a constructor or null\");\n extendStatics(d, b);\n function __() { this.constructor = d; }\n d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __());\n}\n\nexport var __assign = function() {\n __assign = Object.assign || function __assign(t) {\n for (var s, i = 1, n = arguments.length; i < n; i++) {\n s = arguments[i];\n for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) t[p] = s[p];\n }\n return t;\n }\n return __assign.apply(this, arguments);\n}\n\nexport function __rest(s, e) {\n var t = {};\n for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)\n t[p] = s[p];\n if (s != null && typeof Object.getOwnPropertySymbols === \"function\")\n for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {\n if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))\n t[p[i]] = s[p[i]];\n }\n return t;\n}\n\nexport function __decorate(decorators, target, key, desc) {\n var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;\n if (typeof Reflect === \"object\" && typeof Reflect.decorate === \"function\") r = Reflect.decorate(decorators, target, key, desc);\n else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;\n return c > 3 && r && Object.defineProperty(target, key, r), r;\n}\n\nexport function __param(paramIndex, decorator) {\n return function (target, key) { decorator(target, key, paramIndex); }\n}\n\nexport function __esDecorate(ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {\n function accept(f) { if (f !== void 0 && typeof f !== \"function\") throw new TypeError(\"Function expected\"); return f; }\n var kind = contextIn.kind, key = kind === \"getter\" ? \"get\" : kind === \"setter\" ? \"set\" : \"value\";\n var target = !descriptorIn && ctor ? contextIn[\"static\"] ? ctor : ctor.prototype : null;\n var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});\n var _, done = false;\n for (var i = decorators.length - 1; i >= 0; i--) {\n var context = {};\n for (var p in contextIn) context[p] = p === \"access\" ? {} : contextIn[p];\n for (var p in contextIn.access) context.access[p] = contextIn.access[p];\n context.addInitializer = function (f) { if (done) throw new TypeError(\"Cannot add initializers after decoration has completed\"); extraInitializers.push(accept(f || null)); };\n var result = (0, decorators[i])(kind === \"accessor\" ? { get: descriptor.get, set: descriptor.set } : descriptor[key], context);\n if (kind === \"accessor\") {\n if (result === void 0) continue;\n if (result === null || typeof result !== \"object\") throw new TypeError(\"Object expected\");\n if (_ = accept(result.get)) descriptor.get = _;\n if (_ = accept(result.set)) descriptor.set = _;\n if (_ = accept(result.init)) initializers.unshift(_);\n }\n else if (_ = accept(result)) {\n if (kind === \"field\") initializers.unshift(_);\n else descriptor[key] = _;\n }\n }\n if (target) Object.defineProperty(target, contextIn.name, descriptor);\n done = true;\n};\n\nexport function __runInitializers(thisArg, initializers, value) {\n var useValue = arguments.length > 2;\n for (var i = 0; i < initializers.length; i++) {\n value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);\n }\n return useValue ? value : void 0;\n};\n\nexport function __propKey(x) {\n return typeof x === \"symbol\" ? x : \"\".concat(x);\n};\n\nexport function __setFunctionName(f, name, prefix) {\n if (typeof name === \"symbol\") name = name.description ? \"[\".concat(name.description, \"]\") : \"\";\n return Object.defineProperty(f, \"name\", { configurable: true, value: prefix ? \"\".concat(prefix, \" \", name) : name });\n};\n\nexport function __metadata(metadataKey, metadataValue) {\n if (typeof Reflect === \"object\" && typeof Reflect.metadata === \"function\") return Reflect.metadata(metadataKey, metadataValue);\n}\n\nexport function __awaiter(thisArg, _arguments, P, generator) {\n function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }\n return new (P || (P = Promise))(function (resolve, reject) {\n function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }\n function rejected(value) { try { step(generator[\"throw\"](value)); } catch (e) { reject(e); } }\n function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }\n step((generator = generator.apply(thisArg, _arguments || [])).next());\n });\n}\n\nexport function __generator(thisArg, body) {\n var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === \"function\" ? Iterator : Object).prototype);\n return g.next = verb(0), g[\"throw\"] = verb(1), g[\"return\"] = verb(2), typeof Symbol === \"function\" && (g[Symbol.iterator] = function() { return this; }), g;\n function verb(n) { return function (v) { return step([n, v]); }; }\n function step(op) {\n if (f) throw new TypeError(\"Generator is already executing.\");\n while (g && (g = 0, op[0] && (_ = 0)), _) try {\n if (f = 1, y && (t = op[0] & 2 ? y[\"return\"] : op[0] ? y[\"throw\"] || ((t = y[\"return\"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;\n if (y = 0, t) op = [op[0] & 2, t.value];\n switch (op[0]) {\n case 0: case 1: t = op; break;\n case 4: _.label++; return { value: op[1], done: false };\n case 5: _.label++; y = op[1]; op = [0]; continue;\n case 7: op = _.ops.pop(); _.trys.pop(); continue;\n default:\n if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }\n if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }\n if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }\n if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }\n if (t[2]) _.ops.pop();\n _.trys.pop(); continue;\n }\n op = body.call(thisArg, _);\n } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }\n if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };\n }\n}\n\nexport var __createBinding = Object.create ? (function(o, m, k, k2) {\n if (k2 === undefined) k2 = k;\n var desc = Object.getOwnPropertyDescriptor(m, k);\n if (!desc || (\"get\" in desc ? !m.__esModule : desc.writable || desc.configurable)) {\n desc = { enumerable: true, get: function() { return m[k]; } };\n }\n Object.defineProperty(o, k2, desc);\n}) : (function(o, m, k, k2) {\n if (k2 === undefined) k2 = k;\n o[k2] = m[k];\n});\n\nexport function __exportStar(m, o) {\n for (var p in m) if (p !== \"default\" && !Object.prototype.hasOwnProperty.call(o, p)) __createBinding(o, m, p);\n}\n\nexport function __values(o) {\n var s = typeof Symbol === \"function\" && Symbol.iterator, m = s && o[s], i = 0;\n if (m) return m.call(o);\n if (o && typeof o.length === \"number\") return {\n next: function () {\n if (o && i >= o.length) o = void 0;\n return { value: o && o[i++], done: !o };\n }\n };\n throw new TypeError(s ? \"Object is not iterable.\" : \"Symbol.iterator is not defined.\");\n}\n\nexport function __read(o, n) {\n var m = typeof Symbol === \"function\" && o[Symbol.iterator];\n if (!m) return o;\n var i = m.call(o), r, ar = [], e;\n try {\n while ((n === void 0 || n-- > 0) && !(r = i.next()).done) ar.push(r.value);\n }\n catch (error) { e = { error: error }; }\n finally {\n try {\n if (r && !r.done && (m = i[\"return\"])) m.call(i);\n }\n finally { if (e) throw e.error; }\n }\n return ar;\n}\n\n/** @deprecated */\nexport function __spread() {\n for (var ar = [], i = 0; i < arguments.length; i++)\n ar = ar.concat(__read(arguments[i]));\n return ar;\n}\n\n/** @deprecated */\nexport function __spreadArrays() {\n for (var s = 0, i = 0, il = arguments.length; i < il; i++) s += arguments[i].length;\n for (var r = Array(s), k = 0, i = 0; i < il; i++)\n for (var a = arguments[i], j = 0, jl = a.length; j < jl; j++, k++)\n r[k] = a[j];\n return r;\n}\n\nexport function __spreadArray(to, from, pack) {\n if (pack || arguments.length === 2) for (var i = 0, l = from.length, ar; i < l; i++) {\n if (ar || !(i in from)) {\n if (!ar) ar = Array.prototype.slice.call(from, 0, i);\n ar[i] = from[i];\n }\n }\n return to.concat(ar || Array.prototype.slice.call(from));\n}\n\nexport function __await(v) {\n return this instanceof __await ? (this.v = v, this) : new __await(v);\n}\n\nexport function __asyncGenerator(thisArg, _arguments, generator) {\n if (!Symbol.asyncIterator) throw new TypeError(\"Symbol.asyncIterator is not defined.\");\n var g = generator.apply(thisArg, _arguments || []), i, q = [];\n return i = Object.create((typeof AsyncIterator === \"function\" ? AsyncIterator : Object).prototype), verb(\"next\"), verb(\"throw\"), verb(\"return\", awaitReturn), i[Symbol.asyncIterator] = function () { return this; }, i;\n function awaitReturn(f) { return function (v) { return Promise.resolve(v).then(f, reject); }; }\n function verb(n, f) { if (g[n]) { i[n] = function (v) { return new Promise(function (a, b) { q.push([n, v, a, b]) > 1 || resume(n, v); }); }; if (f) i[n] = f(i[n]); } }\n function resume(n, v) { try { step(g[n](v)); } catch (e) { settle(q[0][3], e); } }\n function step(r) { r.value instanceof __await ? Promise.resolve(r.value.v).then(fulfill, reject) : settle(q[0][2], r); }\n function fulfill(value) { resume(\"next\", value); }\n function reject(value) { resume(\"throw\", value); }\n function settle(f, v) { if (f(v), q.shift(), q.length) resume(q[0][0], q[0][1]); }\n}\n\nexport function __asyncDelegator(o) {\n var i, p;\n return i = {}, verb(\"next\"), verb(\"throw\", function (e) { throw e; }), verb(\"return\"), i[Symbol.iterator] = function () { return this; }, i;\n function verb(n, f) { i[n] = o[n] ? function (v) { return (p = !p) ? { value: __await(o[n](v)), done: false } : f ? f(v) : v; } : f; }\n}\n\nexport function __asyncValues(o) {\n if (!Symbol.asyncIterator) throw new TypeError(\"Symbol.asyncIterator is not defined.\");\n var m = o[Symbol.asyncIterator], i;\n return m ? m.call(o) : (o = typeof __values === \"function\" ? __values(o) : o[Symbol.iterator](), i = {}, verb(\"next\"), verb(\"throw\"), verb(\"return\"), i[Symbol.asyncIterator] = function () { return this; }, i);\n function verb(n) { i[n] = o[n] && function (v) { return new Promise(function (resolve, reject) { v = o[n](v), settle(resolve, reject, v.done, v.value); }); }; }\n function settle(resolve, reject, d, v) { Promise.resolve(v).then(function(v) { resolve({ value: v, done: d }); }, reject); }\n}\n\nexport function __makeTemplateObject(cooked, raw) {\n if (Object.defineProperty) { Object.defineProperty(cooked, \"raw\", { value: raw }); } else { cooked.raw = raw; }\n return cooked;\n};\n\nvar __setModuleDefault = Object.create ? (function(o, v) {\n Object.defineProperty(o, \"default\", { enumerable: true, value: v });\n}) : function(o, v) {\n o[\"default\"] = v;\n};\n\nexport function __importStar(mod) {\n if (mod && mod.__esModule) return mod;\n var result = {};\n if (mod != null) for (var k in mod) if (k !== \"default\" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);\n __setModuleDefault(result, mod);\n return result;\n}\n\nexport function __importDefault(mod) {\n return (mod && mod.__esModule) ? mod : { default: mod };\n}\n\nexport function __classPrivateFieldGet(receiver, state, kind, f) {\n if (kind === \"a\" && !f) throw new TypeError(\"Private accessor was defined without a getter\");\n if (typeof state === \"function\" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError(\"Cannot read private member from an object whose class did not declare it\");\n return kind === \"m\" ? f : kind === \"a\" ? f.call(receiver) : f ? f.value : state.get(receiver);\n}\n\nexport function __classPrivateFieldSet(receiver, state, value, kind, f) {\n if (kind === \"m\") throw new TypeError(\"Private method is not writable\");\n if (kind === \"a\" && !f) throw new TypeError(\"Private accessor was defined without a setter\");\n if (typeof state === \"function\" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError(\"Cannot write private member to an object whose class did not declare it\");\n return (kind === \"a\" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;\n}\n\nexport function __classPrivateFieldIn(state, receiver) {\n if (receiver === null || (typeof receiver !== \"object\" && typeof receiver !== \"function\")) throw new TypeError(\"Cannot use 'in' operator on non-object\");\n return typeof state === \"function\" ? receiver === state : state.has(receiver);\n}\n\nexport function __addDisposableResource(env, value, async) {\n if (value !== null && value !== void 0) {\n if (typeof value !== \"object\" && typeof value !== \"function\") throw new TypeError(\"Object expected.\");\n var dispose, inner;\n if (async) {\n if (!Symbol.asyncDispose) throw new TypeError(\"Symbol.asyncDispose is not defined.\");\n dispose = value[Symbol.asyncDispose];\n }\n if (dispose === void 0) {\n if (!Symbol.dispose) throw new TypeError(\"Symbol.dispose is not defined.\");\n dispose = value[Symbol.dispose];\n if (async) inner = dispose;\n }\n if (typeof dispose !== \"function\") throw new TypeError(\"Object not disposable.\");\n if (inner) dispose = function() { try { inner.call(this); } catch (e) { return Promise.reject(e); } };\n env.stack.push({ value: value, dispose: dispose, async: async });\n }\n else if (async) {\n env.stack.push({ async: true });\n }\n return value;\n}\n\nvar _SuppressedError = typeof SuppressedError === \"function\" ? SuppressedError : function (error, suppressed, message) {\n var e = new Error(message);\n return e.name = \"SuppressedError\", e.error = error, e.suppressed = suppressed, e;\n};\n\nexport function __disposeResources(env) {\n function fail(e) {\n env.error = env.hasError ? new _SuppressedError(e, env.error, \"An error was suppressed during disposal.\") : e;\n env.hasError = true;\n }\n var r, s = 0;\n function next() {\n while (r = env.stack.pop()) {\n try {\n if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);\n if (r.dispose) {\n var result = r.dispose.call(r.value);\n if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) { fail(e); return next(); });\n }\n else s |= 1;\n }\n catch (e) {\n fail(e);\n }\n }\n if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();\n if (env.hasError) throw env.error;\n }\n return next();\n}\n\nexport default {\n __extends,\n __assign,\n __rest,\n __decorate,\n __param,\n __metadata,\n __awaiter,\n __generator,\n __createBinding,\n __exportStar,\n __values,\n __read,\n __spread,\n __spreadArrays,\n __spreadArray,\n __await,\n __asyncGenerator,\n __asyncDelegator,\n __asyncValues,\n __makeTemplateObject,\n __importStar,\n __importDefault,\n __classPrivateFieldGet,\n __classPrivateFieldSet,\n __classPrivateFieldIn,\n __addDisposableResource,\n __disposeResources,\n};\n", "/**\n * Returns true if the object is a function.\n * @param value The value to check\n */\nexport function isFunction(value: any): value is (...args: any[]) => any {\n return typeof value === 'function';\n}\n", "/**\n * Used to create Error subclasses until the community moves away from ES5.\n *\n * This is because compiling from TypeScript down to ES5 has issues with subclassing Errors\n * as well as other built-in types: https://github.com/Microsoft/TypeScript/issues/12123\n *\n * @param createImpl A factory function to create the actual constructor implementation. The returned\n * function should be a named function that calls `_super` internally.\n */\nexport function createErrorClass(createImpl: (_super: any) => any): T {\n const _super = (instance: any) => {\n Error.call(instance);\n instance.stack = new Error().stack;\n };\n\n const ctorFunc = createImpl(_super);\n ctorFunc.prototype = Object.create(Error.prototype);\n ctorFunc.prototype.constructor = ctorFunc;\n return ctorFunc;\n}\n", "import { createErrorClass } from './createErrorClass';\n\nexport interface UnsubscriptionError extends Error {\n readonly errors: any[];\n}\n\nexport interface UnsubscriptionErrorCtor {\n /**\n * @deprecated Internal implementation detail. Do not construct error instances.\n * Cannot be tagged as internal: https://github.com/ReactiveX/rxjs/issues/6269\n */\n new (errors: any[]): UnsubscriptionError;\n}\n\n/**\n * An error thrown when one or more errors have occurred during the\n * `unsubscribe` of a {@link Subscription}.\n */\nexport const UnsubscriptionError: UnsubscriptionErrorCtor = createErrorClass(\n (_super) =>\n function UnsubscriptionErrorImpl(this: any, errors: (Error | string)[]) {\n _super(this);\n this.message = errors\n ? `${errors.length} errors occurred during unsubscription:\n${errors.map((err, i) => `${i + 1}) ${err.toString()}`).join('\\n ')}`\n : '';\n this.name = 'UnsubscriptionError';\n this.errors = errors;\n }\n);\n", "/**\n * Removes an item from an array, mutating it.\n * @param arr The array to remove the item from\n * @param item The item to remove\n */\nexport function arrRemove(arr: T[] | undefined | null, item: T) {\n if (arr) {\n const index = arr.indexOf(item);\n 0 <= index && arr.splice(index, 1);\n }\n}\n", "import { isFunction } from './util/isFunction';\nimport { UnsubscriptionError } from './util/UnsubscriptionError';\nimport { SubscriptionLike, TeardownLogic, Unsubscribable } from './types';\nimport { arrRemove } from './util/arrRemove';\n\n/**\n * Represents a disposable resource, such as the execution of an Observable. A\n * Subscription has one important method, `unsubscribe`, that takes no argument\n * and just disposes the resource held by the subscription.\n *\n * Additionally, subscriptions may be grouped together through the `add()`\n * method, which will attach a child Subscription to the current Subscription.\n * When a Subscription is unsubscribed, all its children (and its grandchildren)\n * will be unsubscribed as well.\n */\nexport class Subscription implements SubscriptionLike {\n public static EMPTY = (() => {\n const empty = new Subscription();\n empty.closed = true;\n return empty;\n })();\n\n /**\n * A flag to indicate whether this Subscription has already been unsubscribed.\n */\n public closed = false;\n\n private _parentage: Subscription[] | Subscription | null = null;\n\n /**\n * The list of registered finalizers to execute upon unsubscription. Adding and removing from this\n * list occurs in the {@link #add} and {@link #remove} methods.\n */\n private _finalizers: Exclude[] | null = null;\n\n /**\n * @param initialTeardown A function executed first as part of the finalization\n * process that is kicked off when {@link #unsubscribe} is called.\n */\n constructor(private initialTeardown?: () => void) {}\n\n /**\n * Disposes the resources held by the subscription. May, for instance, cancel\n * an ongoing Observable execution or cancel any other type of work that\n * started when the Subscription was created.\n */\n unsubscribe(): void {\n let errors: any[] | undefined;\n\n if (!this.closed) {\n this.closed = true;\n\n // Remove this from it's parents.\n const { _parentage } = this;\n if (_parentage) {\n this._parentage = null;\n if (Array.isArray(_parentage)) {\n for (const parent of _parentage) {\n parent.remove(this);\n }\n } else {\n _parentage.remove(this);\n }\n }\n\n const { initialTeardown: initialFinalizer } = this;\n if (isFunction(initialFinalizer)) {\n try {\n initialFinalizer();\n } catch (e) {\n errors = e instanceof UnsubscriptionError ? e.errors : [e];\n }\n }\n\n const { _finalizers } = this;\n if (_finalizers) {\n this._finalizers = null;\n for (const finalizer of _finalizers) {\n try {\n execFinalizer(finalizer);\n } catch (err) {\n errors = errors ?? [];\n if (err instanceof UnsubscriptionError) {\n errors = [...errors, ...err.errors];\n } else {\n errors.push(err);\n }\n }\n }\n }\n\n if (errors) {\n throw new UnsubscriptionError(errors);\n }\n }\n }\n\n /**\n * Adds a finalizer to this subscription, so that finalization will be unsubscribed/called\n * when this subscription is unsubscribed. If this subscription is already {@link #closed},\n * because it has already been unsubscribed, then whatever finalizer is passed to it\n * will automatically be executed (unless the finalizer itself is also a closed subscription).\n *\n * Closed Subscriptions cannot be added as finalizers to any subscription. Adding a closed\n * subscription to a any subscription will result in no operation. (A noop).\n *\n * Adding a subscription to itself, or adding `null` or `undefined` will not perform any\n * operation at all. (A noop).\n *\n * `Subscription` instances that are added to this instance will automatically remove themselves\n * if they are unsubscribed. Functions and {@link Unsubscribable} objects that you wish to remove\n * will need to be removed manually with {@link #remove}\n *\n * @param teardown The finalization logic to add to this subscription.\n */\n add(teardown: TeardownLogic): void {\n // Only add the finalizer if it's not undefined\n // and don't add a subscription to itself.\n if (teardown && teardown !== this) {\n if (this.closed) {\n // If this subscription is already closed,\n // execute whatever finalizer is handed to it automatically.\n execFinalizer(teardown);\n } else {\n if (teardown instanceof Subscription) {\n // We don't add closed subscriptions, and we don't add the same subscription\n // twice. Subscription unsubscribe is idempotent.\n if (teardown.closed || teardown._hasParent(this)) {\n return;\n }\n teardown._addParent(this);\n }\n (this._finalizers = this._finalizers ?? []).push(teardown);\n }\n }\n }\n\n /**\n * Checks to see if a this subscription already has a particular parent.\n * This will signal that this subscription has already been added to the parent in question.\n * @param parent the parent to check for\n */\n private _hasParent(parent: Subscription) {\n const { _parentage } = this;\n return _parentage === parent || (Array.isArray(_parentage) && _parentage.includes(parent));\n }\n\n /**\n * Adds a parent to this subscription so it can be removed from the parent if it\n * unsubscribes on it's own.\n *\n * NOTE: THIS ASSUMES THAT {@link _hasParent} HAS ALREADY BEEN CHECKED.\n * @param parent The parent subscription to add\n */\n private _addParent(parent: Subscription) {\n const { _parentage } = this;\n this._parentage = Array.isArray(_parentage) ? (_parentage.push(parent), _parentage) : _parentage ? [_parentage, parent] : parent;\n }\n\n /**\n * Called on a child when it is removed via {@link #remove}.\n * @param parent The parent to remove\n */\n private _removeParent(parent: Subscription) {\n const { _parentage } = this;\n if (_parentage === parent) {\n this._parentage = null;\n } else if (Array.isArray(_parentage)) {\n arrRemove(_parentage, parent);\n }\n }\n\n /**\n * Removes a finalizer from this subscription that was previously added with the {@link #add} method.\n *\n * Note that `Subscription` instances, when unsubscribed, will automatically remove themselves\n * from every other `Subscription` they have been added to. This means that using the `remove` method\n * is not a common thing and should be used thoughtfully.\n *\n * If you add the same finalizer instance of a function or an unsubscribable object to a `Subscription` instance\n * more than once, you will need to call `remove` the same number of times to remove all instances.\n *\n * All finalizer instances are removed to free up memory upon unsubscription.\n *\n * @param teardown The finalizer to remove from this subscription\n */\n remove(teardown: Exclude): void {\n const { _finalizers } = this;\n _finalizers && arrRemove(_finalizers, teardown);\n\n if (teardown instanceof Subscription) {\n teardown._removeParent(this);\n }\n }\n}\n\nexport const EMPTY_SUBSCRIPTION = Subscription.EMPTY;\n\nexport function isSubscription(value: any): value is Subscription {\n return (\n value instanceof Subscription ||\n (value && 'closed' in value && isFunction(value.remove) && isFunction(value.add) && isFunction(value.unsubscribe))\n );\n}\n\nfunction execFinalizer(finalizer: Unsubscribable | (() => void)) {\n if (isFunction(finalizer)) {\n finalizer();\n } else {\n finalizer.unsubscribe();\n }\n}\n", "import { Subscriber } from './Subscriber';\nimport { ObservableNotification } from './types';\n\n/**\n * The {@link GlobalConfig} object for RxJS. It is used to configure things\n * like how to react on unhandled errors.\n */\nexport const config: GlobalConfig = {\n onUnhandledError: null,\n onStoppedNotification: null,\n Promise: undefined,\n useDeprecatedSynchronousErrorHandling: false,\n useDeprecatedNextContext: false,\n};\n\n/**\n * The global configuration object for RxJS, used to configure things\n * like how to react on unhandled errors. Accessible via {@link config}\n * object.\n */\nexport interface GlobalConfig {\n /**\n * A registration point for unhandled errors from RxJS. These are errors that\n * cannot were not handled by consuming code in the usual subscription path. For\n * example, if you have this configured, and you subscribe to an observable without\n * providing an error handler, errors from that subscription will end up here. This\n * will _always_ be called asynchronously on another job in the runtime. This is because\n * we do not want errors thrown in this user-configured handler to interfere with the\n * behavior of the library.\n */\n onUnhandledError: ((err: any) => void) | null;\n\n /**\n * A registration point for notifications that cannot be sent to subscribers because they\n * have completed, errored or have been explicitly unsubscribed. By default, next, complete\n * and error notifications sent to stopped subscribers are noops. However, sometimes callers\n * might want a different behavior. For example, with sources that attempt to report errors\n * to stopped subscribers, a caller can configure RxJS to throw an unhandled error instead.\n * This will _always_ be called asynchronously on another job in the runtime. This is because\n * we do not want errors thrown in this user-configured handler to interfere with the\n * behavior of the library.\n */\n onStoppedNotification: ((notification: ObservableNotification, subscriber: Subscriber) => void) | null;\n\n /**\n * The promise constructor used by default for {@link Observable#toPromise toPromise} and {@link Observable#forEach forEach}\n * methods.\n *\n * @deprecated As of version 8, RxJS will no longer support this sort of injection of a\n * Promise constructor. If you need a Promise implementation other than native promises,\n * please polyfill/patch Promise as you see appropriate. Will be removed in v8.\n */\n Promise?: PromiseConstructorLike;\n\n /**\n * If true, turns on synchronous error rethrowing, which is a deprecated behavior\n * in v6 and higher. This behavior enables bad patterns like wrapping a subscribe\n * call in a try/catch block. It also enables producer interference, a nasty bug\n * where a multicast can be broken for all observers by a downstream consumer with\n * an unhandled error. DO NOT USE THIS FLAG UNLESS IT'S NEEDED TO BUY TIME\n * FOR MIGRATION REASONS.\n *\n * @deprecated As of version 8, RxJS will no longer support synchronous throwing\n * of unhandled errors. All errors will be thrown on a separate call stack to prevent bad\n * behaviors described above. Will be removed in v8.\n */\n useDeprecatedSynchronousErrorHandling: boolean;\n\n /**\n * If true, enables an as-of-yet undocumented feature from v5: The ability to access\n * `unsubscribe()` via `this` context in `next` functions created in observers passed\n * to `subscribe`.\n *\n * This is being removed because the performance was severely problematic, and it could also cause\n * issues when types other than POJOs are passed to subscribe as subscribers, as they will likely have\n * their `this` context overwritten.\n *\n * @deprecated As of version 8, RxJS will no longer support altering the\n * context of next functions provided as part of an observer to Subscribe. Instead,\n * you will have access to a subscription or a signal or token that will allow you to do things like\n * unsubscribe and test closed status. Will be removed in v8.\n */\n useDeprecatedNextContext: boolean;\n}\n", "import type { TimerHandle } from './timerHandle';\ntype SetTimeoutFunction = (handler: () => void, timeout?: number, ...args: any[]) => TimerHandle;\ntype ClearTimeoutFunction = (handle: TimerHandle) => void;\n\ninterface TimeoutProvider {\n setTimeout: SetTimeoutFunction;\n clearTimeout: ClearTimeoutFunction;\n delegate:\n | {\n setTimeout: SetTimeoutFunction;\n clearTimeout: ClearTimeoutFunction;\n }\n | undefined;\n}\n\nexport const timeoutProvider: TimeoutProvider = {\n // When accessing the delegate, use the variable rather than `this` so that\n // the functions can be called without being bound to the provider.\n setTimeout(handler: () => void, timeout?: number, ...args) {\n const { delegate } = timeoutProvider;\n if (delegate?.setTimeout) {\n return delegate.setTimeout(handler, timeout, ...args);\n }\n return setTimeout(handler, timeout, ...args);\n },\n clearTimeout(handle) {\n const { delegate } = timeoutProvider;\n return (delegate?.clearTimeout || clearTimeout)(handle as any);\n },\n delegate: undefined,\n};\n", "import { config } from '../config';\nimport { timeoutProvider } from '../scheduler/timeoutProvider';\n\n/**\n * Handles an error on another job either with the user-configured {@link onUnhandledError},\n * or by throwing it on that new job so it can be picked up by `window.onerror`, `process.on('error')`, etc.\n *\n * This should be called whenever there is an error that is out-of-band with the subscription\n * or when an error hits a terminal boundary of the subscription and no error handler was provided.\n *\n * @param err the error to report\n */\nexport function reportUnhandledError(err: any) {\n timeoutProvider.setTimeout(() => {\n const { onUnhandledError } = config;\n if (onUnhandledError) {\n // Execute the user-configured error handler.\n onUnhandledError(err);\n } else {\n // Throw so it is picked up by the runtime's uncaught error mechanism.\n throw err;\n }\n });\n}\n", "/* tslint:disable:no-empty */\nexport function noop() { }\n", "import { CompleteNotification, NextNotification, ErrorNotification } from './types';\n\n/**\n * A completion object optimized for memory use and created to be the\n * same \"shape\" as other notifications in v8.\n * @internal\n */\nexport const COMPLETE_NOTIFICATION = (() => createNotification('C', undefined, undefined) as CompleteNotification)();\n\n/**\n * Internal use only. Creates an optimized error notification that is the same \"shape\"\n * as other notifications.\n * @internal\n */\nexport function errorNotification(error: any): ErrorNotification {\n return createNotification('E', undefined, error) as any;\n}\n\n/**\n * Internal use only. Creates an optimized next notification that is the same \"shape\"\n * as other notifications.\n * @internal\n */\nexport function nextNotification(value: T) {\n return createNotification('N', value, undefined) as NextNotification;\n}\n\n/**\n * Ensures that all notifications created internally have the same \"shape\" in v8.\n *\n * TODO: This is only exported to support a crazy legacy test in `groupBy`.\n * @internal\n */\nexport function createNotification(kind: 'N' | 'E' | 'C', value: any, error: any) {\n return {\n kind,\n value,\n error,\n };\n}\n", "import { config } from '../config';\n\nlet context: { errorThrown: boolean; error: any } | null = null;\n\n/**\n * Handles dealing with errors for super-gross mode. Creates a context, in which\n * any synchronously thrown errors will be passed to {@link captureError}. Which\n * will record the error such that it will be rethrown after the call back is complete.\n * TODO: Remove in v8\n * @param cb An immediately executed function.\n */\nexport function errorContext(cb: () => void) {\n if (config.useDeprecatedSynchronousErrorHandling) {\n const isRoot = !context;\n if (isRoot) {\n context = { errorThrown: false, error: null };\n }\n cb();\n if (isRoot) {\n const { errorThrown, error } = context!;\n context = null;\n if (errorThrown) {\n throw error;\n }\n }\n } else {\n // This is the general non-deprecated path for everyone that\n // isn't crazy enough to use super-gross mode (useDeprecatedSynchronousErrorHandling)\n cb();\n }\n}\n\n/**\n * Captures errors only in super-gross mode.\n * @param err the error to capture\n */\nexport function captureError(err: any) {\n if (config.useDeprecatedSynchronousErrorHandling && context) {\n context.errorThrown = true;\n context.error = err;\n }\n}\n", "import { isFunction } from './util/isFunction';\nimport { Observer, ObservableNotification } from './types';\nimport { isSubscription, Subscription } from './Subscription';\nimport { config } from './config';\nimport { reportUnhandledError } from './util/reportUnhandledError';\nimport { noop } from './util/noop';\nimport { nextNotification, errorNotification, COMPLETE_NOTIFICATION } from './NotificationFactories';\nimport { timeoutProvider } from './scheduler/timeoutProvider';\nimport { captureError } from './util/errorContext';\n\n/**\n * Implements the {@link Observer} interface and extends the\n * {@link Subscription} class. While the {@link Observer} is the public API for\n * consuming the values of an {@link Observable}, all Observers get converted to\n * a Subscriber, in order to provide Subscription-like capabilities such as\n * `unsubscribe`. Subscriber is a common type in RxJS, and crucial for\n * implementing operators, but it is rarely used as a public API.\n */\nexport class Subscriber extends Subscription implements Observer {\n /**\n * A static factory for a Subscriber, given a (potentially partial) definition\n * of an Observer.\n * @param next The `next` callback of an Observer.\n * @param error The `error` callback of an\n * Observer.\n * @param complete The `complete` callback of an\n * Observer.\n * @return A Subscriber wrapping the (partially defined)\n * Observer represented by the given arguments.\n * @deprecated Do not use. Will be removed in v8. There is no replacement for this\n * method, and there is no reason to be creating instances of `Subscriber` directly.\n * If you have a specific use case, please file an issue.\n */\n static create(next?: (x?: T) => void, error?: (e?: any) => void, complete?: () => void): Subscriber {\n return new SafeSubscriber(next, error, complete);\n }\n\n /** @deprecated Internal implementation detail, do not use directly. Will be made internal in v8. */\n protected isStopped: boolean = false;\n /** @deprecated Internal implementation detail, do not use directly. Will be made internal in v8. */\n protected destination: Subscriber | Observer; // this `any` is the escape hatch to erase extra type param (e.g. R)\n\n /**\n * @deprecated Internal implementation detail, do not use directly. Will be made internal in v8.\n * There is no reason to directly create an instance of Subscriber. This type is exported for typings reasons.\n */\n constructor(destination?: Subscriber | Observer) {\n super();\n if (destination) {\n this.destination = destination;\n // Automatically chain subscriptions together here.\n // if destination is a Subscription, then it is a Subscriber.\n if (isSubscription(destination)) {\n destination.add(this);\n }\n } else {\n this.destination = EMPTY_OBSERVER;\n }\n }\n\n /**\n * The {@link Observer} callback to receive notifications of type `next` from\n * the Observable, with a value. The Observable may call this method 0 or more\n * times.\n * @param value The `next` value.\n */\n next(value: T): void {\n if (this.isStopped) {\n handleStoppedNotification(nextNotification(value), this);\n } else {\n this._next(value!);\n }\n }\n\n /**\n * The {@link Observer} callback to receive notifications of type `error` from\n * the Observable, with an attached `Error`. Notifies the Observer that\n * the Observable has experienced an error condition.\n * @param err The `error` exception.\n */\n error(err?: any): void {\n if (this.isStopped) {\n handleStoppedNotification(errorNotification(err), this);\n } else {\n this.isStopped = true;\n this._error(err);\n }\n }\n\n /**\n * The {@link Observer} callback to receive a valueless notification of type\n * `complete` from the Observable. Notifies the Observer that the Observable\n * has finished sending push-based notifications.\n */\n complete(): void {\n if (this.isStopped) {\n handleStoppedNotification(COMPLETE_NOTIFICATION, this);\n } else {\n this.isStopped = true;\n this._complete();\n }\n }\n\n unsubscribe(): void {\n if (!this.closed) {\n this.isStopped = true;\n super.unsubscribe();\n this.destination = null!;\n }\n }\n\n protected _next(value: T): void {\n this.destination.next(value);\n }\n\n protected _error(err: any): void {\n try {\n this.destination.error(err);\n } finally {\n this.unsubscribe();\n }\n }\n\n protected _complete(): void {\n try {\n this.destination.complete();\n } finally {\n this.unsubscribe();\n }\n }\n}\n\n/**\n * This bind is captured here because we want to be able to have\n * compatibility with monoid libraries that tend to use a method named\n * `bind`. In particular, a library called Monio requires this.\n */\nconst _bind = Function.prototype.bind;\n\nfunction bind any>(fn: Fn, thisArg: any): Fn {\n return _bind.call(fn, thisArg);\n}\n\n/**\n * Internal optimization only, DO NOT EXPOSE.\n * @internal\n */\nclass ConsumerObserver implements Observer {\n constructor(private partialObserver: Partial>) {}\n\n next(value: T): void {\n const { partialObserver } = this;\n if (partialObserver.next) {\n try {\n partialObserver.next(value);\n } catch (error) {\n handleUnhandledError(error);\n }\n }\n }\n\n error(err: any): void {\n const { partialObserver } = this;\n if (partialObserver.error) {\n try {\n partialObserver.error(err);\n } catch (error) {\n handleUnhandledError(error);\n }\n } else {\n handleUnhandledError(err);\n }\n }\n\n complete(): void {\n const { partialObserver } = this;\n if (partialObserver.complete) {\n try {\n partialObserver.complete();\n } catch (error) {\n handleUnhandledError(error);\n }\n }\n }\n}\n\nexport class SafeSubscriber extends Subscriber {\n constructor(\n observerOrNext?: Partial> | ((value: T) => void) | null,\n error?: ((e?: any) => void) | null,\n complete?: (() => void) | null\n ) {\n super();\n\n let partialObserver: Partial>;\n if (isFunction(observerOrNext) || !observerOrNext) {\n // The first argument is a function, not an observer. The next\n // two arguments *could* be observers, or they could be empty.\n partialObserver = {\n next: (observerOrNext ?? undefined) as ((value: T) => void) | undefined,\n error: error ?? undefined,\n complete: complete ?? undefined,\n };\n } else {\n // The first argument is a partial observer.\n let context: any;\n if (this && config.useDeprecatedNextContext) {\n // This is a deprecated path that made `this.unsubscribe()` available in\n // next handler functions passed to subscribe. This only exists behind a flag\n // now, as it is *very* slow.\n context = Object.create(observerOrNext);\n context.unsubscribe = () => this.unsubscribe();\n partialObserver = {\n next: observerOrNext.next && bind(observerOrNext.next, context),\n error: observerOrNext.error && bind(observerOrNext.error, context),\n complete: observerOrNext.complete && bind(observerOrNext.complete, context),\n };\n } else {\n // The \"normal\" path. Just use the partial observer directly.\n partialObserver = observerOrNext;\n }\n }\n\n // Wrap the partial observer to ensure it's a full observer, and\n // make sure proper error handling is accounted for.\n this.destination = new ConsumerObserver(partialObserver);\n }\n}\n\nfunction handleUnhandledError(error: any) {\n if (config.useDeprecatedSynchronousErrorHandling) {\n captureError(error);\n } else {\n // Ideal path, we report this as an unhandled error,\n // which is thrown on a new call stack.\n reportUnhandledError(error);\n }\n}\n\n/**\n * An error handler used when no error handler was supplied\n * to the SafeSubscriber -- meaning no error handler was supplied\n * do the `subscribe` call on our observable.\n * @param err The error to handle\n */\nfunction defaultErrorHandler(err: any) {\n throw err;\n}\n\n/**\n * A handler for notifications that cannot be sent to a stopped subscriber.\n * @param notification The notification being sent.\n * @param subscriber The stopped subscriber.\n */\nfunction handleStoppedNotification(notification: ObservableNotification, subscriber: Subscriber) {\n const { onStoppedNotification } = config;\n onStoppedNotification && timeoutProvider.setTimeout(() => onStoppedNotification(notification, subscriber));\n}\n\n/**\n * The observer used as a stub for subscriptions where the user did not\n * pass any arguments to `subscribe`. Comes with the default error handling\n * behavior.\n */\nexport const EMPTY_OBSERVER: Readonly> & { closed: true } = {\n closed: true,\n next: noop,\n error: defaultErrorHandler,\n complete: noop,\n};\n", "/**\n * Symbol.observable or a string \"@@observable\". Used for interop\n *\n * @deprecated We will no longer be exporting this symbol in upcoming versions of RxJS.\n * Instead polyfill and use Symbol.observable directly *or* use https://www.npmjs.com/package/symbol-observable\n */\nexport const observable: string | symbol = (() => (typeof Symbol === 'function' && Symbol.observable) || '@@observable')();\n", "/**\n * This function takes one parameter and just returns it. Simply put,\n * this is like `(x: T): T => x`.\n *\n * ## Examples\n *\n * This is useful in some cases when using things like `mergeMap`\n *\n * ```ts\n * import { interval, take, map, range, mergeMap, identity } from 'rxjs';\n *\n * const source$ = interval(1000).pipe(take(5));\n *\n * const result$ = source$.pipe(\n * map(i => range(i)),\n * mergeMap(identity) // same as mergeMap(x => x)\n * );\n *\n * result$.subscribe({\n * next: console.log\n * });\n * ```\n *\n * Or when you want to selectively apply an operator\n *\n * ```ts\n * import { interval, take, identity } from 'rxjs';\n *\n * const shouldLimit = () => Math.random() < 0.5;\n *\n * const source$ = interval(1000);\n *\n * const result$ = source$.pipe(shouldLimit() ? take(5) : identity);\n *\n * result$.subscribe({\n * next: console.log\n * });\n * ```\n *\n * @param x Any value that is returned by this function\n * @returns The value passed as the first parameter to this function\n */\nexport function identity(x: T): T {\n return x;\n}\n", "import { identity } from './identity';\nimport { UnaryFunction } from '../types';\n\nexport function pipe(): typeof identity;\nexport function pipe(fn1: UnaryFunction): UnaryFunction;\nexport function pipe(fn1: UnaryFunction, fn2: UnaryFunction): UnaryFunction;\nexport function pipe(fn1: UnaryFunction, fn2: UnaryFunction, fn3: UnaryFunction): UnaryFunction;\nexport function pipe(\n fn1: UnaryFunction,\n fn2: UnaryFunction,\n fn3: UnaryFunction,\n fn4: UnaryFunction\n): UnaryFunction;\nexport function pipe(\n fn1: UnaryFunction,\n fn2: UnaryFunction,\n fn3: UnaryFunction,\n fn4: UnaryFunction,\n fn5: UnaryFunction\n): UnaryFunction;\nexport function pipe(\n fn1: UnaryFunction,\n fn2: UnaryFunction,\n fn3: UnaryFunction,\n fn4: UnaryFunction,\n fn5: UnaryFunction,\n fn6: UnaryFunction\n): UnaryFunction;\nexport function pipe(\n fn1: UnaryFunction,\n fn2: UnaryFunction,\n fn3: UnaryFunction,\n fn4: UnaryFunction,\n fn5: UnaryFunction,\n fn6: UnaryFunction,\n fn7: UnaryFunction\n): UnaryFunction;\nexport function pipe(\n fn1: UnaryFunction,\n fn2: UnaryFunction,\n fn3: UnaryFunction,\n fn4: UnaryFunction,\n fn5: UnaryFunction,\n fn6: UnaryFunction,\n fn7: UnaryFunction,\n fn8: UnaryFunction\n): UnaryFunction;\nexport function pipe(\n fn1: UnaryFunction,\n fn2: UnaryFunction,\n fn3: UnaryFunction,\n fn4: UnaryFunction,\n fn5: UnaryFunction,\n fn6: UnaryFunction,\n fn7: UnaryFunction,\n fn8: UnaryFunction,\n fn9: UnaryFunction\n): UnaryFunction;\nexport function pipe(\n fn1: UnaryFunction,\n fn2: UnaryFunction,\n fn3: UnaryFunction,\n fn4: UnaryFunction,\n fn5: UnaryFunction,\n fn6: UnaryFunction,\n fn7: UnaryFunction,\n fn8: UnaryFunction,\n fn9: UnaryFunction,\n ...fns: UnaryFunction[]\n): UnaryFunction;\n\n/**\n * pipe() can be called on one or more functions, each of which can take one argument (\"UnaryFunction\")\n * and uses it to return a value.\n * It returns a function that takes one argument, passes it to the first UnaryFunction, and then\n * passes the result to the next one, passes that result to the next one, and so on. \n */\nexport function pipe(...fns: Array>): UnaryFunction {\n return pipeFromArray(fns);\n}\n\n/** @internal */\nexport function pipeFromArray(fns: Array>): UnaryFunction {\n if (fns.length === 0) {\n return identity as UnaryFunction;\n }\n\n if (fns.length === 1) {\n return fns[0];\n }\n\n return function piped(input: T): R {\n return fns.reduce((prev: any, fn: UnaryFunction) => fn(prev), input as any);\n };\n}\n", "import { Operator } from './Operator';\nimport { SafeSubscriber, Subscriber } from './Subscriber';\nimport { isSubscription, Subscription } from './Subscription';\nimport { TeardownLogic, OperatorFunction, Subscribable, Observer } from './types';\nimport { observable as Symbol_observable } from './symbol/observable';\nimport { pipeFromArray } from './util/pipe';\nimport { config } from './config';\nimport { isFunction } from './util/isFunction';\nimport { errorContext } from './util/errorContext';\n\n/**\n * A representation of any set of values over any amount of time. This is the most basic building block\n * of RxJS.\n */\nexport class Observable implements Subscribable {\n /**\n * @deprecated Internal implementation detail, do not use directly. Will be made internal in v8.\n */\n source: Observable | undefined;\n\n /**\n * @deprecated Internal implementation detail, do not use directly. Will be made internal in v8.\n */\n operator: Operator | undefined;\n\n /**\n * @param subscribe The function that is called when the Observable is\n * initially subscribed to. This function is given a Subscriber, to which new values\n * can be `next`ed, or an `error` method can be called to raise an error, or\n * `complete` can be called to notify of a successful completion.\n */\n constructor(subscribe?: (this: Observable, subscriber: Subscriber) => TeardownLogic) {\n if (subscribe) {\n this._subscribe = subscribe;\n }\n }\n\n // HACK: Since TypeScript inherits static properties too, we have to\n // fight against TypeScript here so Subject can have a different static create signature\n /**\n * Creates a new Observable by calling the Observable constructor\n * @param subscribe the subscriber function to be passed to the Observable constructor\n * @return A new observable.\n * @deprecated Use `new Observable()` instead. Will be removed in v8.\n */\n static create: (...args: any[]) => any = (subscribe?: (subscriber: Subscriber) => TeardownLogic) => {\n return new Observable(subscribe);\n };\n\n /**\n * Creates a new Observable, with this Observable instance as the source, and the passed\n * operator defined as the new observable's operator.\n * @param operator the operator defining the operation to take on the observable\n * @return A new observable with the Operator applied.\n * @deprecated Internal implementation detail, do not use directly. Will be made internal in v8.\n * If you have implemented an operator using `lift`, it is recommended that you create an\n * operator by simply returning `new Observable()` directly. See \"Creating new operators from\n * scratch\" section here: https://rxjs.dev/guide/operators\n */\n lift(operator?: Operator): Observable {\n const observable = new Observable();\n observable.source = this;\n observable.operator = operator;\n return observable;\n }\n\n subscribe(observerOrNext?: Partial> | ((value: T) => void)): Subscription;\n /** @deprecated Instead of passing separate callback arguments, use an observer argument. Signatures taking separate callback arguments will be removed in v8. Details: https://rxjs.dev/deprecations/subscribe-arguments */\n subscribe(next?: ((value: T) => void) | null, error?: ((error: any) => void) | null, complete?: (() => void) | null): Subscription;\n /**\n * Invokes an execution of an Observable and registers Observer handlers for notifications it will emit.\n *\n * Use it when you have all these Observables, but still nothing is happening.\n *\n * `subscribe` is not a regular operator, but a method that calls Observable's internal `subscribe` function. It\n * might be for example a function that you passed to Observable's constructor, but most of the time it is\n * a library implementation, which defines what will be emitted by an Observable, and when it be will emitted. This means\n * that calling `subscribe` is actually the moment when Observable starts its work, not when it is created, as it is often\n * the thought.\n *\n * Apart from starting the execution of an Observable, this method allows you to listen for values\n * that an Observable emits, as well as for when it completes or errors. You can achieve this in two\n * of the following ways.\n *\n * The first way is creating an object that implements {@link Observer} interface. It should have methods\n * defined by that interface, but note that it should be just a regular JavaScript object, which you can create\n * yourself in any way you want (ES6 class, classic function constructor, object literal etc.). In particular, do\n * not attempt to use any RxJS implementation details to create Observers - you don't need them. Remember also\n * that your object does not have to implement all methods. If you find yourself creating a method that doesn't\n * do anything, you can simply omit it. Note however, if the `error` method is not provided and an error happens,\n * it will be thrown asynchronously. Errors thrown asynchronously cannot be caught using `try`/`catch`. Instead,\n * use the {@link onUnhandledError} configuration option or use a runtime handler (like `window.onerror` or\n * `process.on('error)`) to be notified of unhandled errors. Because of this, it's recommended that you provide\n * an `error` method to avoid missing thrown errors.\n *\n * The second way is to give up on Observer object altogether and simply provide callback functions in place of its methods.\n * This means you can provide three functions as arguments to `subscribe`, where the first function is equivalent\n * of a `next` method, the second of an `error` method and the third of a `complete` method. Just as in case of an Observer,\n * if you do not need to listen for something, you can omit a function by passing `undefined` or `null`,\n * since `subscribe` recognizes these functions by where they were placed in function call. When it comes\n * to the `error` function, as with an Observer, if not provided, errors emitted by an Observable will be thrown asynchronously.\n *\n * You can, however, subscribe with no parameters at all. This may be the case where you're not interested in terminal events\n * and you also handled emissions internally by using operators (e.g. using `tap`).\n *\n * Whichever style of calling `subscribe` you use, in both cases it returns a Subscription object.\n * This object allows you to call `unsubscribe` on it, which in turn will stop the work that an Observable does and will clean\n * up all resources that an Observable used. Note that cancelling a subscription will not call `complete` callback\n * provided to `subscribe` function, which is reserved for a regular completion signal that comes from an Observable.\n *\n * Remember that callbacks provided to `subscribe` are not guaranteed to be called asynchronously.\n * It is an Observable itself that decides when these functions will be called. For example {@link of}\n * by default emits all its values synchronously. Always check documentation for how given Observable\n * will behave when subscribed and if its default behavior can be modified with a `scheduler`.\n *\n * #### Examples\n *\n * Subscribe with an {@link guide/observer Observer}\n *\n * ```ts\n * import { of } from 'rxjs';\n *\n * const sumObserver = {\n * sum: 0,\n * next(value) {\n * console.log('Adding: ' + value);\n * this.sum = this.sum + value;\n * },\n * error() {\n * // We actually could just remove this method,\n * // since we do not really care about errors right now.\n * },\n * complete() {\n * console.log('Sum equals: ' + this.sum);\n * }\n * };\n *\n * of(1, 2, 3) // Synchronously emits 1, 2, 3 and then completes.\n * .subscribe(sumObserver);\n *\n * // Logs:\n * // 'Adding: 1'\n * // 'Adding: 2'\n * // 'Adding: 3'\n * // 'Sum equals: 6'\n * ```\n *\n * Subscribe with functions ({@link deprecations/subscribe-arguments deprecated})\n *\n * ```ts\n * import { of } from 'rxjs'\n *\n * let sum = 0;\n *\n * of(1, 2, 3).subscribe(\n * value => {\n * console.log('Adding: ' + value);\n * sum = sum + value;\n * },\n * undefined,\n * () => console.log('Sum equals: ' + sum)\n * );\n *\n * // Logs:\n * // 'Adding: 1'\n * // 'Adding: 2'\n * // 'Adding: 3'\n * // 'Sum equals: 6'\n * ```\n *\n * Cancel a subscription\n *\n * ```ts\n * import { interval } from 'rxjs';\n *\n * const subscription = interval(1000).subscribe({\n * next(num) {\n * console.log(num)\n * },\n * complete() {\n * // Will not be called, even when cancelling subscription.\n * console.log('completed!');\n * }\n * });\n *\n * setTimeout(() => {\n * subscription.unsubscribe();\n * console.log('unsubscribed!');\n * }, 2500);\n *\n * // Logs:\n * // 0 after 1s\n * // 1 after 2s\n * // 'unsubscribed!' after 2.5s\n * ```\n *\n * @param observerOrNext Either an {@link Observer} with some or all callback methods,\n * or the `next` handler that is called for each value emitted from the subscribed Observable.\n * @param error A handler for a terminal event resulting from an error. If no error handler is provided,\n * the error will be thrown asynchronously as unhandled.\n * @param complete A handler for a terminal event resulting from successful completion.\n * @return A subscription reference to the registered handlers.\n */\n subscribe(\n observerOrNext?: Partial> | ((value: T) => void) | null,\n error?: ((error: any) => void) | null,\n complete?: (() => void) | null\n ): Subscription {\n const subscriber = isSubscriber(observerOrNext) ? observerOrNext : new SafeSubscriber(observerOrNext, error, complete);\n\n errorContext(() => {\n const { operator, source } = this;\n subscriber.add(\n operator\n ? // We're dealing with a subscription in the\n // operator chain to one of our lifted operators.\n operator.call(subscriber, source)\n : source\n ? // If `source` has a value, but `operator` does not, something that\n // had intimate knowledge of our API, like our `Subject`, must have\n // set it. We're going to just call `_subscribe` directly.\n this._subscribe(subscriber)\n : // In all other cases, we're likely wrapping a user-provided initializer\n // function, so we need to catch errors and handle them appropriately.\n this._trySubscribe(subscriber)\n );\n });\n\n return subscriber;\n }\n\n /** @internal */\n protected _trySubscribe(sink: Subscriber): TeardownLogic {\n try {\n return this._subscribe(sink);\n } catch (err) {\n // We don't need to return anything in this case,\n // because it's just going to try to `add()` to a subscription\n // above.\n sink.error(err);\n }\n }\n\n /**\n * Used as a NON-CANCELLABLE means of subscribing to an observable, for use with\n * APIs that expect promises, like `async/await`. You cannot unsubscribe from this.\n *\n * **WARNING**: Only use this with observables you *know* will complete. If the source\n * observable does not complete, you will end up with a promise that is hung up, and\n * potentially all of the state of an async function hanging out in memory. To avoid\n * this situation, look into adding something like {@link timeout}, {@link take},\n * {@link takeWhile}, or {@link takeUntil} amongst others.\n *\n * #### Example\n *\n * ```ts\n * import { interval, take } from 'rxjs';\n *\n * const source$ = interval(1000).pipe(take(4));\n *\n * async function getTotal() {\n * let total = 0;\n *\n * await source$.forEach(value => {\n * total += value;\n * console.log('observable -> ' + value);\n * });\n *\n * return total;\n * }\n *\n * getTotal().then(\n * total => console.log('Total: ' + total)\n * );\n *\n * // Expected:\n * // 'observable -> 0'\n * // 'observable -> 1'\n * // 'observable -> 2'\n * // 'observable -> 3'\n * // 'Total: 6'\n * ```\n *\n * @param next A handler for each value emitted by the observable.\n * @return A promise that either resolves on observable completion or\n * rejects with the handled error.\n */\n forEach(next: (value: T) => void): Promise;\n\n /**\n * @param next a handler for each value emitted by the observable\n * @param promiseCtor a constructor function used to instantiate the Promise\n * @return a promise that either resolves on observable completion or\n * rejects with the handled error\n * @deprecated Passing a Promise constructor will no longer be available\n * in upcoming versions of RxJS. This is because it adds weight to the library, for very\n * little benefit. If you need this functionality, it is recommended that you either\n * polyfill Promise, or you create an adapter to convert the returned native promise\n * to whatever promise implementation you wanted. Will be removed in v8.\n */\n forEach(next: (value: T) => void, promiseCtor: PromiseConstructorLike): Promise;\n\n forEach(next: (value: T) => void, promiseCtor?: PromiseConstructorLike): Promise {\n promiseCtor = getPromiseCtor(promiseCtor);\n\n return new promiseCtor((resolve, reject) => {\n const subscriber = new SafeSubscriber({\n next: (value) => {\n try {\n next(value);\n } catch (err) {\n reject(err);\n subscriber.unsubscribe();\n }\n },\n error: reject,\n complete: resolve,\n });\n this.subscribe(subscriber);\n }) as Promise;\n }\n\n /** @internal */\n protected _subscribe(subscriber: Subscriber): TeardownLogic {\n return this.source?.subscribe(subscriber);\n }\n\n /**\n * An interop point defined by the es7-observable spec https://github.com/zenparsing/es-observable\n * @return This instance of the observable.\n */\n [Symbol_observable]() {\n return this;\n }\n\n /* tslint:disable:max-line-length */\n pipe(): Observable;\n pipe(op1: OperatorFunction): Observable;\n pipe(op1: OperatorFunction, op2: OperatorFunction): Observable;\n pipe(op1: OperatorFunction, op2: OperatorFunction, op3: OperatorFunction): Observable;\n pipe(\n op1: OperatorFunction,\n op2: OperatorFunction,\n op3: OperatorFunction,\n op4: OperatorFunction\n ): Observable;\n pipe(\n op1: OperatorFunction,\n op2: OperatorFunction,\n op3: OperatorFunction,\n op4: OperatorFunction,\n op5: OperatorFunction\n ): Observable;\n pipe(\n op1: OperatorFunction,\n op2: OperatorFunction,\n op3: OperatorFunction,\n op4: OperatorFunction,\n op5: OperatorFunction,\n op6: OperatorFunction\n ): Observable;\n pipe(\n op1: OperatorFunction,\n op2: OperatorFunction,\n op3: OperatorFunction,\n op4: OperatorFunction,\n op5: OperatorFunction,\n op6: OperatorFunction,\n op7: OperatorFunction\n ): Observable;\n pipe(\n op1: OperatorFunction,\n op2: OperatorFunction,\n op3: OperatorFunction,\n op4: OperatorFunction,\n op5: OperatorFunction,\n op6: OperatorFunction,\n op7: OperatorFunction,\n op8: OperatorFunction\n ): Observable;\n pipe(\n op1: OperatorFunction,\n op2: OperatorFunction,\n op3: OperatorFunction,\n op4: OperatorFunction,\n op5: OperatorFunction,\n op6: OperatorFunction,\n op7: OperatorFunction,\n op8: OperatorFunction,\n op9: OperatorFunction\n ): Observable;\n pipe(\n op1: OperatorFunction,\n op2: OperatorFunction,\n op3: OperatorFunction,\n op4: OperatorFunction,\n op5: OperatorFunction,\n op6: OperatorFunction,\n op7: OperatorFunction,\n op8: OperatorFunction,\n op9: OperatorFunction,\n ...operations: OperatorFunction[]\n ): Observable;\n /* tslint:enable:max-line-length */\n\n /**\n * Used to stitch together functional operators into a chain.\n *\n * ## Example\n *\n * ```ts\n * import { interval, filter, map, scan } from 'rxjs';\n *\n * interval(1000)\n * .pipe(\n * filter(x => x % 2 === 0),\n * map(x => x + x),\n * scan((acc, x) => acc + x)\n * )\n * .subscribe(x => console.log(x));\n * ```\n *\n * @return The Observable result of all the operators having been called\n * in the order they were passed in.\n */\n pipe(...operations: OperatorFunction[]): Observable {\n return pipeFromArray(operations)(this);\n }\n\n /* tslint:disable:max-line-length */\n /** @deprecated Replaced with {@link firstValueFrom} and {@link lastValueFrom}. Will be removed in v8. Details: https://rxjs.dev/deprecations/to-promise */\n toPromise(): Promise;\n /** @deprecated Replaced with {@link firstValueFrom} and {@link lastValueFrom}. Will be removed in v8. Details: https://rxjs.dev/deprecations/to-promise */\n toPromise(PromiseCtor: typeof Promise): Promise;\n /** @deprecated Replaced with {@link firstValueFrom} and {@link lastValueFrom}. Will be removed in v8. Details: https://rxjs.dev/deprecations/to-promise */\n toPromise(PromiseCtor: PromiseConstructorLike): Promise;\n /* tslint:enable:max-line-length */\n\n /**\n * Subscribe to this Observable and get a Promise resolving on\n * `complete` with the last emission (if any).\n *\n * **WARNING**: Only use this with observables you *know* will complete. If the source\n * observable does not complete, you will end up with a promise that is hung up, and\n * potentially all of the state of an async function hanging out in memory. To avoid\n * this situation, look into adding something like {@link timeout}, {@link take},\n * {@link takeWhile}, or {@link takeUntil} amongst others.\n *\n * @param [promiseCtor] a constructor function used to instantiate\n * the Promise\n * @return A Promise that resolves with the last value emit, or\n * rejects on an error. If there were no emissions, Promise\n * resolves with undefined.\n * @deprecated Replaced with {@link firstValueFrom} and {@link lastValueFrom}. Will be removed in v8. Details: https://rxjs.dev/deprecations/to-promise\n */\n toPromise(promiseCtor?: PromiseConstructorLike): Promise {\n promiseCtor = getPromiseCtor(promiseCtor);\n\n return new promiseCtor((resolve, reject) => {\n let value: T | undefined;\n this.subscribe(\n (x: T) => (value = x),\n (err: any) => reject(err),\n () => resolve(value)\n );\n }) as Promise;\n }\n}\n\n/**\n * Decides between a passed promise constructor from consuming code,\n * A default configured promise constructor, and the native promise\n * constructor and returns it. If nothing can be found, it will throw\n * an error.\n * @param promiseCtor The optional promise constructor to passed by consuming code\n */\nfunction getPromiseCtor(promiseCtor: PromiseConstructorLike | undefined) {\n return promiseCtor ?? config.Promise ?? Promise;\n}\n\nfunction isObserver(value: any): value is Observer {\n return value && isFunction(value.next) && isFunction(value.error) && isFunction(value.complete);\n}\n\nfunction isSubscriber(value: any): value is Subscriber {\n return (value && value instanceof Subscriber) || (isObserver(value) && isSubscription(value));\n}\n", "import { Observable } from '../Observable';\nimport { Subscriber } from '../Subscriber';\nimport { OperatorFunction } from '../types';\nimport { isFunction } from './isFunction';\n\n/**\n * Used to determine if an object is an Observable with a lift function.\n */\nexport function hasLift(source: any): source is { lift: InstanceType['lift'] } {\n return isFunction(source?.lift);\n}\n\n/**\n * Creates an `OperatorFunction`. Used to define operators throughout the library in a concise way.\n * @param init The logic to connect the liftedSource to the subscriber at the moment of subscription.\n */\nexport function operate(\n init: (liftedSource: Observable, subscriber: Subscriber) => (() => void) | void\n): OperatorFunction {\n return (source: Observable) => {\n if (hasLift(source)) {\n return source.lift(function (this: Subscriber, liftedSource: Observable) {\n try {\n return init(liftedSource, this);\n } catch (err) {\n this.error(err);\n }\n });\n }\n throw new TypeError('Unable to lift unknown Observable type');\n };\n}\n", "import { Subscriber } from '../Subscriber';\n\n/**\n * Creates an instance of an `OperatorSubscriber`.\n * @param destination The downstream subscriber.\n * @param onNext Handles next values, only called if this subscriber is not stopped or closed. Any\n * error that occurs in this function is caught and sent to the `error` method of this subscriber.\n * @param onError Handles errors from the subscription, any errors that occur in this handler are caught\n * and send to the `destination` error handler.\n * @param onComplete Handles completion notification from the subscription. Any errors that occur in\n * this handler are sent to the `destination` error handler.\n * @param onFinalize Additional teardown logic here. This will only be called on teardown if the\n * subscriber itself is not already closed. This is called after all other teardown logic is executed.\n */\nexport function createOperatorSubscriber(\n destination: Subscriber,\n onNext?: (value: T) => void,\n onComplete?: () => void,\n onError?: (err: any) => void,\n onFinalize?: () => void\n): Subscriber {\n return new OperatorSubscriber(destination, onNext, onComplete, onError, onFinalize);\n}\n\n/**\n * A generic helper for allowing operators to be created with a Subscriber and\n * use closures to capture necessary state from the operator function itself.\n */\nexport class OperatorSubscriber extends Subscriber {\n /**\n * Creates an instance of an `OperatorSubscriber`.\n * @param destination The downstream subscriber.\n * @param onNext Handles next values, only called if this subscriber is not stopped or closed. Any\n * error that occurs in this function is caught and sent to the `error` method of this subscriber.\n * @param onError Handles errors from the subscription, any errors that occur in this handler are caught\n * and send to the `destination` error handler.\n * @param onComplete Handles completion notification from the subscription. Any errors that occur in\n * this handler are sent to the `destination` error handler.\n * @param onFinalize Additional finalization logic here. This will only be called on finalization if the\n * subscriber itself is not already closed. This is called after all other finalization logic is executed.\n * @param shouldUnsubscribe An optional check to see if an unsubscribe call should truly unsubscribe.\n * NOTE: This currently **ONLY** exists to support the strange behavior of {@link groupBy}, where unsubscription\n * to the resulting observable does not actually disconnect from the source if there are active subscriptions\n * to any grouped observable. (DO NOT EXPOSE OR USE EXTERNALLY!!!)\n */\n constructor(\n destination: Subscriber,\n onNext?: (value: T) => void,\n onComplete?: () => void,\n onError?: (err: any) => void,\n private onFinalize?: () => void,\n private shouldUnsubscribe?: () => boolean\n ) {\n // It's important - for performance reasons - that all of this class's\n // members are initialized and that they are always initialized in the same\n // order. This will ensure that all OperatorSubscriber instances have the\n // same hidden class in V8. This, in turn, will help keep the number of\n // hidden classes involved in property accesses within the base class as\n // low as possible. If the number of hidden classes involved exceeds four,\n // the property accesses will become megamorphic and performance penalties\n // will be incurred - i.e. inline caches won't be used.\n //\n // The reasons for ensuring all instances have the same hidden class are\n // further discussed in this blog post from Benedikt Meurer:\n // https://benediktmeurer.de/2018/03/23/impact-of-polymorphism-on-component-based-frameworks-like-react/\n super(destination);\n this._next = onNext\n ? function (this: OperatorSubscriber, value: T) {\n try {\n onNext(value);\n } catch (err) {\n destination.error(err);\n }\n }\n : super._next;\n this._error = onError\n ? function (this: OperatorSubscriber, err: any) {\n try {\n onError(err);\n } catch (err) {\n // Send any errors that occur down stream.\n destination.error(err);\n } finally {\n // Ensure finalization.\n this.unsubscribe();\n }\n }\n : super._error;\n this._complete = onComplete\n ? function (this: OperatorSubscriber) {\n try {\n onComplete();\n } catch (err) {\n // Send any errors that occur down stream.\n destination.error(err);\n } finally {\n // Ensure finalization.\n this.unsubscribe();\n }\n }\n : super._complete;\n }\n\n unsubscribe() {\n if (!this.shouldUnsubscribe || this.shouldUnsubscribe()) {\n const { closed } = this;\n super.unsubscribe();\n // Execute additional teardown if we have any and we didn't already do so.\n !closed && this.onFinalize?.();\n }\n }\n}\n", "import { Subscription } from '../Subscription';\n\ninterface AnimationFrameProvider {\n schedule(callback: FrameRequestCallback): Subscription;\n requestAnimationFrame: typeof requestAnimationFrame;\n cancelAnimationFrame: typeof cancelAnimationFrame;\n delegate:\n | {\n requestAnimationFrame: typeof requestAnimationFrame;\n cancelAnimationFrame: typeof cancelAnimationFrame;\n }\n | undefined;\n}\n\nexport const animationFrameProvider: AnimationFrameProvider = {\n // When accessing the delegate, use the variable rather than `this` so that\n // the functions can be called without being bound to the provider.\n schedule(callback) {\n let request = requestAnimationFrame;\n let cancel: typeof cancelAnimationFrame | undefined = cancelAnimationFrame;\n const { delegate } = animationFrameProvider;\n if (delegate) {\n request = delegate.requestAnimationFrame;\n cancel = delegate.cancelAnimationFrame;\n }\n const handle = request((timestamp) => {\n // Clear the cancel function. The request has been fulfilled, so\n // attempting to cancel the request upon unsubscription would be\n // pointless.\n cancel = undefined;\n callback(timestamp);\n });\n return new Subscription(() => cancel?.(handle));\n },\n requestAnimationFrame(...args) {\n const { delegate } = animationFrameProvider;\n return (delegate?.requestAnimationFrame || requestAnimationFrame)(...args);\n },\n cancelAnimationFrame(...args) {\n const { delegate } = animationFrameProvider;\n return (delegate?.cancelAnimationFrame || cancelAnimationFrame)(...args);\n },\n delegate: undefined,\n};\n", "import { createErrorClass } from './createErrorClass';\n\nexport interface ObjectUnsubscribedError extends Error {}\n\nexport interface ObjectUnsubscribedErrorCtor {\n /**\n * @deprecated Internal implementation detail. Do not construct error instances.\n * Cannot be tagged as internal: https://github.com/ReactiveX/rxjs/issues/6269\n */\n new (): ObjectUnsubscribedError;\n}\n\n/**\n * An error thrown when an action is invalid because the object has been\n * unsubscribed.\n *\n * @see {@link Subject}\n * @see {@link BehaviorSubject}\n *\n * @class ObjectUnsubscribedError\n */\nexport const ObjectUnsubscribedError: ObjectUnsubscribedErrorCtor = createErrorClass(\n (_super) =>\n function ObjectUnsubscribedErrorImpl(this: any) {\n _super(this);\n this.name = 'ObjectUnsubscribedError';\n this.message = 'object unsubscribed';\n }\n);\n", "import { Operator } from './Operator';\nimport { Observable } from './Observable';\nimport { Subscriber } from './Subscriber';\nimport { Subscription, EMPTY_SUBSCRIPTION } from './Subscription';\nimport { Observer, SubscriptionLike, TeardownLogic } from './types';\nimport { ObjectUnsubscribedError } from './util/ObjectUnsubscribedError';\nimport { arrRemove } from './util/arrRemove';\nimport { errorContext } from './util/errorContext';\n\n/**\n * A Subject is a special type of Observable that allows values to be\n * multicasted to many Observers. Subjects are like EventEmitters.\n *\n * Every Subject is an Observable and an Observer. You can subscribe to a\n * Subject, and you can call next to feed values as well as error and complete.\n */\nexport class Subject extends Observable implements SubscriptionLike {\n closed = false;\n\n private currentObservers: Observer[] | null = null;\n\n /** @deprecated Internal implementation detail, do not use directly. Will be made internal in v8. */\n observers: Observer[] = [];\n /** @deprecated Internal implementation detail, do not use directly. Will be made internal in v8. */\n isStopped = false;\n /** @deprecated Internal implementation detail, do not use directly. Will be made internal in v8. */\n hasError = false;\n /** @deprecated Internal implementation detail, do not use directly. Will be made internal in v8. */\n thrownError: any = null;\n\n /**\n * Creates a \"subject\" by basically gluing an observer to an observable.\n *\n * @deprecated Recommended you do not use. Will be removed at some point in the future. Plans for replacement still under discussion.\n */\n static create: (...args: any[]) => any = (destination: Observer, source: Observable): AnonymousSubject => {\n return new AnonymousSubject(destination, source);\n };\n\n constructor() {\n // NOTE: This must be here to obscure Observable's constructor.\n super();\n }\n\n /** @deprecated Internal implementation detail, do not use directly. Will be made internal in v8. */\n lift(operator: Operator): Observable {\n const subject = new AnonymousSubject(this, this);\n subject.operator = operator as any;\n return subject as any;\n }\n\n /** @internal */\n protected _throwIfClosed() {\n if (this.closed) {\n throw new ObjectUnsubscribedError();\n }\n }\n\n next(value: T) {\n errorContext(() => {\n this._throwIfClosed();\n if (!this.isStopped) {\n if (!this.currentObservers) {\n this.currentObservers = Array.from(this.observers);\n }\n for (const observer of this.currentObservers) {\n observer.next(value);\n }\n }\n });\n }\n\n error(err: any) {\n errorContext(() => {\n this._throwIfClosed();\n if (!this.isStopped) {\n this.hasError = this.isStopped = true;\n this.thrownError = err;\n const { observers } = this;\n while (observers.length) {\n observers.shift()!.error(err);\n }\n }\n });\n }\n\n complete() {\n errorContext(() => {\n this._throwIfClosed();\n if (!this.isStopped) {\n this.isStopped = true;\n const { observers } = this;\n while (observers.length) {\n observers.shift()!.complete();\n }\n }\n });\n }\n\n unsubscribe() {\n this.isStopped = this.closed = true;\n this.observers = this.currentObservers = null!;\n }\n\n get observed() {\n return this.observers?.length > 0;\n }\n\n /** @internal */\n protected _trySubscribe(subscriber: Subscriber): TeardownLogic {\n this._throwIfClosed();\n return super._trySubscribe(subscriber);\n }\n\n /** @internal */\n protected _subscribe(subscriber: Subscriber): Subscription {\n this._throwIfClosed();\n this._checkFinalizedStatuses(subscriber);\n return this._innerSubscribe(subscriber);\n }\n\n /** @internal */\n protected _innerSubscribe(subscriber: Subscriber) {\n const { hasError, isStopped, observers } = this;\n if (hasError || isStopped) {\n return EMPTY_SUBSCRIPTION;\n }\n this.currentObservers = null;\n observers.push(subscriber);\n return new Subscription(() => {\n this.currentObservers = null;\n arrRemove(observers, subscriber);\n });\n }\n\n /** @internal */\n protected _checkFinalizedStatuses(subscriber: Subscriber) {\n const { hasError, thrownError, isStopped } = this;\n if (hasError) {\n subscriber.error(thrownError);\n } else if (isStopped) {\n subscriber.complete();\n }\n }\n\n /**\n * Creates a new Observable with this Subject as the source. You can do this\n * to create custom Observer-side logic of the Subject and conceal it from\n * code that uses the Observable.\n * @return Observable that this Subject casts to.\n */\n asObservable(): Observable {\n const observable: any = new Observable();\n observable.source = this;\n return observable;\n }\n}\n\nexport class AnonymousSubject extends Subject {\n constructor(\n /** @deprecated Internal implementation detail, do not use directly. Will be made internal in v8. */\n public destination?: Observer,\n source?: Observable\n ) {\n super();\n this.source = source;\n }\n\n next(value: T) {\n this.destination?.next?.(value);\n }\n\n error(err: any) {\n this.destination?.error?.(err);\n }\n\n complete() {\n this.destination?.complete?.();\n }\n\n /** @internal */\n protected _subscribe(subscriber: Subscriber): Subscription {\n return this.source?.subscribe(subscriber) ?? EMPTY_SUBSCRIPTION;\n }\n}\n", "import { Subject } from './Subject';\nimport { Subscriber } from './Subscriber';\nimport { Subscription } from './Subscription';\n\n/**\n * A variant of Subject that requires an initial value and emits its current\n * value whenever it is subscribed to.\n */\nexport class BehaviorSubject extends Subject {\n constructor(private _value: T) {\n super();\n }\n\n get value(): T {\n return this.getValue();\n }\n\n /** @internal */\n protected _subscribe(subscriber: Subscriber): Subscription {\n const subscription = super._subscribe(subscriber);\n !subscription.closed && subscriber.next(this._value);\n return subscription;\n }\n\n getValue(): T {\n const { hasError, thrownError, _value } = this;\n if (hasError) {\n throw thrownError;\n }\n this._throwIfClosed();\n return _value;\n }\n\n next(value: T): void {\n super.next((this._value = value));\n }\n}\n", "import { TimestampProvider } from '../types';\n\ninterface DateTimestampProvider extends TimestampProvider {\n delegate: TimestampProvider | undefined;\n}\n\nexport const dateTimestampProvider: DateTimestampProvider = {\n now() {\n // Use the variable rather than `this` so that the function can be called\n // without being bound to the provider.\n return (dateTimestampProvider.delegate || Date).now();\n },\n delegate: undefined,\n};\n", "import { Subject } from './Subject';\nimport { TimestampProvider } from './types';\nimport { Subscriber } from './Subscriber';\nimport { Subscription } from './Subscription';\nimport { dateTimestampProvider } from './scheduler/dateTimestampProvider';\n\n/**\n * A variant of {@link Subject} that \"replays\" old values to new subscribers by emitting them when they first subscribe.\n *\n * `ReplaySubject` has an internal buffer that will store a specified number of values that it has observed. Like `Subject`,\n * `ReplaySubject` \"observes\" values by having them passed to its `next` method. When it observes a value, it will store that\n * value for a time determined by the configuration of the `ReplaySubject`, as passed to its constructor.\n *\n * When a new subscriber subscribes to the `ReplaySubject` instance, it will synchronously emit all values in its buffer in\n * a First-In-First-Out (FIFO) manner. The `ReplaySubject` will also complete, if it has observed completion; and it will\n * error if it has observed an error.\n *\n * There are two main configuration items to be concerned with:\n *\n * 1. `bufferSize` - This will determine how many items are stored in the buffer, defaults to infinite.\n * 2. `windowTime` - The amount of time to hold a value in the buffer before removing it from the buffer.\n *\n * Both configurations may exist simultaneously. So if you would like to buffer a maximum of 3 values, as long as the values\n * are less than 2 seconds old, you could do so with a `new ReplaySubject(3, 2000)`.\n *\n * ### Differences with BehaviorSubject\n *\n * `BehaviorSubject` is similar to `new ReplaySubject(1)`, with a couple of exceptions:\n *\n * 1. `BehaviorSubject` comes \"primed\" with a single value upon construction.\n * 2. `ReplaySubject` will replay values, even after observing an error, where `BehaviorSubject` will not.\n *\n * @see {@link Subject}\n * @see {@link BehaviorSubject}\n * @see {@link shareReplay}\n */\nexport class ReplaySubject extends Subject {\n private _buffer: (T | number)[] = [];\n private _infiniteTimeWindow = true;\n\n /**\n * @param _bufferSize The size of the buffer to replay on subscription\n * @param _windowTime The amount of time the buffered items will stay buffered\n * @param _timestampProvider An object with a `now()` method that provides the current timestamp. This is used to\n * calculate the amount of time something has been buffered.\n */\n constructor(\n private _bufferSize = Infinity,\n private _windowTime = Infinity,\n private _timestampProvider: TimestampProvider = dateTimestampProvider\n ) {\n super();\n this._infiniteTimeWindow = _windowTime === Infinity;\n this._bufferSize = Math.max(1, _bufferSize);\n this._windowTime = Math.max(1, _windowTime);\n }\n\n next(value: T): void {\n const { isStopped, _buffer, _infiniteTimeWindow, _timestampProvider, _windowTime } = this;\n if (!isStopped) {\n _buffer.push(value);\n !_infiniteTimeWindow && _buffer.push(_timestampProvider.now() + _windowTime);\n }\n this._trimBuffer();\n super.next(value);\n }\n\n /** @internal */\n protected _subscribe(subscriber: Subscriber): Subscription {\n this._throwIfClosed();\n this._trimBuffer();\n\n const subscription = this._innerSubscribe(subscriber);\n\n const { _infiniteTimeWindow, _buffer } = this;\n // We use a copy here, so reentrant code does not mutate our array while we're\n // emitting it to a new subscriber.\n const copy = _buffer.slice();\n for (let i = 0; i < copy.length && !subscriber.closed; i += _infiniteTimeWindow ? 1 : 2) {\n subscriber.next(copy[i] as T);\n }\n\n this._checkFinalizedStatuses(subscriber);\n\n return subscription;\n }\n\n private _trimBuffer() {\n const { _bufferSize, _timestampProvider, _buffer, _infiniteTimeWindow } = this;\n // If we don't have an infinite buffer size, and we're over the length,\n // use splice to truncate the old buffer values off. Note that we have to\n // double the size for instances where we're not using an infinite time window\n // because we're storing the values and the timestamps in the same array.\n const adjustedBufferSize = (_infiniteTimeWindow ? 1 : 2) * _bufferSize;\n _bufferSize < Infinity && adjustedBufferSize < _buffer.length && _buffer.splice(0, _buffer.length - adjustedBufferSize);\n\n // Now, if we're not in an infinite time window, remove all values where the time is\n // older than what is allowed.\n if (!_infiniteTimeWindow) {\n const now = _timestampProvider.now();\n let last = 0;\n // Search the array for the first timestamp that isn't expired and\n // truncate the buffer up to that point.\n for (let i = 1; i < _buffer.length && (_buffer[i] as number) <= now; i += 2) {\n last = i;\n }\n last && _buffer.splice(0, last + 1);\n }\n }\n}\n", "import { Scheduler } from '../Scheduler';\nimport { Subscription } from '../Subscription';\nimport { SchedulerAction } from '../types';\n\n/**\n * A unit of work to be executed in a `scheduler`. An action is typically\n * created from within a {@link SchedulerLike} and an RxJS user does not need to concern\n * themselves about creating and manipulating an Action.\n *\n * ```ts\n * class Action extends Subscription {\n * new (scheduler: Scheduler, work: (state?: T) => void);\n * schedule(state?: T, delay: number = 0): Subscription;\n * }\n * ```\n */\nexport class Action extends Subscription {\n constructor(scheduler: Scheduler, work: (this: SchedulerAction, state?: T) => void) {\n super();\n }\n /**\n * Schedules this action on its parent {@link SchedulerLike} for execution. May be passed\n * some context object, `state`. May happen at some point in the future,\n * according to the `delay` parameter, if specified.\n * @param state Some contextual data that the `work` function uses when called by the\n * Scheduler.\n * @param delay Time to wait before executing the work, where the time unit is implicit\n * and defined by the Scheduler.\n * @return A subscription in order to be able to unsubscribe the scheduled work.\n */\n public schedule(state?: T, delay: number = 0): Subscription {\n return this;\n }\n}\n", "import type { TimerHandle } from './timerHandle';\ntype SetIntervalFunction = (handler: () => void, timeout?: number, ...args: any[]) => TimerHandle;\ntype ClearIntervalFunction = (handle: TimerHandle) => void;\n\ninterface IntervalProvider {\n setInterval: SetIntervalFunction;\n clearInterval: ClearIntervalFunction;\n delegate:\n | {\n setInterval: SetIntervalFunction;\n clearInterval: ClearIntervalFunction;\n }\n | undefined;\n}\n\nexport const intervalProvider: IntervalProvider = {\n // When accessing the delegate, use the variable rather than `this` so that\n // the functions can be called without being bound to the provider.\n setInterval(handler: () => void, timeout?: number, ...args) {\n const { delegate } = intervalProvider;\n if (delegate?.setInterval) {\n return delegate.setInterval(handler, timeout, ...args);\n }\n return setInterval(handler, timeout, ...args);\n },\n clearInterval(handle) {\n const { delegate } = intervalProvider;\n return (delegate?.clearInterval || clearInterval)(handle as any);\n },\n delegate: undefined,\n};\n", "import { Action } from './Action';\nimport { SchedulerAction } from '../types';\nimport { Subscription } from '../Subscription';\nimport { AsyncScheduler } from './AsyncScheduler';\nimport { intervalProvider } from './intervalProvider';\nimport { arrRemove } from '../util/arrRemove';\nimport { TimerHandle } from './timerHandle';\n\nexport class AsyncAction extends Action {\n public id: TimerHandle | undefined;\n public state?: T;\n // @ts-ignore: Property has no initializer and is not definitely assigned\n public delay: number;\n protected pending: boolean = false;\n\n constructor(protected scheduler: AsyncScheduler, protected work: (this: SchedulerAction, state?: T) => void) {\n super(scheduler, work);\n }\n\n public schedule(state?: T, delay: number = 0): Subscription {\n if (this.closed) {\n return this;\n }\n\n // Always replace the current state with the new state.\n this.state = state;\n\n const id = this.id;\n const scheduler = this.scheduler;\n\n //\n // Important implementation note:\n //\n // Actions only execute once by default, unless rescheduled from within the\n // scheduled callback. This allows us to implement single and repeat\n // actions via the same code path, without adding API surface area, as well\n // as mimic traditional recursion but across asynchronous boundaries.\n //\n // However, JS runtimes and timers distinguish between intervals achieved by\n // serial `setTimeout` calls vs. a single `setInterval` call. An interval of\n // serial `setTimeout` calls can be individually delayed, which delays\n // scheduling the next `setTimeout`, and so on. `setInterval` attempts to\n // guarantee the interval callback will be invoked more precisely to the\n // interval period, regardless of load.\n //\n // Therefore, we use `setInterval` to schedule single and repeat actions.\n // If the action reschedules itself with the same delay, the interval is not\n // canceled. If the action doesn't reschedule, or reschedules with a\n // different delay, the interval will be canceled after scheduled callback\n // execution.\n //\n if (id != null) {\n this.id = this.recycleAsyncId(scheduler, id, delay);\n }\n\n // Set the pending flag indicating that this action has been scheduled, or\n // has recursively rescheduled itself.\n this.pending = true;\n\n this.delay = delay;\n // If this action has already an async Id, don't request a new one.\n this.id = this.id ?? this.requestAsyncId(scheduler, this.id, delay);\n\n return this;\n }\n\n protected requestAsyncId(scheduler: AsyncScheduler, _id?: TimerHandle, delay: number = 0): TimerHandle {\n return intervalProvider.setInterval(scheduler.flush.bind(scheduler, this), delay);\n }\n\n protected recycleAsyncId(_scheduler: AsyncScheduler, id?: TimerHandle, delay: number | null = 0): TimerHandle | undefined {\n // If this action is rescheduled with the same delay time, don't clear the interval id.\n if (delay != null && this.delay === delay && this.pending === false) {\n return id;\n }\n // Otherwise, if the action's delay time is different from the current delay,\n // or the action has been rescheduled before it's executed, clear the interval id\n if (id != null) {\n intervalProvider.clearInterval(id);\n }\n\n return undefined;\n }\n\n /**\n * Immediately executes this action and the `work` it contains.\n */\n public execute(state: T, delay: number): any {\n if (this.closed) {\n return new Error('executing a cancelled action');\n }\n\n this.pending = false;\n const error = this._execute(state, delay);\n if (error) {\n return error;\n } else if (this.pending === false && this.id != null) {\n // Dequeue if the action didn't reschedule itself. Don't call\n // unsubscribe(), because the action could reschedule later.\n // For example:\n // ```\n // scheduler.schedule(function doWork(counter) {\n // /* ... I'm a busy worker bee ... */\n // var originalAction = this;\n // /* wait 100ms before rescheduling the action */\n // setTimeout(function () {\n // originalAction.schedule(counter + 1);\n // }, 100);\n // }, 1000);\n // ```\n this.id = this.recycleAsyncId(this.scheduler, this.id, null);\n }\n }\n\n protected _execute(state: T, _delay: number): any {\n let errored: boolean = false;\n let errorValue: any;\n try {\n this.work(state);\n } catch (e) {\n errored = true;\n // HACK: Since code elsewhere is relying on the \"truthiness\" of the\n // return here, we can't have it return \"\" or 0 or false.\n // TODO: Clean this up when we refactor schedulers mid-version-8 or so.\n errorValue = e ? e : new Error('Scheduled action threw falsy error');\n }\n if (errored) {\n this.unsubscribe();\n return errorValue;\n }\n }\n\n unsubscribe() {\n if (!this.closed) {\n const { id, scheduler } = this;\n const { actions } = scheduler;\n\n this.work = this.state = this.scheduler = null!;\n this.pending = false;\n\n arrRemove(actions, this);\n if (id != null) {\n this.id = this.recycleAsyncId(scheduler, id, null);\n }\n\n this.delay = null!;\n super.unsubscribe();\n }\n }\n}\n", "import { Action } from './scheduler/Action';\nimport { Subscription } from './Subscription';\nimport { SchedulerLike, SchedulerAction } from './types';\nimport { dateTimestampProvider } from './scheduler/dateTimestampProvider';\n\n/**\n * An execution context and a data structure to order tasks and schedule their\n * execution. Provides a notion of (potentially virtual) time, through the\n * `now()` getter method.\n *\n * Each unit of work in a Scheduler is called an `Action`.\n *\n * ```ts\n * class Scheduler {\n * now(): number;\n * schedule(work, delay?, state?): Subscription;\n * }\n * ```\n *\n * @deprecated Scheduler is an internal implementation detail of RxJS, and\n * should not be used directly. Rather, create your own class and implement\n * {@link SchedulerLike}. Will be made internal in v8.\n */\nexport class Scheduler implements SchedulerLike {\n public static now: () => number = dateTimestampProvider.now;\n\n constructor(private schedulerActionCtor: typeof Action, now: () => number = Scheduler.now) {\n this.now = now;\n }\n\n /**\n * A getter method that returns a number representing the current time\n * (at the time this function was called) according to the scheduler's own\n * internal clock.\n * @return A number that represents the current time. May or may not\n * have a relation to wall-clock time. May or may not refer to a time unit\n * (e.g. milliseconds).\n */\n public now: () => number;\n\n /**\n * Schedules a function, `work`, for execution. May happen at some point in\n * the future, according to the `delay` parameter, if specified. May be passed\n * some context object, `state`, which will be passed to the `work` function.\n *\n * The given arguments will be processed an stored as an Action object in a\n * queue of actions.\n *\n * @param work A function representing a task, or some unit of work to be\n * executed by the Scheduler.\n * @param delay Time to wait before executing the work, where the time unit is\n * implicit and defined by the Scheduler itself.\n * @param state Some contextual data that the `work` function uses when called\n * by the Scheduler.\n * @return A subscription in order to be able to unsubscribe the scheduled work.\n */\n public schedule(work: (this: SchedulerAction, state?: T) => void, delay: number = 0, state?: T): Subscription {\n return new this.schedulerActionCtor(this, work).schedule(state, delay);\n }\n}\n", "import { Scheduler } from '../Scheduler';\nimport { Action } from './Action';\nimport { AsyncAction } from './AsyncAction';\nimport { TimerHandle } from './timerHandle';\n\nexport class AsyncScheduler extends Scheduler {\n public actions: Array> = [];\n /**\n * A flag to indicate whether the Scheduler is currently executing a batch of\n * queued actions.\n * @internal\n */\n public _active: boolean = false;\n /**\n * An internal ID used to track the latest asynchronous task such as those\n * coming from `setTimeout`, `setInterval`, `requestAnimationFrame`, and\n * others.\n * @internal\n */\n public _scheduled: TimerHandle | undefined;\n\n constructor(SchedulerAction: typeof Action, now: () => number = Scheduler.now) {\n super(SchedulerAction, now);\n }\n\n public flush(action: AsyncAction): void {\n const { actions } = this;\n\n if (this._active) {\n actions.push(action);\n return;\n }\n\n let error: any;\n this._active = true;\n\n do {\n if ((error = action.execute(action.state, action.delay))) {\n break;\n }\n } while ((action = actions.shift()!)); // exhaust the scheduler queue\n\n this._active = false;\n\n if (error) {\n while ((action = actions.shift()!)) {\n action.unsubscribe();\n }\n throw error;\n }\n }\n}\n", "import { AsyncAction } from './AsyncAction';\nimport { AsyncScheduler } from './AsyncScheduler';\n\n/**\n *\n * Async Scheduler\n *\n * Schedule task as if you used setTimeout(task, duration)\n *\n * `async` scheduler schedules tasks asynchronously, by putting them on the JavaScript\n * event loop queue. It is best used to delay tasks in time or to schedule tasks repeating\n * in intervals.\n *\n * If you just want to \"defer\" task, that is to perform it right after currently\n * executing synchronous code ends (commonly achieved by `setTimeout(deferredTask, 0)`),\n * better choice will be the {@link asapScheduler} scheduler.\n *\n * ## Examples\n * Use async scheduler to delay task\n * ```ts\n * import { asyncScheduler } from 'rxjs';\n *\n * const task = () => console.log('it works!');\n *\n * asyncScheduler.schedule(task, 2000);\n *\n * // After 2 seconds logs:\n * // \"it works!\"\n * ```\n *\n * Use async scheduler to repeat task in intervals\n * ```ts\n * import { asyncScheduler } from 'rxjs';\n *\n * function task(state) {\n * console.log(state);\n * this.schedule(state + 1, 1000); // `this` references currently executing Action,\n * // which we reschedule with new state and delay\n * }\n *\n * asyncScheduler.schedule(task, 3000, 0);\n *\n * // Logs:\n * // 0 after 3s\n * // 1 after 4s\n * // 2 after 5s\n * // 3 after 6s\n * ```\n */\n\nexport const asyncScheduler = new AsyncScheduler(AsyncAction);\n\n/**\n * @deprecated Renamed to {@link asyncScheduler}. Will be removed in v8.\n */\nexport const async = asyncScheduler;\n", "import { AsyncAction } from './AsyncAction';\nimport { Subscription } from '../Subscription';\nimport { QueueScheduler } from './QueueScheduler';\nimport { SchedulerAction } from '../types';\nimport { TimerHandle } from './timerHandle';\n\nexport class QueueAction extends AsyncAction {\n constructor(protected scheduler: QueueScheduler, protected work: (this: SchedulerAction, state?: T) => void) {\n super(scheduler, work);\n }\n\n public schedule(state?: T, delay: number = 0): Subscription {\n if (delay > 0) {\n return super.schedule(state, delay);\n }\n this.delay = delay;\n this.state = state;\n this.scheduler.flush(this);\n return this;\n }\n\n public execute(state: T, delay: number): any {\n return delay > 0 || this.closed ? super.execute(state, delay) : this._execute(state, delay);\n }\n\n protected requestAsyncId(scheduler: QueueScheduler, id?: TimerHandle, delay: number = 0): TimerHandle {\n // If delay exists and is greater than 0, or if the delay is null (the\n // action wasn't rescheduled) but was originally scheduled as an async\n // action, then recycle as an async action.\n\n if ((delay != null && delay > 0) || (delay == null && this.delay > 0)) {\n return super.requestAsyncId(scheduler, id, delay);\n }\n\n // Otherwise flush the scheduler starting with this action.\n scheduler.flush(this);\n\n // HACK: In the past, this was returning `void`. However, `void` isn't a valid\n // `TimerHandle`, and generally the return value here isn't really used. So the\n // compromise is to return `0` which is both \"falsy\" and a valid `TimerHandle`,\n // as opposed to refactoring every other instanceo of `requestAsyncId`.\n return 0;\n }\n}\n", "import { AsyncScheduler } from './AsyncScheduler';\n\nexport class QueueScheduler extends AsyncScheduler {\n}\n", "import { QueueAction } from './QueueAction';\nimport { QueueScheduler } from './QueueScheduler';\n\n/**\n *\n * Queue Scheduler\n *\n * Put every next task on a queue, instead of executing it immediately\n *\n * `queue` scheduler, when used with delay, behaves the same as {@link asyncScheduler} scheduler.\n *\n * When used without delay, it schedules given task synchronously - executes it right when\n * it is scheduled. However when called recursively, that is when inside the scheduled task,\n * another task is scheduled with queue scheduler, instead of executing immediately as well,\n * that task will be put on a queue and wait for current one to finish.\n *\n * This means that when you execute task with `queue` scheduler, you are sure it will end\n * before any other task scheduled with that scheduler will start.\n *\n * ## Examples\n * Schedule recursively first, then do something\n * ```ts\n * import { queueScheduler } from 'rxjs';\n *\n * queueScheduler.schedule(() => {\n * queueScheduler.schedule(() => console.log('second')); // will not happen now, but will be put on a queue\n *\n * console.log('first');\n * });\n *\n * // Logs:\n * // \"first\"\n * // \"second\"\n * ```\n *\n * Reschedule itself recursively\n * ```ts\n * import { queueScheduler } from 'rxjs';\n *\n * queueScheduler.schedule(function(state) {\n * if (state !== 0) {\n * console.log('before', state);\n * this.schedule(state - 1); // `this` references currently executing Action,\n * // which we reschedule with new state\n * console.log('after', state);\n * }\n * }, 0, 3);\n *\n * // In scheduler that runs recursively, you would expect:\n * // \"before\", 3\n * // \"before\", 2\n * // \"before\", 1\n * // \"after\", 1\n * // \"after\", 2\n * // \"after\", 3\n *\n * // But with queue it logs:\n * // \"before\", 3\n * // \"after\", 3\n * // \"before\", 2\n * // \"after\", 2\n * // \"before\", 1\n * // \"after\", 1\n * ```\n */\n\nexport const queueScheduler = new QueueScheduler(QueueAction);\n\n/**\n * @deprecated Renamed to {@link queueScheduler}. Will be removed in v8.\n */\nexport const queue = queueScheduler;\n", "import { AsyncAction } from './AsyncAction';\nimport { AnimationFrameScheduler } from './AnimationFrameScheduler';\nimport { SchedulerAction } from '../types';\nimport { animationFrameProvider } from './animationFrameProvider';\nimport { TimerHandle } from './timerHandle';\n\nexport class AnimationFrameAction extends AsyncAction {\n constructor(protected scheduler: AnimationFrameScheduler, protected work: (this: SchedulerAction, state?: T) => void) {\n super(scheduler, work);\n }\n\n protected requestAsyncId(scheduler: AnimationFrameScheduler, id?: TimerHandle, delay: number = 0): TimerHandle {\n // If delay is greater than 0, request as an async action.\n if (delay !== null && delay > 0) {\n return super.requestAsyncId(scheduler, id, delay);\n }\n // Push the action to the end of the scheduler queue.\n scheduler.actions.push(this);\n // If an animation frame has already been requested, don't request another\n // one. If an animation frame hasn't been requested yet, request one. Return\n // the current animation frame request id.\n return scheduler._scheduled || (scheduler._scheduled = animationFrameProvider.requestAnimationFrame(() => scheduler.flush(undefined)));\n }\n\n protected recycleAsyncId(scheduler: AnimationFrameScheduler, id?: TimerHandle, delay: number = 0): TimerHandle | undefined {\n // If delay exists and is greater than 0, or if the delay is null (the\n // action wasn't rescheduled) but was originally scheduled as an async\n // action, then recycle as an async action.\n if (delay != null ? delay > 0 : this.delay > 0) {\n return super.recycleAsyncId(scheduler, id, delay);\n }\n // If the scheduler queue has no remaining actions with the same async id,\n // cancel the requested animation frame and set the scheduled flag to\n // undefined so the next AnimationFrameAction will request its own.\n const { actions } = scheduler;\n if (id != null && id === scheduler._scheduled && actions[actions.length - 1]?.id !== id) {\n animationFrameProvider.cancelAnimationFrame(id as number);\n scheduler._scheduled = undefined;\n }\n // Return undefined so the action knows to request a new async id if it's rescheduled.\n return undefined;\n }\n}\n", "import { AsyncAction } from './AsyncAction';\nimport { AsyncScheduler } from './AsyncScheduler';\n\nexport class AnimationFrameScheduler extends AsyncScheduler {\n public flush(action?: AsyncAction): void {\n this._active = true;\n // The async id that effects a call to flush is stored in _scheduled.\n // Before executing an action, it's necessary to check the action's async\n // id to determine whether it's supposed to be executed in the current\n // flush.\n // Previous implementations of this method used a count to determine this,\n // but that was unsound, as actions that are unsubscribed - i.e. cancelled -\n // are removed from the actions array and that can shift actions that are\n // scheduled to be executed in a subsequent flush into positions at which\n // they are executed within the current flush.\n let flushId;\n if (action) {\n flushId = action.id;\n } else {\n flushId = this._scheduled;\n this._scheduled = undefined;\n }\n\n const { actions } = this;\n let error: any;\n action = action || actions.shift()!;\n\n do {\n if ((error = action.execute(action.state, action.delay))) {\n break;\n }\n } while ((action = actions[0]) && action.id === flushId && actions.shift());\n\n this._active = false;\n\n if (error) {\n while ((action = actions[0]) && action.id === flushId && actions.shift()) {\n action.unsubscribe();\n }\n throw error;\n }\n }\n}\n", "import { AnimationFrameAction } from './AnimationFrameAction';\nimport { AnimationFrameScheduler } from './AnimationFrameScheduler';\n\n/**\n *\n * Animation Frame Scheduler\n *\n * Perform task when `window.requestAnimationFrame` would fire\n *\n * When `animationFrame` scheduler is used with delay, it will fall back to {@link asyncScheduler} scheduler\n * behaviour.\n *\n * Without delay, `animationFrame` scheduler can be used to create smooth browser animations.\n * It makes sure scheduled task will happen just before next browser content repaint,\n * thus performing animations as efficiently as possible.\n *\n * ## Example\n * Schedule div height animation\n * ```ts\n * // html:
\n * import { animationFrameScheduler } from 'rxjs';\n *\n * const div = document.querySelector('div');\n *\n * animationFrameScheduler.schedule(function(height) {\n * div.style.height = height + \"px\";\n *\n * this.schedule(height + 1); // `this` references currently executing Action,\n * // which we reschedule with new state\n * }, 0, 0);\n *\n * // You will see a div element growing in height\n * ```\n */\n\nexport const animationFrameScheduler = new AnimationFrameScheduler(AnimationFrameAction);\n\n/**\n * @deprecated Renamed to {@link animationFrameScheduler}. Will be removed in v8.\n */\nexport const animationFrame = animationFrameScheduler;\n", "import { Observable } from '../Observable';\nimport { SchedulerLike } from '../types';\n\n/**\n * A simple Observable that emits no items to the Observer and immediately\n * emits a complete notification.\n *\n * Just emits 'complete', and nothing else.\n *\n * ![](empty.png)\n *\n * A simple Observable that only emits the complete notification. It can be used\n * for composing with other Observables, such as in a {@link mergeMap}.\n *\n * ## Examples\n *\n * Log complete notification\n *\n * ```ts\n * import { EMPTY } from 'rxjs';\n *\n * EMPTY.subscribe({\n * next: () => console.log('Next'),\n * complete: () => console.log('Complete!')\n * });\n *\n * // Outputs\n * // Complete!\n * ```\n *\n * Emit the number 7, then complete\n *\n * ```ts\n * import { EMPTY, startWith } from 'rxjs';\n *\n * const result = EMPTY.pipe(startWith(7));\n * result.subscribe(x => console.log(x));\n *\n * // Outputs\n * // 7\n * ```\n *\n * Map and flatten only odd numbers to the sequence `'a'`, `'b'`, `'c'`\n *\n * ```ts\n * import { interval, mergeMap, of, EMPTY } from 'rxjs';\n *\n * const interval$ = interval(1000);\n * const result = interval$.pipe(\n * mergeMap(x => x % 2 === 1 ? of('a', 'b', 'c') : EMPTY),\n * );\n * result.subscribe(x => console.log(x));\n *\n * // Results in the following to the console:\n * // x is equal to the count on the interval, e.g. (0, 1, 2, 3, ...)\n * // x will occur every 1000ms\n * // if x % 2 is equal to 1, print a, b, c (each on its own)\n * // if x % 2 is not equal to 1, nothing will be output\n * ```\n *\n * @see {@link Observable}\n * @see {@link NEVER}\n * @see {@link of}\n * @see {@link throwError}\n */\nexport const EMPTY = new Observable((subscriber) => subscriber.complete());\n\n/**\n * @param scheduler A {@link SchedulerLike} to use for scheduling\n * the emission of the complete notification.\n * @deprecated Replaced with the {@link EMPTY} constant or {@link scheduled} (e.g. `scheduled([], scheduler)`). Will be removed in v8.\n */\nexport function empty(scheduler?: SchedulerLike) {\n return scheduler ? emptyScheduled(scheduler) : EMPTY;\n}\n\nfunction emptyScheduled(scheduler: SchedulerLike) {\n return new Observable((subscriber) => scheduler.schedule(() => subscriber.complete()));\n}\n", "import { SchedulerLike } from '../types';\nimport { isFunction } from './isFunction';\n\nexport function isScheduler(value: any): value is SchedulerLike {\n return value && isFunction(value.schedule);\n}\n", "import { SchedulerLike } from '../types';\nimport { isFunction } from './isFunction';\nimport { isScheduler } from './isScheduler';\n\nfunction last(arr: T[]): T | undefined {\n return arr[arr.length - 1];\n}\n\nexport function popResultSelector(args: any[]): ((...args: unknown[]) => unknown) | undefined {\n return isFunction(last(args)) ? args.pop() : undefined;\n}\n\nexport function popScheduler(args: any[]): SchedulerLike | undefined {\n return isScheduler(last(args)) ? args.pop() : undefined;\n}\n\nexport function popNumber(args: any[], defaultValue: number): number {\n return typeof last(args) === 'number' ? args.pop()! : defaultValue;\n}\n", "export const isArrayLike = ((x: any): x is ArrayLike => x && typeof x.length === 'number' && typeof x !== 'function');", "import { isFunction } from \"./isFunction\";\n\n/**\n * Tests to see if the object is \"thennable\".\n * @param value the object to test\n */\nexport function isPromise(value: any): value is PromiseLike {\n return isFunction(value?.then);\n}\n", "import { InteropObservable } from '../types';\nimport { observable as Symbol_observable } from '../symbol/observable';\nimport { isFunction } from './isFunction';\n\n/** Identifies an input as being Observable (but not necessary an Rx Observable) */\nexport function isInteropObservable(input: any): input is InteropObservable {\n return isFunction(input[Symbol_observable]);\n}\n", "import { isFunction } from './isFunction';\n\nexport function isAsyncIterable(obj: any): obj is AsyncIterable {\n return Symbol.asyncIterator && isFunction(obj?.[Symbol.asyncIterator]);\n}\n", "/**\n * Creates the TypeError to throw if an invalid object is passed to `from` or `scheduled`.\n * @param input The object that was passed.\n */\nexport function createInvalidObservableTypeError(input: any) {\n // TODO: We should create error codes that can be looked up, so this can be less verbose.\n return new TypeError(\n `You provided ${\n input !== null && typeof input === 'object' ? 'an invalid object' : `'${input}'`\n } where a stream was expected. You can provide an Observable, Promise, ReadableStream, Array, AsyncIterable, or Iterable.`\n );\n}\n", "export function getSymbolIterator(): symbol {\n if (typeof Symbol !== 'function' || !Symbol.iterator) {\n return '@@iterator' as any;\n }\n\n return Symbol.iterator;\n}\n\nexport const iterator = getSymbolIterator();\n", "import { iterator as Symbol_iterator } from '../symbol/iterator';\nimport { isFunction } from './isFunction';\n\n/** Identifies an input as being an Iterable */\nexport function isIterable(input: any): input is Iterable {\n return isFunction(input?.[Symbol_iterator]);\n}\n", "import { ReadableStreamLike } from '../types';\nimport { isFunction } from './isFunction';\n\nexport async function* readableStreamLikeToAsyncGenerator(readableStream: ReadableStreamLike): AsyncGenerator {\n const reader = readableStream.getReader();\n try {\n while (true) {\n const { value, done } = await reader.read();\n if (done) {\n return;\n }\n yield value!;\n }\n } finally {\n reader.releaseLock();\n }\n}\n\nexport function isReadableStreamLike(obj: any): obj is ReadableStreamLike {\n // We don't want to use instanceof checks because they would return\n // false for instances from another Realm, like an