diff --git a/Dockerfile.backend b/Dockerfile.backend index 62be7f1..8cf2b2b 100644 --- a/Dockerfile.backend +++ b/Dockerfile.backend @@ -8,7 +8,7 @@ RUN apt-get update -y && \ libdrogon-dev libjsoncpp-dev libyaml-cpp-dev libhiredis-dev \ libpq-dev libmariadb-dev libmariadb-dev-compat \ libsqlite3-dev sqlite3 \ - libssl-dev && \ + libssl-dev uuid-dev libbrotli-dev catch2 && \ rm -rf /var/lib/apt/lists/* WORKDIR /src @@ -24,11 +24,13 @@ FROM ubuntu:24.04 AS runtime RUN apt-get update -y && \ DEBIAN_FRONTEND=noninteractive apt-get install -y \ libdrogon1t64 libjsoncpp25 libyaml-cpp0.8 libhiredis1.1.0 \ - libpq5 libmariadb3 libsqlite3-0 libssl3t64 ca-certificates && \ + libpq5 libmariadb3 libsqlite3-0 libssl3t64 libuuid1 ca-certificates \ + g++ python3 python3-requests curl poppler-utils && \ rm -rf /var/lib/apt/lists/* WORKDIR /app COPY --from=build /src/build/backend/csp_server /app/csp_server +COPY scripts/ /app/scripts/ EXPOSE 8080 diff --git a/README.md b/README.md index ec2d19f..4caf3be 100644 --- a/README.md +++ b/README.md @@ -1,45 +1,148 @@ -# CSP 在线练习 / 模拟竞赛平台(前后端分离) +# CSP 在线学习与竞赛平台 -- 前端:Next.js(目录:`frontend/`) -- 后端:C++20 + Drogon + SQLite(目录:`backend/`) +面向 OI/CSP 学习场景的全栈 Web 系统(前后端分离): -## 1. 本地开发(Ubuntu 24.04) +- 前端:Next.js 16(App Router, TypeScript) +- 后端:C++20 + Drogon + SQLite +- 部署:Docker Compose -### 1.1 安装依赖 +核心能力: + +- 用户注册/登录(Bearer Token) +- 题库检索、题面查看、在线提交评测 +- 日常练习提交记录 +- 错题本(自动沉淀 + 手动备注) +- 模拟竞赛(报名、题单、排行榜) +- 学习知识库(文章 + 题目关联) +- 在线 C++ 编写/编译/运行调试 +- 题目页 Markdown 语义渲染(数学公式 / 图片本地缓存) +- 代码草稿保存、试运行、异步多解题解生成 +- MCP JSON-RPC 接口(供 Agent 调用) +- Swagger API 文档页面 + +## 1. 快速开始 + +### 1.1 Docker 一键启动(推荐) + +```bash +git clone ssh://git@git.hk.hao.work:2222/hao/csp.git +cd csp +docker compose up -d --build +``` + +访问: + +- 前端:`http://<你的IP>:7888/` +- 后端健康检查(经前端反代):`http://<你的IP>:7888/admin139/api/health` + +### 1.2 本机开发启动 ```bash ./scripts/bootstrap_ubuntu.sh -``` - -### 1.2 构建与运行后端 - -```bash cmake -S . -B build -G Ninja cmake --build build ctest --test-dir build -V - ./build/backend/csp_server -# http://localhost:8080/api/health ``` -### 1.3 运行前端 +前端: ```bash -cd frontend -npm run dev -# http://localhost:3000 +npm --prefix frontend ci +npm --prefix frontend run dev ``` ## 2. 目录结构 -- `backend/` 后端 C++ 服务与测试 -- `frontend/` 前端 Next.js -- `docs/` 开发文档(设计、接口、部署等) -- `scripts/` 一键脚本 +- `backend/`:Drogon 后端(控制器/服务/领域模型/SQLite) +- `frontend/`:Next.js 前端 +- `docs/`:架构、API、数据库、测试、部署文档 +- `scripts/`:开发与初始化脚本 -## 3. 当前状态 +## 3. API 入口说明 -已完成工程骨架: -- 后端:Drogon 服务启动 + `/api/health` -- 后端:Catch2 单测接入(`ctest` 可跑) -- 前端:Next.js 工程初始化 +生产/Compose 场景建议统一通过前端同域反代访问后端: + +- 浏览器访问:`/admin139/...` +- 例如:`/admin139/api/v1/problems` + +本地后端直连调试时可用 `http://localhost:8080`。 + +详细 API:见 `docs/API参考.md`,或直接访问 Swagger 页面 `/api-docs`(规范地址 `/admin139/api/openapi.json`)。 + +## 4. 测试与 TDD + +- 后端测试框架:Catch2 +- 测试命令:`ctest --test-dir build -V` +- 当前覆盖:迁移、鉴权、题库、提交评测、错题本、竞赛、知识库、HTTP 控制器核心路径 + +详见:`docs/测试与TDD.md`。 + +## 5. 端口与外部访问 + +- 对外端口:`7888 -> frontend:3000` +- 默认监听:`0.0.0.0:7888` +- 若需排查网络访问/Clash 影响,见 `docs/Docker部署.md` 的“故障排查”章节。 + +## 6. 题库初始化(默认:洛谷 CSP J/S) + +默认导入脚本:`scripts/import_luogu_csp.py`,按洛谷标签抓取 CSP-J / CSP-S / NOIP 题目,结果写入 `problems` / `problem_tags`,并将任务进度写入 `import_jobs` / `import_job_items`。 + +```bash +python3 scripts/import_luogu_csp.py \ + --db-path /var/lib/docker/volumes/csp_csp_data/_data/csp.db \ + --workers 3 \ + --clear-all-problems +``` + +前端 `/imports` 页面可查看导入状态和明细;后端容器重启后默认自动执行一次(可通过环境变量关闭)。 + +`.env` / `docker-compose` 常用参数: + +```env +OI_IMPORT_AUTO_RUN=true +OI_IMPORT_WORKERS=3 +OI_IMPORT_SCRIPT_PATH=/app/scripts/import_luogu_csp.py +OI_IMPORT_CLEAR_ALL_PROBLEMS=true +``` + +如果你还要使用旧的 PDF + LLM 导入流程,可手动运行 `scripts/import_winterant_oi.py`。 + +## 7. CSP-J 题目自动生成(RAG + 去重) + +提供脚本:`scripts/generate_cspj_problem_rag.py`,流程为: + +1. 爬取洛谷 CSP-J/NOIP 入门题名作为外部语料 +2. 融合本地 `problems` 题库做关键词压缩(RAG 上下文) +3. 调用 LLM 生成新题 JSON +4. 生成前+入库前各做一次相似题检索,疑似重复则跳过 + +手动执行一次(默认 1 题): + +```bash +python3 scripts/generate_cspj_problem_rag.py \ + --db-path /var/lib/docker/volumes/csp_csp_data/_data/csp.db \ + --count 1 +``` + +容器默认会在后端启动时自动尝试生成 1 题(可通过环境变量关闭)。当同时开启自动导入时,会等待导入完成后再触发生题,避免被清库流程覆盖: + +```env +CSP_GEN_AUTO_RUN=true +CSP_GEN_COUNT=1 +CSP_GEN_WAIT_FOR_IMPORT=true +``` + +## 8. MCP 接口 + +- 入口:`POST /admin139/api/v1/mcp` +- 支持方法:`initialize`、`tools/list`、`tools/call` +- 内置工具:`health`、`list_problems`、`get_problem`、`run_cpp`、`generate_cspj_problem` + +## 9. 文档索引 + +- `docs/平台总体设计.md` +- `docs/数据库设计.md` +- `docs/API参考.md` +- `docs/测试与TDD.md` +- `docs/Docker部署.md` diff --git a/backend/CMakeLists.txt b/backend/CMakeLists.txt index 6cfd36e..b883d60 100644 --- a/backend/CMakeLists.txt +++ b/backend/CMakeLists.txt @@ -14,6 +14,17 @@ add_library(csp_core src/app_state.cc src/services/crypto.cc src/services/auth_service.cc + src/services/problem_service.cc + src/services/user_service.cc + src/services/wrong_book_service.cc + src/services/kb_service.cc + src/services/contest_service.cc + src/services/submission_service.cc + src/services/problem_workspace_service.cc + src/services/problem_solution_runner.cc + src/services/problem_gen_runner.cc + src/services/import_service.cc + src/services/import_runner.cc src/domain/enum_strings.cc src/domain/json.cc ) @@ -30,6 +41,15 @@ target_link_libraries(csp_core PUBLIC add_library(csp_web src/controllers/auth_controller.cc + src/controllers/problem_controller.cc + src/controllers/submission_controller.cc + src/controllers/me_controller.cc + src/controllers/contest_controller.cc + src/controllers/leaderboard_controller.cc + src/controllers/kb_controller.cc + src/controllers/import_controller.cc + src/controllers/meta_controller.cc + src/controllers/problem_gen_controller.cc src/health_controller.cc ) @@ -53,7 +73,7 @@ target_include_directories(csp_server PRIVATE target_link_libraries(csp_server PRIVATE Drogon::Drogon csp_core - csp_web + "$" ) enable_testing() @@ -64,6 +84,18 @@ add_executable(csp_tests tests/auth_service_test.cc tests/auth_http_test.cc tests/domain_test.cc + tests/problem_service_test.cc + tests/kb_service_test.cc + tests/contest_service_test.cc + tests/submission_service_test.cc + tests/me_http_test.cc + tests/problem_http_test.cc + tests/problem_workspace_service_test.cc + tests/problem_workspace_http_test.cc + tests/contest_http_test.cc + tests/submission_http_test.cc + tests/import_service_test.cc + tests/import_http_test.cc ) target_include_directories(csp_tests PRIVATE @@ -74,7 +106,7 @@ target_link_libraries(csp_tests PRIVATE Catch2::Catch2WithMain Drogon::Drogon csp_core - csp_web + "$" ) include(CTest) diff --git a/backend/include/csp/controllers/contest_controller.h b/backend/include/csp/controllers/contest_controller.h new file mode 100644 index 0000000..2a6736b --- /dev/null +++ b/backend/include/csp/controllers/contest_controller.h @@ -0,0 +1,34 @@ +#pragma once + +#include + +#include + +namespace csp::controllers { + +class ContestController : public drogon::HttpController { + public: + METHOD_LIST_BEGIN + ADD_METHOD_TO(ContestController::list, "/api/v1/contests", drogon::Get); + ADD_METHOD_TO(ContestController::getById, "/api/v1/contests/{1}", drogon::Get); + ADD_METHOD_TO(ContestController::registerForContest, "/api/v1/contests/{1}/register", drogon::Post); + ADD_METHOD_TO(ContestController::leaderboard, "/api/v1/contests/{1}/leaderboard", drogon::Get); + METHOD_LIST_END + + void list(const drogon::HttpRequestPtr& req, + std::function&& cb); + + void getById(const drogon::HttpRequestPtr& req, + std::function&& cb, + int64_t contest_id); + + void registerForContest(const drogon::HttpRequestPtr& req, + std::function&& cb, + int64_t contest_id); + + void leaderboard(const drogon::HttpRequestPtr& req, + std::function&& cb, + int64_t contest_id); +}; + +} // namespace csp::controllers diff --git a/backend/include/csp/controllers/import_controller.h b/backend/include/csp/controllers/import_controller.h new file mode 100644 index 0000000..7ff79dc --- /dev/null +++ b/backend/include/csp/controllers/import_controller.h @@ -0,0 +1,33 @@ +#pragma once + +#include + +#include + +namespace csp::controllers { + +class ImportController : public drogon::HttpController { + public: + METHOD_LIST_BEGIN + ADD_METHOD_TO(ImportController::latestJob, "/api/v1/import/jobs/latest", drogon::Get); + ADD_METHOD_TO(ImportController::jobById, "/api/v1/import/jobs/{1}", drogon::Get); + ADD_METHOD_TO(ImportController::jobItems, "/api/v1/import/jobs/{1}/items", drogon::Get); + ADD_METHOD_TO(ImportController::runJob, "/api/v1/import/jobs/run", drogon::Post); + METHOD_LIST_END + + void latestJob(const drogon::HttpRequestPtr& req, + std::function&& cb); + + void jobById(const drogon::HttpRequestPtr& req, + std::function&& cb, + int64_t job_id); + + void jobItems(const drogon::HttpRequestPtr& req, + std::function&& cb, + int64_t job_id); + + void runJob(const drogon::HttpRequestPtr& req, + std::function&& cb); +}; + +} // namespace csp::controllers diff --git a/backend/include/csp/controllers/kb_controller.h b/backend/include/csp/controllers/kb_controller.h new file mode 100644 index 0000000..9ae5ec4 --- /dev/null +++ b/backend/include/csp/controllers/kb_controller.h @@ -0,0 +1,24 @@ +#pragma once + +#include + +#include + +namespace csp::controllers { + +class KbController : public drogon::HttpController { + public: + METHOD_LIST_BEGIN + ADD_METHOD_TO(KbController::listArticles, "/api/v1/kb/articles", drogon::Get); + ADD_METHOD_TO(KbController::getArticle, "/api/v1/kb/articles/{1}", drogon::Get); + METHOD_LIST_END + + void listArticles(const drogon::HttpRequestPtr& req, + std::function&& cb); + + void getArticle(const drogon::HttpRequestPtr& req, + std::function&& cb, + std::string slug); +}; + +} // namespace csp::controllers diff --git a/backend/include/csp/controllers/leaderboard_controller.h b/backend/include/csp/controllers/leaderboard_controller.h new file mode 100644 index 0000000..3f029d9 --- /dev/null +++ b/backend/include/csp/controllers/leaderboard_controller.h @@ -0,0 +1,17 @@ +#pragma once + +#include + +namespace csp::controllers { + +class LeaderboardController : public drogon::HttpController { + public: + METHOD_LIST_BEGIN + ADD_METHOD_TO(LeaderboardController::global, "/api/v1/leaderboard/global", drogon::Get); + METHOD_LIST_END + + void global(const drogon::HttpRequestPtr& req, + std::function&& cb); +}; + +} // namespace csp::controllers diff --git a/backend/include/csp/controllers/me_controller.h b/backend/include/csp/controllers/me_controller.h new file mode 100644 index 0000000..e0ad2d4 --- /dev/null +++ b/backend/include/csp/controllers/me_controller.h @@ -0,0 +1,33 @@ +#pragma once + +#include + +#include + +namespace csp::controllers { + +class MeController : public drogon::HttpController { + public: + METHOD_LIST_BEGIN + ADD_METHOD_TO(MeController::profile, "/api/v1/me", drogon::Get); + ADD_METHOD_TO(MeController::listWrongBook, "/api/v1/me/wrong-book", drogon::Get); + ADD_METHOD_TO(MeController::upsertWrongBookNote, "/api/v1/me/wrong-book/{1}", drogon::Patch); + ADD_METHOD_TO(MeController::deleteWrongBookItem, "/api/v1/me/wrong-book/{1}", drogon::Delete); + METHOD_LIST_END + + void profile(const drogon::HttpRequestPtr& req, + std::function&& cb); + + void listWrongBook(const drogon::HttpRequestPtr& req, + std::function&& cb); + + void upsertWrongBookNote(const drogon::HttpRequestPtr& req, + std::function&& cb, + int64_t problem_id); + + void deleteWrongBookItem(const drogon::HttpRequestPtr& req, + std::function&& cb, + int64_t problem_id); +}; + +} // namespace csp::controllers diff --git a/backend/include/csp/controllers/meta_controller.h b/backend/include/csp/controllers/meta_controller.h new file mode 100644 index 0000000..58945de --- /dev/null +++ b/backend/include/csp/controllers/meta_controller.h @@ -0,0 +1,21 @@ +#pragma once + +#include + +namespace csp::controllers { + +class MetaController : public drogon::HttpController { + public: + METHOD_LIST_BEGIN + ADD_METHOD_TO(MetaController::openapi, "/api/openapi.json", drogon::Get); + ADD_METHOD_TO(MetaController::mcp, "/api/v1/mcp", drogon::Post); + METHOD_LIST_END + + void openapi(const drogon::HttpRequestPtr& req, + std::function&& cb); + + void mcp(const drogon::HttpRequestPtr& req, + std::function&& cb); +}; + +} // namespace csp::controllers diff --git a/backend/include/csp/controllers/problem_controller.h b/backend/include/csp/controllers/problem_controller.h new file mode 100644 index 0000000..4fbfa1f --- /dev/null +++ b/backend/include/csp/controllers/problem_controller.h @@ -0,0 +1,44 @@ +#pragma once + +#include + +#include + +namespace csp::controllers { + +class ProblemController : public drogon::HttpController { + public: + METHOD_LIST_BEGIN + ADD_METHOD_TO(ProblemController::list, "/api/v1/problems", drogon::Get); + ADD_METHOD_TO(ProblemController::getById, "/api/v1/problems/{1}", drogon::Get); + ADD_METHOD_TO(ProblemController::getDraft, "/api/v1/problems/{1}/draft", drogon::Get); + ADD_METHOD_TO(ProblemController::saveDraft, "/api/v1/problems/{1}/draft", drogon::Put); + ADD_METHOD_TO(ProblemController::listSolutions, "/api/v1/problems/{1}/solutions", drogon::Get); + ADD_METHOD_TO(ProblemController::generateSolutions, "/api/v1/problems/{1}/solutions/generate", drogon::Post); + METHOD_LIST_END + + void list(const drogon::HttpRequestPtr& req, + std::function&& cb); + + void getById(const drogon::HttpRequestPtr& req, + std::function&& cb, + int64_t problem_id); + + void getDraft(const drogon::HttpRequestPtr& req, + std::function&& cb, + int64_t problem_id); + + void saveDraft(const drogon::HttpRequestPtr& req, + std::function&& cb, + int64_t problem_id); + + void listSolutions(const drogon::HttpRequestPtr& req, + std::function&& cb, + int64_t problem_id); + + void generateSolutions(const drogon::HttpRequestPtr& req, + std::function&& cb, + int64_t problem_id); +}; + +} // namespace csp::controllers diff --git a/backend/include/csp/controllers/problem_gen_controller.h b/backend/include/csp/controllers/problem_gen_controller.h new file mode 100644 index 0000000..497e53e --- /dev/null +++ b/backend/include/csp/controllers/problem_gen_controller.h @@ -0,0 +1,21 @@ +#pragma once + +#include + +namespace csp::controllers { + +class ProblemGenController : public drogon::HttpController { + public: + METHOD_LIST_BEGIN + ADD_METHOD_TO(ProblemGenController::status, "/api/v1/problem-gen/status", drogon::Get); + ADD_METHOD_TO(ProblemGenController::run, "/api/v1/problem-gen/run", drogon::Post); + METHOD_LIST_END + + void status(const drogon::HttpRequestPtr& req, + std::function&& cb); + + void run(const drogon::HttpRequestPtr& req, + std::function&& cb); +}; + +} // namespace csp::controllers diff --git a/backend/include/csp/controllers/submission_controller.h b/backend/include/csp/controllers/submission_controller.h new file mode 100644 index 0000000..75cedef --- /dev/null +++ b/backend/include/csp/controllers/submission_controller.h @@ -0,0 +1,33 @@ +#pragma once + +#include + +#include + +namespace csp::controllers { + +class SubmissionController : public drogon::HttpController { + public: + METHOD_LIST_BEGIN + ADD_METHOD_TO(SubmissionController::submitProblem, "/api/v1/problems/{1}/submit", drogon::Post); + ADD_METHOD_TO(SubmissionController::listSubmissions, "/api/v1/submissions", drogon::Get); + ADD_METHOD_TO(SubmissionController::getSubmission, "/api/v1/submissions/{1}", drogon::Get); + ADD_METHOD_TO(SubmissionController::runCpp, "/api/v1/run/cpp", drogon::Post); + METHOD_LIST_END + + void submitProblem(const drogon::HttpRequestPtr& req, + std::function&& cb, + int64_t problem_id); + + void listSubmissions(const drogon::HttpRequestPtr& req, + std::function&& cb); + + void getSubmission(const drogon::HttpRequestPtr& req, + std::function&& cb, + int64_t submission_id); + + void runCpp(const drogon::HttpRequestPtr& req, + std::function&& cb); +}; + +} // namespace csp::controllers diff --git a/backend/include/csp/db/sqlite_db.h b/backend/include/csp/db/sqlite_db.h index 491e91a..039ee41 100644 --- a/backend/include/csp/db/sqlite_db.h +++ b/backend/include/csp/db/sqlite_db.h @@ -33,5 +33,6 @@ class SqliteDb { // Apply SQL migrations in order. For now we ship a single init migration. void ApplyMigrations(SqliteDb& db); +void SeedDemoData(SqliteDb& db); } // namespace csp::db diff --git a/backend/include/csp/domain/entities.h b/backend/include/csp/domain/entities.h index ee77737..33f9b9c 100644 --- a/backend/include/csp/domain/entities.h +++ b/backend/include/csp/domain/entities.h @@ -33,6 +33,10 @@ struct Problem { std::string statement_md; int32_t difficulty = 1; std::string source; + std::string statement_url; + std::string llm_profile_json; + std::string sample_input; + std::string sample_output; int64_t created_at = 0; }; @@ -64,12 +68,15 @@ struct Submission { int64_t id = 0; int64_t user_id = 0; int64_t problem_id = 0; + std::optional contest_id; Language language = Language::Cpp; std::string code; SubmissionStatus status = SubmissionStatus::Pending; int32_t score = 0; int32_t time_ms = 0; int32_t memory_kb = 0; + std::string compile_log; + std::string runtime_log; int64_t created_at = 0; }; @@ -81,4 +88,51 @@ struct WrongBookItem { int64_t updated_at = 0; }; +struct Contest { + int64_t id = 0; + std::string title; + int64_t starts_at = 0; + int64_t ends_at = 0; + std::string rule_json; +}; + +struct ContestProblem { + int64_t contest_id = 0; + int64_t problem_id = 0; + int32_t idx = 0; +}; + +struct ContestRegistration { + int64_t contest_id = 0; + int64_t user_id = 0; + int64_t registered_at = 0; +}; + +struct KbArticle { + int64_t id = 0; + std::string slug; + std::string title; + std::string content_md; + int64_t created_at = 0; +}; + +struct KbArticleLink { + int64_t article_id = 0; + int64_t problem_id = 0; +}; + +struct GlobalLeaderboardEntry { + int64_t user_id = 0; + std::string username; + int32_t rating = 0; + int64_t created_at = 0; +}; + +struct ContestLeaderboardEntry { + int64_t user_id = 0; + std::string username; + int32_t solved = 0; + int64_t penalty_sec = 0; +}; + } // namespace csp::domain diff --git a/backend/include/csp/domain/json.h b/backend/include/csp/domain/json.h index 94be3be..614b329 100644 --- a/backend/include/csp/domain/json.h +++ b/backend/include/csp/domain/json.h @@ -14,5 +14,9 @@ Json::Value ToPublicJson(const User& u); Json::Value ToJson(const Problem& p); Json::Value ToJson(const Submission& s); Json::Value ToJson(const WrongBookItem& w); +Json::Value ToJson(const Contest& c); +Json::Value ToJson(const KbArticle& a); +Json::Value ToJson(const GlobalLeaderboardEntry& e); +Json::Value ToJson(const ContestLeaderboardEntry& e); } // namespace csp::domain diff --git a/backend/include/csp/services/contest_service.h b/backend/include/csp/services/contest_service.h new file mode 100644 index 0000000..ec7db35 --- /dev/null +++ b/backend/include/csp/services/contest_service.h @@ -0,0 +1,36 @@ +#pragma once + +#include "csp/db/sqlite_db.h" +#include "csp/domain/entities.h" + +#include +#include +#include + +namespace csp::services { + +struct ContestDetail { + domain::Contest contest; + std::vector problems; +}; + +class ContestService { + public: + explicit ContestService(db::SqliteDb& db) : db_(db) {} + + std::vector ListContests(); + std::optional GetContest(int64_t contest_id); + std::vector ListContestProblems(int64_t contest_id); + + void Register(int64_t contest_id, int64_t user_id); + bool IsRegistered(int64_t contest_id, int64_t user_id); + bool ContainsProblem(int64_t contest_id, int64_t problem_id); + bool IsRunning(int64_t contest_id); + + std::vector Leaderboard(int64_t contest_id); + + private: + db::SqliteDb& db_; +}; + +} // namespace csp::services diff --git a/backend/include/csp/services/import_runner.h b/backend/include/csp/services/import_runner.h new file mode 100644 index 0000000..e388a00 --- /dev/null +++ b/backend/include/csp/services/import_runner.h @@ -0,0 +1,40 @@ +#pragma once + +#include +#include +#include +#include + +namespace csp::services { + +struct ImportRunOptions { + bool clear_all_problems = false; +}; + +class ImportRunner { + public: + static ImportRunner& Instance(); + + void Configure(std::string db_path); + bool TriggerAsync(const std::string& trigger, const ImportRunOptions& options); + void AutoStartIfEnabled(); + + bool IsRunning() const; + std::string LastCommand() const; + std::optional LastExitCode() const; + int64_t LastStartedAt() const; + int64_t LastFinishedAt() const; + + private: + ImportRunner() = default; + + std::string db_path_; + mutable std::mutex mu_; + bool running_ = false; + std::string last_command_; + std::optional last_exit_code_; + int64_t last_started_at_ = 0; + int64_t last_finished_at_ = 0; +}; + +} // namespace csp::services diff --git a/backend/include/csp/services/import_service.h b/backend/include/csp/services/import_service.h new file mode 100644 index 0000000..2a66185 --- /dev/null +++ b/backend/include/csp/services/import_service.h @@ -0,0 +1,61 @@ +#pragma once + +#include "csp/db/sqlite_db.h" + +#include +#include +#include +#include + +namespace csp::services { + +struct ImportJob { + int64_t id = 0; + std::string status; + std::string trigger; + int32_t total_count = 0; + int32_t processed_count = 0; + int32_t success_count = 0; + int32_t failed_count = 0; + std::string options_json; + std::string last_error; + int64_t started_at = 0; + std::optional finished_at; + int64_t updated_at = 0; + int64_t created_at = 0; +}; + +struct ImportJobItem { + int64_t id = 0; + int64_t job_id = 0; + std::string source_path; + std::string status; + std::string title; + int32_t difficulty = 0; + std::optional problem_id; + std::string error_text; + std::optional started_at; + std::optional finished_at; + int64_t updated_at = 0; + int64_t created_at = 0; +}; + +struct ImportJobItemQuery { + std::string status; + int page = 1; + int page_size = 50; +}; + +class ImportService { + public: + explicit ImportService(db::SqliteDb& db) : db_(db) {} + + std::optional GetLatestJob(); + std::optional GetById(int64_t job_id); + std::vector ListItems(int64_t job_id, const ImportJobItemQuery& query); + + private: + db::SqliteDb& db_; +}; + +} // namespace csp::services diff --git a/backend/include/csp/services/kb_service.h b/backend/include/csp/services/kb_service.h new file mode 100644 index 0000000..eab4963 --- /dev/null +++ b/backend/include/csp/services/kb_service.h @@ -0,0 +1,29 @@ +#pragma once + +#include "csp/db/sqlite_db.h" +#include "csp/domain/entities.h" + +#include +#include +#include +#include + +namespace csp::services { + +struct KbArticleDetail { + domain::KbArticle article; + std::vector> related_problems; +}; + +class KbService { + public: + explicit KbService(db::SqliteDb& db) : db_(db) {} + + std::vector ListArticles(); + std::optional GetBySlug(const std::string& slug); + + private: + db::SqliteDb& db_; +}; + +} // namespace csp::services diff --git a/backend/include/csp/services/problem_gen_runner.h b/backend/include/csp/services/problem_gen_runner.h new file mode 100644 index 0000000..0e9c651 --- /dev/null +++ b/backend/include/csp/services/problem_gen_runner.h @@ -0,0 +1,36 @@ +#pragma once + +#include +#include +#include +#include + +namespace csp::services { + +class ProblemGenRunner { + public: + static ProblemGenRunner& Instance(); + + void Configure(std::string db_path); + bool TriggerAsync(const std::string& trigger, int count = 1); + void AutoStartIfEnabled(); + + bool IsRunning() const; + std::string LastCommand() const; + std::optional LastExitCode() const; + int64_t LastStartedAt() const; + int64_t LastFinishedAt() const; + + private: + ProblemGenRunner() = default; + + mutable std::mutex mu_; + std::string db_path_; + bool running_ = false; + std::string last_command_; + std::optional last_exit_code_; + int64_t last_started_at_ = 0; + int64_t last_finished_at_ = 0; +}; + +} // namespace csp::services diff --git a/backend/include/csp/services/problem_service.h b/backend/include/csp/services/problem_service.h new file mode 100644 index 0000000..5d05492 --- /dev/null +++ b/backend/include/csp/services/problem_service.h @@ -0,0 +1,41 @@ +#pragma once + +#include "csp/db/sqlite_db.h" +#include "csp/domain/entities.h" + +#include +#include +#include +#include + +namespace csp::services { + +struct ProblemQuery { + std::string q; + std::string tag; + std::vector tags; + std::string source_prefix; + int difficulty = 0; + int page = 1; + int page_size = 20; + std::string order_by = "id"; + std::string order = "asc"; +}; + +struct ProblemListResult { + std::vector items; + int total_count = 0; +}; + +class ProblemService { + public: + explicit ProblemService(db::SqliteDb& db) : db_(db) {} + + ProblemListResult List(const ProblemQuery& query); + std::optional GetById(int64_t id); + + private: + db::SqliteDb& db_; +}; + +} // namespace csp::services diff --git a/backend/include/csp/services/problem_solution_runner.h b/backend/include/csp/services/problem_solution_runner.h new file mode 100644 index 0000000..c6961e7 --- /dev/null +++ b/backend/include/csp/services/problem_solution_runner.h @@ -0,0 +1,28 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace csp::services { + +class ProblemSolutionRunner { + public: + static ProblemSolutionRunner& Instance(); + + void Configure(std::string db_path); + + bool TriggerAsync(int64_t problem_id, int64_t job_id, int max_solutions); + bool IsRunning(int64_t problem_id) const; + + private: + ProblemSolutionRunner() = default; + + std::string db_path_; + mutable std::mutex mu_; + std::set running_problem_ids_; +}; + +} // namespace csp::services diff --git a/backend/include/csp/services/problem_workspace_service.h b/backend/include/csp/services/problem_workspace_service.h new file mode 100644 index 0000000..6ea7182 --- /dev/null +++ b/backend/include/csp/services/problem_workspace_service.h @@ -0,0 +1,69 @@ +#pragma once + +#include "csp/db/sqlite_db.h" + +#include +#include +#include +#include + +namespace csp::services { + +struct ProblemDraft { + std::string language; + std::string code; + std::string stdin_text; + int64_t updated_at = 0; +}; + +struct ProblemSolution { + int64_t id = 0; + int64_t problem_id = 0; + int variant = 1; + std::string title; + std::string idea_md; + std::string explanation_md; + std::string code_cpp; + std::string complexity; + std::string tags_json; + std::string source; + int64_t created_at = 0; + int64_t updated_at = 0; +}; + +struct ProblemSolutionJob { + int64_t id = 0; + int64_t problem_id = 0; + std::string status; + int progress = 0; + std::string message; + int64_t created_by = 0; + int max_solutions = 3; + int64_t created_at = 0; + std::optional started_at; + std::optional finished_at; + int64_t updated_at = 0; +}; + +class ProblemWorkspaceService { + public: + explicit ProblemWorkspaceService(db::SqliteDb& db) : db_(db) {} + + bool ProblemExists(int64_t problem_id); + + void SaveDraft(int64_t user_id, + int64_t problem_id, + const std::string& language, + const std::string& code, + const std::string& stdin_text); + std::optional GetDraft(int64_t user_id, int64_t problem_id); + + int64_t CreateSolutionJob(int64_t problem_id, int64_t created_by, int max_solutions); + std::optional GetLatestSolutionJob(int64_t problem_id); + std::vector ListSolutions(int64_t problem_id); + + private: + db::SqliteDb& db_; +}; + +} // namespace csp::services diff --git a/backend/include/csp/services/submission_service.h b/backend/include/csp/services/submission_service.h new file mode 100644 index 0000000..95a7526 --- /dev/null +++ b/backend/include/csp/services/submission_service.h @@ -0,0 +1,48 @@ +#pragma once + +#include "csp/db/sqlite_db.h" +#include "csp/domain/entities.h" + +#include +#include +#include +#include + +namespace csp::services { + +struct SubmissionCreateRequest { + int64_t user_id = 0; + int64_t problem_id = 0; + std::optional contest_id; + std::string language = "cpp"; + std::string code; +}; + +struct RunOnlyResult { + domain::SubmissionStatus status = domain::SubmissionStatus::Unknown; + int time_ms = 0; + std::string stdout_text; + std::string stderr_text; + std::string compile_log; +}; + +class SubmissionService { + public: + explicit SubmissionService(db::SqliteDb& db) : db_(db) {} + + domain::Submission CreateAndJudge(const SubmissionCreateRequest& req); + + std::vector List(std::optional user_id, + std::optional problem_id, + std::optional contest_id, + int page, + int page_size); + std::optional GetById(int64_t id); + + RunOnlyResult RunOnlyCpp(const std::string& code, const std::string& input); + + private: + db::SqliteDb& db_; +}; + +} // namespace csp::services diff --git a/backend/include/csp/services/user_service.h b/backend/include/csp/services/user_service.h new file mode 100644 index 0000000..b6c4d36 --- /dev/null +++ b/backend/include/csp/services/user_service.h @@ -0,0 +1,23 @@ +#pragma once + +#include "csp/db/sqlite_db.h" +#include "csp/domain/entities.h" + +#include +#include +#include + +namespace csp::services { + +class UserService { + public: + explicit UserService(db::SqliteDb& db) : db_(db) {} + + std::optional GetById(int64_t id); + std::vector GlobalLeaderboard(int limit = 100); + + private: + db::SqliteDb& db_; +}; + +} // namespace csp::services diff --git a/backend/include/csp/services/wrong_book_service.h b/backend/include/csp/services/wrong_book_service.h new file mode 100644 index 0000000..0d33e1a --- /dev/null +++ b/backend/include/csp/services/wrong_book_service.h @@ -0,0 +1,33 @@ +#pragma once + +#include "csp/db/sqlite_db.h" +#include "csp/domain/entities.h" + +#include +#include +#include + +namespace csp::services { + +struct WrongBookEntry { + domain::WrongBookItem item; + std::string problem_title; +}; + +class WrongBookService { + public: + explicit WrongBookService(db::SqliteDb& db) : db_(db) {} + + std::vector ListByUser(int64_t user_id); + void UpsertNote(int64_t user_id, int64_t problem_id, const std::string& note); + void UpsertBySubmission(int64_t user_id, + int64_t problem_id, + int64_t submission_id, + const std::string& note); + void Remove(int64_t user_id, int64_t problem_id); + + private: + db::SqliteDb& db_; +}; + +} // namespace csp::services diff --git a/backend/migrations/001_init.sql b/backend/migrations/001_init.sql index 63b2adf..2aeb2df 100644 --- a/backend/migrations/001_init.sql +++ b/backend/migrations/001_init.sql @@ -24,7 +24,11 @@ CREATE TABLE IF NOT EXISTS problems ( title TEXT NOT NULL, statement_md TEXT NOT NULL, difficulty INTEGER NOT NULL DEFAULT 1, - source TEXT NOT NULL DEFAULT "", + source TEXT NOT NULL DEFAULT '', + statement_url TEXT NOT NULL DEFAULT '', + llm_profile_json TEXT NOT NULL DEFAULT '{}', + sample_input TEXT NOT NULL DEFAULT '', + sample_output TEXT NOT NULL DEFAULT '', created_at INTEGER NOT NULL ); @@ -39,22 +43,26 @@ CREATE TABLE IF NOT EXISTS submissions ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, problem_id INTEGER NOT NULL, + contest_id INTEGER, language TEXT NOT NULL, code TEXT NOT NULL, status TEXT NOT NULL, score INTEGER NOT NULL DEFAULT 0, time_ms INTEGER NOT NULL DEFAULT 0, memory_kb INTEGER NOT NULL DEFAULT 0, + compile_log TEXT NOT NULL DEFAULT '', + runtime_log TEXT NOT NULL DEFAULT '', created_at INTEGER NOT NULL, FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, - FOREIGN KEY(problem_id) REFERENCES problems(id) ON DELETE CASCADE + FOREIGN KEY(problem_id) REFERENCES problems(id) ON DELETE CASCADE, + FOREIGN KEY(contest_id) REFERENCES contests(id) ON DELETE SET NULL ); CREATE TABLE IF NOT EXISTS wrong_book ( user_id INTEGER NOT NULL, problem_id INTEGER NOT NULL, last_submission_id INTEGER, - note TEXT NOT NULL DEFAULT "", + note TEXT NOT NULL DEFAULT '', updated_at INTEGER NOT NULL, PRIMARY KEY(user_id, problem_id), FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, @@ -62,5 +70,133 @@ CREATE TABLE IF NOT EXISTS wrong_book ( FOREIGN KEY(last_submission_id) REFERENCES submissions(id) ON DELETE SET NULL ); +CREATE TABLE IF NOT EXISTS contests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + starts_at INTEGER NOT NULL, + ends_at INTEGER NOT NULL, + rule_json TEXT NOT NULL DEFAULT '{}' +); + +CREATE TABLE IF NOT EXISTS contest_problems ( + contest_id INTEGER NOT NULL, + problem_id INTEGER NOT NULL, + idx INTEGER NOT NULL, + PRIMARY KEY(contest_id, problem_id), + FOREIGN KEY(contest_id) REFERENCES contests(id) ON DELETE CASCADE, + FOREIGN KEY(problem_id) REFERENCES problems(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS contest_registrations ( + contest_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + registered_at INTEGER NOT NULL, + PRIMARY KEY(contest_id, user_id), + FOREIGN KEY(contest_id) REFERENCES contests(id) ON DELETE CASCADE, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS kb_articles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + slug TEXT NOT NULL UNIQUE, + title TEXT NOT NULL, + content_md TEXT NOT NULL, + created_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS kb_article_links ( + article_id INTEGER NOT NULL, + problem_id INTEGER NOT NULL, + PRIMARY KEY(article_id, problem_id), + FOREIGN KEY(article_id) REFERENCES kb_articles(id) ON DELETE CASCADE, + FOREIGN KEY(problem_id) REFERENCES problems(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS import_jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + status TEXT NOT NULL, + trigger TEXT NOT NULL DEFAULT 'manual', + total_count INTEGER NOT NULL DEFAULT 0, + processed_count INTEGER NOT NULL DEFAULT 0, + success_count INTEGER NOT NULL DEFAULT 0, + failed_count INTEGER NOT NULL DEFAULT 0, + options_json TEXT NOT NULL DEFAULT '{}', + last_error TEXT NOT NULL DEFAULT '', + started_at INTEGER NOT NULL, + finished_at INTEGER, + updated_at INTEGER NOT NULL, + created_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS import_job_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + job_id INTEGER NOT NULL, + source_path TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'queued', + title TEXT NOT NULL DEFAULT '', + difficulty INTEGER NOT NULL DEFAULT 0, + problem_id INTEGER, + error_text TEXT NOT NULL DEFAULT '', + started_at INTEGER, + finished_at INTEGER, + updated_at INTEGER NOT NULL, + created_at INTEGER NOT NULL, + FOREIGN KEY(job_id) REFERENCES import_jobs(id) ON DELETE CASCADE, + UNIQUE(job_id, source_path) +); + +CREATE TABLE IF NOT EXISTS problem_drafts ( + user_id INTEGER NOT NULL, + problem_id INTEGER NOT NULL, + language TEXT NOT NULL DEFAULT 'cpp', + code TEXT NOT NULL DEFAULT '', + stdin TEXT NOT NULL DEFAULT '', + updated_at INTEGER NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY(user_id, problem_id), + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY(problem_id) REFERENCES problems(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS problem_solution_jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + problem_id INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'queued', + progress INTEGER NOT NULL DEFAULT 0, + message TEXT NOT NULL DEFAULT '', + created_by INTEGER NOT NULL DEFAULT 0, + max_solutions INTEGER NOT NULL DEFAULT 3, + created_at INTEGER NOT NULL, + started_at INTEGER, + finished_at INTEGER, + updated_at INTEGER NOT NULL, + FOREIGN KEY(problem_id) REFERENCES problems(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS problem_solutions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + problem_id INTEGER NOT NULL, + variant INTEGER NOT NULL DEFAULT 1, + title TEXT NOT NULL DEFAULT '', + idea_md TEXT NOT NULL DEFAULT '', + explanation_md TEXT NOT NULL DEFAULT '', + code_cpp TEXT NOT NULL DEFAULT '', + complexity TEXT NOT NULL DEFAULT '', + tags_json TEXT NOT NULL DEFAULT '[]', + source TEXT NOT NULL DEFAULT 'llm', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY(problem_id) REFERENCES problems(id) ON DELETE CASCADE +); + CREATE INDEX IF NOT EXISTS idx_submissions_user_created_at ON submissions(user_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_submissions_problem_created_at ON submissions(problem_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_submissions_contest_user_created_at ON submissions(contest_id, user_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_problem_tags_tag ON problem_tags(tag); +CREATE INDEX IF NOT EXISTS idx_kb_article_links_problem_id ON kb_article_links(problem_id); +CREATE INDEX IF NOT EXISTS idx_import_jobs_created_at ON import_jobs(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_import_jobs_status ON import_jobs(status, updated_at DESC); +CREATE INDEX IF NOT EXISTS idx_import_job_items_job_status ON import_job_items(job_id, status, updated_at DESC); +CREATE INDEX IF NOT EXISTS idx_problem_drafts_updated ON problem_drafts(updated_at DESC); +CREATE INDEX IF NOT EXISTS idx_problem_solution_jobs_problem ON problem_solution_jobs(problem_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_problem_solutions_problem ON problem_solutions(problem_id, variant, id); diff --git a/backend/src/app_state.cc b/backend/src/app_state.cc index b305205..11cd66e 100644 --- a/backend/src/app_state.cc +++ b/backend/src/app_state.cc @@ -16,6 +16,7 @@ void AppState::Init(const std::string& sqlite_path) { db_ = std::make_unique(db::SqliteDb::OpenFile(sqlite_path)); } csp::db::ApplyMigrations(*db_); + csp::db::SeedDemoData(*db_); } csp::db::SqliteDb& AppState::db() { diff --git a/backend/src/controllers/contest_controller.cc b/backend/src/controllers/contest_controller.cc new file mode 100644 index 0000000..5e7a513 --- /dev/null +++ b/backend/src/controllers/contest_controller.cc @@ -0,0 +1,133 @@ +#include "csp/controllers/contest_controller.h" + +#include "csp/app_state.h" +#include "csp/domain/json.h" +#include "csp/services/contest_service.h" +#include "http_auth.h" + +#include +#include + +namespace csp::controllers { + +namespace { + +drogon::HttpResponsePtr JsonError(drogon::HttpStatusCode code, + const std::string& msg) { + Json::Value j; + j["ok"] = false; + j["error"] = msg; + auto resp = drogon::HttpResponse::newHttpJsonResponse(j); + resp->setStatusCode(code); + return resp; +} + +drogon::HttpResponsePtr JsonOk(const Json::Value& data) { + Json::Value j; + j["ok"] = true; + j["data"] = data; + auto resp = drogon::HttpResponse::newHttpJsonResponse(j); + resp->setStatusCode(drogon::k200OK); + return resp; +} + +} // namespace + +void ContestController::list( + const drogon::HttpRequestPtr& /*req*/, + std::function&& cb) { + try { + services::ContestService svc(csp::AppState::Instance().db()); + const auto contests = svc.ListContests(); + Json::Value arr(Json::arrayValue); + for (const auto& c : contests) arr.append(domain::ToJson(c)); + cb(JsonOk(arr)); + } catch (const std::exception& e) { + cb(JsonError(drogon::k500InternalServerError, e.what())); + } +} + +void ContestController::getById( + const drogon::HttpRequestPtr& req, + std::function&& cb, + int64_t contest_id) { + try { + services::ContestService svc(csp::AppState::Instance().db()); + const auto contest = svc.GetContest(contest_id); + if (!contest.has_value()) { + cb(JsonError(drogon::k404NotFound, "contest not found")); + return; + } + + Json::Value data; + data["contest"] = domain::ToJson(*contest); + + Json::Value problems(Json::arrayValue); + for (const auto& p : svc.ListContestProblems(contest_id)) { + problems.append(domain::ToJson(p)); + } + data["problems"] = problems; + + std::string auth_error; + const auto user_id = GetAuthedUserId(req, auth_error); + if (user_id.has_value()) { + data["registered"] = svc.IsRegistered(contest_id, *user_id); + } + + cb(JsonOk(data)); + } catch (const std::exception& e) { + cb(JsonError(drogon::k500InternalServerError, e.what())); + } +} + +void ContestController::registerForContest( + const drogon::HttpRequestPtr& req, + std::function&& cb, + int64_t contest_id) { + try { + std::string auth_error; + const auto user_id = GetAuthedUserId(req, auth_error); + if (!user_id.has_value()) { + cb(JsonError(drogon::k401Unauthorized, auth_error)); + return; + } + + services::ContestService svc(csp::AppState::Instance().db()); + if (!svc.GetContest(contest_id).has_value()) { + cb(JsonError(drogon::k404NotFound, "contest not found")); + return; + } + + svc.Register(contest_id, *user_id); + + Json::Value data; + data["contest_id"] = Json::Int64(contest_id); + data["user_id"] = Json::Int64(*user_id); + data["registered"] = true; + cb(JsonOk(data)); + } catch (const std::exception& e) { + cb(JsonError(drogon::k500InternalServerError, e.what())); + } +} + +void ContestController::leaderboard( + const drogon::HttpRequestPtr& /*req*/, + std::function&& cb, + int64_t contest_id) { + try { + services::ContestService svc(csp::AppState::Instance().db()); + if (!svc.GetContest(contest_id).has_value()) { + cb(JsonError(drogon::k404NotFound, "contest not found")); + return; + } + + const auto rows = svc.Leaderboard(contest_id); + Json::Value arr(Json::arrayValue); + for (const auto& r : rows) arr.append(domain::ToJson(r)); + cb(JsonOk(arr)); + } catch (const std::exception& e) { + cb(JsonError(drogon::k500InternalServerError, e.what())); + } +} + +} // namespace csp::controllers diff --git a/backend/src/controllers/http_auth.h b/backend/src/controllers/http_auth.h new file mode 100644 index 0000000..8a06d32 --- /dev/null +++ b/backend/src/controllers/http_auth.h @@ -0,0 +1,37 @@ +#pragma once + +#include "csp/app_state.h" +#include "csp/services/auth_service.h" + +#include + +#include +#include + +namespace csp::controllers { + +inline std::optional GetAuthedUserId(const drogon::HttpRequestPtr& req, + std::string& error) { + const std::string authz = req->getHeader("Authorization"); + const std::string prefix = "Bearer "; + if (authz.rfind(prefix, 0) != 0) { + error = "missing Authorization: Bearer "; + return std::nullopt; + } + + const std::string token = authz.substr(prefix.size()); + if (token.empty()) { + error = "empty bearer token"; + return std::nullopt; + } + + services::AuthService auth(csp::AppState::Instance().db()); + const auto user_id = auth.VerifyToken(token); + if (!user_id.has_value()) { + error = "invalid or expired token"; + return std::nullopt; + } + return static_cast(*user_id); +} + +} // namespace csp::controllers diff --git a/backend/src/controllers/import_controller.cc b/backend/src/controllers/import_controller.cc new file mode 100644 index 0000000..e865601 --- /dev/null +++ b/backend/src/controllers/import_controller.cc @@ -0,0 +1,190 @@ +#include "csp/controllers/import_controller.h" + +#include "csp/app_state.h" +#include "csp/services/import_runner.h" +#include "csp/services/import_service.h" + +#include +#include +#include + +namespace csp::controllers { + +namespace { + +drogon::HttpResponsePtr JsonError(drogon::HttpStatusCode code, + const std::string& msg) { + Json::Value j; + j["ok"] = false; + j["error"] = msg; + auto resp = drogon::HttpResponse::newHttpJsonResponse(j); + resp->setStatusCode(code); + return resp; +} + +drogon::HttpResponsePtr JsonOk(const Json::Value& data) { + Json::Value j; + j["ok"] = true; + j["data"] = data; + auto resp = drogon::HttpResponse::newHttpJsonResponse(j); + resp->setStatusCode(drogon::k200OK); + return resp; +} + +int ParsePositiveInt(const std::string& s, + int default_value, + int min_value, + int max_value) { + if (s.empty()) return default_value; + const int v = std::stoi(s); + return std::max(min_value, std::min(max_value, v)); +} + +Json::Value ToJson(const services::ImportJob& job) { + Json::Value j; + j["id"] = Json::Int64(job.id); + j["status"] = job.status; + j["trigger"] = job.trigger; + j["total_count"] = job.total_count; + j["processed_count"] = job.processed_count; + j["success_count"] = job.success_count; + j["failed_count"] = job.failed_count; + j["options_json"] = job.options_json; + j["last_error"] = job.last_error; + j["started_at"] = Json::Int64(job.started_at); + if (job.finished_at.has_value()) { + j["finished_at"] = Json::Int64(*job.finished_at); + } else { + j["finished_at"] = Json::nullValue; + } + j["updated_at"] = Json::Int64(job.updated_at); + j["created_at"] = Json::Int64(job.created_at); + return j; +} + +Json::Value ToJson(const services::ImportJobItem& item) { + Json::Value j; + j["id"] = Json::Int64(item.id); + j["job_id"] = Json::Int64(item.job_id); + j["source_path"] = item.source_path; + j["status"] = item.status; + j["title"] = item.title; + j["difficulty"] = item.difficulty; + if (item.problem_id.has_value()) { + j["problem_id"] = Json::Int64(*item.problem_id); + } else { + j["problem_id"] = Json::nullValue; + } + j["error_text"] = item.error_text; + if (item.started_at.has_value()) { + j["started_at"] = Json::Int64(*item.started_at); + } else { + j["started_at"] = Json::nullValue; + } + if (item.finished_at.has_value()) { + j["finished_at"] = Json::Int64(*item.finished_at); + } else { + j["finished_at"] = Json::nullValue; + } + j["updated_at"] = Json::Int64(item.updated_at); + j["created_at"] = Json::Int64(item.created_at); + return j; +} + +} // namespace + +void ImportController::latestJob( + const drogon::HttpRequestPtr& /*req*/, + std::function&& cb) { + try { + services::ImportService svc(csp::AppState::Instance().db()); + Json::Value payload; + const auto job = svc.GetLatestJob(); + payload["runner_running"] = services::ImportRunner::Instance().IsRunning(); + if (job.has_value()) { + payload["job"] = ToJson(*job); + } else { + payload["job"] = Json::nullValue; + } + cb(JsonOk(payload)); + } catch (const std::exception& e) { + cb(JsonError(drogon::k500InternalServerError, e.what())); + } +} + +void ImportController::jobById( + const drogon::HttpRequestPtr& /*req*/, + std::function&& cb, + int64_t job_id) { + try { + services::ImportService svc(csp::AppState::Instance().db()); + const auto job = svc.GetById(job_id); + if (!job.has_value()) { + cb(JsonError(drogon::k404NotFound, "import job not found")); + return; + } + Json::Value payload = ToJson(*job); + payload["runner_running"] = services::ImportRunner::Instance().IsRunning(); + cb(JsonOk(payload)); + } catch (const std::exception& e) { + cb(JsonError(drogon::k500InternalServerError, e.what())); + } +} + +void ImportController::jobItems( + const drogon::HttpRequestPtr& req, + std::function&& cb, + int64_t job_id) { + try { + services::ImportJobItemQuery query; + query.status = req->getParameter("status"); + query.page = ParsePositiveInt(req->getParameter("page"), 1, 1, 100000); + query.page_size = ParsePositiveInt(req->getParameter("page_size"), 100, 1, 500); + + services::ImportService svc(csp::AppState::Instance().db()); + const auto rows = svc.ListItems(job_id, query); + Json::Value arr(Json::arrayValue); + for (const auto& row : rows) arr.append(ToJson(row)); + + Json::Value payload; + payload["items"] = arr; + payload["page"] = query.page; + payload["page_size"] = query.page_size; + cb(JsonOk(payload)); + } catch (const std::invalid_argument&) { + cb(JsonError(drogon::k400BadRequest, "invalid query parameter")); + } catch (const std::out_of_range&) { + cb(JsonError(drogon::k400BadRequest, "query parameter out of range")); + } catch (const std::exception& e) { + cb(JsonError(drogon::k500InternalServerError, e.what())); + } +} + +void ImportController::runJob( + const drogon::HttpRequestPtr& req, + std::function&& cb) { + try { + services::ImportRunOptions opts; + const auto json = req->getJsonObject(); + if (json) { + opts.clear_all_problems = + (*json).isMember("clear_all_problems") && + (*json)["clear_all_problems"].asBool(); + } + const bool started = + services::ImportRunner::Instance().TriggerAsync("manual", opts); + if (!started) { + cb(JsonError(drogon::k409Conflict, "import job already running")); + return; + } + + Json::Value payload; + payload["started"] = true; + payload["running"] = true; + cb(JsonOk(payload)); + } catch (const std::exception& e) { + cb(JsonError(drogon::k500InternalServerError, e.what())); + } +} + +} // namespace csp::controllers diff --git a/backend/src/controllers/kb_controller.cc b/backend/src/controllers/kb_controller.cc new file mode 100644 index 0000000..f6c3996 --- /dev/null +++ b/backend/src/controllers/kb_controller.cc @@ -0,0 +1,81 @@ +#include "csp/controllers/kb_controller.h" + +#include "csp/app_state.h" +#include "csp/domain/json.h" +#include "csp/services/kb_service.h" + +#include +#include + +namespace csp::controllers { + +namespace { + +drogon::HttpResponsePtr JsonError(drogon::HttpStatusCode code, + const std::string& msg) { + Json::Value j; + j["ok"] = false; + j["error"] = msg; + auto resp = drogon::HttpResponse::newHttpJsonResponse(j); + resp->setStatusCode(code); + return resp; +} + +drogon::HttpResponsePtr JsonOk(const Json::Value& data) { + Json::Value j; + j["ok"] = true; + j["data"] = data; + auto resp = drogon::HttpResponse::newHttpJsonResponse(j); + resp->setStatusCode(drogon::k200OK); + return resp; +} + +} // namespace + +void KbController::listArticles( + const drogon::HttpRequestPtr& /*req*/, + std::function&& cb) { + try { + services::KbService svc(csp::AppState::Instance().db()); + const auto rows = svc.ListArticles(); + + Json::Value arr(Json::arrayValue); + for (const auto& a : rows) arr.append(domain::ToJson(a)); + + cb(JsonOk(arr)); + } catch (const std::exception& e) { + cb(JsonError(drogon::k500InternalServerError, e.what())); + } +} + +void KbController::getArticle( + const drogon::HttpRequestPtr& /*req*/, + std::function&& cb, + std::string slug) { + try { + services::KbService svc(csp::AppState::Instance().db()); + const auto detail = svc.GetBySlug(slug); + if (!detail.has_value()) { + cb(JsonError(drogon::k404NotFound, "article not found")); + return; + } + + Json::Value data; + data["article"] = domain::ToJson(detail->article); + + Json::Value rel(Json::arrayValue); + for (const auto& p : detail->related_problems) { + Json::Value item; + item["problem_id"] = Json::Int64(p.first); + item["title"] = p.second; + rel.append(item); + } + data["related_problems"] = rel; + + cb(JsonOk(data)); + } catch (const std::exception& e) { + cb(JsonError(drogon::k500InternalServerError, e.what())); + } +} + +} // namespace csp::controllers diff --git a/backend/src/controllers/leaderboard_controller.cc b/backend/src/controllers/leaderboard_controller.cc new file mode 100644 index 0000000..97749ae --- /dev/null +++ b/backend/src/controllers/leaderboard_controller.cc @@ -0,0 +1,57 @@ +#include "csp/controllers/leaderboard_controller.h" + +#include "csp/app_state.h" +#include "csp/domain/json.h" +#include "csp/services/user_service.h" + +#include +#include +#include + +namespace csp::controllers { + +namespace { + +drogon::HttpResponsePtr JsonError(drogon::HttpStatusCode code, + const std::string& msg) { + Json::Value j; + j["ok"] = false; + j["error"] = msg; + auto resp = drogon::HttpResponse::newHttpJsonResponse(j); + resp->setStatusCode(code); + return resp; +} + +drogon::HttpResponsePtr JsonOkArray(const Json::Value& arr) { + Json::Value j; + j["ok"] = true; + j["data"] = arr; + auto resp = drogon::HttpResponse::newHttpJsonResponse(j); + resp->setStatusCode(drogon::k200OK); + return resp; +} + +} // namespace + +void LeaderboardController::global( + const drogon::HttpRequestPtr& req, + std::function&& cb) { + try { + int limit = 100; + const auto limit_str = req->getParameter("limit"); + if (!limit_str.empty()) { + limit = std::max(1, std::min(500, std::stoi(limit_str))); + } + + services::UserService users(csp::AppState::Instance().db()); + const auto rows = users.GlobalLeaderboard(limit); + + Json::Value arr(Json::arrayValue); + for (const auto& r : rows) arr.append(domain::ToJson(r)); + cb(JsonOkArray(arr)); + } catch (const std::exception& e) { + cb(JsonError(drogon::k500InternalServerError, e.what())); + } +} + +} // namespace csp::controllers diff --git a/backend/src/controllers/me_controller.cc b/backend/src/controllers/me_controller.cc new file mode 100644 index 0000000..6d8bc4f --- /dev/null +++ b/backend/src/controllers/me_controller.cc @@ -0,0 +1,146 @@ +#include "csp/controllers/me_controller.h" + +#include "csp/app_state.h" +#include "csp/domain/json.h" +#include "csp/services/user_service.h" +#include "csp/services/wrong_book_service.h" +#include "http_auth.h" + +#include +#include +#include + +namespace csp::controllers { + +namespace { + +drogon::HttpResponsePtr JsonError(drogon::HttpStatusCode code, + const std::string& msg) { + Json::Value j; + j["ok"] = false; + j["error"] = msg; + auto resp = drogon::HttpResponse::newHttpJsonResponse(j); + resp->setStatusCode(code); + return resp; +} + +drogon::HttpResponsePtr JsonOk(const Json::Value& data) { + Json::Value j; + j["ok"] = true; + j["data"] = data; + auto resp = drogon::HttpResponse::newHttpJsonResponse(j); + resp->setStatusCode(drogon::k200OK); + return resp; +} + +std::optional RequireAuth(const drogon::HttpRequestPtr& req, + std::function& cb) { + std::string auth_error; + const auto user_id = GetAuthedUserId(req, auth_error); + if (!user_id.has_value()) { + cb(JsonError(drogon::k401Unauthorized, auth_error)); + return std::nullopt; + } + return user_id; +} + +} // namespace + +void MeController::profile( + const drogon::HttpRequestPtr& req, + std::function&& cb) { + try { + const auto user_id = RequireAuth(req, cb); + if (!user_id.has_value()) return; + + services::UserService users(csp::AppState::Instance().db()); + const auto user = users.GetById(*user_id); + if (!user.has_value()) { + cb(JsonError(drogon::k404NotFound, "user not found")); + return; + } + + cb(JsonOk(domain::ToPublicJson(*user))); + } catch (const std::exception& e) { + cb(JsonError(drogon::k500InternalServerError, e.what())); + } +} + +void MeController::listWrongBook( + const drogon::HttpRequestPtr& req, + std::function&& cb) { + try { + const auto user_id = RequireAuth(req, cb); + if (!user_id.has_value()) return; + + services::WrongBookService wrong_book(csp::AppState::Instance().db()); + const auto rows = wrong_book.ListByUser(*user_id); + + Json::Value arr(Json::arrayValue); + for (const auto& row : rows) { + Json::Value item = domain::ToJson(row.item); + item["problem_title"] = row.problem_title; + arr.append(item); + } + + cb(JsonOk(arr)); + } catch (const std::exception& e) { + cb(JsonError(drogon::k500InternalServerError, e.what())); + } +} + +void MeController::upsertWrongBookNote( + const drogon::HttpRequestPtr& req, + std::function&& cb, + int64_t problem_id) { + try { + const auto user_id = RequireAuth(req, cb); + if (!user_id.has_value()) return; + + const auto json = req->getJsonObject(); + if (!json) { + cb(JsonError(drogon::k400BadRequest, "body must be json")); + return; + } + + const std::string note = (*json).get("note", "").asString(); + if (note.size() > 4000) { + cb(JsonError(drogon::k400BadRequest, "note too long")); + return; + } + + services::WrongBookService wrong_book(csp::AppState::Instance().db()); + wrong_book.UpsertNote(*user_id, problem_id, note); + + Json::Value data; + data["user_id"] = Json::Int64(*user_id); + data["problem_id"] = Json::Int64(problem_id); + data["note"] = note; + cb(JsonOk(data)); + } catch (const std::exception& e) { + cb(JsonError(drogon::k500InternalServerError, e.what())); + } +} + +void MeController::deleteWrongBookItem( + const drogon::HttpRequestPtr& req, + std::function&& cb, + int64_t problem_id) { + try { + const auto user_id = RequireAuth(req, cb); + if (!user_id.has_value()) return; + + services::WrongBookService wrong_book(csp::AppState::Instance().db()); + wrong_book.Remove(*user_id, problem_id); + + Json::Value data; + data["user_id"] = Json::Int64(*user_id); + data["problem_id"] = Json::Int64(problem_id); + data["deleted"] = true; + cb(JsonOk(data)); + } catch (const std::exception& e) { + cb(JsonError(drogon::k500InternalServerError, e.what())); + } +} + +} // namespace csp::controllers diff --git a/backend/src/controllers/meta_controller.cc b/backend/src/controllers/meta_controller.cc new file mode 100644 index 0000000..9cafbf0 --- /dev/null +++ b/backend/src/controllers/meta_controller.cc @@ -0,0 +1,273 @@ +#include "csp/controllers/meta_controller.h" + +#include "csp/app_state.h" +#include "csp/domain/enum_strings.h" +#include "csp/domain/json.h" +#include "csp/services/problem_gen_runner.h" +#include "csp/services/problem_service.h" +#include "csp/services/submission_service.h" + +#include +#include +#include + +namespace csp::controllers { + +namespace { + +drogon::HttpResponsePtr JsonError(drogon::HttpStatusCode code, + const std::string& msg) { + Json::Value j; + j["ok"] = false; + j["error"] = msg; + auto resp = drogon::HttpResponse::newHttpJsonResponse(j); + resp->setStatusCode(code); + return resp; +} + +Json::Value BuildOpenApiSpec() { + Json::Value root; + root["openapi"] = "3.1.0"; + root["info"]["title"] = "CSP Platform API"; + root["info"]["version"] = "1.0.0"; + root["info"]["description"] = + "CSP 训练平台接口文档,含认证、题库、评测、导入、MCP。"; + + root["servers"][0]["url"] = "/"; + + auto& paths = root["paths"]; + paths["/api/health"]["get"]["summary"] = "健康检查"; + paths["/api/health"]["get"]["responses"]["200"]["description"] = "OK"; + + paths["/api/v1/auth/login"]["post"]["summary"] = "登录"; + paths["/api/v1/auth/register"]["post"]["summary"] = "注册"; + + paths["/api/v1/problems"]["get"]["summary"] = "题库列表"; + paths["/api/v1/problems/{id}"]["get"]["summary"] = "题目详情"; + paths["/api/v1/problems/{id}/submit"]["post"]["summary"] = "提交评测"; + paths["/api/v1/problems/{id}/draft"]["get"]["summary"] = "读取代码草稿"; + paths["/api/v1/problems/{id}/draft"]["put"]["summary"] = "保存代码草稿"; + paths["/api/v1/problems/{id}/solutions"]["get"]["summary"] = "题解列表/任务状态"; + paths["/api/v1/problems/{id}/solutions/generate"]["post"]["summary"] = + "异步生成题解"; + + paths["/api/v1/run/cpp"]["post"]["summary"] = "C++ 试运行"; + paths["/api/v1/submissions"]["get"]["summary"] = "提交记录"; + paths["/api/v1/submissions/{id}"]["get"]["summary"] = "提交详情"; + + paths["/api/v1/import/jobs/latest"]["get"]["summary"] = "最新导入任务"; + paths["/api/v1/import/jobs/run"]["post"]["summary"] = "触发导入任务"; + paths["/api/v1/problem-gen/status"]["get"]["summary"] = "CSP-J 生成任务状态"; + paths["/api/v1/problem-gen/run"]["post"]["summary"] = "触发生成新题(RAG+去重)"; + + paths["/api/v1/mcp"]["post"]["summary"] = "MCP JSON-RPC 入口"; + + auto& components = root["components"]; + components["securitySchemes"]["bearerAuth"]["type"] = "http"; + components["securitySchemes"]["bearerAuth"]["scheme"] = "bearer"; + components["securitySchemes"]["bearerAuth"]["bearerFormat"] = "JWT"; + + return root; +} + +Json::Value BuildMcpError(const Json::Value& id, + int code, + const std::string& message) { + Json::Value out; + out["jsonrpc"] = "2.0"; + out["id"] = id; + out["error"]["code"] = code; + out["error"]["message"] = message; + return out; +} + +Json::Value BuildMcpOk(const Json::Value& id, const Json::Value& result) { + Json::Value out; + out["jsonrpc"] = "2.0"; + out["id"] = id; + out["result"] = result; + return out; +} + +Json::Value McpToolsList() { + Json::Value tools(Json::arrayValue); + + Json::Value t1; + t1["name"] = "health"; + t1["description"] = "Get backend health"; + tools.append(t1); + + Json::Value t2; + t2["name"] = "list_problems"; + t2["description"] = "List problems with filters"; + tools.append(t2); + + Json::Value t3; + t3["name"] = "get_problem"; + t3["description"] = "Get problem by id"; + tools.append(t3); + + Json::Value t4; + t4["name"] = "run_cpp"; + t4["description"] = "Run C++ code with input"; + tools.append(t4); + + Json::Value t5; + t5["name"] = "generate_cspj_problem"; + t5["description"] = "Trigger RAG-based CSP-J problem generation"; + tools.append(t5); + + return tools; +} + +} // namespace + +void MetaController::openapi( + const drogon::HttpRequestPtr& /*req*/, + std::function&& cb) { + auto resp = drogon::HttpResponse::newHttpJsonResponse(BuildOpenApiSpec()); + resp->setStatusCode(drogon::k200OK); + cb(resp); +} + +void MetaController::mcp( + const drogon::HttpRequestPtr& req, + std::function&& cb) { + try { + const auto json = req->getJsonObject(); + if (!json) { + auto resp = drogon::HttpResponse::newHttpJsonResponse( + BuildMcpError(Json::nullValue, -32700, "body must be json")); + resp->setStatusCode(drogon::k400BadRequest); + cb(resp); + return; + } + + const Json::Value id = (*json).isMember("id") ? (*json)["id"] : Json::nullValue; + const std::string method = (*json).get("method", "").asString(); + const Json::Value params = (*json).isMember("params") ? (*json)["params"] : Json::Value(); + + if (method == "initialize") { + Json::Value result; + result["server_name"] = "csp-platform-mcp"; + result["server_version"] = "1.0.0"; + result["capabilities"]["tools"] = true; + auto resp = drogon::HttpResponse::newHttpJsonResponse(BuildMcpOk(id, result)); + cb(resp); + return; + } + + if (method == "tools/list") { + Json::Value result; + result["tools"] = McpToolsList(); + auto resp = drogon::HttpResponse::newHttpJsonResponse(BuildMcpOk(id, result)); + cb(resp); + return; + } + + if (method != "tools/call") { + auto resp = drogon::HttpResponse::newHttpJsonResponse( + BuildMcpError(id, -32601, "method not found")); + cb(resp); + return; + } + + const std::string tool_name = params.get("name", "").asString(); + const Json::Value args = params.isMember("arguments") ? params["arguments"] : Json::Value(); + + if (tool_name == "health") { + Json::Value result; + result["ok"] = true; + result["service"] = "csp-backend"; + auto resp = drogon::HttpResponse::newHttpJsonResponse(BuildMcpOk(id, result)); + cb(resp); + return; + } + + if (tool_name == "list_problems") { + services::ProblemQuery q; + q.q = args.get("q", "").asString(); + q.page = std::max(1, args.get("page", 1).asInt()); + q.page_size = std::max(1, std::min(100, args.get("page_size", 20).asInt())); + q.source_prefix = args.get("source_prefix", "").asString(); + q.difficulty = std::max(0, std::min(10, args.get("difficulty", 0).asInt())); + + services::ProblemService svc(csp::AppState::Instance().db()); + const auto rows = svc.List(q); + + Json::Value items(Json::arrayValue); + for (const auto& row : rows.items) { + items.append(domain::ToJson(row)); + } + + Json::Value result; + result["items"] = items; + result["total_count"] = rows.total_count; + auto resp = drogon::HttpResponse::newHttpJsonResponse(BuildMcpOk(id, result)); + cb(resp); + return; + } + + if (tool_name == "get_problem") { + const int64_t problem_id = args.get("problem_id", 0).asInt64(); + services::ProblemService svc(csp::AppState::Instance().db()); + const auto p = svc.GetById(problem_id); + if (!p.has_value()) { + auto resp = drogon::HttpResponse::newHttpJsonResponse( + BuildMcpError(id, -32004, "problem not found")); + cb(resp); + return; + } + Json::Value result = domain::ToJson(*p); + auto resp = drogon::HttpResponse::newHttpJsonResponse(BuildMcpOk(id, result)); + cb(resp); + return; + } + + if (tool_name == "run_cpp") { + const std::string code = args.get("code", "").asString(); + const std::string input = args.get("input", "").asString(); + services::SubmissionService svc(csp::AppState::Instance().db()); + const auto r = svc.RunOnlyCpp(code, input); + + Json::Value result; + result["status"] = domain::ToString(r.status); + result["time_ms"] = r.time_ms; + result["stdout"] = r.stdout_text; + result["stderr"] = r.stderr_text; + result["compile_log"] = r.compile_log; + auto resp = drogon::HttpResponse::newHttpJsonResponse(BuildMcpOk(id, result)); + cb(resp); + return; + } + + if (tool_name == "generate_cspj_problem") { + const int count = std::max(1, std::min(5, args.get("count", 1).asInt())); + const bool started = + services::ProblemGenRunner::Instance().TriggerAsync("mcp", count); + Json::Value result; + result["started"] = started; + result["count"] = count; + result["running"] = services::ProblemGenRunner::Instance().IsRunning(); + auto resp = drogon::HttpResponse::newHttpJsonResponse(BuildMcpOk(id, result)); + cb(resp); + return; + } + + auto resp = drogon::HttpResponse::newHttpJsonResponse( + BuildMcpError(id, -32602, "unknown tool")); + cb(resp); + } catch (const std::runtime_error& e) { + auto resp = drogon::HttpResponse::newHttpJsonResponse( + BuildMcpError(Json::nullValue, -32000, e.what())); + resp->setStatusCode(drogon::k400BadRequest); + cb(resp); + } catch (const std::exception& e) { + auto resp = drogon::HttpResponse::newHttpJsonResponse( + BuildMcpError(Json::nullValue, -32000, e.what())); + resp->setStatusCode(drogon::k500InternalServerError); + cb(resp); + } +} + +} // namespace csp::controllers diff --git a/backend/src/controllers/problem_controller.cc b/backend/src/controllers/problem_controller.cc new file mode 100644 index 0000000..53a4485 --- /dev/null +++ b/backend/src/controllers/problem_controller.cc @@ -0,0 +1,299 @@ +#include "csp/controllers/problem_controller.h" + +#include "csp/app_state.h" +#include "csp/domain/json.h" +#include "csp/services/problem_service.h" +#include "csp/services/problem_solution_runner.h" +#include "csp/services/problem_workspace_service.h" +#include "http_auth.h" + +#include +#include +#include +#include +#include +#include + +namespace csp::controllers { + +namespace { + +drogon::HttpResponsePtr JsonError(drogon::HttpStatusCode code, + const std::string& msg) { + Json::Value j; + j["ok"] = false; + j["error"] = msg; + auto resp = drogon::HttpResponse::newHttpJsonResponse(j); + resp->setStatusCode(code); + return resp; +} + +drogon::HttpResponsePtr JsonOk(const Json::Value& data) { + Json::Value j; + j["ok"] = true; + j["data"] = data; + auto resp = drogon::HttpResponse::newHttpJsonResponse(j); + resp->setStatusCode(drogon::k200OK); + return resp; +} + +int ParsePositiveInt(const std::string& s, + int default_value, + int min_value, + int max_value) { + if (s.empty()) return default_value; + const int v = std::stoi(s); + return std::max(min_value, std::min(max_value, v)); +} + +std::vector ParseCsv(const std::string& raw) { + auto trim = [](std::string s) { + while (!s.empty() && std::isspace(static_cast(s.front()))) { + s.erase(s.begin()); + } + while (!s.empty() && std::isspace(static_cast(s.back()))) { + s.pop_back(); + } + return s; + }; + std::vector out; + std::stringstream ss(raw); + std::string item; + while (std::getline(ss, item, ',')) { + item = trim(item); + if (item.empty()) continue; + out.push_back(item); + } + return out; +} + +} // namespace + +void ProblemController::list( + const drogon::HttpRequestPtr& req, + std::function&& cb) { + try { + services::ProblemQuery q; + q.q = req->getParameter("q"); + q.tag = req->getParameter("tag"); + q.tags = ParseCsv(req->getParameter("tags")); + q.source_prefix = req->getParameter("source_prefix"); + q.difficulty = ParsePositiveInt(req->getParameter("difficulty"), 0, 0, 10); + q.page = ParsePositiveInt(req->getParameter("page"), 1, 1, 100000); + q.page_size = ParsePositiveInt(req->getParameter("page_size"), 20, 1, 200); + q.order_by = req->getParameter("order_by"); + q.order = req->getParameter("order"); + + services::ProblemService svc(csp::AppState::Instance().db()); + const auto result = svc.List(q); + + Json::Value arr(Json::arrayValue); + for (const auto& p : result.items) arr.append(domain::ToJson(p)); + + Json::Value payload; + payload["items"] = arr; + payload["total_count"] = result.total_count; + payload["page"] = q.page; + payload["page_size"] = q.page_size; + cb(JsonOk(payload)); + } catch (const std::invalid_argument&) { + cb(JsonError(drogon::k400BadRequest, "invalid query parameter")); + } catch (const std::out_of_range&) { + cb(JsonError(drogon::k400BadRequest, "query parameter out of range")); + } catch (const std::exception& e) { + cb(JsonError(drogon::k500InternalServerError, e.what())); + } +} + +void ProblemController::getById( + const drogon::HttpRequestPtr& /*req*/, + std::function&& cb, + int64_t problem_id) { + try { + services::ProblemService svc(csp::AppState::Instance().db()); + const auto p = svc.GetById(problem_id); + if (!p.has_value()) { + cb(JsonError(drogon::k404NotFound, "problem not found")); + return; + } + cb(JsonOk(domain::ToJson(*p))); + } catch (const std::exception& e) { + cb(JsonError(drogon::k500InternalServerError, e.what())); + } +} + +void ProblemController::getDraft( + const drogon::HttpRequestPtr& req, + std::function&& cb, + int64_t problem_id) { + try { + std::string auth_error; + const auto user_id = GetAuthedUserId(req, auth_error); + if (!user_id.has_value()) { + cb(JsonError(drogon::k401Unauthorized, auth_error)); + return; + } + + services::ProblemWorkspaceService svc(csp::AppState::Instance().db()); + const auto draft = svc.GetDraft(*user_id, problem_id); + if (!draft.has_value()) { + cb(JsonError(drogon::k404NotFound, "draft not found")); + return; + } + + Json::Value payload; + payload["language"] = draft->language; + payload["code"] = draft->code; + payload["stdin"] = draft->stdin_text; + payload["updated_at"] = Json::Int64(draft->updated_at); + cb(JsonOk(payload)); + } catch (const std::exception& e) { + cb(JsonError(drogon::k500InternalServerError, e.what())); + } +} + +void ProblemController::saveDraft( + const drogon::HttpRequestPtr& req, + std::function&& cb, + int64_t problem_id) { + try { + std::string auth_error; + const auto user_id = GetAuthedUserId(req, auth_error); + if (!user_id.has_value()) { + cb(JsonError(drogon::k401Unauthorized, auth_error)); + return; + } + + const auto json = req->getJsonObject(); + if (!json) { + cb(JsonError(drogon::k400BadRequest, "body must be json")); + return; + } + + const std::string language = (*json).get("language", "cpp").asString(); + const std::string code = (*json).get("code", "").asString(); + const std::string stdin_text = (*json).get("stdin", "").asString(); + + services::ProblemWorkspaceService svc(csp::AppState::Instance().db()); + if (!svc.ProblemExists(problem_id)) { + cb(JsonError(drogon::k404NotFound, "problem not found")); + return; + } + svc.SaveDraft(*user_id, problem_id, language, code, stdin_text); + + Json::Value payload; + payload["saved"] = true; + cb(JsonOk(payload)); + } catch (const std::runtime_error& e) { + cb(JsonError(drogon::k400BadRequest, e.what())); + } catch (const std::exception& e) { + cb(JsonError(drogon::k500InternalServerError, e.what())); + } +} + +void ProblemController::listSolutions( + const drogon::HttpRequestPtr& /*req*/, + std::function&& cb, + int64_t problem_id) { + try { + services::ProblemWorkspaceService svc(csp::AppState::Instance().db()); + if (!svc.ProblemExists(problem_id)) { + cb(JsonError(drogon::k404NotFound, "problem not found")); + return; + } + + const auto rows = svc.ListSolutions(problem_id); + const auto latest_job = svc.GetLatestSolutionJob(problem_id); + + Json::Value arr(Json::arrayValue); + for (const auto& item : rows) { + Json::Value j; + j["id"] = Json::Int64(item.id); + j["problem_id"] = Json::Int64(item.problem_id); + j["variant"] = item.variant; + j["title"] = item.title; + j["idea_md"] = item.idea_md; + j["explanation_md"] = item.explanation_md; + j["code_cpp"] = item.code_cpp; + j["complexity"] = item.complexity; + j["tags_json"] = item.tags_json; + j["source"] = item.source; + j["created_at"] = Json::Int64(item.created_at); + j["updated_at"] = Json::Int64(item.updated_at); + arr.append(j); + } + + Json::Value payload; + payload["items"] = arr; + payload["runner_running"] = + services::ProblemSolutionRunner::Instance().IsRunning(problem_id); + if (latest_job.has_value()) { + Json::Value j; + j["id"] = Json::Int64(latest_job->id); + j["problem_id"] = Json::Int64(latest_job->problem_id); + j["status"] = latest_job->status; + j["progress"] = latest_job->progress; + j["message"] = latest_job->message; + j["created_at"] = Json::Int64(latest_job->created_at); + if (latest_job->started_at.has_value()) { + j["started_at"] = Json::Int64(*latest_job->started_at); + } else { + j["started_at"] = Json::nullValue; + } + if (latest_job->finished_at.has_value()) { + j["finished_at"] = Json::Int64(*latest_job->finished_at); + } else { + j["finished_at"] = Json::nullValue; + } + payload["latest_job"] = j; + } else { + payload["latest_job"] = Json::nullValue; + } + cb(JsonOk(payload)); + } catch (const std::exception& e) { + cb(JsonError(drogon::k500InternalServerError, e.what())); + } +} + +void ProblemController::generateSolutions( + const drogon::HttpRequestPtr& req, + std::function&& cb, + int64_t problem_id) { + try { + std::string auth_error; + const auto user_id = GetAuthedUserId(req, auth_error); + if (!user_id.has_value()) { + cb(JsonError(drogon::k401Unauthorized, auth_error)); + return; + } + + int max_solutions = 3; + const auto json = req->getJsonObject(); + if (json && (*json).isMember("max_solutions")) { + max_solutions = std::max(1, std::min(5, (*json)["max_solutions"].asInt())); + } + + services::ProblemWorkspaceService svc(csp::AppState::Instance().db()); + if (!svc.ProblemExists(problem_id)) { + cb(JsonError(drogon::k404NotFound, "problem not found")); + return; + } + + const int64_t job_id = svc.CreateSolutionJob(problem_id, *user_id, max_solutions); + const bool started = services::ProblemSolutionRunner::Instance().TriggerAsync( + problem_id, job_id, max_solutions); + if (!started) { + cb(JsonError(drogon::k409Conflict, "solution generation is already running")); + return; + } + + Json::Value payload; + payload["started"] = true; + payload["job_id"] = Json::Int64(job_id); + cb(JsonOk(payload)); + } catch (const std::exception& e) { + cb(JsonError(drogon::k500InternalServerError, e.what())); + } +} + +} // namespace csp::controllers diff --git a/backend/src/controllers/problem_gen_controller.cc b/backend/src/controllers/problem_gen_controller.cc new file mode 100644 index 0000000..7fc2d0d --- /dev/null +++ b/backend/src/controllers/problem_gen_controller.cc @@ -0,0 +1,80 @@ +#include "csp/controllers/problem_gen_controller.h" + +#include "csp/services/problem_gen_runner.h" + +#include +#include + +namespace csp::controllers { + +namespace { + +drogon::HttpResponsePtr JsonError(drogon::HttpStatusCode code, + const std::string& msg) { + Json::Value j; + j["ok"] = false; + j["error"] = msg; + auto resp = drogon::HttpResponse::newHttpJsonResponse(j); + resp->setStatusCode(code); + return resp; +} + +drogon::HttpResponsePtr JsonOk(const Json::Value& data) { + Json::Value j; + j["ok"] = true; + j["data"] = data; + auto resp = drogon::HttpResponse::newHttpJsonResponse(j); + resp->setStatusCode(drogon::k200OK); + return resp; +} + +} // namespace + +void ProblemGenController::status( + const drogon::HttpRequestPtr& /*req*/, + std::function&& cb) { + try { + auto& runner = services::ProblemGenRunner::Instance(); + Json::Value payload; + payload["running"] = runner.IsRunning(); + payload["last_command"] = runner.LastCommand(); + if (runner.LastExitCode().has_value()) { + payload["last_exit_code"] = *runner.LastExitCode(); + } else { + payload["last_exit_code"] = Json::nullValue; + } + payload["last_started_at"] = Json::Int64(runner.LastStartedAt()); + payload["last_finished_at"] = Json::Int64(runner.LastFinishedAt()); + cb(JsonOk(payload)); + } catch (const std::exception& e) { + cb(JsonError(drogon::k500InternalServerError, e.what())); + } +} + +void ProblemGenController::run( + const drogon::HttpRequestPtr& req, + std::function&& cb) { + try { + int count = 1; + const auto json = req->getJsonObject(); + if (json && (*json).isMember("count")) { + count = (*json)["count"].asInt(); + } + + auto& runner = services::ProblemGenRunner::Instance(); + const bool started = runner.TriggerAsync("manual", count); + if (!started) { + cb(JsonError(drogon::k409Conflict, "problem generation job already running")); + return; + } + + Json::Value payload; + payload["started"] = true; + payload["count"] = count; + cb(JsonOk(payload)); + } catch (const std::exception& e) { + cb(JsonError(drogon::k500InternalServerError, e.what())); + } +} + +} // namespace csp::controllers diff --git a/backend/src/controllers/submission_controller.cc b/backend/src/controllers/submission_controller.cc new file mode 100644 index 0000000..d058d20 --- /dev/null +++ b/backend/src/controllers/submission_controller.cc @@ -0,0 +1,192 @@ +#include "csp/controllers/submission_controller.h" + +#include "csp/app_state.h" +#include "csp/domain/enum_strings.h" +#include "csp/domain/json.h" +#include "csp/services/contest_service.h" +#include "csp/services/submission_service.h" +#include "http_auth.h" + +#include +#include +#include +#include + +namespace csp::controllers { + +namespace { + +drogon::HttpResponsePtr JsonError(drogon::HttpStatusCode code, + const std::string& msg) { + Json::Value j; + j["ok"] = false; + j["error"] = msg; + auto resp = drogon::HttpResponse::newHttpJsonResponse(j); + resp->setStatusCode(code); + return resp; +} + +drogon::HttpResponsePtr JsonOk(const Json::Value& data) { + Json::Value j; + j["ok"] = true; + j["data"] = data; + auto resp = drogon::HttpResponse::newHttpJsonResponse(j); + resp->setStatusCode(drogon::k200OK); + return resp; +} + +int ParseClampedInt(const std::string& s, + int default_value, + int min_value, + int max_value) { + if (s.empty()) return default_value; + const int v = std::stoi(s); + return std::max(min_value, std::min(max_value, v)); +} + +std::optional ParseOptionalInt64(const std::string& s) { + if (s.empty()) return std::nullopt; + return std::stoll(s); +} + +} // namespace + +void SubmissionController::submitProblem( + const drogon::HttpRequestPtr& req, + std::function&& cb, + int64_t problem_id) { + try { + std::string auth_error; + const auto user_id = GetAuthedUserId(req, auth_error); + if (!user_id.has_value()) { + cb(JsonError(drogon::k401Unauthorized, auth_error)); + return; + } + + const auto json = req->getJsonObject(); + if (!json) { + cb(JsonError(drogon::k400BadRequest, "body must be json")); + return; + } + + services::SubmissionCreateRequest create; + create.user_id = *user_id; + create.problem_id = problem_id; + create.language = (*json).get("language", "cpp").asString(); + create.code = (*json).get("code", "").asString(); + + if ((*json).isMember("contest_id") && !(*json)["contest_id"].isNull()) { + create.contest_id = (*json)["contest_id"].asInt64(); + services::ContestService contest(csp::AppState::Instance().db()); + + if (!contest.GetContest(*create.contest_id).has_value()) { + cb(JsonError(drogon::k400BadRequest, "contest not found")); + return; + } + if (!contest.ContainsProblem(*create.contest_id, problem_id)) { + cb(JsonError(drogon::k400BadRequest, "problem not in contest")); + return; + } + if (!contest.IsRegistered(*create.contest_id, *user_id)) { + cb(JsonError(drogon::k403Forbidden, "user is not registered for contest")); + return; + } + if (!contest.IsRunning(*create.contest_id)) { + cb(JsonError(drogon::k403Forbidden, "contest is not running")); + return; + } + } + + services::SubmissionService svc(csp::AppState::Instance().db()); + auto s = svc.CreateAndJudge(create); + cb(JsonOk(domain::ToJson(s))); + } catch (const std::invalid_argument&) { + cb(JsonError(drogon::k400BadRequest, "invalid numeric field")); + } catch (const std::out_of_range&) { + cb(JsonError(drogon::k400BadRequest, "numeric field out of range")); + } catch (const std::runtime_error& e) { + cb(JsonError(drogon::k400BadRequest, e.what())); + } catch (const std::exception& e) { + cb(JsonError(drogon::k500InternalServerError, e.what())); + } +} + +void SubmissionController::listSubmissions( + const drogon::HttpRequestPtr& req, + std::function&& cb) { + try { + const auto user_id = ParseOptionalInt64(req->getParameter("user_id")); + const auto problem_id = ParseOptionalInt64(req->getParameter("problem_id")); + const auto contest_id = ParseOptionalInt64(req->getParameter("contest_id")); + const int page = ParseClampedInt(req->getParameter("page"), 1, 1, 100000); + const int page_size = + ParseClampedInt(req->getParameter("page_size"), 20, 1, 200); + + services::SubmissionService svc(csp::AppState::Instance().db()); + const auto rows = svc.List(user_id, problem_id, contest_id, page, page_size); + + Json::Value arr(Json::arrayValue); + for (const auto& s : rows) arr.append(domain::ToJson(s)); + + Json::Value payload; + payload["items"] = arr; + payload["page"] = page; + payload["page_size"] = page_size; + cb(JsonOk(payload)); + } catch (const std::invalid_argument&) { + cb(JsonError(drogon::k400BadRequest, "invalid query parameter")); + } catch (const std::out_of_range&) { + cb(JsonError(drogon::k400BadRequest, "query parameter out of range")); + } catch (const std::exception& e) { + cb(JsonError(drogon::k500InternalServerError, e.what())); + } +} + +void SubmissionController::getSubmission( + const drogon::HttpRequestPtr& /*req*/, + std::function&& cb, + int64_t submission_id) { + try { + services::SubmissionService svc(csp::AppState::Instance().db()); + const auto s = svc.GetById(submission_id); + if (!s.has_value()) { + cb(JsonError(drogon::k404NotFound, "submission not found")); + return; + } + cb(JsonOk(domain::ToJson(*s))); + } catch (const std::exception& e) { + cb(JsonError(drogon::k500InternalServerError, e.what())); + } +} + +void SubmissionController::runCpp( + const drogon::HttpRequestPtr& req, + std::function&& cb) { + try { + const auto json = req->getJsonObject(); + if (!json) { + cb(JsonError(drogon::k400BadRequest, "body must be json")); + return; + } + + const std::string code = (*json).get("code", "").asString(); + const std::string input = (*json).get("input", "").asString(); + + services::SubmissionService svc(csp::AppState::Instance().db()); + const auto r = svc.RunOnlyCpp(code, input); + + Json::Value payload; + payload["status"] = domain::ToString(r.status); + payload["time_ms"] = r.time_ms; + payload["stdout"] = r.stdout_text; + payload["stderr"] = r.stderr_text; + payload["compile_log"] = r.compile_log; + cb(JsonOk(payload)); + } catch (const std::runtime_error& e) { + cb(JsonError(drogon::k400BadRequest, e.what())); + } catch (const std::exception& e) { + cb(JsonError(drogon::k500InternalServerError, e.what())); + } +} + +} // namespace csp::controllers diff --git a/backend/src/db/sqlite_db.cc b/backend/src/db/sqlite_db.cc index 3a320aa..f4a9414 100644 --- a/backend/src/db/sqlite_db.cc +++ b/backend/src/db/sqlite_db.cc @@ -1,6 +1,9 @@ #include "csp/db/sqlite_db.h" +#include +#include #include +#include #include namespace csp::db { @@ -8,11 +11,203 @@ namespace csp::db { namespace { void ThrowSqlite(int rc, sqlite3* db, const char* what) { - if (rc == SQLITE_OK) return; + if (rc == SQLITE_OK || rc == SQLITE_DONE || rc == SQLITE_ROW) return; const char* msg = db ? sqlite3_errmsg(db) : ""; throw std::runtime_error(std::string(what) + ": " + msg); } +int64_t NowSec() { + using namespace std::chrono; + return duration_cast(system_clock::now().time_since_epoch()).count(); +} + +bool ColumnExists(sqlite3* db, const char* table, const char* col) { + sqlite3_stmt* stmt = nullptr; + const std::string sql = std::string("PRAGMA table_info(") + table + ")"; + const int rc = sqlite3_prepare_v2(db, sql.c_str(), -1, &stmt, nullptr); + if (rc != SQLITE_OK) { + if (stmt) sqlite3_finalize(stmt); + return false; + } + + bool found = false; + while (sqlite3_step(stmt) == SQLITE_ROW) { + const unsigned char* name = sqlite3_column_text(stmt, 1); + if (name && std::string(reinterpret_cast(name)) == col) { + found = true; + break; + } + } + sqlite3_finalize(stmt); + return found; +} + +void EnsureColumn(SqliteDb& db, + const char* table, + const char* col_name, + const char* col_def) { + if (ColumnExists(db.raw(), table, col_name)) return; + db.Exec(std::string("ALTER TABLE ") + table + " ADD COLUMN " + col_def + ";"); +} + +int CountRows(sqlite3* db, const char* table) { + sqlite3_stmt* stmt = nullptr; + const std::string sql = std::string("SELECT COUNT(1) FROM ") + table; + int rc = sqlite3_prepare_v2(db, sql.c_str(), -1, &stmt, nullptr); + if (rc != SQLITE_OK) { + if (stmt) sqlite3_finalize(stmt); + return 0; + } + + rc = sqlite3_step(stmt); + int count = 0; + if (rc == SQLITE_ROW) count = sqlite3_column_int(stmt, 0); + sqlite3_finalize(stmt); + return count; +} + +std::optional QueryOneId(sqlite3* db, const std::string& sql) { + sqlite3_stmt* stmt = nullptr; + const int rc = sqlite3_prepare_v2(db, sql.c_str(), -1, &stmt, nullptr); + if (rc != SQLITE_OK) { + if (stmt) sqlite3_finalize(stmt); + return std::nullopt; + } + if (sqlite3_step(stmt) != SQLITE_ROW) { + sqlite3_finalize(stmt); + return std::nullopt; + } + const auto id = sqlite3_column_int64(stmt, 0); + sqlite3_finalize(stmt); + return id; +} + +void InsertProblem(sqlite3* db, + const std::string& slug, + const std::string& title, + const std::string& statement, + int difficulty, + const std::string& source, + const std::string& sample_in, + const std::string& sample_out, + int64_t created_at) { + sqlite3_stmt* stmt = nullptr; + const char* sql = + "INSERT INTO problems(slug,title,statement_md,difficulty,source,sample_input,sample_output,created_at) " + "VALUES(?,?,?,?,?,?,?,?)"; + ThrowSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare insert problem"); + ThrowSqlite(sqlite3_bind_text(stmt, 1, slug.c_str(), -1, SQLITE_TRANSIENT), db, + "bind problem.slug"); + ThrowSqlite(sqlite3_bind_text(stmt, 2, title.c_str(), -1, SQLITE_TRANSIENT), db, + "bind problem.title"); + ThrowSqlite(sqlite3_bind_text(stmt, 3, statement.c_str(), -1, SQLITE_TRANSIENT), db, + "bind problem.statement"); + ThrowSqlite(sqlite3_bind_int(stmt, 4, difficulty), db, + "bind problem.difficulty"); + ThrowSqlite(sqlite3_bind_text(stmt, 5, source.c_str(), -1, SQLITE_TRANSIENT), db, + "bind problem.source"); + ThrowSqlite(sqlite3_bind_text(stmt, 6, sample_in.c_str(), -1, SQLITE_TRANSIENT), db, + "bind problem.sample_input"); + ThrowSqlite(sqlite3_bind_text(stmt, 7, sample_out.c_str(), -1, SQLITE_TRANSIENT), db, + "bind problem.sample_output"); + ThrowSqlite(sqlite3_bind_int64(stmt, 8, created_at), db, + "bind problem.created_at"); + ThrowSqlite(sqlite3_step(stmt), db, "insert problem"); + sqlite3_finalize(stmt); +} + +void InsertProblemTag(sqlite3* db, int64_t problem_id, const std::string& tag) { + sqlite3_stmt* stmt = nullptr; + const char* sql = + "INSERT OR IGNORE INTO problem_tags(problem_id,tag) VALUES(?,?)"; + ThrowSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare insert problem_tag"); + ThrowSqlite(sqlite3_bind_int64(stmt, 1, problem_id), db, + "bind problem_tag.problem_id"); + ThrowSqlite(sqlite3_bind_text(stmt, 2, tag.c_str(), -1, SQLITE_TRANSIENT), db, + "bind problem_tag.tag"); + ThrowSqlite(sqlite3_step(stmt), db, "insert problem_tag"); + sqlite3_finalize(stmt); +} + +void InsertKbArticle(sqlite3* db, + const std::string& slug, + const std::string& title, + const std::string& content_md, + int64_t created_at) { + sqlite3_stmt* stmt = nullptr; + const char* sql = + "INSERT INTO kb_articles(slug,title,content_md,created_at) VALUES(?,?,?,?)"; + ThrowSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare insert kb_article"); + ThrowSqlite(sqlite3_bind_text(stmt, 1, slug.c_str(), -1, SQLITE_TRANSIENT), db, + "bind kb_article.slug"); + ThrowSqlite(sqlite3_bind_text(stmt, 2, title.c_str(), -1, SQLITE_TRANSIENT), db, + "bind kb_article.title"); + ThrowSqlite(sqlite3_bind_text(stmt, 3, content_md.c_str(), -1, SQLITE_TRANSIENT), db, + "bind kb_article.content"); + ThrowSqlite(sqlite3_bind_int64(stmt, 4, created_at), db, + "bind kb_article.created_at"); + ThrowSqlite(sqlite3_step(stmt), db, "insert kb_article"); + sqlite3_finalize(stmt); +} + +void InsertKbLink(sqlite3* db, int64_t article_id, int64_t problem_id) { + sqlite3_stmt* stmt = nullptr; + const char* sql = + "INSERT OR IGNORE INTO kb_article_links(article_id,problem_id) VALUES(?,?)"; + ThrowSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare insert kb_article_link"); + ThrowSqlite(sqlite3_bind_int64(stmt, 1, article_id), db, + "bind kb_article_link.article_id"); + ThrowSqlite(sqlite3_bind_int64(stmt, 2, problem_id), db, + "bind kb_article_link.problem_id"); + ThrowSqlite(sqlite3_step(stmt), db, "insert kb_article_link"); + sqlite3_finalize(stmt); +} + +void InsertContest(sqlite3* db, + const std::string& title, + int64_t starts_at, + int64_t ends_at, + const std::string& rule_json) { + sqlite3_stmt* stmt = nullptr; + const char* sql = + "INSERT INTO contests(title,starts_at,ends_at,rule_json) VALUES(?,?,?,?)"; + ThrowSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare insert contest"); + ThrowSqlite(sqlite3_bind_text(stmt, 1, title.c_str(), -1, SQLITE_TRANSIENT), db, + "bind contest.title"); + ThrowSqlite(sqlite3_bind_int64(stmt, 2, starts_at), db, + "bind contest.starts_at"); + ThrowSqlite(sqlite3_bind_int64(stmt, 3, ends_at), db, + "bind contest.ends_at"); + ThrowSqlite(sqlite3_bind_text(stmt, 4, rule_json.c_str(), -1, SQLITE_TRANSIENT), db, + "bind contest.rule_json"); + ThrowSqlite(sqlite3_step(stmt), db, "insert contest"); + sqlite3_finalize(stmt); +} + +void InsertContestProblem(sqlite3* db, + int64_t contest_id, + int64_t problem_id, + int idx) { + sqlite3_stmt* stmt = nullptr; + const char* sql = + "INSERT OR IGNORE INTO contest_problems(contest_id,problem_id,idx) VALUES(?,?,?)"; + ThrowSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare insert contest_problem"); + ThrowSqlite(sqlite3_bind_int64(stmt, 1, contest_id), db, + "bind contest_problem.contest_id"); + ThrowSqlite(sqlite3_bind_int64(stmt, 2, problem_id), db, + "bind contest_problem.problem_id"); + ThrowSqlite(sqlite3_bind_int(stmt, 3, idx), db, + "bind contest_problem.idx"); + ThrowSqlite(sqlite3_step(stmt), db, "insert contest_problem"); + sqlite3_finalize(stmt); +} + } // namespace SqliteDb SqliteDb::OpenFile(const std::string& path) { @@ -60,11 +255,9 @@ void SqliteDb::Exec(const std::string& sql) { } void ApplyMigrations(SqliteDb& db) { - // Keep it simple for MVP: apply the bundled init SQL. - // In later iterations we'll add a migrations table + incremental runner. + // Keep it simple for MVP: create missing tables, then patch missing columns. db.Exec("PRAGMA foreign_keys = ON;"); - // 001_init.sql (embedded) db.Exec(R"SQL( CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -90,6 +283,10 @@ CREATE TABLE IF NOT EXISTS problems ( statement_md TEXT NOT NULL, difficulty INTEGER NOT NULL DEFAULT 1, source TEXT NOT NULL DEFAULT "", + statement_url TEXT NOT NULL DEFAULT "", + llm_profile_json TEXT NOT NULL DEFAULT "{}", + sample_input TEXT NOT NULL DEFAULT "", + sample_output TEXT NOT NULL DEFAULT "", created_at INTEGER NOT NULL ); @@ -104,15 +301,19 @@ CREATE TABLE IF NOT EXISTS submissions ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, problem_id INTEGER NOT NULL, + contest_id INTEGER, language TEXT NOT NULL, code TEXT NOT NULL, status TEXT NOT NULL, score INTEGER NOT NULL DEFAULT 0, time_ms INTEGER NOT NULL DEFAULT 0, memory_kb INTEGER NOT NULL DEFAULT 0, + compile_log TEXT NOT NULL DEFAULT "", + runtime_log TEXT NOT NULL DEFAULT "", created_at INTEGER NOT NULL, FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, - FOREIGN KEY(problem_id) REFERENCES problems(id) ON DELETE CASCADE + FOREIGN KEY(problem_id) REFERENCES problems(id) ON DELETE CASCADE, + FOREIGN KEY(contest_id) REFERENCES contests(id) ON DELETE SET NULL ); CREATE TABLE IF NOT EXISTS wrong_book ( @@ -127,9 +328,280 @@ CREATE TABLE IF NOT EXISTS wrong_book ( FOREIGN KEY(last_submission_id) REFERENCES submissions(id) ON DELETE SET NULL ); +CREATE TABLE IF NOT EXISTS contests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + starts_at INTEGER NOT NULL, + ends_at INTEGER NOT NULL, + rule_json TEXT NOT NULL DEFAULT "{}" +); + +CREATE TABLE IF NOT EXISTS contest_problems ( + contest_id INTEGER NOT NULL, + problem_id INTEGER NOT NULL, + idx INTEGER NOT NULL, + PRIMARY KEY(contest_id, problem_id), + FOREIGN KEY(contest_id) REFERENCES contests(id) ON DELETE CASCADE, + FOREIGN KEY(problem_id) REFERENCES problems(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS contest_registrations ( + contest_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + registered_at INTEGER NOT NULL, + PRIMARY KEY(contest_id, user_id), + FOREIGN KEY(contest_id) REFERENCES contests(id) ON DELETE CASCADE, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS kb_articles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + slug TEXT NOT NULL UNIQUE, + title TEXT NOT NULL, + content_md TEXT NOT NULL, + created_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS kb_article_links ( + article_id INTEGER NOT NULL, + problem_id INTEGER NOT NULL, + PRIMARY KEY(article_id, problem_id), + FOREIGN KEY(article_id) REFERENCES kb_articles(id) ON DELETE CASCADE, + FOREIGN KEY(problem_id) REFERENCES problems(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS import_jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + status TEXT NOT NULL, + trigger TEXT NOT NULL DEFAULT "manual", + total_count INTEGER NOT NULL DEFAULT 0, + processed_count INTEGER NOT NULL DEFAULT 0, + success_count INTEGER NOT NULL DEFAULT 0, + failed_count INTEGER NOT NULL DEFAULT 0, + options_json TEXT NOT NULL DEFAULT "{}", + last_error TEXT NOT NULL DEFAULT "", + started_at INTEGER NOT NULL, + finished_at INTEGER, + updated_at INTEGER NOT NULL, + created_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS import_job_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + job_id INTEGER NOT NULL, + source_path TEXT NOT NULL, + status TEXT NOT NULL DEFAULT "queued", + title TEXT NOT NULL DEFAULT "", + difficulty INTEGER NOT NULL DEFAULT 0, + problem_id INTEGER, + error_text TEXT NOT NULL DEFAULT "", + started_at INTEGER, + finished_at INTEGER, + updated_at INTEGER NOT NULL, + created_at INTEGER NOT NULL, + FOREIGN KEY(job_id) REFERENCES import_jobs(id) ON DELETE CASCADE, + UNIQUE(job_id, source_path) +); + +CREATE TABLE IF NOT EXISTS problem_drafts ( + user_id INTEGER NOT NULL, + problem_id INTEGER NOT NULL, + language TEXT NOT NULL DEFAULT "cpp", + code TEXT NOT NULL DEFAULT "", + stdin TEXT NOT NULL DEFAULT "", + updated_at INTEGER NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY(user_id, problem_id), + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY(problem_id) REFERENCES problems(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS problem_solution_jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + problem_id INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT "queued", + progress INTEGER NOT NULL DEFAULT 0, + message TEXT NOT NULL DEFAULT "", + created_by INTEGER NOT NULL DEFAULT 0, + max_solutions INTEGER NOT NULL DEFAULT 3, + created_at INTEGER NOT NULL, + started_at INTEGER, + finished_at INTEGER, + updated_at INTEGER NOT NULL, + FOREIGN KEY(problem_id) REFERENCES problems(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS problem_solutions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + problem_id INTEGER NOT NULL, + variant INTEGER NOT NULL DEFAULT 1, + title TEXT NOT NULL DEFAULT "", + idea_md TEXT NOT NULL DEFAULT "", + explanation_md TEXT NOT NULL DEFAULT "", + code_cpp TEXT NOT NULL DEFAULT "", + complexity TEXT NOT NULL DEFAULT "", + tags_json TEXT NOT NULL DEFAULT "[]", + source TEXT NOT NULL DEFAULT "llm", + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY(problem_id) REFERENCES problems(id) ON DELETE CASCADE +); + )SQL"); + + // Backward-compatible schema upgrades for existing deployments. + EnsureColumn(db, "problems", "sample_input", + "sample_input TEXT NOT NULL DEFAULT ''"); + EnsureColumn(db, "problems", "sample_output", + "sample_output TEXT NOT NULL DEFAULT ''"); + EnsureColumn(db, "problems", "statement_url", + "statement_url TEXT NOT NULL DEFAULT ''"); + EnsureColumn(db, "problems", "llm_profile_json", + "llm_profile_json TEXT NOT NULL DEFAULT '{}'"); + EnsureColumn(db, "import_jobs", "trigger", + "trigger TEXT NOT NULL DEFAULT 'manual'"); + EnsureColumn(db, "import_jobs", "options_json", + "options_json TEXT NOT NULL DEFAULT '{}'"); + EnsureColumn(db, "import_jobs", "last_error", + "last_error TEXT NOT NULL DEFAULT ''"); + EnsureColumn(db, "submissions", "contest_id", "contest_id INTEGER"); + EnsureColumn(db, "submissions", "compile_log", + "compile_log TEXT NOT NULL DEFAULT ''"); + EnsureColumn(db, "submissions", "runtime_log", + "runtime_log TEXT NOT NULL DEFAULT ''"); + EnsureColumn(db, "problem_drafts", "stdin", "stdin TEXT NOT NULL DEFAULT ''"); + EnsureColumn(db, "problem_solution_jobs", "max_solutions", + "max_solutions INTEGER NOT NULL DEFAULT 3"); + EnsureColumn(db, "problem_solutions", "variant", "variant INTEGER NOT NULL DEFAULT 1"); + EnsureColumn(db, "problem_solutions", "idea_md", "idea_md TEXT NOT NULL DEFAULT ''"); + EnsureColumn(db, "problem_solutions", "explanation_md", + "explanation_md TEXT NOT NULL DEFAULT ''"); + EnsureColumn(db, "problem_solutions", "code_cpp", "code_cpp TEXT NOT NULL DEFAULT ''"); + EnsureColumn(db, "problem_solutions", "complexity", + "complexity TEXT NOT NULL DEFAULT ''"); + EnsureColumn(db, "problem_solutions", "tags_json", "tags_json TEXT NOT NULL DEFAULT '[]'"); + + // Build indexes after compatibility ALTERs so old schemas won't fail on + // missing columns (e.g. legacy submissions table without contest_id). + db.Exec(R"SQL( CREATE INDEX IF NOT EXISTS idx_submissions_user_created_at ON submissions(user_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_submissions_problem_created_at ON submissions(problem_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_submissions_contest_user_created_at ON submissions(contest_id, user_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_problem_tags_tag ON problem_tags(tag); +CREATE INDEX IF NOT EXISTS idx_kb_article_links_problem_id ON kb_article_links(problem_id); +CREATE INDEX IF NOT EXISTS idx_import_jobs_created_at ON import_jobs(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_import_jobs_status ON import_jobs(status, updated_at DESC); +CREATE INDEX IF NOT EXISTS idx_import_job_items_job_status ON import_job_items(job_id, status, updated_at DESC); +CREATE INDEX IF NOT EXISTS idx_problem_drafts_updated ON problem_drafts(updated_at DESC); +CREATE INDEX IF NOT EXISTS idx_problem_solution_jobs_problem ON problem_solution_jobs(problem_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_problem_solutions_problem ON problem_solutions(problem_id, variant, id); )SQL"); } +void SeedDemoData(SqliteDb& db) { + sqlite3* raw = db.raw(); + const int64_t now = NowSec(); + + if (CountRows(raw, "problems") == 0) { + InsertProblem( + raw, + "a-plus-b", + "A + B", + "给定两个整数 A 与 B,输出它们的和。", + 1, + "CSP-入门", + "1 2\n", + "3\n", + now); + + InsertProblem( + raw, + "fibonacci-n", + "Fibonacci 第 n 项", + "输入 n (0<=n<=40),输出第 n 项 Fibonacci 数。", + 2, + "CSP-基础", + "10\n", + "55\n", + now); + + InsertProblem( + raw, + "sort-numbers", + "整数排序", + "输入 n 和 n 个整数,按升序输出。", + 2, + "CSP-基础", + "5\n5 1 4 2 3\n", + "1 2 3 4 5\n", + now); + } + + if (CountRows(raw, "problem_tags") == 0) { + const auto p1 = + QueryOneId(raw, "SELECT id FROM problems WHERE slug='a-plus-b'"); + const auto p2 = + QueryOneId(raw, "SELECT id FROM problems WHERE slug='fibonacci-n'"); + const auto p3 = + QueryOneId(raw, "SELECT id FROM problems WHERE slug='sort-numbers'"); + if (p1) { + InsertProblemTag(raw, *p1, "math"); + InsertProblemTag(raw, *p1, "implementation"); + } + if (p2) { + InsertProblemTag(raw, *p2, "dp"); + InsertProblemTag(raw, *p2, "recursion"); + } + if (p3) { + InsertProblemTag(raw, *p3, "sort"); + InsertProblemTag(raw, *p3, "array"); + } + } + + if (CountRows(raw, "kb_articles") == 0) { + InsertKbArticle( + raw, + "cpp-fast-io", + "C++ 快速输入输出", + "# C++ 快速输入输出\n\n在 OI/CSP 中,建议关闭同步并解绑 cin/cout:\n\n```cpp\nstd::ios::sync_with_stdio(false);\nstd::cin.tie(nullptr);\n```\n", + now); + + InsertKbArticle( + raw, + "intro-dp", + "动态规划入门", + "# 动态规划入门\n\n动态规划的核心是:**状态定义**、**状态转移**、**边界条件**。\n", + now); + } + + if (CountRows(raw, "kb_article_links") == 0) { + const auto p1 = + QueryOneId(raw, "SELECT id FROM problems WHERE slug='a-plus-b'"); + const auto p2 = + QueryOneId(raw, "SELECT id FROM problems WHERE slug='fibonacci-n'"); + const auto a1 = + QueryOneId(raw, "SELECT id FROM kb_articles WHERE slug='cpp-fast-io'"); + const auto a2 = + QueryOneId(raw, "SELECT id FROM kb_articles WHERE slug='intro-dp'"); + if (a1 && p1) InsertKbLink(raw, *a1, *p1); + if (a2 && p2) InsertKbLink(raw, *a2, *p2); + } + + if (CountRows(raw, "contests") == 0) { + InsertContest( + raw, + "CSP 模拟赛(示例)", + now - 3600, + now + 7 * 24 * 3600, + R"({"type":"acm","desc":"按通过题数与罚时排名"})"); + } + + if (CountRows(raw, "contest_problems") == 0) { + const auto contest_id = QueryOneId(raw, "SELECT id FROM contests ORDER BY id LIMIT 1"); + const auto p1 = QueryOneId(raw, "SELECT id FROM problems ORDER BY id LIMIT 1"); + const auto p2 = QueryOneId(raw, "SELECT id FROM problems ORDER BY id LIMIT 1 OFFSET 1"); + if (contest_id && p1) InsertContestProblem(raw, *contest_id, *p1, 1); + if (contest_id && p2) InsertContestProblem(raw, *contest_id, *p2, 2); + } +} + } // namespace csp::db diff --git a/backend/src/domain/json.cc b/backend/src/domain/json.cc index 69e14ac..87a1894 100644 --- a/backend/src/domain/json.cc +++ b/backend/src/domain/json.cc @@ -19,6 +19,10 @@ Json::Value ToJson(const Problem& p) { j["statement_md"] = p.statement_md; j["difficulty"] = p.difficulty; j["source"] = p.source; + j["statement_url"] = p.statement_url; + j["llm_profile_json"] = p.llm_profile_json; + j["sample_input"] = p.sample_input; + j["sample_output"] = p.sample_output; j["created_at"] = Json::Int64(p.created_at); return j; } @@ -28,11 +32,18 @@ Json::Value ToJson(const Submission& s) { j["id"] = Json::Int64(s.id); j["user_id"] = Json::Int64(s.user_id); j["problem_id"] = Json::Int64(s.problem_id); + if (s.contest_id.has_value()) { + j["contest_id"] = Json::Int64(*s.contest_id); + } else { + j["contest_id"] = Json::nullValue; + } j["language"] = ToString(s.language); j["status"] = ToString(s.status); j["score"] = s.score; j["time_ms"] = s.time_ms; j["memory_kb"] = s.memory_kb; + j["compile_log"] = s.compile_log; + j["runtime_log"] = s.runtime_log; j["created_at"] = Json::Int64(s.created_at); return j; } @@ -51,4 +62,42 @@ Json::Value ToJson(const WrongBookItem& w) { return j; } +Json::Value ToJson(const Contest& c) { + Json::Value j; + j["id"] = Json::Int64(c.id); + j["title"] = c.title; + j["starts_at"] = Json::Int64(c.starts_at); + j["ends_at"] = Json::Int64(c.ends_at); + j["rule_json"] = c.rule_json; + return j; +} + +Json::Value ToJson(const KbArticle& a) { + Json::Value j; + j["id"] = Json::Int64(a.id); + j["slug"] = a.slug; + j["title"] = a.title; + j["content_md"] = a.content_md; + j["created_at"] = Json::Int64(a.created_at); + return j; +} + +Json::Value ToJson(const GlobalLeaderboardEntry& e) { + Json::Value j; + j["user_id"] = Json::Int64(e.user_id); + j["username"] = e.username; + j["rating"] = e.rating; + j["created_at"] = Json::Int64(e.created_at); + return j; +} + +Json::Value ToJson(const ContestLeaderboardEntry& e) { + Json::Value j; + j["user_id"] = Json::Int64(e.user_id); + j["username"] = e.username; + j["solved"] = e.solved; + j["penalty_sec"] = Json::Int64(e.penalty_sec); + return j; +} + } // namespace csp::domain diff --git a/backend/src/main.cc b/backend/src/main.cc index 2c95044..21a59ed 100644 --- a/backend/src/main.cc +++ b/backend/src/main.cc @@ -2,6 +2,9 @@ #include "csp/app_state.h" #include "csp/services/auth_service.h" +#include "csp/services/import_runner.h" +#include "csp/services/problem_gen_runner.h" +#include "csp/services/problem_solution_runner.h" #include #include @@ -12,6 +15,9 @@ int main(int argc, char** argv) { if (!parent.empty()) std::filesystem::create_directories(parent); csp::AppState::Instance().Init(db_path); + csp::services::ImportRunner::Instance().Configure(db_path); + csp::services::ProblemSolutionRunner::Instance().Configure(db_path); + csp::services::ProblemGenRunner::Instance().Configure(db_path); // Optional seed admin user for dev/test. { @@ -29,6 +35,11 @@ int main(int argc, char** argv) { } } + // Auto-run PDF -> LLM import workflow on startup unless explicitly disabled. + csp::services::ImportRunner::Instance().AutoStartIfEnabled(); + // Auto-generate one CSP-J new problem (RAG + dedupe) on startup by default. + csp::services::ProblemGenRunner::Instance().AutoStartIfEnabled(); + // CORS (dev-friendly). In production, prefer reverse proxy same-origin. drogon::app().registerPreRoutingAdvice([](const drogon::HttpRequestPtr& req, drogon::AdviceCallback&& cb, diff --git a/backend/src/services/contest_service.cc b/backend/src/services/contest_service.cc new file mode 100644 index 0000000..8e43f5d --- /dev/null +++ b/backend/src/services/contest_service.cc @@ -0,0 +1,210 @@ +#include "csp/services/contest_service.h" + +#include + +#include +#include +#include + +namespace csp::services { + +namespace { + +void CheckSqlite(int rc, sqlite3* db, const char* what) { + if (rc == SQLITE_OK || rc == SQLITE_ROW || rc == SQLITE_DONE) return; + throw std::runtime_error(std::string(what) + ": " + sqlite3_errmsg(db)); +} + +int64_t NowSec() { + using namespace std::chrono; + return duration_cast(system_clock::now().time_since_epoch()).count(); +} + +std::string ColText(sqlite3_stmt* stmt, int col) { + const unsigned char* txt = sqlite3_column_text(stmt, col); + return txt ? reinterpret_cast(txt) : std::string(); +} + +} // namespace + +std::vector ContestService::ListContests() { + sqlite3* db = db_.raw(); + sqlite3_stmt* stmt = nullptr; + const char* sql = + "SELECT id,title,starts_at,ends_at,rule_json FROM contests ORDER BY id ASC"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare list contests"); + + std::vector out; + while (sqlite3_step(stmt) == SQLITE_ROW) { + domain::Contest c; + c.id = sqlite3_column_int64(stmt, 0); + c.title = ColText(stmt, 1); + c.starts_at = sqlite3_column_int64(stmt, 2); + c.ends_at = sqlite3_column_int64(stmt, 3); + c.rule_json = ColText(stmt, 4); + out.push_back(std::move(c)); + } + sqlite3_finalize(stmt); + return out; +} + +std::optional ContestService::GetContest(int64_t contest_id) { + sqlite3* db = db_.raw(); + sqlite3_stmt* stmt = nullptr; + const char* sql = + "SELECT id,title,starts_at,ends_at,rule_json FROM contests WHERE id=?"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare get contest"); + CheckSqlite(sqlite3_bind_int64(stmt, 1, contest_id), db, + "bind contest_id"); + + if (sqlite3_step(stmt) != SQLITE_ROW) { + sqlite3_finalize(stmt); + return std::nullopt; + } + + domain::Contest c; + c.id = sqlite3_column_int64(stmt, 0); + c.title = ColText(stmt, 1); + c.starts_at = sqlite3_column_int64(stmt, 2); + c.ends_at = sqlite3_column_int64(stmt, 3); + c.rule_json = ColText(stmt, 4); + sqlite3_finalize(stmt); + return c; +} + +std::vector ContestService::ListContestProblems(int64_t contest_id) { + sqlite3* db = db_.raw(); + sqlite3_stmt* stmt = nullptr; + const char* sql = + "SELECT p.id,p.slug,p.title,p.statement_md,p.difficulty,p.source,p.statement_url,p.llm_profile_json," + "p.sample_input,p.sample_output,p.created_at " + "FROM contest_problems cp " + "JOIN problems p ON p.id=cp.problem_id " + "WHERE cp.contest_id=? ORDER BY cp.idx ASC"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare list contest problems"); + CheckSqlite(sqlite3_bind_int64(stmt, 1, contest_id), db, + "bind contest_id"); + + std::vector out; + while (sqlite3_step(stmt) == SQLITE_ROW) { + domain::Problem p; + p.id = sqlite3_column_int64(stmt, 0); + p.slug = ColText(stmt, 1); + p.title = ColText(stmt, 2); + p.statement_md = ColText(stmt, 3); + p.difficulty = sqlite3_column_int(stmt, 4); + p.source = ColText(stmt, 5); + p.statement_url = ColText(stmt, 6); + p.llm_profile_json = ColText(stmt, 7); + p.sample_input = ColText(stmt, 8); + p.sample_output = ColText(stmt, 9); + p.created_at = sqlite3_column_int64(stmt, 10); + out.push_back(std::move(p)); + } + sqlite3_finalize(stmt); + return out; +} + +void ContestService::Register(int64_t contest_id, int64_t user_id) { + sqlite3* db = db_.raw(); + sqlite3_stmt* stmt = nullptr; + const char* sql = + "INSERT OR IGNORE INTO contest_registrations(contest_id,user_id,registered_at) VALUES(?,?,?)"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare contest register"); + CheckSqlite(sqlite3_bind_int64(stmt, 1, contest_id), db, + "bind contest_id"); + CheckSqlite(sqlite3_bind_int64(stmt, 2, user_id), db, + "bind user_id"); + CheckSqlite(sqlite3_bind_int64(stmt, 3, NowSec()), db, + "bind registered_at"); + CheckSqlite(sqlite3_step(stmt), db, "contest register"); + sqlite3_finalize(stmt); +} + +bool ContestService::IsRegistered(int64_t contest_id, int64_t user_id) { + sqlite3* db = db_.raw(); + sqlite3_stmt* stmt = nullptr; + const char* sql = + "SELECT 1 FROM contest_registrations WHERE contest_id=? AND user_id=?"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare contest is_registered"); + CheckSqlite(sqlite3_bind_int64(stmt, 1, contest_id), db, + "bind contest_id"); + CheckSqlite(sqlite3_bind_int64(stmt, 2, user_id), db, "bind user_id"); + const bool exists = sqlite3_step(stmt) == SQLITE_ROW; + sqlite3_finalize(stmt); + return exists; +} + +bool ContestService::ContainsProblem(int64_t contest_id, int64_t problem_id) { + sqlite3* db = db_.raw(); + sqlite3_stmt* stmt = nullptr; + const char* sql = + "SELECT 1 FROM contest_problems WHERE contest_id=? AND problem_id=?"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare contest contains_problem"); + CheckSqlite(sqlite3_bind_int64(stmt, 1, contest_id), db, + "bind contest_id"); + CheckSqlite(sqlite3_bind_int64(stmt, 2, problem_id), db, + "bind problem_id"); + const bool exists = sqlite3_step(stmt) == SQLITE_ROW; + sqlite3_finalize(stmt); + return exists; +} + +bool ContestService::IsRunning(int64_t contest_id) { + const auto c = GetContest(contest_id); + if (!c.has_value()) return false; + const auto now = NowSec(); + return now >= c->starts_at && now <= c->ends_at; +} + +std::vector ContestService::Leaderboard(int64_t contest_id) { + sqlite3* db = db_.raw(); + sqlite3_stmt* stmt = nullptr; + + const char* sql = R"SQL( +SELECT + r.user_id, + u.username, + COUNT(a.problem_id) AS solved, + COALESCE(SUM(a.first_ac - c.starts_at), 0) AS penalty_sec +FROM contest_registrations r +JOIN users u ON u.id = r.user_id +JOIN contests c ON c.id = r.contest_id +LEFT JOIN ( + SELECT user_id, problem_id, MIN(created_at) AS first_ac + FROM submissions + WHERE contest_id = ? AND status = 'AC' + GROUP BY user_id, problem_id +) a ON a.user_id = r.user_id +WHERE r.contest_id = ? +GROUP BY r.user_id, u.username, c.starts_at +ORDER BY solved DESC, penalty_sec ASC, r.user_id ASC +)SQL"; + + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare contest leaderboard"); + CheckSqlite(sqlite3_bind_int64(stmt, 1, contest_id), db, + "bind contest_id ac"); + CheckSqlite(sqlite3_bind_int64(stmt, 2, contest_id), db, + "bind contest_id reg"); + + std::vector out; + while (sqlite3_step(stmt) == SQLITE_ROW) { + domain::ContestLeaderboardEntry e; + e.user_id = sqlite3_column_int64(stmt, 0); + e.username = ColText(stmt, 1); + e.solved = sqlite3_column_int(stmt, 2); + e.penalty_sec = sqlite3_column_int64(stmt, 3); + out.push_back(std::move(e)); + } + sqlite3_finalize(stmt); + return out; +} + +} // namespace csp::services diff --git a/backend/src/services/import_runner.cc b/backend/src/services/import_runner.cc new file mode 100644 index 0000000..5b59b35 --- /dev/null +++ b/backend/src/services/import_runner.cc @@ -0,0 +1,183 @@ +#include "csp/services/import_runner.h" + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace csp::services { + +namespace { + +int64_t NowSec() { + using namespace std::chrono; + return duration_cast(system_clock::now().time_since_epoch()).count(); +} + +bool EnvBool(const char* key, bool default_value) { + const char* raw = std::getenv(key); + if (!raw) return default_value; + std::string val(raw); + for (auto& c : val) c = static_cast(::tolower(static_cast(c))); + if (val == "1" || val == "true" || val == "yes" || val == "on") return true; + if (val == "0" || val == "false" || val == "no" || val == "off") return false; + return default_value; +} + +int EnvInt(const char* key, int default_value) { + const char* raw = std::getenv(key); + if (!raw) return default_value; + try { + return std::stoi(raw); + } catch (...) { + return default_value; + } +} + +std::string ShellQuote(const std::string& text) { + std::string out = "'"; + for (char c : text) { + if (c == '\'') { + out += "'\"'\"'"; + } else { + out.push_back(c); + } + } + out.push_back('\''); + return out; +} + +std::string ResolveScriptPath() { + const char* env_path = std::getenv("OI_IMPORT_SCRIPT_PATH"); + if (env_path && std::filesystem::exists(env_path)) return env_path; + + const std::vector candidates = { + "/app/scripts/import_luogu_csp.py", + "scripts/import_luogu_csp.py", + "../scripts/import_luogu_csp.py", + "../../scripts/import_luogu_csp.py", + "/app/scripts/import_winterant_oi.py", + "scripts/import_winterant_oi.py", + "../scripts/import_winterant_oi.py", + "../../scripts/import_winterant_oi.py", + }; + for (const auto& p : candidates) { + if (std::filesystem::exists(p)) return p; + } + return "/app/scripts/import_luogu_csp.py"; +} + +std::string BuildCommand(const std::string& db_path, + const std::string& trigger, + const ImportRunOptions& options) { + const std::string script_path = ResolveScriptPath(); + const int workers = std::max(1, EnvInt("OI_IMPORT_WORKERS", 3)); + const int llm_limit = EnvInt("OI_IMPORT_LLM_LIMIT", 0); + const int max_problems = EnvInt("OI_IMPORT_MAX_PROBLEMS", 0); + const bool skip_llm = EnvBool("OI_IMPORT_SKIP_LLM", false); + const bool clear_existing = EnvBool("OI_IMPORT_CLEAR_EXISTING", true); + const bool clear_all_default = EnvBool("OI_IMPORT_CLEAR_ALL_PROBLEMS", false); + const std::string clear_source_prefix = + std::getenv("OI_IMPORT_CLEAR_SOURCE_PREFIX") + ? std::string(std::getenv("OI_IMPORT_CLEAR_SOURCE_PREFIX")) + : std::string("winterant/oi"); + + std::string cmd = "python3 " + ShellQuote(script_path) + " --db-path " + + ShellQuote(db_path) + " --workers " + + std::to_string(workers) + " --job-trigger " + + ShellQuote(trigger); + + if (max_problems > 0) cmd += " --max-problems " + std::to_string(max_problems); + if (skip_llm) cmd += " --skip-llm"; + if (llm_limit > 0) cmd += " --llm-limit " + std::to_string(llm_limit); + + const bool clear_all = options.clear_all_problems || clear_all_default; + if (clear_all) { + cmd += " --clear-all-problems"; + } else if (clear_existing) { + cmd += " --clear-existing --clear-existing-source-prefix " + + ShellQuote(clear_source_prefix); + } + + return cmd; +} + +} // namespace + +ImportRunner& ImportRunner::Instance() { + static ImportRunner inst; + return inst; +} + +void ImportRunner::Configure(std::string db_path) { + std::lock_guard lock(mu_); + db_path_ = std::move(db_path); +} + +bool ImportRunner::TriggerAsync(const std::string& trigger, + const ImportRunOptions& options) { + std::string cmd; + { + std::lock_guard lock(mu_); + if (running_) return false; + if (db_path_.empty()) return false; + + cmd = BuildCommand(db_path_, trigger, options); + running_ = true; + last_started_at_ = NowSec(); + last_command_ = cmd; + last_exit_code_.reset(); + } + + std::thread([this, command = std::move(cmd)]() { + const int rc = std::system(command.c_str()); + std::lock_guard lock(mu_); + last_exit_code_ = rc; + last_finished_at_ = NowSec(); + running_ = false; + }).detach(); + + return true; +} + +void ImportRunner::AutoStartIfEnabled() { + if (!EnvBool("OI_IMPORT_AUTO_RUN", true)) return; + const bool started = TriggerAsync("auto", ImportRunOptions{}); + if (started) { + LOG_INFO << "import runner auto-started"; + } else { + LOG_INFO << "import runner auto-start skipped"; + } +} + +bool ImportRunner::IsRunning() const { + std::lock_guard lock(mu_); + return running_; +} + +std::string ImportRunner::LastCommand() const { + std::lock_guard lock(mu_); + return last_command_; +} + +std::optional ImportRunner::LastExitCode() const { + std::lock_guard lock(mu_); + return last_exit_code_; +} + +int64_t ImportRunner::LastStartedAt() const { + std::lock_guard lock(mu_); + return last_started_at_; +} + +int64_t ImportRunner::LastFinishedAt() const { + std::lock_guard lock(mu_); + return last_finished_at_; +} + +} // namespace csp::services diff --git a/backend/src/services/import_service.cc b/backend/src/services/import_service.cc new file mode 100644 index 0000000..eb27bc6 --- /dev/null +++ b/backend/src/services/import_service.cc @@ -0,0 +1,138 @@ +#include "csp/services/import_service.h" + +#include + +#include +#include +#include + +namespace csp::services { + +namespace { + +void CheckSqlite(int rc, sqlite3* db, const char* what) { + if (rc == SQLITE_OK || rc == SQLITE_ROW || rc == SQLITE_DONE) return; + throw std::runtime_error(std::string(what) + ": " + sqlite3_errmsg(db)); +} + +std::string ColText(sqlite3_stmt* stmt, int col) { + const unsigned char* txt = sqlite3_column_text(stmt, col); + return txt ? reinterpret_cast(txt) : std::string(); +} + +std::optional ColNullableInt64(sqlite3_stmt* stmt, int col) { + if (sqlite3_column_type(stmt, col) == SQLITE_NULL) return std::nullopt; + return sqlite3_column_int64(stmt, col); +} + +ImportJob ReadJob(sqlite3_stmt* stmt) { + ImportJob j; + j.id = sqlite3_column_int64(stmt, 0); + j.status = ColText(stmt, 1); + j.trigger = ColText(stmt, 2); + j.total_count = sqlite3_column_int(stmt, 3); + j.processed_count = sqlite3_column_int(stmt, 4); + j.success_count = sqlite3_column_int(stmt, 5); + j.failed_count = sqlite3_column_int(stmt, 6); + j.options_json = ColText(stmt, 7); + j.last_error = ColText(stmt, 8); + j.started_at = sqlite3_column_int64(stmt, 9); + j.finished_at = ColNullableInt64(stmt, 10); + j.updated_at = sqlite3_column_int64(stmt, 11); + j.created_at = sqlite3_column_int64(stmt, 12); + return j; +} + +ImportJobItem ReadItem(sqlite3_stmt* stmt) { + ImportJobItem item; + item.id = sqlite3_column_int64(stmt, 0); + item.job_id = sqlite3_column_int64(stmt, 1); + item.source_path = ColText(stmt, 2); + item.status = ColText(stmt, 3); + item.title = ColText(stmt, 4); + item.difficulty = sqlite3_column_int(stmt, 5); + item.problem_id = ColNullableInt64(stmt, 6); + item.error_text = ColText(stmt, 7); + item.started_at = ColNullableInt64(stmt, 8); + item.finished_at = ColNullableInt64(stmt, 9); + item.updated_at = sqlite3_column_int64(stmt, 10); + item.created_at = sqlite3_column_int64(stmt, 11); + return item; +} + +} // namespace + +std::optional ImportService::GetLatestJob() { + sqlite3* db = db_.raw(); + sqlite3_stmt* stmt = nullptr; + const char* sql = + "SELECT id,status,trigger,total_count,processed_count,success_count,failed_count," + "options_json,last_error,started_at,finished_at,updated_at,created_at " + "FROM import_jobs ORDER BY id DESC LIMIT 1"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare get latest import job"); + if (sqlite3_step(stmt) != SQLITE_ROW) { + sqlite3_finalize(stmt); + return std::nullopt; + } + auto row = ReadJob(stmt); + sqlite3_finalize(stmt); + return row; +} + +std::optional ImportService::GetById(int64_t job_id) { + sqlite3* db = db_.raw(); + sqlite3_stmt* stmt = nullptr; + const char* sql = + "SELECT id,status,trigger,total_count,processed_count,success_count,failed_count," + "options_json,last_error,started_at,finished_at,updated_at,created_at " + "FROM import_jobs WHERE id=?"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare get import job by id"); + CheckSqlite(sqlite3_bind_int64(stmt, 1, job_id), db, "bind job_id"); + if (sqlite3_step(stmt) != SQLITE_ROW) { + sqlite3_finalize(stmt); + return std::nullopt; + } + auto row = ReadJob(stmt); + sqlite3_finalize(stmt); + return row; +} + +std::vector ImportService::ListItems(int64_t job_id, + const ImportJobItemQuery& query) { + sqlite3* db = db_.raw(); + sqlite3_stmt* stmt = nullptr; + std::string sql = + "SELECT id,job_id,source_path,status,title,difficulty,problem_id,error_text," + "started_at,finished_at,updated_at,created_at " + "FROM import_job_items WHERE job_id=?"; + if (!query.status.empty()) { + sql += " AND status=?"; + } + sql += " ORDER BY id ASC LIMIT ? OFFSET ?"; + + CheckSqlite(sqlite3_prepare_v2(db, sql.c_str(), -1, &stmt, nullptr), db, + "prepare list import job items"); + int bind_index = 1; + CheckSqlite(sqlite3_bind_int64(stmt, bind_index++, job_id), db, "bind job_id"); + if (!query.status.empty()) { + CheckSqlite( + sqlite3_bind_text(stmt, bind_index++, query.status.c_str(), -1, SQLITE_TRANSIENT), + db, "bind status"); + } + const int page = query.page <= 0 ? 1 : query.page; + const int page_size = std::max(1, std::min(500, query.page_size <= 0 ? 50 : query.page_size)); + const int offset = (page - 1) * page_size; + CheckSqlite(sqlite3_bind_int(stmt, bind_index++, page_size), db, "bind page_size"); + CheckSqlite(sqlite3_bind_int(stmt, bind_index++, offset), db, "bind offset"); + + std::vector rows; + while (sqlite3_step(stmt) == SQLITE_ROW) { + rows.push_back(ReadItem(stmt)); + } + sqlite3_finalize(stmt); + return rows; +} + +} // namespace csp::services diff --git a/backend/src/services/kb_service.cc b/backend/src/services/kb_service.cc new file mode 100644 index 0000000..9b82c64 --- /dev/null +++ b/backend/src/services/kb_service.cc @@ -0,0 +1,87 @@ +#include "csp/services/kb_service.h" + +#include + +#include +#include + +namespace csp::services { + +namespace { + +void CheckSqlite(int rc, sqlite3* db, const char* what) { + if (rc == SQLITE_OK || rc == SQLITE_ROW || rc == SQLITE_DONE) return; + throw std::runtime_error(std::string(what) + ": " + sqlite3_errmsg(db)); +} + +std::string ColText(sqlite3_stmt* stmt, int col) { + const unsigned char* txt = sqlite3_column_text(stmt, col); + return txt ? reinterpret_cast(txt) : std::string(); +} + +} // namespace + +std::vector KbService::ListArticles() { + sqlite3* db = db_.raw(); + sqlite3_stmt* stmt = nullptr; + const char* sql = + "SELECT id,slug,title,content_md,created_at FROM kb_articles ORDER BY id ASC"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare kb list"); + + std::vector out; + while (sqlite3_step(stmt) == SQLITE_ROW) { + domain::KbArticle a; + a.id = sqlite3_column_int64(stmt, 0); + a.slug = ColText(stmt, 1); + a.title = ColText(stmt, 2); + a.content_md = ColText(stmt, 3); + a.created_at = sqlite3_column_int64(stmt, 4); + out.push_back(std::move(a)); + } + sqlite3_finalize(stmt); + return out; +} + +std::optional KbService::GetBySlug(const std::string& slug) { + sqlite3* db = db_.raw(); + + sqlite3_stmt* stmt = nullptr; + const char* sql = + "SELECT id,slug,title,content_md,created_at FROM kb_articles WHERE slug=?"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare kb get"); + CheckSqlite(sqlite3_bind_text(stmt, 1, slug.c_str(), -1, SQLITE_TRANSIENT), db, + "bind kb slug"); + if (sqlite3_step(stmt) != SQLITE_ROW) { + sqlite3_finalize(stmt); + return std::nullopt; + } + + KbArticleDetail detail; + detail.article.id = sqlite3_column_int64(stmt, 0); + detail.article.slug = ColText(stmt, 1); + detail.article.title = ColText(stmt, 2); + detail.article.content_md = ColText(stmt, 3); + detail.article.created_at = sqlite3_column_int64(stmt, 4); + sqlite3_finalize(stmt); + + sqlite3_stmt* link_stmt = nullptr; + const char* link_sql = + "SELECT p.id,p.title FROM kb_article_links l " + "JOIN problems p ON p.id=l.problem_id " + "WHERE l.article_id=? ORDER BY p.id ASC"; + CheckSqlite(sqlite3_prepare_v2(db, link_sql, -1, &link_stmt, nullptr), db, + "prepare kb links"); + CheckSqlite(sqlite3_bind_int64(link_stmt, 1, detail.article.id), db, + "bind article_id"); + while (sqlite3_step(link_stmt) == SQLITE_ROW) { + detail.related_problems.emplace_back(sqlite3_column_int64(link_stmt, 0), + ColText(link_stmt, 1)); + } + sqlite3_finalize(link_stmt); + + return detail; +} + +} // namespace csp::services diff --git a/backend/src/services/problem_gen_runner.cc b/backend/src/services/problem_gen_runner.cc new file mode 100644 index 0000000..3f5c1f6 --- /dev/null +++ b/backend/src/services/problem_gen_runner.cc @@ -0,0 +1,174 @@ +#include "csp/services/problem_gen_runner.h" + +#include + +#include "csp/services/import_runner.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace csp::services { + +namespace { + +int64_t NowSec() { + using namespace std::chrono; + return duration_cast(system_clock::now().time_since_epoch()).count(); +} + +bool EnvBool(const char* key, bool default_value) { + const char* raw = std::getenv(key); + if (!raw) return default_value; + std::string val(raw); + for (auto& c : val) c = static_cast(::tolower(static_cast(c))); + if (val == "1" || val == "true" || val == "yes" || val == "on") return true; + if (val == "0" || val == "false" || val == "no" || val == "off") return false; + return default_value; +} + +int EnvInt(const char* key, int default_value) { + const char* raw = std::getenv(key); + if (!raw) return default_value; + try { + return std::stoi(raw); + } catch (...) { + return default_value; + } +} + +std::string ShellQuote(const std::string& text) { + std::string out = "'"; + for (char c : text) { + if (c == '\'') { + out += "'\"'\"'"; + } else { + out.push_back(c); + } + } + out.push_back('\''); + return out; +} + +std::string ResolveScriptPath() { + const char* env_path = std::getenv("CSP_GEN_SCRIPT_PATH"); + if (env_path && std::filesystem::exists(env_path)) return env_path; + + const std::vector candidates = { + "/app/scripts/generate_cspj_problem_rag.py", + "scripts/generate_cspj_problem_rag.py", + "../scripts/generate_cspj_problem_rag.py", + "../../scripts/generate_cspj_problem_rag.py", + }; + for (const auto& p : candidates) { + if (std::filesystem::exists(p)) return p; + } + return "/app/scripts/generate_cspj_problem_rag.py"; +} + +std::string BuildCommand(const std::string& db_path, + const std::string& trigger, + int count) { + const std::string script_path = ResolveScriptPath(); + const int final_count = std::max(1, std::min(5, count)); + return "python3 " + ShellQuote(script_path) + " --db-path " + + ShellQuote(db_path) + " --count " + std::to_string(final_count); +} + +} // namespace + +ProblemGenRunner& ProblemGenRunner::Instance() { + static ProblemGenRunner inst; + return inst; +} + +void ProblemGenRunner::Configure(std::string db_path) { + std::lock_guard lock(mu_); + db_path_ = std::move(db_path); +} + +bool ProblemGenRunner::TriggerAsync(const std::string& /*trigger*/, int count) { + std::string cmd; + { + std::lock_guard lock(mu_); + if (running_) return false; + if (db_path_.empty()) return false; + + cmd = BuildCommand(db_path_, "manual", count); + running_ = true; + last_started_at_ = NowSec(); + last_command_ = cmd; + last_exit_code_.reset(); + } + + std::thread([this, command = std::move(cmd)]() { + const int rc = std::system(command.c_str()); + std::lock_guard lock(mu_); + last_exit_code_ = rc; + last_finished_at_ = NowSec(); + running_ = false; + }).detach(); + + return true; +} + +void ProblemGenRunner::AutoStartIfEnabled() { + if (!EnvBool("CSP_GEN_AUTO_RUN", true)) return; + const int count = EnvInt("CSP_GEN_COUNT", 1); + + if (EnvBool("CSP_GEN_WAIT_FOR_IMPORT", true) && + ImportRunner::Instance().IsRunning()) { + std::thread([count]() { + using namespace std::chrono_literals; + while (ImportRunner::Instance().IsRunning()) { + std::this_thread::sleep_for(2s); + } + const bool started = ProblemGenRunner::Instance().TriggerAsync("auto", count); + if (started) { + LOG_INFO << "problem generator auto-started after import"; + } else { + LOG_INFO << "problem generator delayed auto-start skipped"; + } + }).detach(); + LOG_INFO << "problem generator waiting for import completion"; + return; + } + + const bool started = TriggerAsync("auto", count); + if (started) { + LOG_INFO << "problem generator auto-started"; + } else { + LOG_INFO << "problem generator auto-start skipped"; + } +} + +bool ProblemGenRunner::IsRunning() const { + std::lock_guard lock(mu_); + return running_; +} + +std::string ProblemGenRunner::LastCommand() const { + std::lock_guard lock(mu_); + return last_command_; +} + +std::optional ProblemGenRunner::LastExitCode() const { + std::lock_guard lock(mu_); + return last_exit_code_; +} + +int64_t ProblemGenRunner::LastStartedAt() const { + std::lock_guard lock(mu_); + return last_started_at_; +} + +int64_t ProblemGenRunner::LastFinishedAt() const { + std::lock_guard lock(mu_); + return last_finished_at_; +} + +} // namespace csp::services diff --git a/backend/src/services/problem_service.cc b/backend/src/services/problem_service.cc new file mode 100644 index 0000000..7cc1845 --- /dev/null +++ b/backend/src/services/problem_service.cc @@ -0,0 +1,201 @@ +#include "csp/services/problem_service.h" + +#include + +#include +#include +#include +#include +#include + +namespace csp::services { + +namespace { + +void CheckSqlite(int rc, sqlite3* db, const char* what) { + if (rc == SQLITE_OK || rc == SQLITE_ROW || rc == SQLITE_DONE) return; + throw std::runtime_error(std::string(what) + ": " + sqlite3_errmsg(db)); +} + +std::string ColText(sqlite3_stmt* stmt, int col) { + const unsigned char* txt = sqlite3_column_text(stmt, col); + return txt ? reinterpret_cast(txt) : std::string(); +} + +domain::Problem ReadProblem(sqlite3_stmt* stmt) { + domain::Problem p; + p.id = sqlite3_column_int64(stmt, 0); + p.slug = ColText(stmt, 1); + p.title = ColText(stmt, 2); + p.statement_md = ColText(stmt, 3); + p.difficulty = sqlite3_column_int(stmt, 4); + p.source = ColText(stmt, 5); + p.statement_url = ColText(stmt, 6); + p.llm_profile_json = ColText(stmt, 7); + p.sample_input = ColText(stmt, 8); + p.sample_output = ColText(stmt, 9); + p.created_at = sqlite3_column_int64(stmt, 10); + return p; +} + +std::string LowerCopy(const std::string& input) { + std::string out = input; + std::transform(out.begin(), out.end(), out.begin(), [](unsigned char c) { + return static_cast(std::tolower(c)); + }); + return out; +} + +std::string JoinWithAnd(const std::vector& clauses) { + if (clauses.empty()) return "1=1"; + std::string out; + for (size_t i = 0; i < clauses.size(); ++i) { + if (i) out += " AND "; + out += clauses[i]; + } + return out; +} + +struct BindValue { + enum class Type { Text, Int } type = Type::Text; + std::string text; + int value = 0; +}; + +void BindStmt(sqlite3* db, sqlite3_stmt* stmt, const std::vector& binds) { + int bind_index = 1; + for (const auto& bind : binds) { + if (bind.type == BindValue::Type::Text) { + CheckSqlite( + sqlite3_bind_text(stmt, bind_index++, bind.text.c_str(), -1, SQLITE_TRANSIENT), + db, "bind text"); + } else { + CheckSqlite(sqlite3_bind_int(stmt, bind_index++, bind.value), db, "bind int"); + } + } +} + +std::pair ResolveOrderBy(const ProblemQuery& query) { + const std::string key = LowerCopy(query.order_by); + const std::string dir = LowerCopy(query.order); + + std::string order_col = "p.id"; + if (key == "difficulty") { + order_col = "p.difficulty"; + } else if (key == "created_at") { + order_col = "p.created_at"; + } else if (key == "title") { + order_col = "p.title"; + } + + std::string order_dir = "ASC"; + if (dir == "desc") order_dir = "DESC"; + return {order_col, order_dir}; +} + +} // namespace + +ProblemListResult ProblemService::List(const ProblemQuery& query) { + sqlite3* db = db_.raw(); + std::vector clauses; + std::vector binds; + + if (!query.q.empty()) { + clauses.push_back("(p.title LIKE ? OR p.statement_md LIKE ?)"); + const std::string pat = "%" + query.q + "%"; + binds.push_back(BindValue{BindValue::Type::Text, pat, 0}); + binds.push_back(BindValue{BindValue::Type::Text, pat, 0}); + } + if (query.difficulty > 0) { + clauses.push_back("p.difficulty=?"); + binds.push_back(BindValue{BindValue::Type::Int, "", query.difficulty}); + } + if (!query.source_prefix.empty()) { + clauses.push_back("p.source LIKE ?"); + binds.push_back( + BindValue{BindValue::Type::Text, query.source_prefix + "%", 0}); + } + if (!query.tag.empty()) { + clauses.push_back( + "EXISTS(SELECT 1 FROM problem_tags pt1 WHERE pt1.problem_id=p.id AND pt1.tag=?)"); + binds.push_back(BindValue{BindValue::Type::Text, query.tag, 0}); + } + if (!query.tags.empty()) { + std::string in_clause; + for (size_t i = 0; i < query.tags.size(); ++i) { + if (i) in_clause += ","; + in_clause += "?"; + binds.push_back(BindValue{BindValue::Type::Text, query.tags[i], 0}); + } + clauses.push_back( + "EXISTS(SELECT 1 FROM problem_tags ptm WHERE ptm.problem_id=p.id AND ptm.tag IN (" + + in_clause + "))"); + } + + const std::string where_sql = JoinWithAnd(clauses); + const auto [order_col, order_dir] = ResolveOrderBy(query); + const int page = query.page <= 0 ? 1 : query.page; + const int page_size = std::max(1, std::min(200, query.page_size <= 0 ? 20 : query.page_size)); + const int offset = (page - 1) * page_size; + + ProblemListResult result; + + { + sqlite3_stmt* count_stmt = nullptr; + const std::string count_sql = "SELECT COUNT(1) FROM problems p WHERE " + where_sql; + CheckSqlite(sqlite3_prepare_v2(db, count_sql.c_str(), -1, &count_stmt, nullptr), + db, "prepare count problems"); + BindStmt(db, count_stmt, binds); + if (sqlite3_step(count_stmt) == SQLITE_ROW) { + result.total_count = sqlite3_column_int(count_stmt, 0); + } + sqlite3_finalize(count_stmt); + } + + sqlite3_stmt* stmt = nullptr; + const std::string sql = + "SELECT " + "p.id,p.slug,p.title,p.statement_md,p.difficulty,p.source,p.statement_url,p.llm_profile_json," + "p.sample_input,p.sample_output,p.created_at " + "FROM problems p " + "WHERE " + where_sql + " " + "ORDER BY " + order_col + " " + order_dir + " " + "LIMIT ? OFFSET ?"; + + CheckSqlite(sqlite3_prepare_v2(db, sql.c_str(), -1, &stmt, nullptr), db, + "prepare list problems"); + + std::vector list_binds = binds; + list_binds.push_back(BindValue{BindValue::Type::Int, "", page_size}); + list_binds.push_back(BindValue{BindValue::Type::Int, "", offset}); + BindStmt(db, stmt, list_binds); + + while (sqlite3_step(stmt) == SQLITE_ROW) { + result.items.push_back(ReadProblem(stmt)); + } + sqlite3_finalize(stmt); + return result; +} + +std::optional ProblemService::GetById(int64_t id) { + sqlite3* db = db_.raw(); + sqlite3_stmt* stmt = nullptr; + const char* sql = + "SELECT id,slug,title,statement_md,difficulty,source,statement_url,llm_profile_json," + "sample_input,sample_output,created_at " + "FROM problems WHERE id=?"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare get problem"); + CheckSqlite(sqlite3_bind_int64(stmt, 1, id), db, "bind problem_id"); + + if (sqlite3_step(stmt) != SQLITE_ROW) { + sqlite3_finalize(stmt); + return std::nullopt; + } + + auto p = ReadProblem(stmt); + sqlite3_finalize(stmt); + return p; +} + +} // namespace csp::services diff --git a/backend/src/services/problem_solution_runner.cc b/backend/src/services/problem_solution_runner.cc new file mode 100644 index 0000000..54868ce --- /dev/null +++ b/backend/src/services/problem_solution_runner.cc @@ -0,0 +1,86 @@ +#include "csp/services/problem_solution_runner.h" + +#include +#include +#include +#include +#include + +namespace csp::services { + +namespace { + +std::string ShellQuote(const std::string& text) { + std::string out = "'"; + for (char c : text) { + if (c == '\'') { + out += "'\"'\"'"; + } else { + out.push_back(c); + } + } + out.push_back('\''); + return out; +} + +std::string ResolveScriptPath() { + const char* env_path = std::getenv("CSP_SOLUTION_SCRIPT_PATH"); + if (env_path && std::filesystem::exists(env_path)) return env_path; + + const std::vector candidates = { + "/app/scripts/generate_problem_solutions.py", + "scripts/generate_problem_solutions.py", + "../scripts/generate_problem_solutions.py", + "../../scripts/generate_problem_solutions.py", + }; + for (const auto& p : candidates) { + if (std::filesystem::exists(p)) return p; + } + return "/app/scripts/generate_problem_solutions.py"; +} + +} // namespace + +ProblemSolutionRunner& ProblemSolutionRunner::Instance() { + static ProblemSolutionRunner inst; + return inst; +} + +void ProblemSolutionRunner::Configure(std::string db_path) { + std::lock_guard lock(mu_); + db_path_ = std::move(db_path); +} + +bool ProblemSolutionRunner::TriggerAsync(int64_t problem_id, + int64_t job_id, + int max_solutions) { + std::string cmd; + { + std::lock_guard lock(mu_); + if (db_path_.empty()) return false; + if (running_problem_ids_.count(problem_id) > 0) return false; + running_problem_ids_.insert(problem_id); + + const std::string script_path = ResolveScriptPath(); + const int clamped = std::max(1, std::min(5, max_solutions)); + cmd = "python3 " + ShellQuote(script_path) + " --db-path " + + ShellQuote(db_path_) + " --problem-id " + std::to_string(problem_id) + + " --job-id " + std::to_string(job_id) + " --max-solutions " + + std::to_string(clamped); + } + + std::thread([this, problem_id, command = std::move(cmd)]() { + std::system(command.c_str()); + std::lock_guard lock(mu_); + running_problem_ids_.erase(problem_id); + }).detach(); + + return true; +} + +bool ProblemSolutionRunner::IsRunning(int64_t problem_id) const { + std::lock_guard lock(mu_); + return running_problem_ids_.count(problem_id) > 0; +} + +} // namespace csp::services diff --git a/backend/src/services/problem_workspace_service.cc b/backend/src/services/problem_workspace_service.cc new file mode 100644 index 0000000..1e1ce8b --- /dev/null +++ b/backend/src/services/problem_workspace_service.cc @@ -0,0 +1,212 @@ +#include "csp/services/problem_workspace_service.h" + +#include + +#include +#include +#include +#include + +namespace csp::services { + +namespace { + +int64_t NowSec() { + using namespace std::chrono; + return duration_cast(system_clock::now().time_since_epoch()).count(); +} + +void CheckSqlite(int rc, sqlite3* db, const char* what) { + if (rc == SQLITE_OK || rc == SQLITE_ROW || rc == SQLITE_DONE) return; + throw std::runtime_error(std::string(what) + ": " + sqlite3_errmsg(db)); +} + +std::string ColText(sqlite3_stmt* stmt, int col) { + const unsigned char* txt = sqlite3_column_text(stmt, col); + return txt ? reinterpret_cast(txt) : std::string(); +} + +std::optional ColNullableInt64(sqlite3_stmt* stmt, int col) { + if (sqlite3_column_type(stmt, col) == SQLITE_NULL) return std::nullopt; + return sqlite3_column_int64(stmt, col); +} + +ProblemSolution ReadSolution(sqlite3_stmt* stmt) { + ProblemSolution s; + s.id = sqlite3_column_int64(stmt, 0); + s.problem_id = sqlite3_column_int64(stmt, 1); + s.variant = sqlite3_column_int(stmt, 2); + s.title = ColText(stmt, 3); + s.idea_md = ColText(stmt, 4); + s.explanation_md = ColText(stmt, 5); + s.code_cpp = ColText(stmt, 6); + s.complexity = ColText(stmt, 7); + s.tags_json = ColText(stmt, 8); + s.source = ColText(stmt, 9); + s.created_at = sqlite3_column_int64(stmt, 10); + s.updated_at = sqlite3_column_int64(stmt, 11); + return s; +} + +ProblemSolutionJob ReadSolutionJob(sqlite3_stmt* stmt) { + ProblemSolutionJob j; + j.id = sqlite3_column_int64(stmt, 0); + j.problem_id = sqlite3_column_int64(stmt, 1); + j.status = ColText(stmt, 2); + j.progress = sqlite3_column_int(stmt, 3); + j.message = ColText(stmt, 4); + j.created_by = sqlite3_column_int64(stmt, 5); + j.max_solutions = sqlite3_column_int(stmt, 6); + j.created_at = sqlite3_column_int64(stmt, 7); + j.started_at = ColNullableInt64(stmt, 8); + j.finished_at = ColNullableInt64(stmt, 9); + j.updated_at = sqlite3_column_int64(stmt, 10); + return j; +} + +} // namespace + +bool ProblemWorkspaceService::ProblemExists(int64_t problem_id) { + sqlite3* db = db_.raw(); + sqlite3_stmt* stmt = nullptr; + const char* sql = "SELECT 1 FROM problems WHERE id=?"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare exists problem"); + CheckSqlite(sqlite3_bind_int64(stmt, 1, problem_id), db, "bind problem_id"); + const bool ok = sqlite3_step(stmt) == SQLITE_ROW; + sqlite3_finalize(stmt); + return ok; +} + +void ProblemWorkspaceService::SaveDraft(int64_t user_id, + int64_t problem_id, + const std::string& language, + const std::string& code, + const std::string& stdin_text) { + if (user_id <= 0 || problem_id <= 0) { + throw std::runtime_error("invalid user_id/problem_id"); + } + if (code.empty()) { + throw std::runtime_error("code is empty"); + } + + sqlite3* db = db_.raw(); + sqlite3_stmt* stmt = nullptr; + const char* sql = + "INSERT INTO problem_drafts(user_id,problem_id,language,code,stdin,updated_at,created_at) " + "VALUES(?,?,?,?,?,?,?) " + "ON CONFLICT(user_id,problem_id) DO UPDATE SET " + "language=excluded.language,code=excluded.code,stdin=excluded.stdin,updated_at=excluded.updated_at"; + const int64_t now = NowSec(); + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare save draft"); + CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id"); + CheckSqlite(sqlite3_bind_int64(stmt, 2, problem_id), db, "bind problem_id"); + CheckSqlite(sqlite3_bind_text(stmt, 3, language.c_str(), -1, SQLITE_TRANSIENT), + db, "bind language"); + CheckSqlite(sqlite3_bind_text(stmt, 4, code.c_str(), -1, SQLITE_TRANSIENT), db, + "bind code"); + CheckSqlite( + sqlite3_bind_text(stmt, 5, stdin_text.c_str(), -1, SQLITE_TRANSIENT), db, + "bind stdin"); + CheckSqlite(sqlite3_bind_int64(stmt, 6, now), db, "bind updated_at"); + CheckSqlite(sqlite3_bind_int64(stmt, 7, now), db, "bind created_at"); + CheckSqlite(sqlite3_step(stmt), db, "save draft"); + sqlite3_finalize(stmt); +} + +std::optional ProblemWorkspaceService::GetDraft(int64_t user_id, + int64_t problem_id) { + sqlite3* db = db_.raw(); + sqlite3_stmt* stmt = nullptr; + const char* sql = + "SELECT language,code,stdin,updated_at FROM problem_drafts WHERE user_id=? AND problem_id=?"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare get draft"); + CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id"); + CheckSqlite(sqlite3_bind_int64(stmt, 2, problem_id), db, "bind problem_id"); + if (sqlite3_step(stmt) != SQLITE_ROW) { + sqlite3_finalize(stmt); + return std::nullopt; + } + + ProblemDraft d; + d.language = ColText(stmt, 0); + d.code = ColText(stmt, 1); + d.stdin_text = ColText(stmt, 2); + d.updated_at = sqlite3_column_int64(stmt, 3); + sqlite3_finalize(stmt); + return d; +} + +int64_t ProblemWorkspaceService::CreateSolutionJob(int64_t problem_id, + int64_t created_by, + int max_solutions) { + if (problem_id <= 0) throw std::runtime_error("invalid problem_id"); + const int clamped = std::max(1, std::min(5, max_solutions)); + + sqlite3* db = db_.raw(); + sqlite3_stmt* stmt = nullptr; + const int64_t now = NowSec(); + const char* sql = + "INSERT INTO problem_solution_jobs(problem_id,status,progress,message,created_by,max_solutions," + "created_at,started_at,finished_at,updated_at) " + "VALUES(?,?,?,?,?,?,?,?,?,?)"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare create solution job"); + CheckSqlite(sqlite3_bind_int64(stmt, 1, problem_id), db, "bind problem_id"); + CheckSqlite(sqlite3_bind_text(stmt, 2, "queued", -1, SQLITE_STATIC), db, + "bind status"); + CheckSqlite(sqlite3_bind_int(stmt, 3, 0), db, "bind progress"); + CheckSqlite(sqlite3_bind_text(stmt, 4, "", -1, SQLITE_STATIC), db, + "bind message"); + CheckSqlite(sqlite3_bind_int64(stmt, 5, created_by), db, "bind created_by"); + CheckSqlite(sqlite3_bind_int(stmt, 6, clamped), db, "bind max_solutions"); + CheckSqlite(sqlite3_bind_int64(stmt, 7, now), db, "bind created_at"); + CheckSqlite(sqlite3_bind_null(stmt, 8), db, "bind started_at"); + CheckSqlite(sqlite3_bind_null(stmt, 9), db, "bind finished_at"); + CheckSqlite(sqlite3_bind_int64(stmt, 10, now), db, "bind updated_at"); + CheckSqlite(sqlite3_step(stmt), db, "create solution job"); + sqlite3_finalize(stmt); + return sqlite3_last_insert_rowid(db); +} + +std::optional ProblemWorkspaceService::GetLatestSolutionJob( + int64_t problem_id) { + sqlite3* db = db_.raw(); + sqlite3_stmt* stmt = nullptr; + const char* sql = + "SELECT id,problem_id,status,progress,message,created_by,max_solutions," + "created_at,started_at,finished_at,updated_at " + "FROM problem_solution_jobs WHERE problem_id=? ORDER BY id DESC LIMIT 1"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare latest solution job"); + CheckSqlite(sqlite3_bind_int64(stmt, 1, problem_id), db, "bind problem_id"); + if (sqlite3_step(stmt) != SQLITE_ROW) { + sqlite3_finalize(stmt); + return std::nullopt; + } + auto row = ReadSolutionJob(stmt); + sqlite3_finalize(stmt); + return row; +} + +std::vector ProblemWorkspaceService::ListSolutions(int64_t problem_id) { + sqlite3* db = db_.raw(); + sqlite3_stmt* stmt = nullptr; + const char* sql = + "SELECT id,problem_id,variant,title,idea_md,explanation_md,code_cpp,complexity,tags_json,source,created_at,updated_at " + "FROM problem_solutions WHERE problem_id=? ORDER BY variant ASC, id ASC"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare list solutions"); + CheckSqlite(sqlite3_bind_int64(stmt, 1, problem_id), db, "bind problem_id"); + + std::vector rows; + while (sqlite3_step(stmt) == SQLITE_ROW) { + rows.push_back(ReadSolution(stmt)); + } + sqlite3_finalize(stmt); + return rows; +} + +} // namespace csp::services diff --git a/backend/src/services/submission_service.cc b/backend/src/services/submission_service.cc new file mode 100644 index 0000000..67605c3 --- /dev/null +++ b/backend/src/services/submission_service.cc @@ -0,0 +1,418 @@ +#include "csp/services/submission_service.h" + +#include "csp/domain/enum_strings.h" +#include "csp/services/crypto.h" +#include "csp/services/wrong_book_service.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace csp::services { + +namespace { + +struct JudgeOutcome { + domain::SubmissionStatus status = domain::SubmissionStatus::Unknown; + int time_ms = 0; + std::string stdout_text; + std::string stderr_text; + std::string compile_log; +}; + +void CheckSqlite(int rc, sqlite3* db, const char* what) { + if (rc == SQLITE_OK || rc == SQLITE_ROW || rc == SQLITE_DONE) return; + throw std::runtime_error(std::string(what) + ": " + sqlite3_errmsg(db)); +} + +int64_t NowSec() { + using namespace std::chrono; + return duration_cast(system_clock::now().time_since_epoch()).count(); +} + +std::string ColText(sqlite3_stmt* stmt, int col) { + const unsigned char* txt = sqlite3_column_text(stmt, col); + return txt ? reinterpret_cast(txt) : std::string(); +} + +std::string ReadFile(const std::filesystem::path& p) { + std::ifstream in(p, std::ios::in | std::ios::binary); + if (!in) return ""; + return std::string((std::istreambuf_iterator(in)), + std::istreambuf_iterator()); +} + +void WriteFile(const std::filesystem::path& p, const std::string& s) { + std::ofstream out(p, std::ios::out | std::ios::binary | std::ios::trunc); + if (!out) throw std::runtime_error("cannot open file: " + p.string()); + out << s; +} + +std::string Trim(std::string s) { + while (!s.empty() && + (s.back() == '\n' || s.back() == '\r' || s.back() == ' ' || + s.back() == '\t')) { + s.pop_back(); + } + size_t i = 0; + while (i < s.size() && + (s[i] == ' ' || s[i] == '\t' || s[i] == '\n' || s[i] == '\r')) { + ++i; + } + return s.substr(i); +} + +int ExitCodeFromSystem(int rc) { + if (rc == -1) return -1; + if (WIFEXITED(rc)) return WEXITSTATUS(rc); + if (WIFSIGNALED(rc)) return 128 + WTERMSIG(rc); + return -1; +} + +JudgeOutcome JudgeCpp(const std::string& code, + const std::string& input, + const std::optional& expected_output) { + namespace fs = std::filesystem; + const fs::path workdir = fs::path("/tmp") / ("csp_judge_" + crypto::RandomHex(8)); + fs::create_directories(workdir); + + const fs::path src = workdir / "main.cpp"; + const fs::path bin = workdir / "main.bin"; + const fs::path in = workdir / "input.txt"; + const fs::path out = workdir / "output.txt"; + const fs::path compile_log = workdir / "compile.log"; + const fs::path runtime_log = workdir / "runtime.log"; + + WriteFile(src, code); + WriteFile(in, input); + + JudgeOutcome outcome; + try { + const std::string compile_cmd = + "g++ -std=c++20 -O2 \"" + src.string() + "\" -o \"" + bin.string() + + "\" 2> \"" + compile_log.string() + "\""; + const int compile_rc = std::system(compile_cmd.c_str()); + outcome.compile_log = ReadFile(compile_log); + if (ExitCodeFromSystem(compile_rc) != 0) { + outcome.status = domain::SubmissionStatus::CE; + fs::remove_all(workdir); + return outcome; + } + + const auto start = std::chrono::steady_clock::now(); + const std::string run_cmd = + "/usr/bin/timeout 2s \"" + bin.string() + "\" < \"" + in.string() + + "\" > \"" + out.string() + "\" 2> \"" + runtime_log.string() + "\""; + const int run_rc = std::system(run_cmd.c_str()); + const auto end = std::chrono::steady_clock::now(); + outcome.time_ms = static_cast( + std::chrono::duration_cast(end - start).count()); + + outcome.stdout_text = ReadFile(out); + outcome.stderr_text = ReadFile(runtime_log); + + const int code_rc = ExitCodeFromSystem(run_rc); + if (code_rc == 124) { + outcome.status = domain::SubmissionStatus::TLE; + fs::remove_all(workdir); + return outcome; + } + if (code_rc != 0) { + outcome.status = domain::SubmissionStatus::RE; + fs::remove_all(workdir); + return outcome; + } + + if (expected_output.has_value()) { + outcome.status = + (Trim(outcome.stdout_text) == Trim(*expected_output)) + ? domain::SubmissionStatus::AC + : domain::SubmissionStatus::WA; + fs::remove_all(workdir); + return outcome; + } + + outcome.status = domain::SubmissionStatus::Running; + fs::remove_all(workdir); + return outcome; + } catch (...) { + fs::remove_all(workdir); + throw; + } +} + +std::optional GetProblem(sqlite3* db, int64_t problem_id) { + sqlite3_stmt* stmt = nullptr; + const char* sql = + "SELECT id,slug,title,statement_md,difficulty,source,statement_url,llm_profile_json," + "sample_input,sample_output,created_at " + "FROM problems WHERE id=?"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare submission.get_problem"); + CheckSqlite(sqlite3_bind_int64(stmt, 1, problem_id), db, "bind problem_id"); + if (sqlite3_step(stmt) != SQLITE_ROW) { + sqlite3_finalize(stmt); + return std::nullopt; + } + + domain::Problem p; + p.id = sqlite3_column_int64(stmt, 0); + p.slug = ColText(stmt, 1); + p.title = ColText(stmt, 2); + p.statement_md = ColText(stmt, 3); + p.difficulty = sqlite3_column_int(stmt, 4); + p.source = ColText(stmt, 5); + p.statement_url = ColText(stmt, 6); + p.llm_profile_json = ColText(stmt, 7); + p.sample_input = ColText(stmt, 8); + p.sample_output = ColText(stmt, 9); + p.created_at = sqlite3_column_int64(stmt, 10); + sqlite3_finalize(stmt); + return p; +} + +bool HasSolvedBefore(sqlite3* db, + int64_t user_id, + int64_t problem_id, + int64_t submission_id) { + sqlite3_stmt* stmt = nullptr; + const char* sql = + "SELECT 1 FROM submissions WHERE user_id=? AND problem_id=? AND status='AC' AND id 200000) { + throw std::runtime_error("code is too large"); + } + + sqlite3* db = db_.raw(); + const auto problem = GetProblem(db, req.problem_id); + if (!problem.has_value()) { + throw std::runtime_error("problem not found"); + } + + sqlite3_stmt* ins = nullptr; + const char* ins_sql = + "INSERT INTO submissions(user_id,problem_id,contest_id,language,code,status,score,time_ms,memory_kb,compile_log,runtime_log,created_at) " + "VALUES(?,?,?,?,?,?,?,?,?,?,?,?)"; + CheckSqlite(sqlite3_prepare_v2(db, ins_sql, -1, &ins, nullptr), db, + "prepare insert submission"); + CheckSqlite(sqlite3_bind_int64(ins, 1, req.user_id), db, "bind user_id"); + CheckSqlite(sqlite3_bind_int64(ins, 2, req.problem_id), db, "bind problem_id"); + if (req.contest_id.has_value()) { + CheckSqlite(sqlite3_bind_int64(ins, 3, *req.contest_id), db, "bind contest_id"); + } else { + CheckSqlite(sqlite3_bind_null(ins, 3), db, "bind contest_id null"); + } + CheckSqlite(sqlite3_bind_text(ins, 4, "cpp", -1, SQLITE_STATIC), db, + "bind language"); + CheckSqlite(sqlite3_bind_text(ins, 5, req.code.c_str(), -1, SQLITE_TRANSIENT), db, + "bind code"); + CheckSqlite(sqlite3_bind_text(ins, 6, "Pending", -1, SQLITE_STATIC), db, + "bind status"); + CheckSqlite(sqlite3_bind_int(ins, 7, 0), db, "bind score"); + CheckSqlite(sqlite3_bind_int(ins, 8, 0), db, "bind time_ms"); + CheckSqlite(sqlite3_bind_int(ins, 9, 0), db, "bind memory_kb"); + CheckSqlite(sqlite3_bind_text(ins, 10, "", -1, SQLITE_STATIC), db, + "bind compile_log"); + CheckSqlite(sqlite3_bind_text(ins, 11, "", -1, SQLITE_STATIC), db, + "bind runtime_log"); + CheckSqlite(sqlite3_bind_int64(ins, 12, NowSec()), db, "bind created_at"); + CheckSqlite(sqlite3_step(ins), db, "insert submission"); + sqlite3_finalize(ins); + + const int64_t submission_id = sqlite3_last_insert_rowid(db); + + JudgeOutcome outcome = JudgeCpp(req.code, problem->sample_input, problem->sample_output); + const int score = outcome.status == domain::SubmissionStatus::AC ? 100 : 0; + + sqlite3_stmt* upd = nullptr; + const char* upd_sql = + "UPDATE submissions SET status=?,score=?,time_ms=?,memory_kb=?,compile_log=?,runtime_log=? WHERE id=?"; + CheckSqlite(sqlite3_prepare_v2(db, upd_sql, -1, &upd, nullptr), db, + "prepare update submission"); + const auto status_text = ToStatusText(outcome.status); + CheckSqlite(sqlite3_bind_text(upd, 1, status_text.c_str(), -1, SQLITE_TRANSIENT), db, + "bind status"); + CheckSqlite(sqlite3_bind_int(upd, 2, score), db, "bind score"); + CheckSqlite(sqlite3_bind_int(upd, 3, outcome.time_ms), db, "bind time_ms"); + CheckSqlite(sqlite3_bind_int(upd, 4, 0), db, "bind memory_kb"); + CheckSqlite(sqlite3_bind_text(upd, 5, outcome.compile_log.c_str(), -1, SQLITE_TRANSIENT), + db, "bind compile_log"); + CheckSqlite(sqlite3_bind_text(upd, 6, outcome.stderr_text.c_str(), -1, SQLITE_TRANSIENT), + db, "bind runtime_log"); + CheckSqlite(sqlite3_bind_int64(upd, 7, submission_id), db, "bind submission_id"); + CheckSqlite(sqlite3_step(upd), db, "update submission"); + sqlite3_finalize(upd); + + WrongBookService wb(db_); + if (outcome.status == domain::SubmissionStatus::AC) { + wb.Remove(req.user_id, req.problem_id); + if (!HasSolvedBefore(db, req.user_id, req.problem_id, submission_id)) { + AddRating(db, req.user_id, problem->difficulty * 10); + } + } else { + wb.UpsertBySubmission(req.user_id, req.problem_id, submission_id, + "最近一次提交未通过,请复盘题解和思路。"); + } + + const auto saved = GetById(submission_id); + if (!saved.has_value()) { + throw std::runtime_error("submission saved but reload failed"); + } + return *saved; +} + +std::vector SubmissionService::List(std::optional user_id, + std::optional problem_id, + std::optional contest_id, + int page, + int page_size) { + sqlite3* db = db_.raw(); + sqlite3_stmt* stmt = nullptr; + + std::string sql = + "SELECT id,user_id,problem_id,contest_id,language,code,status,score,time_ms,memory_kb,compile_log,runtime_log,created_at " + "FROM submissions WHERE 1=1 "; + if (user_id.has_value()) sql += "AND user_id=? "; + if (problem_id.has_value()) sql += "AND problem_id=? "; + if (contest_id.has_value()) sql += "AND contest_id=? "; + sql += "ORDER BY id DESC LIMIT ? OFFSET ?"; + + CheckSqlite(sqlite3_prepare_v2(db, sql.c_str(), -1, &stmt, nullptr), db, + "prepare list submissions"); + + int idx = 1; + if (user_id.has_value()) + CheckSqlite(sqlite3_bind_int64(stmt, idx++, *user_id), db, "bind user_id"); + if (problem_id.has_value()) + CheckSqlite(sqlite3_bind_int64(stmt, idx++, *problem_id), db, "bind problem_id"); + if (contest_id.has_value()) + CheckSqlite(sqlite3_bind_int64(stmt, idx++, *contest_id), db, "bind contest_id"); + + if (page <= 0) page = 1; + if (page_size <= 0) page_size = 20; + const int offset = (page - 1) * page_size; + CheckSqlite(sqlite3_bind_int(stmt, idx++, page_size), db, "bind limit"); + CheckSqlite(sqlite3_bind_int(stmt, idx++, offset), db, "bind offset"); + + std::vector out; + while (sqlite3_step(stmt) == SQLITE_ROW) { + domain::Submission s; + s.id = sqlite3_column_int64(stmt, 0); + s.user_id = sqlite3_column_int64(stmt, 1); + s.problem_id = sqlite3_column_int64(stmt, 2); + if (sqlite3_column_type(stmt, 3) == SQLITE_NULL) { + s.contest_id = std::nullopt; + } else { + s.contest_id = sqlite3_column_int64(stmt, 3); + } + s.language = domain::LanguageFromString(ColText(stmt, 4)); + s.code = ColText(stmt, 5); + s.status = domain::SubmissionStatusFromString(ColText(stmt, 6)); + s.score = sqlite3_column_int(stmt, 7); + s.time_ms = sqlite3_column_int(stmt, 8); + s.memory_kb = sqlite3_column_int(stmt, 9); + s.compile_log = ColText(stmt, 10); + s.runtime_log = ColText(stmt, 11); + s.created_at = sqlite3_column_int64(stmt, 12); + out.push_back(std::move(s)); + } + sqlite3_finalize(stmt); + return out; +} + +std::optional SubmissionService::GetById(int64_t id) { + sqlite3* db = db_.raw(); + sqlite3_stmt* stmt = nullptr; + const char* sql = + "SELECT id,user_id,problem_id,contest_id,language,code,status,score,time_ms,memory_kb,compile_log,runtime_log,created_at " + "FROM submissions WHERE id=?"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare get submission"); + CheckSqlite(sqlite3_bind_int64(stmt, 1, id), db, "bind submission_id"); + + if (sqlite3_step(stmt) != SQLITE_ROW) { + sqlite3_finalize(stmt); + return std::nullopt; + } + + domain::Submission s; + s.id = sqlite3_column_int64(stmt, 0); + s.user_id = sqlite3_column_int64(stmt, 1); + s.problem_id = sqlite3_column_int64(stmt, 2); + if (sqlite3_column_type(stmt, 3) == SQLITE_NULL) { + s.contest_id = std::nullopt; + } else { + s.contest_id = sqlite3_column_int64(stmt, 3); + } + s.language = domain::LanguageFromString(ColText(stmt, 4)); + s.code = ColText(stmt, 5); + s.status = domain::SubmissionStatusFromString(ColText(stmt, 6)); + s.score = sqlite3_column_int(stmt, 7); + s.time_ms = sqlite3_column_int(stmt, 8); + s.memory_kb = sqlite3_column_int(stmt, 9); + s.compile_log = ColText(stmt, 10); + s.runtime_log = ColText(stmt, 11); + s.created_at = sqlite3_column_int64(stmt, 12); + sqlite3_finalize(stmt); + return s; +} + +RunOnlyResult SubmissionService::RunOnlyCpp(const std::string& code, + const std::string& input) { + if (code.empty()) throw std::runtime_error("code is empty"); + if (code.size() > 200000) throw std::runtime_error("code is too large"); + + auto outcome = JudgeCpp(code, input, std::nullopt); + RunOnlyResult r; + r.status = outcome.status; + r.time_ms = outcome.time_ms; + r.stdout_text = outcome.stdout_text; + r.stderr_text = outcome.stderr_text; + r.compile_log = outcome.compile_log; + return r; +} + +} // namespace csp::services diff --git a/backend/src/services/user_service.cc b/backend/src/services/user_service.cc new file mode 100644 index 0000000..fcc632d --- /dev/null +++ b/backend/src/services/user_service.cc @@ -0,0 +1,73 @@ +#include "csp/services/user_service.h" + +#include + +#include +#include + +namespace csp::services { + +namespace { + +void CheckSqlite(int rc, sqlite3* db, const char* what) { + if (rc == SQLITE_OK || rc == SQLITE_ROW || rc == SQLITE_DONE) return; + throw std::runtime_error(std::string(what) + ": " + sqlite3_errmsg(db)); +} + +std::string ColText(sqlite3_stmt* stmt, int col) { + const unsigned char* txt = sqlite3_column_text(stmt, col); + return txt ? reinterpret_cast(txt) : std::string(); +} + +} // namespace + +std::optional UserService::GetById(int64_t id) { + sqlite3* db = db_.raw(); + sqlite3_stmt* stmt = nullptr; + const char* sql = + "SELECT id,username,password_salt,password_hash,rating,created_at FROM users WHERE id=?"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare user get"); + CheckSqlite(sqlite3_bind_int64(stmt, 1, id), db, "bind user_id"); + + if (sqlite3_step(stmt) != SQLITE_ROW) { + sqlite3_finalize(stmt); + return std::nullopt; + } + + domain::User u; + u.id = sqlite3_column_int64(stmt, 0); + u.username = ColText(stmt, 1); + u.password_salt = ColText(stmt, 2); + u.password_hash = ColText(stmt, 3); + u.rating = sqlite3_column_int(stmt, 4); + u.created_at = sqlite3_column_int64(stmt, 5); + sqlite3_finalize(stmt); + return u; +} + +std::vector UserService::GlobalLeaderboard(int limit) { + if (limit <= 0) limit = 100; + + sqlite3* db = db_.raw(); + sqlite3_stmt* stmt = nullptr; + const char* sql = + "SELECT id,username,rating,created_at FROM users ORDER BY rating DESC,id ASC LIMIT ?"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare leaderboard"); + CheckSqlite(sqlite3_bind_int(stmt, 1, limit), db, "bind limit"); + + std::vector out; + while (sqlite3_step(stmt) == SQLITE_ROW) { + domain::GlobalLeaderboardEntry e; + e.user_id = sqlite3_column_int64(stmt, 0); + e.username = ColText(stmt, 1); + e.rating = sqlite3_column_int(stmt, 2); + e.created_at = sqlite3_column_int64(stmt, 3); + out.push_back(std::move(e)); + } + sqlite3_finalize(stmt); + return out; +} + +} // namespace csp::services diff --git a/backend/src/services/wrong_book_service.cc b/backend/src/services/wrong_book_service.cc new file mode 100644 index 0000000..70cc026 --- /dev/null +++ b/backend/src/services/wrong_book_service.cc @@ -0,0 +1,123 @@ +#include "csp/services/wrong_book_service.h" + +#include + +#include +#include +#include + +namespace csp::services { + +namespace { + +void CheckSqlite(int rc, sqlite3* db, const char* what) { + if (rc == SQLITE_OK || rc == SQLITE_ROW || rc == SQLITE_DONE) return; + throw std::runtime_error(std::string(what) + ": " + sqlite3_errmsg(db)); +} + +int64_t NowSec() { + using namespace std::chrono; + return duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); +} + +std::string ColText(sqlite3_stmt* stmt, int col) { + const unsigned char* txt = sqlite3_column_text(stmt, col); + return txt ? reinterpret_cast(txt) : std::string(); +} + +} // namespace + +std::vector WrongBookService::ListByUser(int64_t user_id) { + sqlite3* db = db_.raw(); + sqlite3_stmt* stmt = nullptr; + const char* sql = + "SELECT w.user_id,w.problem_id,w.last_submission_id,w.note,w.updated_at,p.title " + "FROM wrong_book w " + "JOIN problems p ON p.id=w.problem_id " + "WHERE w.user_id=? ORDER BY w.updated_at DESC"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare wrong_book list"); + CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id"); + + std::vector out; + while (sqlite3_step(stmt) == SQLITE_ROW) { + WrongBookEntry e; + e.item.user_id = sqlite3_column_int64(stmt, 0); + e.item.problem_id = sqlite3_column_int64(stmt, 1); + if (sqlite3_column_type(stmt, 2) == SQLITE_NULL) { + e.item.last_submission_id = std::nullopt; + } else { + e.item.last_submission_id = sqlite3_column_int64(stmt, 2); + } + e.item.note = ColText(stmt, 3); + e.item.updated_at = sqlite3_column_int64(stmt, 4); + e.problem_title = ColText(stmt, 5); + out.push_back(std::move(e)); + } + sqlite3_finalize(stmt); + return out; +} + +void WrongBookService::UpsertNote(int64_t user_id, + int64_t problem_id, + const std::string& note) { + sqlite3* db = db_.raw(); + sqlite3_stmt* stmt = nullptr; + const char* sql = + "INSERT INTO wrong_book(user_id,problem_id,last_submission_id,note,updated_at) " + "VALUES(?,?,?,?,?) " + "ON CONFLICT(user_id,problem_id) DO UPDATE SET note=excluded.note,updated_at=excluded.updated_at"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare wrong_book upsert note"); + CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id"); + CheckSqlite(sqlite3_bind_int64(stmt, 2, problem_id), db, "bind problem_id"); + CheckSqlite(sqlite3_bind_null(stmt, 3), db, "bind last_submission_id"); + CheckSqlite(sqlite3_bind_text(stmt, 4, note.c_str(), -1, SQLITE_TRANSIENT), db, + "bind note"); + CheckSqlite(sqlite3_bind_int64(stmt, 5, NowSec()), db, "bind updated_at"); + CheckSqlite(sqlite3_step(stmt), db, "wrong_book upsert note"); + sqlite3_finalize(stmt); +} + +void WrongBookService::UpsertBySubmission(int64_t user_id, + int64_t problem_id, + int64_t submission_id, + const std::string& note) { + sqlite3* db = db_.raw(); + sqlite3_stmt* stmt = nullptr; + const char* sql = + "INSERT INTO wrong_book(user_id,problem_id,last_submission_id,note,updated_at) " + "VALUES(?,?,?,?,?) " + "ON CONFLICT(user_id,problem_id) DO UPDATE SET " + "last_submission_id=excluded.last_submission_id," + "updated_at=excluded.updated_at," + "note=CASE WHEN wrong_book.note='' THEN excluded.note ELSE wrong_book.note END"; + + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare wrong_book upsert by submission"); + CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id"); + CheckSqlite(sqlite3_bind_int64(stmt, 2, problem_id), db, "bind problem_id"); + CheckSqlite(sqlite3_bind_int64(stmt, 3, submission_id), db, + "bind submission_id"); + CheckSqlite(sqlite3_bind_text(stmt, 4, note.c_str(), -1, SQLITE_TRANSIENT), db, + "bind note"); + CheckSqlite(sqlite3_bind_int64(stmt, 5, NowSec()), db, "bind updated_at"); + CheckSqlite(sqlite3_step(stmt), db, "wrong_book upsert by submission"); + sqlite3_finalize(stmt); +} + +void WrongBookService::Remove(int64_t user_id, int64_t problem_id) { + sqlite3* db = db_.raw(); + sqlite3_stmt* stmt = nullptr; + const char* sql = "DELETE FROM wrong_book WHERE user_id=? AND problem_id=?"; + CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db, + "prepare wrong_book delete"); + CheckSqlite(sqlite3_bind_int64(stmt, 1, user_id), db, "bind user_id"); + CheckSqlite(sqlite3_bind_int64(stmt, 2, problem_id), db, "bind problem_id"); + CheckSqlite(sqlite3_step(stmt), db, "wrong_book delete"); + sqlite3_finalize(stmt); +} + +} // namespace csp::services diff --git a/backend/tests/contest_http_test.cc b/backend/tests/contest_http_test.cc new file mode 100644 index 0000000..f50a74e --- /dev/null +++ b/backend/tests/contest_http_test.cc @@ -0,0 +1,94 @@ +#include + +#include "csp/app_state.h" +#include "csp/controllers/contest_controller.h" +#include "csp/services/auth_service.h" + +#include + +#include + +namespace { + +drogon::HttpResponsePtr CallList(csp::controllers::ContestController& ctl) { + auto req = drogon::HttpRequest::newHttpRequest(); + req->setMethod(drogon::Get); + + std::promise p; + ctl.list(req, [&p](const drogon::HttpResponsePtr& resp) { p.set_value(resp); }); + return p.get_future().get(); +} + +drogon::HttpResponsePtr CallGet(csp::controllers::ContestController& ctl, + int64_t contest_id, + const std::string& token) { + auto req = drogon::HttpRequest::newHttpRequest(); + req->setMethod(drogon::Get); + req->addHeader("Authorization", "Bearer " + token); + + std::promise p; + ctl.getById(req, + [&p](const drogon::HttpResponsePtr& resp) { p.set_value(resp); }, + contest_id); + return p.get_future().get(); +} + +drogon::HttpResponsePtr CallRegister(csp::controllers::ContestController& ctl, + int64_t contest_id, + const std::string& token) { + auto req = drogon::HttpRequest::newHttpRequest(); + req->setMethod(drogon::Post); + req->addHeader("Authorization", "Bearer " + token); + + std::promise p; + ctl.registerForContest(req, + [&p](const drogon::HttpResponsePtr& resp) { + p.set_value(resp); + }, + contest_id); + return p.get_future().get(); +} + +drogon::HttpResponsePtr CallBoard(csp::controllers::ContestController& ctl, + int64_t contest_id) { + auto req = drogon::HttpRequest::newHttpRequest(); + req->setMethod(drogon::Get); + + std::promise p; + ctl.leaderboard(req, + [&p](const drogon::HttpResponsePtr& resp) { p.set_value(resp); }, + contest_id); + return p.get_future().get(); +} + +} // namespace + +TEST_CASE("contest controller list/get/register/leaderboard") { + csp::AppState::Instance().Init(":memory:"); + + csp::services::AuthService auth(csp::AppState::Instance().db()); + const auto user = auth.Register("contest_http_user", "password123"); + + csp::controllers::ContestController ctl; + + auto list = CallList(ctl); + REQUIRE(list->statusCode() == drogon::k200OK); + auto list_json = list->jsonObject(); + REQUIRE(list_json != nullptr); + REQUIRE((*list_json)["data"].isArray()); + REQUIRE((*list_json)["data"].size() >= 1); + + const int64_t contest_id = (*list_json)["data"][0]["id"].asInt64(); + + auto get = CallGet(ctl, contest_id, user.token); + REQUIRE(get->statusCode() == drogon::k200OK); + + auto reg = CallRegister(ctl, contest_id, user.token); + REQUIRE(reg->statusCode() == drogon::k200OK); + + auto board = CallBoard(ctl, contest_id); + REQUIRE(board->statusCode() == drogon::k200OK); + auto board_json = board->jsonObject(); + REQUIRE(board_json != nullptr); + REQUIRE((*board_json)["data"].isArray()); +} diff --git a/backend/tests/contest_service_test.cc b/backend/tests/contest_service_test.cc new file mode 100644 index 0000000..db8a2e7 --- /dev/null +++ b/backend/tests/contest_service_test.cc @@ -0,0 +1,75 @@ +#include + +#include "csp/db/sqlite_db.h" +#include "csp/services/auth_service.h" +#include "csp/services/contest_service.h" +#include "csp/services/submission_service.h" + +namespace { + +const char* kAcCode = R"CPP(#include +using namespace std; +int main() { + long long a, b; + if (!(cin >> a >> b)) return 0; + cout << (a + b) << "\n"; + return 0; +} +)CPP"; + +const char* kWaCode = R"CPP(#include +using namespace std; +int main() { + cout << 0 << "\n"; + return 0; +} +)CPP"; + +} // namespace + +TEST_CASE("contest service leaderboard") { + auto db = csp::db::SqliteDb::OpenMemory(); + csp::db::ApplyMigrations(db); + csp::db::SeedDemoData(db); + + csp::services::AuthService auth(db); + const auto u1 = auth.Register("contest_user_1", "password123"); + const auto u2 = auth.Register("contest_user_2", "password123"); + + csp::services::ContestService contest(db); + const auto contests = contest.ListContests(); + REQUIRE_FALSE(contests.empty()); + + const int64_t contest_id = contests.front().id; + contest.Register(contest_id, u1.user_id); + contest.Register(contest_id, u2.user_id); + + const auto problems = contest.ListContestProblems(contest_id); + REQUIRE_FALSE(problems.empty()); + const int64_t problem_id = problems.front().id; + + csp::services::SubmissionService submissions(db); + + csp::services::SubmissionCreateRequest r1; + r1.user_id = u1.user_id; + r1.problem_id = problem_id; + r1.contest_id = contest_id; + r1.language = "cpp"; + r1.code = kAcCode; + const auto s1 = submissions.CreateAndJudge(r1); + REQUIRE(s1.status == csp::domain::SubmissionStatus::AC); + + csp::services::SubmissionCreateRequest r2; + r2.user_id = u2.user_id; + r2.problem_id = problem_id; + r2.contest_id = contest_id; + r2.language = "cpp"; + r2.code = kWaCode; + const auto s2 = submissions.CreateAndJudge(r2); + REQUIRE(s2.status == csp::domain::SubmissionStatus::WA); + + const auto board = contest.Leaderboard(contest_id); + REQUIRE(board.size() >= 2); + REQUIRE(board.front().user_id == u1.user_id); + REQUIRE(board.front().solved >= 1); +} diff --git a/backend/tests/import_http_test.cc b/backend/tests/import_http_test.cc new file mode 100644 index 0000000..b6678a8 --- /dev/null +++ b/backend/tests/import_http_test.cc @@ -0,0 +1,60 @@ +#include + +#include "csp/app_state.h" +#include "csp/controllers/import_controller.h" + +#include + +#include + +namespace { + +drogon::HttpResponsePtr CallLatest(csp::controllers::ImportController& ctl) { + auto req = drogon::HttpRequest::newHttpRequest(); + req->setMethod(drogon::Get); + std::promise p; + ctl.latestJob(req, [&p](const drogon::HttpResponsePtr& resp) { p.set_value(resp); }); + return p.get_future().get(); +} + +drogon::HttpResponsePtr CallItems(csp::controllers::ImportController& ctl, int64_t job_id) { + auto req = drogon::HttpRequest::newHttpRequest(); + req->setMethod(drogon::Get); + req->setParameter("page", "1"); + req->setParameter("page_size", "20"); + std::promise p; + ctl.jobItems(req, [&p](const drogon::HttpResponsePtr& resp) { p.set_value(resp); }, job_id); + return p.get_future().get(); +} + +} // namespace + +TEST_CASE("import controller latest and items") { + csp::AppState::Instance().Init(":memory:"); + auto& db = csp::AppState::Instance().db(); + db.Exec( + "INSERT INTO import_jobs(status,trigger,total_count,processed_count,success_count," + "failed_count,options_json,last_error,started_at,finished_at,updated_at,created_at)" + "VALUES('success','auto',2,2,2,0,'{\"workers\":3}','',100,120,120,90);"); + db.Exec( + "INSERT INTO import_job_items(job_id,source_path,status,title,difficulty,problem_id," + "error_text,started_at,finished_at,updated_at,created_at)" + "VALUES(1,'CSP-J/2024/Round2/B.pdf','success','B',3,7,'',101,110,120,100);"); + + csp::controllers::ImportController ctl; + + auto latest = CallLatest(ctl); + REQUIRE(latest->statusCode() == drogon::k200OK); + auto latest_json = latest->jsonObject(); + REQUIRE(latest_json != nullptr); + REQUIRE((*latest_json)["ok"].asBool()); + REQUIRE((*latest_json)["data"]["job"]["id"].asInt64() == 1); + + auto items = CallItems(ctl, 1); + REQUIRE(items->statusCode() == drogon::k200OK); + auto items_json = items->jsonObject(); + REQUIRE(items_json != nullptr); + REQUIRE((*items_json)["ok"].asBool()); + REQUIRE((*items_json)["data"]["items"].isArray()); + REQUIRE((*items_json)["data"]["items"].size() == 1); +} diff --git a/backend/tests/import_service_test.cc b/backend/tests/import_service_test.cc new file mode 100644 index 0000000..52e8a13 --- /dev/null +++ b/backend/tests/import_service_test.cc @@ -0,0 +1,34 @@ +#include + +#include "csp/db/sqlite_db.h" +#include "csp/services/import_service.h" + +TEST_CASE("import service query latest job and items") { + auto db = csp::db::SqliteDb::OpenMemory(); + csp::db::ApplyMigrations(db); + + db.Exec( + "INSERT INTO import_jobs(status,trigger,total_count,processed_count,success_count," + "failed_count,options_json,last_error,started_at,finished_at,updated_at,created_at)" + "VALUES('running','manual',10,3,2,1,'{}','',100,null,120,90);"); + db.Exec( + "INSERT INTO import_job_items(job_id,source_path,status,title,difficulty,problem_id," + "error_text,started_at,finished_at,updated_at,created_at)" + "VALUES(1,'CSP-J/2024/Round1/A.pdf','success','A',2,11,'',101,102,120,100);"); + + csp::services::ImportService svc(db); + const auto latest = svc.GetLatestJob(); + REQUIRE(latest.has_value()); + REQUIRE(latest->id == 1); + REQUIRE(latest->status == "running"); + REQUIRE(latest->processed_count == 3); + + csp::services::ImportJobItemQuery q; + q.page = 1; + q.page_size = 20; + const auto items = svc.ListItems(1, q); + REQUIRE(items.size() == 1); + REQUIRE(items[0].source_path == "CSP-J/2024/Round1/A.pdf"); + REQUIRE(items[0].status == "success"); + REQUIRE(items[0].problem_id.has_value()); +} diff --git a/backend/tests/kb_service_test.cc b/backend/tests/kb_service_test.cc new file mode 100644 index 0000000..447c978 --- /dev/null +++ b/backend/tests/kb_service_test.cc @@ -0,0 +1,18 @@ +#include + +#include "csp/db/sqlite_db.h" +#include "csp/services/kb_service.h" + +TEST_CASE("kb service list/detail") { + auto db = csp::db::SqliteDb::OpenMemory(); + csp::db::ApplyMigrations(db); + csp::db::SeedDemoData(db); + + csp::services::KbService svc(db); + const auto rows = svc.ListArticles(); + REQUIRE(rows.size() >= 2); + + const auto detail = svc.GetBySlug(rows.front().slug); + REQUIRE(detail.has_value()); + REQUIRE(detail->article.slug == rows.front().slug); +} diff --git a/backend/tests/me_http_test.cc b/backend/tests/me_http_test.cc new file mode 100644 index 0000000..d260c3b --- /dev/null +++ b/backend/tests/me_http_test.cc @@ -0,0 +1,105 @@ +#include + +#include "csp/app_state.h" +#include "csp/controllers/me_controller.h" +#include "csp/services/auth_service.h" +#include "csp/services/problem_service.h" + +#include + +#include + +namespace { + +drogon::HttpResponsePtr CallProfile(csp::controllers::MeController& ctl, + const std::string& token) { + auto req = drogon::HttpRequest::newHttpRequest(); + req->setMethod(drogon::Get); + req->addHeader("Authorization", "Bearer " + token); + + std::promise p; + ctl.profile(req, [&p](const drogon::HttpResponsePtr& resp) { p.set_value(resp); }); + return p.get_future().get(); +} + +drogon::HttpResponsePtr CallListWrongBook(csp::controllers::MeController& ctl, + const std::string& token) { + auto req = drogon::HttpRequest::newHttpRequest(); + req->setMethod(drogon::Get); + req->addHeader("Authorization", "Bearer " + token); + + std::promise p; + ctl.listWrongBook(req, + [&p](const drogon::HttpResponsePtr& resp) { p.set_value(resp); }); + return p.get_future().get(); +} + +drogon::HttpResponsePtr CallPatchWrongBook(csp::controllers::MeController& ctl, + const std::string& token, + int64_t problem_id, + const std::string& note) { + Json::Value body; + body["note"] = note; + auto req = drogon::HttpRequest::newHttpJsonRequest(body); + req->setMethod(drogon::Patch); + req->addHeader("Authorization", "Bearer " + token); + + std::promise p; + ctl.upsertWrongBookNote(req, + [&p](const drogon::HttpResponsePtr& resp) { + p.set_value(resp); + }, + problem_id); + return p.get_future().get(); +} + +drogon::HttpResponsePtr CallDeleteWrongBook(csp::controllers::MeController& ctl, + const std::string& token, + int64_t problem_id) { + auto req = drogon::HttpRequest::newHttpRequest(); + req->setMethod(drogon::Delete); + req->addHeader("Authorization", "Bearer " + token); + + std::promise p; + ctl.deleteWrongBookItem(req, + [&p](const drogon::HttpResponsePtr& resp) { + p.set_value(resp); + }, + problem_id); + return p.get_future().get(); +} + +} // namespace + +TEST_CASE("me controller profile and wrong-book") { + csp::AppState::Instance().Init(":memory:"); + + csp::services::AuthService auth(csp::AppState::Instance().db()); + const auto login = auth.Register("me_http_user", "password123"); + + csp::services::ProblemService problems(csp::AppState::Instance().db()); + const auto list = problems.List(csp::services::ProblemQuery{}); + REQUIRE_FALSE(list.items.empty()); + const int64_t problem_id = list.items.front().id; + + csp::controllers::MeController ctl; + + auto profile = CallProfile(ctl, login.token); + REQUIRE(profile->statusCode() == drogon::k200OK); + auto profile_json = profile->jsonObject(); + REQUIRE(profile_json != nullptr); + REQUIRE((*profile_json)["ok"].asBool()); + + auto patch = CallPatchWrongBook(ctl, login.token, problem_id, "复盘记录"); + REQUIRE(patch->statusCode() == drogon::k200OK); + + auto list_resp = CallListWrongBook(ctl, login.token); + REQUIRE(list_resp->statusCode() == drogon::k200OK); + auto list_json = list_resp->jsonObject(); + REQUIRE(list_json != nullptr); + REQUIRE((*list_json)["data"].isArray()); + REQUIRE((*list_json)["data"].size() >= 1); + + auto del = CallDeleteWrongBook(ctl, login.token, problem_id); + REQUIRE(del->statusCode() == drogon::k200OK); +} diff --git a/backend/tests/problem_http_test.cc b/backend/tests/problem_http_test.cc new file mode 100644 index 0000000..4fe8af0 --- /dev/null +++ b/backend/tests/problem_http_test.cc @@ -0,0 +1,61 @@ +#include + +#include "csp/app_state.h" +#include "csp/controllers/problem_controller.h" + +#include + +#include + +namespace { + +drogon::HttpResponsePtr CallList(csp::controllers::ProblemController& ctl, + const std::string& page_size = "", + const std::string& tags = "") { + auto req = drogon::HttpRequest::newHttpRequest(); + req->setMethod(drogon::Get); + if (!page_size.empty()) req->setParameter("page_size", page_size); + if (!tags.empty()) req->setParameter("tags", tags); + + std::promise p; + ctl.list(req, [&p](const drogon::HttpResponsePtr& resp) { p.set_value(resp); }); + return p.get_future().get(); +} + +drogon::HttpResponsePtr CallGet(csp::controllers::ProblemController& ctl, + int64_t problem_id) { + auto req = drogon::HttpRequest::newHttpRequest(); + req->setMethod(drogon::Get); + + std::promise p; + ctl.getById(req, + [&p](const drogon::HttpResponsePtr& resp) { p.set_value(resp); }, + problem_id); + return p.get_future().get(); +} + +} // namespace + +TEST_CASE("problem controller list/get") { + csp::AppState::Instance().Init(":memory:"); + + csp::controllers::ProblemController ctl; + + auto list_resp = CallList(ctl, "1"); + REQUIRE(list_resp->statusCode() == drogon::k200OK); + + auto list_json = list_resp->jsonObject(); + REQUIRE(list_json != nullptr); + REQUIRE((*list_json)["ok"].asBool()); + REQUIRE((*list_json)["data"]["items"].isArray()); + REQUIRE((*list_json)["data"]["items"].size() == 1); + REQUIRE((*list_json)["data"]["total_count"].asInt() >= 1); + + const int64_t problem_id = (*list_json)["data"]["items"][0]["id"].asInt64(); + auto get_resp = CallGet(ctl, problem_id); + REQUIRE(get_resp->statusCode() == drogon::k200OK); + + auto get_json = get_resp->jsonObject(); + REQUIRE(get_json != nullptr); + REQUIRE((*get_json)["data"]["id"].asInt64() == problem_id); +} diff --git a/backend/tests/problem_service_test.cc b/backend/tests/problem_service_test.cc new file mode 100644 index 0000000..127e6c0 --- /dev/null +++ b/backend/tests/problem_service_test.cc @@ -0,0 +1,50 @@ +#include + +#include "csp/db/sqlite_db.h" +#include "csp/services/problem_service.h" + +TEST_CASE("problem service list/get") { + auto db = csp::db::SqliteDb::OpenMemory(); + csp::db::ApplyMigrations(db); + csp::db::SeedDemoData(db); + + db.Exec( + "INSERT INTO problems(slug,title,statement_md,difficulty,source,statement_url,llm_profile_json," + "sample_input,sample_output,created_at)" + "VALUES('luogu-p1000','P1000','x',2,'luogu:P1000','https://www.luogu.com.cn/problem/P1000','{}','','',100);"); + db.Exec( + "INSERT INTO problems(slug,title,statement_md,difficulty,source,statement_url,llm_profile_json," + "sample_input,sample_output,created_at)" + "VALUES('luogu-p1001','P1001','x',3,'luogu:P1001','https://www.luogu.com.cn/problem/P1001','{}','','',100);"); + db.Exec("INSERT INTO problem_tags(problem_id,tag) VALUES((SELECT id FROM problems WHERE slug='luogu-p1000'),'csp-j');"); + db.Exec("INSERT INTO problem_tags(problem_id,tag) VALUES((SELECT id FROM problems WHERE slug='luogu-p1001'),'csp-s');"); + + csp::services::ProblemService svc(db); + + csp::services::ProblemQuery q_all; + const auto all = svc.List(q_all); + REQUIRE(all.total_count >= 5); + REQUIRE(all.items.size() >= 5); + + csp::services::ProblemQuery q_dp; + q_dp.tag = "dp"; + const auto dp = svc.List(q_dp); + REQUIRE(dp.total_count >= 1); + REQUIRE(dp.items.size() >= 1); + + csp::services::ProblemQuery q_source; + q_source.source_prefix = "luogu:"; + const auto luogu = svc.List(q_source); + REQUIRE(luogu.total_count == 2); + REQUIRE(luogu.items.size() == 2); + + csp::services::ProblemQuery q_tags; + q_tags.tags = {"csp-j", "csp-s"}; + const auto csp = svc.List(q_tags); + REQUIRE(csp.total_count == 2); + REQUIRE(csp.items.size() == 2); + + const auto one = svc.GetById(all.items.front().id); + REQUIRE(one.has_value()); + REQUIRE(one->id == all.items.front().id); +} diff --git a/backend/tests/problem_workspace_http_test.cc b/backend/tests/problem_workspace_http_test.cc new file mode 100644 index 0000000..eece88c --- /dev/null +++ b/backend/tests/problem_workspace_http_test.cc @@ -0,0 +1,89 @@ +#include + +#include "csp/app_state.h" +#include "csp/controllers/problem_controller.h" +#include "csp/services/auth_service.h" + +#include +#include + +#include + +namespace { + +int64_t FirstProblemId() { + sqlite3_stmt* stmt = nullptr; + auto* db = csp::AppState::Instance().db().raw(); + REQUIRE(sqlite3_prepare_v2(db, "SELECT id FROM problems ORDER BY id LIMIT 1", -1, + &stmt, nullptr) == SQLITE_OK); + REQUIRE(sqlite3_step(stmt) == SQLITE_ROW); + const auto id = sqlite3_column_int64(stmt, 0); + sqlite3_finalize(stmt); + return id; +} + +std::string NewToken() { + csp::services::AuthService auth(csp::AppState::Instance().db()); + const auto r = auth.Register("workspace_tester", "password123"); + return r.token; +} + +} // namespace + +TEST_CASE("problem controller draft and solution list") { + csp::AppState::Instance().Init(":memory:"); + csp::controllers::ProblemController ctl; + + const auto problem_id = FirstProblemId(); + const auto token = NewToken(); + + { + Json::Value body; + body["language"] = "cpp"; + body["code"] = "int main(){return 0;}"; + body["stdin"] = "1 2\n"; + + auto req = drogon::HttpRequest::newHttpJsonRequest(body); + req->setMethod(drogon::Put); + req->addHeader("Authorization", "Bearer " + token); + + std::promise p; + ctl.saveDraft(req, + [&p](const drogon::HttpResponsePtr& resp) { p.set_value(resp); }, + problem_id); + auto resp = p.get_future().get(); + REQUIRE(resp->statusCode() == drogon::k200OK); + } + + { + auto req = drogon::HttpRequest::newHttpRequest(); + req->setMethod(drogon::Get); + req->addHeader("Authorization", "Bearer " + token); + + std::promise p; + ctl.getDraft(req, + [&p](const drogon::HttpResponsePtr& resp) { p.set_value(resp); }, + problem_id); + auto resp = p.get_future().get(); + REQUIRE(resp->statusCode() == drogon::k200OK); + auto json = resp->jsonObject(); + REQUIRE(json != nullptr); + REQUIRE((*json)["data"]["code"].asString().find("main") != std::string::npos); + } + + { + auto req = drogon::HttpRequest::newHttpRequest(); + req->setMethod(drogon::Get); + + std::promise p; + ctl.listSolutions( + req, + [&p](const drogon::HttpResponsePtr& resp) { p.set_value(resp); }, + problem_id); + auto resp = p.get_future().get(); + REQUIRE(resp->statusCode() == drogon::k200OK); + auto json = resp->jsonObject(); + REQUIRE(json != nullptr); + REQUIRE((*json)["data"]["items"].isArray()); + } +} diff --git a/backend/tests/problem_workspace_service_test.cc b/backend/tests/problem_workspace_service_test.cc new file mode 100644 index 0000000..6932aef --- /dev/null +++ b/backend/tests/problem_workspace_service_test.cc @@ -0,0 +1,48 @@ +#include + +#include "csp/db/sqlite_db.h" +#include "csp/services/problem_workspace_service.h" + +#include + +TEST_CASE("problem workspace service drafts and solution jobs") { + auto db = csp::db::SqliteDb::OpenMemory(); + csp::db::ApplyMigrations(db); + csp::db::SeedDemoData(db); + REQUIRE(sqlite3_exec( + db.raw(), + "INSERT INTO users(username,password_salt,password_hash,created_at)" + " VALUES('tester','salt','hash',0)", + nullptr, + nullptr, + nullptr) == SQLITE_OK); + + sqlite3_stmt* stmt = nullptr; + REQUIRE(sqlite3_prepare_v2(db.raw(), "SELECT id FROM problems ORDER BY id LIMIT 1", + -1, &stmt, nullptr) == SQLITE_OK); + REQUIRE(sqlite3_step(stmt) == SQLITE_ROW); + const int64_t pid = sqlite3_column_int64(stmt, 0); + sqlite3_finalize(stmt); + REQUIRE(pid > 0); + + csp::services::ProblemWorkspaceService svc(db); + REQUIRE(svc.ProblemExists(pid)); + + svc.SaveDraft(1, pid, "cpp", "int main(){return 0;}", "1 2\n"); + const auto draft = svc.GetDraft(1, pid); + REQUIRE(draft.has_value()); + REQUIRE(draft->language == "cpp"); + REQUIRE(draft->code.find("main") != std::string::npos); + + const auto job_id = svc.CreateSolutionJob(pid, 1, 3); + REQUIRE(job_id > 0); + + const auto latest = svc.GetLatestSolutionJob(pid); + REQUIRE(latest.has_value()); + REQUIRE(latest->id == job_id); + REQUIRE(latest->status == "queued"); + REQUIRE(latest->max_solutions == 3); + + const auto solutions = svc.ListSolutions(pid); + REQUIRE(solutions.empty()); +} diff --git a/backend/tests/sqlite_db_test.cc b/backend/tests/sqlite_db_test.cc index cb0ae78..efafb5f 100644 --- a/backend/tests/sqlite_db_test.cc +++ b/backend/tests/sqlite_db_test.cc @@ -28,6 +28,17 @@ TEST_CASE("migrations create core tables") { REQUIRE(CountTable(db.raw(), "users") == 1); REQUIRE(CountTable(db.raw(), "sessions") == 1); REQUIRE(CountTable(db.raw(), "problems") == 1); + REQUIRE(CountTable(db.raw(), "problem_tags") == 1); REQUIRE(CountTable(db.raw(), "submissions") == 1); REQUIRE(CountTable(db.raw(), "wrong_book") == 1); + REQUIRE(CountTable(db.raw(), "contests") == 1); + REQUIRE(CountTable(db.raw(), "contest_problems") == 1); + REQUIRE(CountTable(db.raw(), "contest_registrations") == 1); + REQUIRE(CountTable(db.raw(), "kb_articles") == 1); + REQUIRE(CountTable(db.raw(), "kb_article_links") == 1); + REQUIRE(CountTable(db.raw(), "import_jobs") == 1); + REQUIRE(CountTable(db.raw(), "import_job_items") == 1); + REQUIRE(CountTable(db.raw(), "problem_drafts") == 1); + REQUIRE(CountTable(db.raw(), "problem_solution_jobs") == 1); + REQUIRE(CountTable(db.raw(), "problem_solutions") == 1); } diff --git a/backend/tests/submission_http_test.cc b/backend/tests/submission_http_test.cc new file mode 100644 index 0000000..da785fa --- /dev/null +++ b/backend/tests/submission_http_test.cc @@ -0,0 +1,80 @@ +#include + +#include "csp/app_state.h" +#include "csp/controllers/submission_controller.h" +#include "csp/services/auth_service.h" +#include "csp/services/problem_service.h" + +#include + +#include + +namespace { + +drogon::HttpResponsePtr CallRun(csp::controllers::SubmissionController& ctl, + const std::string& code, + const std::string& input) { + Json::Value body; + body["code"] = code; + body["input"] = input; + auto req = drogon::HttpRequest::newHttpJsonRequest(body); + req->setMethod(drogon::Post); + + std::promise p; + ctl.runCpp(req, [&p](const drogon::HttpResponsePtr& resp) { p.set_value(resp); }); + return p.get_future().get(); +} + +drogon::HttpResponsePtr CallSubmit(csp::controllers::SubmissionController& ctl, + int64_t problem_id, + const std::string& token, + const std::string& code) { + Json::Value body; + body["language"] = "cpp"; + body["code"] = code; + auto req = drogon::HttpRequest::newHttpJsonRequest(body); + req->setMethod(drogon::Post); + req->addHeader("Authorization", "Bearer " + token); + + std::promise p; + ctl.submitProblem(req, + [&p](const drogon::HttpResponsePtr& resp) { + p.set_value(resp); + }, + problem_id); + return p.get_future().get(); +} + +} // namespace + +TEST_CASE("submission controller run and submit") { + csp::AppState::Instance().Init(":memory:"); + + csp::services::AuthService auth(csp::AppState::Instance().db()); + const auto user = auth.Register("submission_http_user", "password123"); + + csp::services::ProblemService problems(csp::AppState::Instance().db()); + const auto list = problems.List(csp::services::ProblemQuery{}); + REQUIRE_FALSE(list.items.empty()); + + csp::controllers::SubmissionController ctl; + + auto run = CallRun(ctl, + "#include \nint main(){std::cout<<\"ok\\n\";}", + ""); + REQUIRE(run->statusCode() == drogon::k200OK); + + auto submit = CallSubmit( + ctl, + list.items.front().id, + user.token, + R"CPP(#include +using namespace std; +int main(){ long long a,b; if(!(cin>>a>>b)) return 0; cout << (a+b) << "\n"; } +)CPP"); + + REQUIRE(submit->statusCode() == drogon::k200OK); + auto submit_json = submit->jsonObject(); + REQUIRE(submit_json != nullptr); + REQUIRE((*submit_json)["data"]["id"].asInt64() > 0); +} diff --git a/backend/tests/submission_service_test.cc b/backend/tests/submission_service_test.cc new file mode 100644 index 0000000..7ffb61b --- /dev/null +++ b/backend/tests/submission_service_test.cc @@ -0,0 +1,86 @@ +#include + +#include "csp/db/sqlite_db.h" +#include "csp/domain/enum_strings.h" +#include "csp/services/auth_service.h" +#include "csp/services/problem_service.h" +#include "csp/services/submission_service.h" +#include "csp/services/wrong_book_service.h" + +namespace { + +const char* kAcCode = R"CPP(#include +using namespace std; +int main() { + long long a, b; + if (!(cin >> a >> b)) return 0; + cout << (a + b) << "\n"; + return 0; +} +)CPP"; + +const char* kWaCode = R"CPP(#include +using namespace std; +int main() { + cout << 0 << "\n"; + return 0; +} +)CPP"; + +} // namespace + +TEST_CASE("submission service judge and wrong-book flow") { + auto db = csp::db::SqliteDb::OpenMemory(); + csp::db::ApplyMigrations(db); + csp::db::SeedDemoData(db); + + csp::services::AuthService auth(db); + const auto user = auth.Register("submit_user", "password123"); + + csp::services::ProblemService problems(db); + csp::services::ProblemQuery q; + const auto list = problems.List(q); + REQUIRE_FALSE(list.items.empty()); + const int64_t problem_id = list.items.front().id; + + csp::services::SubmissionService submissions(db); + csp::services::WrongBookService wrong_book(db); + + csp::services::SubmissionCreateRequest bad; + bad.user_id = user.user_id; + bad.problem_id = problem_id; + bad.language = "cpp"; + bad.code = kWaCode; + + const auto bad_result = submissions.CreateAndJudge(bad); + REQUIRE(bad_result.status == csp::domain::SubmissionStatus::WA); + + const auto wb_after_bad = wrong_book.ListByUser(user.user_id); + REQUIRE_FALSE(wb_after_bad.empty()); + + csp::services::SubmissionCreateRequest good; + good.user_id = user.user_id; + good.problem_id = problem_id; + good.language = "cpp"; + good.code = kAcCode; + + const auto good_result = submissions.CreateAndJudge(good); + REQUIRE(good_result.status == csp::domain::SubmissionStatus::AC); + + bool still_in_wrong_book = false; + for (const auto& row : wrong_book.ListByUser(user.user_id)) { + if (row.item.problem_id == problem_id) { + still_in_wrong_book = true; + break; + } + } + REQUIRE_FALSE(still_in_wrong_book); + + const auto run_only = + submissions.RunOnlyCpp(R"CPP(#include +int main(){std::cout<<42<<"\n";} +)CPP", + ""); + REQUIRE(run_only.status == csp::domain::SubmissionStatus::Running); + REQUIRE(run_only.stdout_text == "42\n"); +} diff --git a/docker-compose.yml b/docker-compose.yml index ecd5d27..17251c6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,6 +2,18 @@ services: backend: env_file: - .env + environment: + - OI_IMPORT_AUTO_RUN=true + - OI_IMPORT_WORKERS=3 + - OI_IMPORT_SCRIPT_PATH=/app/scripts/import_luogu_csp.py + - OI_IMPORT_CLEAR_ALL_PROBLEMS=true + - OI_IMPORT_CLEAR_EXISTING=true + - "OI_IMPORT_CLEAR_SOURCE_PREFIX=luogu:" + - CSP_GEN_AUTO_RUN=true + - CSP_GEN_COUNT=1 + - CSP_GEN_WAIT_FOR_IMPORT=true + - CSP_GEN_SCRIPT_PATH=/app/scripts/generate_cspj_problem_rag.py + - CSP_SOLUTION_SCRIPT_PATH=/app/scripts/generate_problem_solutions.py build: context: . dockerfile: Dockerfile.backend diff --git a/docs/API参考.md b/docs/API参考.md new file mode 100644 index 0000000..77af0cb --- /dev/null +++ b/docs/API参考.md @@ -0,0 +1,142 @@ +# API 参考(v1) + +统一前缀:`/api/v1` + +> Docker/生产推荐通过前端同域反代访问:`/admin139/api/v1/...` + +## 通用约定 + +- 鉴权头:`Authorization: Bearer ` +- 成功响应:`{ "ok": true, "data": ... }`(Auth 接口除外) +- 失败响应:`{ "ok": false, "error": "..." }` + +--- + +## 1) Auth + +### `POST /auth/register` +请求: +```json +{ "username": "alice", "password": "password123" } +``` +响应: +```json +{ "ok": true, "user_id": 1, "token": "...", "expires_at": 1730000000 } +``` + +### `POST /auth/login` +请求同上,响应同上。 + +--- + +## 2) 用户与排行榜 + +### `GET /me`(需鉴权) +返回当前用户信息。 + +### `GET /leaderboard/global?limit=100` +返回全站 rating 排行。 + +--- + +## 3) 题库 + +### `GET /problems?q=&tag=&difficulty=&page=&page_size=` +返回题目列表。 + +### `GET /problems/:id` +返回题目详情。`Problem` 结构包含: + +- `statement_url`:原始 PDF 链接 +- `llm_profile_json`:固定 JSON 字符串(`title/difficulty/answer/explanation/knowledge_points/tags/...`) + +--- + +## 4) 提交与在线运行 + +### `POST /problems/:id/submit`(需鉴权) +请求: +```json +{ + "language": "cpp", + "code": "#include ...", + "contest_id": 1 +} +``` +- `contest_id` 可选;若提交到比赛,需已报名且比赛进行中。 + +### `GET /submissions?user_id=&problem_id=&contest_id=&page=&page_size=` +返回提交列表。 + +### `GET /submissions/:id` +返回提交详情(含代码、编译日志、运行日志)。 + +### `POST /run/cpp` +请求: +```json +{ "code": "...", "input": "1 2\n" } +``` +返回:运行状态、stdout/stderr、compile_log。 + +--- + +## 5) 错题本 + +### `GET /me/wrong-book`(需鉴权) +返回当前用户错题本。 + +### `PATCH /me/wrong-book/:problemId`(需鉴权) +请求: +```json +{ "note": "复盘思路" } +``` + +### `DELETE /me/wrong-book/:problemId`(需鉴权) +删除错题项。 + +--- + +## 6) 模拟竞赛 + +### `GET /contests` +返回比赛列表。 + +### `GET /contests/:id` +返回比赛详情与题单;若请求带 token,还会返回 `registered`。 + +### `POST /contests/:id/register`(需鉴权) +报名比赛。 + +### `GET /contests/:id/leaderboard` +比赛排行榜(按 solved desc, penalty asc)。 + +--- + +## 7) 学习知识库 + +### `GET /kb/articles` +返回知识库文章列表。 + +### `GET /kb/articles/:slug` +返回文章详情与关联题目。 + +--- + +## 8) 题库导入任务(PDF + LLM) + +### `GET /import/jobs/latest` +返回最近一次导入任务及运行状态(`runner_running`)。 + +### `GET /import/jobs/:id` +返回指定导入任务详情。 + +### `GET /import/jobs/:id/items?status=&page=&page_size=` +返回任务明细(每个 PDF 的处理状态、结果或错误)。 + +### `POST /import/jobs/run` +手动触发导入任务(若已有运行中的任务会返回 `409`)。 + +请求体(可选): +```json +{ "clear_all_problems": true } +``` diff --git a/docs/Docker部署.md b/docs/Docker部署.md index 6e0f60e..49d933d 100644 --- a/docs/Docker部署.md +++ b/docs/Docker部署.md @@ -1,24 +1,137 @@ -# Docker Compose 部署 +# Docker Compose 部署指南 -## 一键启动 +## 1. 启动 ```bash docker compose up -d --build ``` -## 访问 +查看状态: -- 前端:http://localhost:7888 -- 后端(通过前端反代):http://localhost:7888/admin139/api/health -- 后端(注册):`POST http://localhost:7888/admin139/api/v1/auth/register` -- 后端(登录):`POST http://localhost:7888/admin139/api/v1/auth/login` +```bash +docker compose ps +docker compose logs --tail=100 backend +docker compose logs --tail=100 frontend +``` -## 数据持久化 +## 2. 访问地址 -- SQLite 数据库文件:compose volume `csp_data` -> 容器内 `/data/csp.db` +- 前端:`http://<服务器IP>:7888/` +- 后端健康检查(反代):`http://<服务器IP>:7888/admin139/api/health` -## 停止 +示例: + +- `http://caddns.pandoras.work:7888/` +- `http://caddns.pandoras.work:7888/admin139/api/health` + +## 3. 端口与持久化 + +- `7888:3000`(frontend 对外) +- backend 默认不直接暴露端口(经 frontend 反代) +- 数据卷:`csp_data` -> `/data/csp.db` + +## 4. 常用运维命令 ```bash docker compose down +docker compose up -d --build +docker compose restart frontend backend ``` + +## 4.1 初始化大规模题库(winterant/oi) + +```bash +set -a; source .env; set +a +python3 scripts/import_winterant_oi.py \ + --db-path /var/lib/docker/volumes/csp_csp_data/_data/csp.db \ + --workers 3 \ + --clear-all-problems +``` + +当前导入流程为:下载 PDF -> LLM 识别 -> 固定 JSON 落库 + 任务状态落库(内置 500/502/503/504 自动重试)。 + +仅下载导入、跳过 LLM: + +```bash +python3 scripts/import_winterant_oi.py --skip-llm --workers 3 +``` + +前端可通过 `/imports` 查看导入进度与明细结果。 + +请在 `.env` 中配置: + +```env +OI_LLM_API_URL=https://one.hao.work/v1/chat/completions +OI_LLM_API_KEY= +OI_LLM_MODEL=qwen3-max +OI_LLM_STREAM=true +OI_LLM_RETRY_MAX=5 +OI_LLM_RETRY_SLEEP_SEC=1.5 +OI_PDF_RETRY_MAX=5 +OI_PDF_RETRY_SLEEP_SEC=1.5 +OI_IMPORT_DIRECT_FALLBACK=true +OI_IMPORT_PREFER_DIRECT=true +OI_IMPORT_AUTO_RUN=true +OI_IMPORT_WORKERS=3 +OI_IMPORT_CLEAR_EXISTING=true +OI_IMPORT_CLEAR_SOURCE_PREFIX=winterant/oi +OI_IMPORT_CLEAR_ALL_PROBLEMS=false +``` + +## 5. 故障排查 + +### 5.1 无法访问 7888 + +1) 检查监听: +```bash +ss -ltnp | rg 7888 +``` + +2) 检查容器端口映射: +```bash +docker compose ps +``` + +3) 本机探活: +```bash +curl -i http://127.0.0.1:7888/ +curl -i http://127.0.0.1:7888/admin139/api/health +``` + +### 5.2 检查是否被 Clash 拦截 + +通常 inbound 到 `7888` 不会被 Clash 代理规则拦截,但可按下列方式确认: + +```bash +ps -ef | rg clash +cat /opt/clash/runtime.yaml | rg "allow-lan|port|socks-port|redir-port" +iptables -t nat -S +nft list ruleset +``` + +若你希望 Clash 自身也允许局域网访问(与本项目 7888 端口不是同一件事),可设置: + +- `/opt/clash/runtime.yaml` 中 `allow-lan: true` +- 重启 Clash 服务/进程 + +### 5.3 Docker 拉取慢/失败 + +可为 Docker 配置镜像加速,例如: + +`/etc/docker/daemon.json` +```json +{ + "registry-mirrors": ["https://docker.m.daocloud.io"] +} +``` + +然后: +```bash +systemctl daemon-reload +systemctl restart docker +``` + +## 6. 安全建议 + +- 生产环境建议在 Nginx/Caddy 前加 TLS。 +- 在线编译运行属于高风险能力,建议部署到隔离沙箱执行。 diff --git a/docs/平台总体设计.md b/docs/平台总体设计.md index f96bfb5..be744dd 100644 --- a/docs/平台总体设计.md +++ b/docs/平台总体设计.md @@ -1,174 +1,63 @@ -# 平台总体设计(草案) +# 平台总体设计 -> 目标:面向初学者的 OI/CSP 学习知识库 + 日常练习 + 模拟竞赛系统,并提供在线 C++ 编写/编译/调试能力。前后端分离:Next.js + C++(Drogon) + SQLite。 +> 目标:围绕 CSP/OI 学习闭环构建一体化系统:知识学习 -> 日常练习 -> 错题复盘 -> 模拟竞赛 -> 排名反馈。 -## 1. 术语与核心对象 +## 1. 产品模块 -- **用户(User)**:注册/登录;拥有积分、等级、学习进度。 -- **题目(Problem)**:题面、标签、难度、来源(如 CSP/NOIP/自建)。 -- **提交(Submission)**:用户对题目的一次代码提交(含编译/运行结果、耗时、内存、得分)。 -- **练习(Practice)**:非比赛场景的做题记录(可以直接通过 submissions 体现)。 -- **错题本(WrongBook)**:用户在练习/比赛中未通过的题目集合 + 错因备注。 -- **比赛(Contest)**:模拟 CSP/NOIP 的比赛;包含题目列表、开始/结束、计分规则。 -- **排名(Leaderboard)**:全站积分排行、比赛排行。 -- **知识库(KnowledgeBase)**:学习文章/笔记/专题目录;可关联题目。 +1. 用户与鉴权 +2. 题库与在线提交 +3. 提交记录与结果追踪 +4. 错题本与复盘 +5. 模拟竞赛与排行 +6. 学习知识库 +7. 在线 C++ 编写/编译/运行 ## 2. 技术架构 -### 2.1 前端 +### 前端(Next.js) +- App Router + TypeScript +- 页面模块:`/problems`、`/submissions`、`/wrong-book`、`/contests`、`/kb`、`/run`、`/me`、`/leaderboard` +- 通过 `/admin139` 反代后端 API,避免跨域复杂度 -- Next.js(App Router) + TypeScript + Tailwind -- 负责:题库/题面、编辑器页面、提交列表、错题本、排行、比赛大厅、知识库阅读 +### 后端(Drogon C++) +- Controller:HTTP 路由与请求校验 +- Service:业务逻辑(题库/提交/错题本/竞赛/知识库) +- Domain:实体与 JSON 序列化 +- DB:SQLite + 启动迁移 + 演示数据种子 -### 2.2 后端 +### 数据层(SQLite) +- 单文件数据库,便于快速部署 +- 覆盖用户、题库、提交、错题本、竞赛、知识库完整模型 -- Drogon (HTTP + JSON) -- SQLite(单文件数据库,便于部署) -- 模块分层(建议): - - `controller/`:HTTP 路由 - - `service/`:业务逻辑 - - `repo/`:DB 访问(SQL + 映射) - - `domain/`:实体与枚举 - - `judge/`:编译与判题执行器(后续) +## 3. 核心业务流 -### 2.3 在线编译/运行(安全边界) +### 3.1 练习流 +1) 用户登录 +2) 浏览题目并提交 +3) 后端调用 `g++` 编译并运行样例 +4) 结果写入 `submissions` +5) 若未通过,写入/更新 `wrong_book` +6) 若首次 AC,提升 `users.rating` -- MVP:后端在临时目录中调用 `g++` 编译,并用子进程运行,使用 `ulimit`/超时 kill 做基础限制。 -- 生产建议:判题/运行必须放在容器或隔离工具(如 nsjail/isolate)中;否则存在逃逸风险。 +### 3.2 竞赛流 +1) 用户报名比赛 +2) 在比赛时间内提交比赛题目 +3) 按 AC 数与罚时生成排行榜 -## 3. 数据库设计(SQLite) +### 3.3 学习流 +1) 浏览知识库文章 +2) 查看文章关联题目 +3) 进入题目页面进行练习 -### 3.1 表清单 +## 4. 安全与运行边界 -1) `users` -- `id` INTEGER PK -- `username` TEXT UNIQUE -- `password_hash` TEXT -- `created_at` INTEGER -- `rating` INTEGER DEFAULT 0 (综合积分) +- 当前在线运行模块用于开发/教学环境。 +- 生产环境应将编译运行迁移到受限沙箱,避免宿主机风险。 -2) `problems` -- `id` INTEGER PK -- `slug` TEXT UNIQUE -- `title` TEXT -- `statement_md` TEXT -- `difficulty` INTEGER -- `source` TEXT -- `created_at` INTEGER +## 5. 可扩展方向 -3) `problem_tags` -- `problem_id` INTEGER -- `tag` TEXT -- PK(`problem_id`,`tag`) +- 增加多测试点判题与标准答案管理 +- 引入更细粒度比赛规则(罚时、封榜、重判) +- 支持多语言判题 +- 增加学习路径与章节化知识图谱 -4) `submissions` -- `id` INTEGER PK -- `user_id` INTEGER -- `problem_id` INTEGER -- `language` TEXT (先支持 cpp) -- `code` TEXT -- `status` TEXT (Pending/Compiling/Running/AC/WA/TLE/MLE/RE/CE) -- `score` INTEGER -- `time_ms` INTEGER -- `memory_kb` INTEGER -- `created_at` INTEGER - -5) `wrong_book` -- `user_id` INTEGER -- `problem_id` INTEGER -- `last_submission_id` INTEGER -- `note` TEXT -- `updated_at` INTEGER -- PK(`user_id`,`problem_id`) - -6) `contests` -- `id` INTEGER PK -- `title` TEXT -- `starts_at` INTEGER -- `ends_at` INTEGER -- `rule_json` TEXT (计分规则/罚时规则) - -7) `contest_problems` -- `contest_id` INTEGER -- `problem_id` INTEGER -- `idx` INTEGER -- PK(`contest_id`,`problem_id`) - -8) `contest_registrations` -- `contest_id` INTEGER -- `user_id` INTEGER -- `registered_at` INTEGER -- PK(`contest_id`,`user_id`) - -9) `kb_articles` -- `id` INTEGER PK -- `slug` TEXT UNIQUE -- `title` TEXT -- `content_md` TEXT -- `created_at` INTEGER - -10) `kb_article_links` -- `article_id` INTEGER -- `problem_id` INTEGER -- PK(`article_id`,`problem_id`) - -### 3.2 积分/排行 - -- 全站排行:按 `users.rating` 降序。 -- rating 更新策略(MVP): - - 练习 AC:+difficulty * 常数 - - 比赛:按名次发放奖励分(rule_json 可配置) - -> 该策略后续可替换为 ELO/Codeforces 风格。 - -## 4. HTTP API 设计(v1草案) - -统一前缀:`/api/v1` - -### 4.1 Auth -- `POST /auth/register` {username,password} -- `POST /auth/login` {username,password} -> {token} -- 鉴权:`Authorization: Bearer `(MVP 可用 HMAC JWT) - -### 4.2 Problems -- `GET /problems?tag=&difficulty=&q=&page=` -- `GET /problems/:id` - -### 4.3 Submissions -- `POST /problems/:id/submit` {language,code} -- `GET /submissions?user_id=&problem_id=&page=` -- `GET /submissions/:id` - -### 4.4 WrongBook -- `GET /me/wrong-book` -- `PATCH /me/wrong-book/:problemId` {note} -- `DELETE /me/wrong-book/:problemId` - -### 4.5 Contests -- `GET /contests` -- `GET /contests/:id` -- `POST /contests/:id/register` -- `GET /contests/:id/leaderboard` - -### 4.6 Leaderboard -- `GET /leaderboard/global` - -### 4.7 Knowledge Base -- `GET /kb/articles` -- `GET /kb/articles/:slug` - -## 5. 测试策略(TDD) - -- `repo` 层:用内存 SQLite(`:memory:`)+ 迁移脚本,做 CRUD 单测。 -- `service` 层:对积分、错题本更新等写业务单测。 -- `controller` 层:Drogon 自带测试/或以 HTTP 集成测试(启动测试服务器,发请求断言 JSON)。 - -## 6. 下一步落地顺序(MVP) - -1) 用户注册/登录(token) -2) 题库(只读)+ 提交记录入库 -3) 在线编译(仅编译 + 返回CE/OK),再扩展到运行 -4) 错题本(由 WA/未通过自动写入) -5) 积分与全站排行 -6) 比赛(创建/报名/排行榜) -7) 知识库文章与题目关联 diff --git a/docs/数据库设计.md b/docs/数据库设计.md new file mode 100644 index 0000000..db40d55 --- /dev/null +++ b/docs/数据库设计.md @@ -0,0 +1,137 @@ +# 数据库设计(SQLite) + +数据库文件默认位置:`/data/csp.db`(Docker volume `csp_data` 持久化)。 + +## 1. 核心表 + +### `users` +- `id` INTEGER PK AUTOINCREMENT +- `username` TEXT UNIQUE +- `password_salt` TEXT +- `password_hash` TEXT +- `rating` INTEGER DEFAULT 0 +- `created_at` INTEGER + +### `sessions` +- `token` TEXT PK +- `user_id` INTEGER FK -> users.id +- `expires_at` INTEGER +- `created_at` INTEGER + +### `problems` +- `id` INTEGER PK AUTOINCREMENT +- `slug` TEXT UNIQUE +- `title` TEXT +- `statement_md` TEXT +- `difficulty` INTEGER +- `source` TEXT +- `statement_url` TEXT(原始 PDF/题面链接) +- `llm_profile_json` TEXT(固定 JSON:难度、答案、讲解、知识点、标签等) +- `sample_input` TEXT +- `sample_output` TEXT +- `created_at` INTEGER + +### `problem_tags` +- `problem_id` INTEGER FK -> problems.id +- `tag` TEXT +- PK(`problem_id`, `tag`) + +### `submissions` +- `id` INTEGER PK AUTOINCREMENT +- `user_id` INTEGER FK -> users.id +- `problem_id` INTEGER FK -> problems.id +- `contest_id` INTEGER NULL FK -> contests.id +- `language` TEXT +- `code` TEXT +- `status` TEXT (`Pending/Compiling/Running/AC/WA/TLE/MLE/RE/CE/Unknown`) +- `score` INTEGER +- `time_ms` INTEGER +- `memory_kb` INTEGER +- `compile_log` TEXT +- `runtime_log` TEXT +- `created_at` INTEGER + +### `wrong_book` +- `user_id` INTEGER FK -> users.id +- `problem_id` INTEGER FK -> problems.id +- `last_submission_id` INTEGER NULL FK -> submissions.id +- `note` TEXT +- `updated_at` INTEGER +- PK(`user_id`, `problem_id`) + +### `contests` +- `id` INTEGER PK AUTOINCREMENT +- `title` TEXT +- `starts_at` INTEGER +- `ends_at` INTEGER +- `rule_json` TEXT + +### `contest_problems` +- `contest_id` INTEGER FK -> contests.id +- `problem_id` INTEGER FK -> problems.id +- `idx` INTEGER +- PK(`contest_id`, `problem_id`) + +### `contest_registrations` +- `contest_id` INTEGER FK -> contests.id +- `user_id` INTEGER FK -> users.id +- `registered_at` INTEGER +- PK(`contest_id`, `user_id`) + +### `kb_articles` +- `id` INTEGER PK AUTOINCREMENT +- `slug` TEXT UNIQUE +- `title` TEXT +- `content_md` TEXT +- `created_at` INTEGER + +### `kb_article_links` +- `article_id` INTEGER FK -> kb_articles.id +- `problem_id` INTEGER FK -> problems.id +- PK(`article_id`, `problem_id`) + +### `import_jobs` +- `id` INTEGER PK AUTOINCREMENT +- `status` TEXT(`running/success/partial_failed/failed`) +- `trigger` TEXT(`auto/manual`) +- `total_count` / `processed_count` / `success_count` / `failed_count` +- `options_json` TEXT(本次导入参数快照) +- `last_error` TEXT +- `started_at` / `finished_at` / `updated_at` / `created_at` + +### `import_job_items` +- `id` INTEGER PK AUTOINCREMENT +- `job_id` INTEGER FK -> import_jobs.id +- `source_path` TEXT +- `status` TEXT(`queued/running/success/failed`) +- `title` TEXT +- `difficulty` INTEGER +- `problem_id` INTEGER NULL FK -> problems.id +- `error_text` TEXT +- `started_at` / `finished_at` / `updated_at` / `created_at` +- UNIQUE(`job_id`, `source_path`) + +## 2. 关键索引 + +- `idx_submissions_user_created_at` +- `idx_submissions_problem_created_at` +- `idx_submissions_contest_user_created_at` +- `idx_problem_tags_tag` +- `idx_kb_article_links_problem_id` +- `idx_import_jobs_created_at` +- `idx_import_jobs_status` +- `idx_import_job_items_job_status` + +## 3. 初始化与演示数据 + +系统启动时会自动: + +1. 执行 `ApplyMigrations` +2. 执行 `SeedDemoData` + +演示数据包含: + +- 基础题目(A+B、Fibonacci、排序) +- 题目标签(math/dp/sort 等) +- 知识库文章(快速 IO、DP 入门) +- 示例模拟赛(含题目关联) diff --git a/docs/测试与TDD.md b/docs/测试与TDD.md new file mode 100644 index 0000000..bd102c8 --- /dev/null +++ b/docs/测试与TDD.md @@ -0,0 +1,44 @@ +# 测试与 TDD + +## 1. 测试分层 + +后端采用 Catch2,按分层设计测试: + +1. **DB/迁移层**:表结构、索引、迁移兼容性 +2. **Service 层**:题库、提交判题、错题本、竞赛、知识库业务逻辑 +3. **Controller 层**:鉴权、参数解析、核心 HTTP 路径 + +## 2. 执行命令 + +```bash +cmake -S . -B build -G Ninja +cmake --build build +ctest --test-dir build -V +``` + +## 3. 现有测试覆盖 + +- `sqlite_db_test.cc`:迁移后核心表存在 +- `auth_service_test.cc`:注册/登录/token 校验 +- `auth_http_test.cc`:Auth 接口 +- `problem_service_test.cc`:题库查询 +- `submission_service_test.cc`:提交评测 + 错题本流转 +- `contest_service_test.cc`:竞赛排行榜逻辑 +- `kb_service_test.cc`:知识库读取 +- `problem_http_test.cc`:题库 HTTP +- `me_http_test.cc`:个人信息 + 错题本 HTTP +- `contest_http_test.cc`:竞赛 HTTP +- `submission_http_test.cc`:运行与提交 HTTP + +## 4. TDD 落地流程建议 + +1. 先写失败测试(接口契约/业务规则) +2. 实现最小功能通过测试 +3. 重构并保持测试全绿 +4. 增加边界条件测试(无 token、参数错误、not found、竞赛状态校验) + +## 5. 注意事项 + +- 当前判题使用本机 `g++` + `timeout`,用于开发环境。 +- 生产环境建议接入隔离沙箱(如 isolate/nsjail/容器沙箱)。 + diff --git a/frontend/README.md b/frontend/README.md index e215bc4..ab3af47 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,36 +1,43 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# Frontend (Next.js) -## Getting Started - -First, run the development server: +## 开发 ```bash +npm ci npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +默认访问:`http://localhost:3000` -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +## 构建 -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +```bash +npm run lint +npm run build +npm run start +``` -## Learn More +## 环境变量 -To learn more about Next.js, take a look at the following resources: +- `NEXT_PUBLIC_API_BASE`:浏览器访问后端 API 的基地址。 + - 开发默认:`http://localhost:8080` + - Docker/生产推荐:`/admin139` +- `BACKEND_INTERNAL_URL`:Next.js 反向代理后端目标(服务端) + - Docker 默认:`http://backend:8080` -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +## 页面 -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! - -## Deploy on Vercel - -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. - -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +- `/auth` 登录/注册 +- `/problems` 题库列表 +- `/problems/:id` 题目详情与提交 +- `/submissions` 提交列表 +- `/submissions/:id` 提交详情 +- `/wrong-book` 错题本 +- `/contests` 模拟竞赛列表 +- `/contests/:id` 比赛详情/报名/排行榜 +- `/kb` 知识库列表 +- `/kb/:slug` 文章详情 +- `/imports` 题库导入任务状态与结果 +- `/run` 在线 C++ 运行 +- `/me` 当前用户信息 +- `/leaderboard` 全站排行 diff --git a/frontend/next.config.ts b/frontend/next.config.ts index 9c30999..582afd3 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -6,8 +6,11 @@ const nextConfig: NextConfig = { async rewrites() { // Reverse proxy backend under a path prefix, so browser can access backend // with same-origin (no CORS): http://:7888/admin139/... - const backendInternal = process.env.BACKEND_INTERNAL_URL; - if (!backendInternal) return []; + const backendInternal = + process.env.BACKEND_INTERNAL_URL ?? + (process.env.NODE_ENV === "development" + ? "http://127.0.0.1:8080" + : "http://backend:8080"); return [ { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a36d6bc..6187bb5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,9 +8,18 @@ "name": "frontend", "version": "0.1.0", "dependencies": { + "@monaco-editor/react": "^4.7.0", + "highlight.js": "^11.11.1", + "katex": "^0.16.28", "next": "16.1.6", "react": "19.2.3", - "react-dom": "19.2.3" + "react-dom": "19.2.3", + "react-markdown": "^10.1.0", + "rehype-highlight": "^7.0.2", + "rehype-katex": "^7.0.1", + "remark-gfm": "^4.0.1", + "remark-math": "^6.0.0", + "swagger-ui-react": "^5.31.0" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -67,7 +76,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -229,6 +237,27 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime-corejs3": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.29.0.tgz", + "integrity": "sha512-TgUkdp71C9pIbBcHudc+gXZnihEDOjUAmXO1VO4HHGES7QLZcShR0stfKIxLSNIYx2fqhmJChOjm/wkF8wv4gA==", + "license": "MIT", + "dependencies": { + "core-js-pure": "^3.48.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -1022,6 +1051,29 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@monaco-editor/loader": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz", + "integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==", + "license": "MIT", + "dependencies": { + "state-local": "^1.0.6" + } + }, + "node_modules/@monaco-editor/react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz", + "integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==", + "license": "MIT", + "dependencies": { + "@monaco-editor/loader": "^1.5.0" + }, + "peerDependencies": { + "monaco-editor": ">= 0.25.0 < 1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -1234,6 +1286,655 @@ "dev": true, "license": "MIT" }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, + "node_modules/@swagger-api/apidom-ast": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ast/-/apidom-ast-1.4.0.tgz", + "integrity": "sha512-vaN7kA/okd3dWn45BRdsPHgGfMQdjL3TqRcxTmb41q6SLRJezINTE5nyJ8qVmH9bverOTAfn5IjYziwhV0lh7A==", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime-corejs3": "^7.26.10", + "@swagger-api/apidom-error": "^1.4.0", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0", + "unraw": "^3.0.0" + } + }, + "node_modules/@swagger-api/apidom-core": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-core/-/apidom-core-1.4.0.tgz", + "integrity": "sha512-otHtWG8J/0bvaKThUfbSuYG9ega1jAM4ugIcxQRRozOMS7r1+pTwntbY0my5MJwZ+TeBJfVA39NRjMPiPdGttg==", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime-corejs3": "^7.26.10", + "@swagger-api/apidom-ast": "^1.4.0", + "@swagger-api/apidom-error": "^1.4.0", + "@types/ramda": "~0.30.0", + "minim": "~0.23.8", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0", + "short-unique-id": "^5.3.2", + "ts-mixer": "^6.0.3" + } + }, + "node_modules/@swagger-api/apidom-error": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-error/-/apidom-error-1.4.0.tgz", + "integrity": "sha512-oYVvGSE/y7g2mNnfNPq1IrEhhEQ0Faz1YWZWpaLF786iT0lunzzph9JG1Q9YSl+Z5yZ6XsmbKSOdvql+gK9xCw==", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime-corejs3": "^7.20.7" + } + }, + "node_modules/@swagger-api/apidom-json-pointer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-json-pointer/-/apidom-json-pointer-1.4.0.tgz", + "integrity": "sha512-LAMOgJaKy2id2zTmMmQzSEh4HahXYfWoR6pBsdNIKaLWx6sirznFnpKHa2+shfjksAkm0Gng5/eKO1jcdySOrw==", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime-corejs3": "^7.26.10", + "@swagger-api/apidom-core": "^1.4.0", + "@swagger-api/apidom-error": "^1.4.0", + "@swaggerexpert/json-pointer": "^2.10.1" + } + }, + "node_modules/@swagger-api/apidom-ns-api-design-systems": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-api-design-systems/-/apidom-ns-api-design-systems-1.4.0.tgz", + "integrity": "sha512-uRTqvqSUdQwkiNlO9QMPcpI5ZF7PbwnavFwIkupbkZDb5r+kSdgu4bepXY9BjzvVeiOWqyvviIfTeccVcVOCeA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.26.10", + "@swagger-api/apidom-core": "^1.4.0", + "@swagger-api/apidom-error": "^1.4.0", + "@swagger-api/apidom-ns-openapi-3-1": "^1.4.0", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0", + "ts-mixer": "^6.0.3" + } + }, + "node_modules/@swagger-api/apidom-ns-arazzo-1": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-arazzo-1/-/apidom-ns-arazzo-1-1.4.0.tgz", + "integrity": "sha512-gUWldcU8cCN4zYfpGdgK+rXFkcmNCv/g9eXOvQaAzYHp+N8mccRl5E0weEDRVvn9kUOA0bew7NjEEZhMulblWQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.26.10", + "@swagger-api/apidom-core": "^1.4.0", + "@swagger-api/apidom-ns-json-schema-2020-12": "^1.4.0", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0", + "ts-mixer": "^6.0.3" + } + }, + "node_modules/@swagger-api/apidom-ns-asyncapi-2": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-asyncapi-2/-/apidom-ns-asyncapi-2-1.4.0.tgz", + "integrity": "sha512-QCzE1Dio18x/1oyElrMzwCOjnpQxlWErhcd4EIJCC61cblkHQC7feuhuZHVhH7dYqBehNH3lvJC/rBnUz8Fmlg==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.26.10", + "@swagger-api/apidom-core": "^1.4.0", + "@swagger-api/apidom-ns-json-schema-draft-7": "^1.4.0", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0", + "ts-mixer": "^6.0.3" + } + }, + "node_modules/@swagger-api/apidom-ns-asyncapi-3": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-asyncapi-3/-/apidom-ns-asyncapi-3-1.4.0.tgz", + "integrity": "sha512-XcPsuGVKO8ed8WDvA56UAUR6b8YgsL+JB/pQrSnUm2KRajg6+736c8L83a2EZB0ulaZOY2+Ex/bkuYNak0ceJA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.26.10", + "@swagger-api/apidom-core": "^1.4.0", + "@swagger-api/apidom-ns-asyncapi-2": "^1.4.0", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0", + "ts-mixer": "^6.0.3" + } + }, + "node_modules/@swagger-api/apidom-ns-json-schema-2019-09": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-json-schema-2019-09/-/apidom-ns-json-schema-2019-09-1.4.0.tgz", + "integrity": "sha512-xvsYdcfk00vcDYAK+qA2PBQs/uyuYUc3Bubv19s8rRd+gykdzm+7vZjYEMXIZuqn4ao+DDe7kIFipx31Cz4zTw==", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime-corejs3": "^7.26.10", + "@swagger-api/apidom-core": "^1.4.0", + "@swagger-api/apidom-error": "^1.4.0", + "@swagger-api/apidom-ns-json-schema-draft-7": "^1.4.0", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0", + "ts-mixer": "^6.0.4" + } + }, + "node_modules/@swagger-api/apidom-ns-json-schema-2020-12": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-json-schema-2020-12/-/apidom-ns-json-schema-2020-12-1.4.0.tgz", + "integrity": "sha512-FOadSRcNmKmXDi71DpOaNyXIvIY13FSFzCVLoMrIJfAVMmuuWN+hgBjedjnp8mkRjzTW96XRjAuCQ1WUT2OCFA==", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime-corejs3": "^7.26.10", + "@swagger-api/apidom-core": "^1.4.0", + "@swagger-api/apidom-error": "^1.4.0", + "@swagger-api/apidom-ns-json-schema-2019-09": "^1.4.0", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0", + "ts-mixer": "^6.0.4" + } + }, + "node_modules/@swagger-api/apidom-ns-json-schema-draft-4": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-json-schema-draft-4/-/apidom-ns-json-schema-draft-4-1.4.0.tgz", + "integrity": "sha512-LKzAUqHEAgm4WmuYbhPCDycr1nO/G14NwNG35RQJHVZi+FRrvzFgD80h5yNNzYjXYtNQrRGtKPTR6LXImZjWag==", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime-corejs3": "^7.26.10", + "@swagger-api/apidom-ast": "^1.4.0", + "@swagger-api/apidom-core": "^1.4.0", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0", + "ts-mixer": "^6.0.4" + } + }, + "node_modules/@swagger-api/apidom-ns-json-schema-draft-6": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-json-schema-draft-6/-/apidom-ns-json-schema-draft-6-1.4.0.tgz", + "integrity": "sha512-QoxxQw0xDpFe2SYeX96Gt2rp1MSQcvSoiiV9yR6G9j0Jbr3bipFiI6rK4OFSXq3Wcalfccjz77aiG42K9QDWUQ==", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime-corejs3": "^7.26.10", + "@swagger-api/apidom-core": "^1.4.0", + "@swagger-api/apidom-error": "^1.4.0", + "@swagger-api/apidom-ns-json-schema-draft-4": "^1.4.0", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0", + "ts-mixer": "^6.0.4" + } + }, + "node_modules/@swagger-api/apidom-ns-json-schema-draft-7": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-json-schema-draft-7/-/apidom-ns-json-schema-draft-7-1.4.0.tgz", + "integrity": "sha512-jnjuj4n26FhS2ZXwA6n86PlRi2sZ5GIP18JBRLGwDxTRCEqguX7Ar1NgKDn9VCoCmSZQ04ebLoipFGafvlA3VA==", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime-corejs3": "^7.26.10", + "@swagger-api/apidom-core": "^1.4.0", + "@swagger-api/apidom-error": "^1.4.0", + "@swagger-api/apidom-ns-json-schema-draft-6": "^1.4.0", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0", + "ts-mixer": "^6.0.4" + } + }, + "node_modules/@swagger-api/apidom-ns-openapi-2": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-openapi-2/-/apidom-ns-openapi-2-1.4.0.tgz", + "integrity": "sha512-2Uxy+dv4oU5Zw03Twkoxz1FWz4117Sz6UEWUmRaFsijGpaxWkauy6tBpp/U43D86eMRpkR6Jb6OA+UTaENYxxQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.26.10", + "@swagger-api/apidom-core": "^1.4.0", + "@swagger-api/apidom-error": "^1.4.0", + "@swagger-api/apidom-ns-json-schema-draft-4": "^1.4.0", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0", + "ts-mixer": "^6.0.3" + } + }, + "node_modules/@swagger-api/apidom-ns-openapi-3-0": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-openapi-3-0/-/apidom-ns-openapi-3-0-1.4.0.tgz", + "integrity": "sha512-012Zx/r6ncajjc98AzZNDUpt1ShXwutUtrvCuBoz8dwQzpJfPc2HNGMM6tOvYX/isqzeCG7+zlKEG8tEv0X1RA==", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime-corejs3": "^7.26.10", + "@swagger-api/apidom-core": "^1.4.0", + "@swagger-api/apidom-error": "^1.4.0", + "@swagger-api/apidom-ns-json-schema-draft-4": "^1.4.0", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0", + "ts-mixer": "^6.0.3" + } + }, + "node_modules/@swagger-api/apidom-ns-openapi-3-1": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-openapi-3-1/-/apidom-ns-openapi-3-1-1.4.0.tgz", + "integrity": "sha512-ZW91r56RyalrNdxB4vnJNOXLNi/iQZSyvUjNDkJAO5e8/z3Hf7evU1ftCpgvsQVwiVXOG+b5KfgOw3Lwbc+a2w==", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime-corejs3": "^7.26.10", + "@swagger-api/apidom-ast": "^1.4.0", + "@swagger-api/apidom-core": "^1.4.0", + "@swagger-api/apidom-json-pointer": "^1.4.0", + "@swagger-api/apidom-ns-json-schema-2020-12": "^1.4.0", + "@swagger-api/apidom-ns-openapi-3-0": "^1.4.0", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0", + "ts-mixer": "^6.0.3" + } + }, + "node_modules/@swagger-api/apidom-parser-adapter-api-design-systems-json": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-api-design-systems-json/-/apidom-parser-adapter-api-design-systems-json-1.4.0.tgz", + "integrity": "sha512-XtLjh4dHQZwLkqhYSA2Uizu33/MrJsnk2KYXujokMJN4BkfGa6thzzvjTUCC7ZFQW6hV2aMm6tmwm51UdWCCMQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.26.10", + "@swagger-api/apidom-core": "^1.4.0", + "@swagger-api/apidom-ns-api-design-systems": "^1.4.0", + "@swagger-api/apidom-parser-adapter-json": "^1.4.0", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0" + } + }, + "node_modules/@swagger-api/apidom-parser-adapter-api-design-systems-yaml": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-api-design-systems-yaml/-/apidom-parser-adapter-api-design-systems-yaml-1.4.0.tgz", + "integrity": "sha512-1adlUHS7X1FAFVFb/3XnLh/D8PLcbBcf40OXmIpdQW92fIUdhhUJfuthTD6i4no0bEplkFATyO6Yd6LYNAGSCw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.26.10", + "@swagger-api/apidom-core": "^1.4.0", + "@swagger-api/apidom-ns-api-design-systems": "^1.4.0", + "@swagger-api/apidom-parser-adapter-yaml-1-2": "^1.4.0", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0" + } + }, + "node_modules/@swagger-api/apidom-parser-adapter-arazzo-json-1": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-arazzo-json-1/-/apidom-parser-adapter-arazzo-json-1-1.4.0.tgz", + "integrity": "sha512-Xqzi8ciYxNaMIXLtWh/80xQAh6ulqd/yOxDXJKdQqAQSExGwRI3bXHo0ynIvMJIkO049VKDhvqmTv1vEmZwQUA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.26.10", + "@swagger-api/apidom-core": "^1.4.0", + "@swagger-api/apidom-ns-arazzo-1": "^1.4.0", + "@swagger-api/apidom-parser-adapter-json": "^1.4.0", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0" + } + }, + "node_modules/@swagger-api/apidom-parser-adapter-arazzo-yaml-1": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-arazzo-yaml-1/-/apidom-parser-adapter-arazzo-yaml-1-1.4.0.tgz", + "integrity": "sha512-HkWOuNjKnzgaa/ZCSLT1v+kqBof1dou6m3dXUfWw/hTpXk4Pp/kwKtqz9n+LCvG2bNfVEZFvwgGF3N6ZSujdog==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.26.10", + "@swagger-api/apidom-core": "^1.4.0", + "@swagger-api/apidom-ns-arazzo-1": "^1.4.0", + "@swagger-api/apidom-parser-adapter-yaml-1-2": "^1.4.0", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0" + } + }, + "node_modules/@swagger-api/apidom-parser-adapter-asyncapi-json-2": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-asyncapi-json-2/-/apidom-parser-adapter-asyncapi-json-2-1.4.0.tgz", + "integrity": "sha512-i0WWw0xPZvPf3jTET0YzE8INu7PyNTyypD09WCKhhKgmFpNmZ2SHbHTP6+/OTTRu4nId9+icD6cHKCma0/W2tQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.26.10", + "@swagger-api/apidom-core": "^1.4.0", + "@swagger-api/apidom-ns-asyncapi-2": "^1.4.0", + "@swagger-api/apidom-parser-adapter-json": "^1.4.0", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0" + } + }, + "node_modules/@swagger-api/apidom-parser-adapter-asyncapi-json-3": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-asyncapi-json-3/-/apidom-parser-adapter-asyncapi-json-3-1.4.0.tgz", + "integrity": "sha512-zKklrdThBa9yqmYeZYyX5zd0NKCLIUOIkV1HtMXsCg2f9ceTHldtenn3RvqYMY0EDd6IpVRfLfOhUNPpttTLOg==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.26.10", + "@swagger-api/apidom-core": "^1.4.0", + "@swagger-api/apidom-ns-asyncapi-3": "^1.4.0", + "@swagger-api/apidom-parser-adapter-json": "^1.4.0", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0" + } + }, + "node_modules/@swagger-api/apidom-parser-adapter-asyncapi-yaml-2": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-asyncapi-yaml-2/-/apidom-parser-adapter-asyncapi-yaml-2-1.4.0.tgz", + "integrity": "sha512-+hzzfCSJ8KaoApYmI22aYKoXi0La0/696FaOnCloPloKZwcPvjR5S7WxleZTMuAcXzeM4R/92LbD5BTUein4Ow==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.26.10", + "@swagger-api/apidom-core": "^1.4.0", + "@swagger-api/apidom-ns-asyncapi-2": "^1.4.0", + "@swagger-api/apidom-parser-adapter-yaml-1-2": "^1.4.0", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0" + } + }, + "node_modules/@swagger-api/apidom-parser-adapter-asyncapi-yaml-3": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-asyncapi-yaml-3/-/apidom-parser-adapter-asyncapi-yaml-3-1.4.0.tgz", + "integrity": "sha512-R7cbEbFYTJ8hCV04VkaRap54T0t4Q+v9gXvxVf+Kn1csRXoR0O/yJBakCcmv7taswzv+1dCWbjJOJSHDJFIB0w==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.26.10", + "@swagger-api/apidom-core": "^1.4.0", + "@swagger-api/apidom-ns-asyncapi-3": "^1.4.0", + "@swagger-api/apidom-parser-adapter-yaml-1-2": "^1.4.0", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0" + } + }, + "node_modules/@swagger-api/apidom-parser-adapter-json": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-json/-/apidom-parser-adapter-json-1.4.0.tgz", + "integrity": "sha512-QfwjuG1UaEYGf+tpPfN6GAZTJezhq2uy5Dgxmxe1f9Z/0V3mZmmdFck+NEXU698tAvFsf+qMeFmeR22EB4mzSg==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.26.10", + "@swagger-api/apidom-ast": "^1.4.0", + "@swagger-api/apidom-core": "^1.4.0", + "@swagger-api/apidom-error": "^1.4.0", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0", + "tree-sitter": "=0.22.4", + "tree-sitter-json": "=0.24.8", + "web-tree-sitter": "=0.24.5" + } + }, + "node_modules/@swagger-api/apidom-parser-adapter-json/node_modules/tree-sitter": { + "version": "0.22.4", + "resolved": "https://registry.npmjs.org/tree-sitter/-/tree-sitter-0.22.4.tgz", + "integrity": "sha512-usbHZP9/oxNsUY65MQUsduGRqDHQOou1cagUSwjhoSYAmSahjQDAVsh9s+SlZkn8X8+O1FULRGwHu7AFP3kjzg==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-addon-api": "^8.3.0", + "node-gyp-build": "^4.8.4" + } + }, + "node_modules/@swagger-api/apidom-parser-adapter-openapi-json-2": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-json-2/-/apidom-parser-adapter-openapi-json-2-1.4.0.tgz", + "integrity": "sha512-1crFQpjzGod/wdW8BE1C8j/qMe02EaUlXt8ZWePns3GzEWSEdxRuiiNh/b0EzXVlAUStcRigzaajNsn+KT3tUA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.26.10", + "@swagger-api/apidom-core": "^1.4.0", + "@swagger-api/apidom-ns-openapi-2": "^1.4.0", + "@swagger-api/apidom-parser-adapter-json": "^1.4.0", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0" + } + }, + "node_modules/@swagger-api/apidom-parser-adapter-openapi-json-3-0": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-json-3-0/-/apidom-parser-adapter-openapi-json-3-0-1.4.0.tgz", + "integrity": "sha512-K7r2qZ3wNu+ql4yUsTILbMCTZ5BY0deEi+ig+9KskRqjJ/0TZeeqq3rj8GCgHw7ocQh2WnMBeGGX1YO8bWBAUQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.26.10", + "@swagger-api/apidom-core": "^1.4.0", + "@swagger-api/apidom-ns-openapi-3-0": "^1.4.0", + "@swagger-api/apidom-parser-adapter-json": "^1.4.0", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0" + } + }, + "node_modules/@swagger-api/apidom-parser-adapter-openapi-json-3-1": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-json-3-1/-/apidom-parser-adapter-openapi-json-3-1-1.4.0.tgz", + "integrity": "sha512-oECL5TrqbYzVRv17Jco1G3KruZkh7N7iKhfqgBcb1irZ4ozXNlv1FtZ8bZxpczXxvGhSkSLFKbEd0pHZaO0iPA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.26.10", + "@swagger-api/apidom-core": "^1.4.0", + "@swagger-api/apidom-ns-openapi-3-1": "^1.4.0", + "@swagger-api/apidom-parser-adapter-json": "^1.4.0", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0" + } + }, + "node_modules/@swagger-api/apidom-parser-adapter-openapi-yaml-2": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-yaml-2/-/apidom-parser-adapter-openapi-yaml-2-1.4.0.tgz", + "integrity": "sha512-Ai0j0oInutDAb0Lvpb/UBhatc0QOLgdYpAPupafvjCEFt6fTpomO2HjNA+4Z+0cqH69ggKgQ/ldJ5G8FgqKcrw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.26.10", + "@swagger-api/apidom-core": "^1.4.0", + "@swagger-api/apidom-ns-openapi-2": "^1.4.0", + "@swagger-api/apidom-parser-adapter-yaml-1-2": "^1.4.0", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0" + } + }, + "node_modules/@swagger-api/apidom-parser-adapter-openapi-yaml-3-0": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-yaml-3-0/-/apidom-parser-adapter-openapi-yaml-3-0-1.4.0.tgz", + "integrity": "sha512-DD6JwfHcywPxZl7e3UqeAU4qdzjQaVudUnnFHWH83m6qWEHldN1+vSfQTzn4DQgi9dz88XfIakUJ1HFHIfEBhg==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.26.10", + "@swagger-api/apidom-core": "^1.4.0", + "@swagger-api/apidom-ns-openapi-3-0": "^1.4.0", + "@swagger-api/apidom-parser-adapter-yaml-1-2": "^1.4.0", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0" + } + }, + "node_modules/@swagger-api/apidom-parser-adapter-openapi-yaml-3-1": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-yaml-3-1/-/apidom-parser-adapter-openapi-yaml-3-1-1.4.0.tgz", + "integrity": "sha512-FWGMgP1iJGIXISLSyz0j/Pthux11H712gPYzaL7DRZz8wevQvgc4TrYF4hHZwSvN8xpEkJLURBclfwsLDKzIXQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.26.10", + "@swagger-api/apidom-core": "^1.4.0", + "@swagger-api/apidom-ns-openapi-3-1": "^1.4.0", + "@swagger-api/apidom-parser-adapter-yaml-1-2": "^1.4.0", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0" + } + }, + "node_modules/@swagger-api/apidom-parser-adapter-yaml-1-2": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-yaml-1-2/-/apidom-parser-adapter-yaml-1-2-1.4.0.tgz", + "integrity": "sha512-rqVxBEiuCyBbNhnFUsBxPEewFjKDBYkiFqJGmwNo79G51wJOK9GnSjT1AyMZ+fW3RDlDZ+HR8IojQCykhYoAGg==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.26.10", + "@swagger-api/apidom-ast": "^1.4.0", + "@swagger-api/apidom-core": "^1.4.0", + "@swagger-api/apidom-error": "^1.4.0", + "@tree-sitter-grammars/tree-sitter-yaml": "=0.7.1", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0", + "tree-sitter": "=0.22.4", + "web-tree-sitter": "=0.24.5" + } + }, + "node_modules/@swagger-api/apidom-parser-adapter-yaml-1-2/node_modules/@tree-sitter-grammars/tree-sitter-yaml": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@tree-sitter-grammars/tree-sitter-yaml/-/tree-sitter-yaml-0.7.1.tgz", + "integrity": "sha512-AynBwkIoQCTgjDR33bDUp9Mqq+YTco0is3n5hRApMqG9of/6A4eQsfC1/uSEeHSUyMQSYawcAWamsexnVpIP4Q==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-addon-api": "^8.3.1", + "node-gyp-build": "^4.8.4" + }, + "peerDependencies": { + "tree-sitter": "^0.22.4" + }, + "peerDependenciesMeta": { + "tree-sitter": { + "optional": true + } + } + }, + "node_modules/@swagger-api/apidom-parser-adapter-yaml-1-2/node_modules/tree-sitter": { + "version": "0.22.4", + "resolved": "https://registry.npmjs.org/tree-sitter/-/tree-sitter-0.22.4.tgz", + "integrity": "sha512-usbHZP9/oxNsUY65MQUsduGRqDHQOou1cagUSwjhoSYAmSahjQDAVsh9s+SlZkn8X8+O1FULRGwHu7AFP3kjzg==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-addon-api": "^8.3.0", + "node-gyp-build": "^4.8.4" + } + }, + "node_modules/@swagger-api/apidom-reference": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-reference/-/apidom-reference-1.4.0.tgz", + "integrity": "sha512-MRqShsNMzigiJxFNYWF4YyPfzVNyEAm1E4DngYENuZxV+Hf+2Zri+DEk3FL6JPoDLLx/UYO1EB9S8VuPZeNT6g==", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime-corejs3": "^7.26.10", + "@swagger-api/apidom-core": "^1.4.0", + "@swagger-api/apidom-error": "^1.4.0", + "@types/ramda": "~0.30.0", + "axios": "^1.12.2", + "minimatch": "^7.4.3", + "process": "^0.11.10", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0" + }, + "optionalDependencies": { + "@swagger-api/apidom-json-pointer": "^1.4.0", + "@swagger-api/apidom-ns-arazzo-1": "^1.4.0", + "@swagger-api/apidom-ns-asyncapi-2": "^1.4.0", + "@swagger-api/apidom-ns-openapi-2": "^1.4.0", + "@swagger-api/apidom-ns-openapi-3-0": "^1.4.0", + "@swagger-api/apidom-ns-openapi-3-1": "^1.4.0", + "@swagger-api/apidom-parser-adapter-api-design-systems-json": "^1.4.0", + "@swagger-api/apidom-parser-adapter-api-design-systems-yaml": "^1.4.0", + "@swagger-api/apidom-parser-adapter-arazzo-json-1": "^1.4.0", + "@swagger-api/apidom-parser-adapter-arazzo-yaml-1": "^1.4.0", + "@swagger-api/apidom-parser-adapter-asyncapi-json-2": "^1.4.0", + "@swagger-api/apidom-parser-adapter-asyncapi-json-3": "^1.4.0", + "@swagger-api/apidom-parser-adapter-asyncapi-yaml-2": "^1.4.0", + "@swagger-api/apidom-parser-adapter-asyncapi-yaml-3": "^1.4.0", + "@swagger-api/apidom-parser-adapter-json": "^1.4.0", + "@swagger-api/apidom-parser-adapter-openapi-json-2": "^1.4.0", + "@swagger-api/apidom-parser-adapter-openapi-json-3-0": "^1.4.0", + "@swagger-api/apidom-parser-adapter-openapi-json-3-1": "^1.4.0", + "@swagger-api/apidom-parser-adapter-openapi-yaml-2": "^1.4.0", + "@swagger-api/apidom-parser-adapter-openapi-yaml-3-0": "^1.4.0", + "@swagger-api/apidom-parser-adapter-openapi-yaml-3-1": "^1.4.0", + "@swagger-api/apidom-parser-adapter-yaml-1-2": "^1.4.0" + } + }, + "node_modules/@swagger-api/apidom-reference/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@swagger-api/apidom-reference/node_modules/minimatch": { + "version": "7.4.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-7.4.6.tgz", + "integrity": "sha512-sBz8G/YjVniEz6lKPNpKxXwazJe4c19fEfV2GDMX6AjFz+MX9uDWIZW8XreVhkFW3fkIdTv/gxWr/Kks5FFAVw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@swaggerexpert/cookie": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@swaggerexpert/cookie/-/cookie-2.0.2.tgz", + "integrity": "sha512-DPI8YJ0Vznk4CT+ekn3rcFNq1uQwvUHZhH6WvTSPD0YKBIlMS9ur2RYKghXuxxOiqOam/i4lHJH4xTIiTgs3Mg==", + "license": "Apache-2.0", + "dependencies": { + "apg-lite": "^1.0.3" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/@swaggerexpert/json-pointer": { + "version": "2.10.2", + "resolved": "https://registry.npmjs.org/@swaggerexpert/json-pointer/-/json-pointer-2.10.2.tgz", + "integrity": "sha512-qMx1nOrzoB+PF+pzb26Q4Tc2sOlrx9Ba2UBNX9hB31Omrq+QoZ2Gly0KLrQWw4Of1AQ4J9lnD+XOdwOdcdXqqw==", + "license": "Apache-2.0", + "dependencies": { + "apg-lite": "^1.0.4" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -1525,13 +2226,39 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, "license": "MIT" }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1546,6 +2273,27 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/katex": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz", + "integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==", + "license": "MIT" + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.33", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz", @@ -1556,13 +2304,26 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/prismjs": { + "version": "1.26.6", + "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz", + "integrity": "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==", + "license": "MIT" + }, + "node_modules/@types/ramda": { + "version": "0.30.2", + "resolved": "https://registry.npmjs.org/@types/ramda/-/ramda-0.30.2.tgz", + "integrity": "sha512-PyzHvjCalm2BRYjAU6nIB3TprYwMNOUY/7P/N8bSzp9W/yM2YrtGtAnnVtaCNSeOZ8DzKyFDvaqQs7LnWwwmBA==", + "license": "MIT", + "dependencies": { + "types-ramda": "^0.30.1" + } + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1577,6 +2338,25 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.55.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.55.0.tgz", @@ -1622,7 +2402,6 @@ "integrity": "sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.55.0", "@typescript-eslint/types": "8.55.0", @@ -1847,6 +2626,12 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, "node_modules/@unrs/resolver-binding-android-arm-eabi": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", @@ -2122,7 +2907,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2173,11 +2957,16 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/apg-lite": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/apg-lite/-/apg-lite-1.0.5.tgz", + "integrity": "sha512-SlI+nLMQDzCZfS39ihzjGp3JNBQfJXyMi6cg9tkLOCPVErgFsUIAEdO9IezR7kbP5Xd0ozcPNQBkf9TO5cHgWw==", + "license": "BSD-2-Clause" + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/aria-query": { @@ -2367,11 +3156,25 @@ "node": ">= 0.4" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/autolinker": { + "version": "3.16.2", + "resolved": "https://registry.npmjs.org/autolinker/-/autolinker-3.16.2.tgz", + "integrity": "sha512-JiYl7j2Z19F9NdTmirENSUUIIL/9MytEWtmzhfmsKPCp9E+G35Y0UNCMoM9tFigxT59qSc8Ml2dlZXOCVTYwuA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + } + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", - "dev": true, "license": "MIT", "dependencies": { "possible-typed-array-names": "^1.0.0" @@ -2393,6 +3196,17 @@ "node": ">=4" } }, + "node_modules/axios": { + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", + "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -2403,11 +3217,40 @@ "node": ">= 0.4" } }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], "license": "MIT" }, "node_modules/baseline-browser-mapping": { @@ -2463,7 +3306,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2478,11 +3320,34 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.0", @@ -2501,7 +3366,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -2515,7 +3379,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -2558,6 +3421,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2575,6 +3448,52 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -2601,6 +3520,37 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2615,6 +3565,26 @@ "dev": true, "license": "MIT" }, + "node_modules/copy-to-clipboard": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz", + "integrity": "sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==", + "license": "MIT", + "dependencies": { + "toggle-selection": "^1.0.6" + } + }, + "node_modules/core-js-pure": { + "version": "3.48.0", + "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.48.0.tgz", + "integrity": "sha512-1slJgk89tWC51HQ1AEqG+s2VuwpTRr8ocu4n20QUcH1v9lAN0RXen0Q0AABa/DK1I7RrNWLucplOHMx8hfTGTw==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2630,11 +3600,16 @@ "node": ">= 8" } }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, "license": "MIT" }, "node_modules/damerau-levenshtein": { @@ -2702,7 +3677,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2716,6 +3690,28 @@ } } }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2723,11 +3719,19 @@ "dev": true, "license": "MIT" }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, "license": "MIT", "dependencies": { "es-define-property": "^1.0.0", @@ -2759,6 +3763,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -2769,6 +3791,19 @@ "node": ">=8" } }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -2782,11 +3817,29 @@ "node": ">=0.10.0" } }, + "node_modules/dompurify": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", + "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "peer": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/drange": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/drange/-/drange-1.1.1.tgz", + "integrity": "sha512-pYxfDYpued//QpnLIm4Avk7rsNtAtQkUES2cwAYSvD/wd2pKD71gN2Ebj3e7klzXwjocvE8c5vx/1fxwpqmSxA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -2825,6 +3878,18 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-abstract": { "version": "1.24.1", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", @@ -2898,7 +3963,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -2908,7 +3972,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -2946,7 +4009,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -2959,7 +4021,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -3031,7 +4092,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3440,6 +4500,16 @@ "node": ">=4.0" } }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -3450,6 +4520,12 @@ "node": ">=0.10.0" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3487,6 +4563,12 @@ "node": ">= 6" } }, + "node_modules/fast-json-patch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/fast-json-patch/-/fast-json-patch-3.1.1.tgz", + "integrity": "sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ==", + "license": "MIT" + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -3511,6 +4593,19 @@ "reusify": "^1.0.4" } }, + "node_modules/fault": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/fault/-/fault-1.0.4.tgz", + "integrity": "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==", + "license": "MIT", + "dependencies": { + "format": "^0.2.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -3575,11 +4670,30 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", - "dev": true, "license": "MIT", "dependencies": { "is-callable": "^1.2.7" @@ -3591,11 +4705,34 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/format": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", + "integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==", + "engines": { + "node": ">=0.4.x" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -3656,7 +4793,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -3681,7 +4817,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -3769,7 +4904,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -3812,7 +4946,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, "license": "MIT", "dependencies": { "es-define-property": "^1.0.0" @@ -3841,7 +4974,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -3854,7 +4986,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -3870,7 +5001,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -3879,6 +5009,174 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-from-dom": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/hast-util-from-dom/-/hast-util-from-dom-5.0.1.tgz", + "integrity": "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==", + "license": "ISC", + "dependencies": { + "@types/hast": "^3.0.0", + "hastscript": "^9.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-html": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz", + "integrity": "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.1.0", + "hast-util-from-parse5": "^8.0.0", + "parse5": "^7.0.0", + "vfile": "^6.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-html-isomorphic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hast-util-from-html-isomorphic/-/hast-util-from-html-isomorphic-2.0.0.tgz", + "integrity": "sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-from-dom": "^5.0.0", + "hast-util-from-html": "^2.0.0", + "unist-util-remove-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-parse5": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", + "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^9.0.0", + "property-information": "^7.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-is-element": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", + "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-text": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", + "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "unist-util-find-after": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -3896,6 +5194,51 @@ "hermes-estree": "0.25.1" } }, + "node_modules/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/highlightjs-vue": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/highlightjs-vue/-/highlightjs-vue-1.0.0.tgz", + "integrity": "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==", + "license": "CC0-1.0" + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3906,6 +5249,15 @@ "node": ">= 4" } }, + "node_modules/immutable": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-3.8.2.tgz", + "integrity": "sha512-15gZoQ38eYjEjxkorfbcgBKBL6R7T459OuK+CpcWt7O3KF4uPCx2tD0uFETlUDIyo+1789crbMhTvQBSR5yBMg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -3933,6 +5285,18 @@ "node": ">=0.8.19" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -3948,6 +5312,39 @@ "node": ">= 0.4" } }, + "node_modules/invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -4046,7 +5443,6 @@ "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -4106,6 +5502,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -4165,6 +5571,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-map": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", @@ -4218,6 +5634,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -4305,7 +5733,6 @@ "version": "1.1.15", "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", - "dev": true, "license": "MIT", "dependencies": { "which-typed-array": "^1.1.16" @@ -4367,7 +5794,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true, "license": "MIT" }, "node_modules/isexe": { @@ -4405,18 +5831,22 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/js-file-download": { + "version": "0.4.12", + "resolved": "https://registry.npmjs.org/js-file-download/-/js-file-download-0.4.12.tgz", + "integrity": "sha512-rML+NkoD08p5Dllpjo0ffy4jRHeY6Zsapvr/W86N7E0yuzAO6qa5X9+xog6zQNlH102J7IXljNY2FtS6Lj3ucg==", + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, "license": "MIT" }, "node_modules/js-yaml": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -4488,6 +5918,22 @@ "node": ">=4.0" } }, + "node_modules/katex": { + "version": "0.16.28", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.28.tgz", + "integrity": "sha512-YHzO7721WbmAL6Ov1uzN/l5mY5WWWhJBSW+jq4tkfZfsxmo1hu6frS0EOswvjBUnWE6NtjEs48SFn5CQESRLZg==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -4809,6 +6255,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -4816,11 +6274,20 @@ "dev": true, "license": "MIT" }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dev": true, "license": "MIT", "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" @@ -4829,6 +6296,21 @@ "loose-envify": "cli.js" } }, + "node_modules/lowlight": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz", + "integrity": "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.0.0", + "highlight.js": "~11.11.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -4849,16 +6331,339 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/marked": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", + "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", + "license": "MIT", + "peer": true, + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" } }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", + "integrity": "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-math": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-math/-/mdast-util-math-3.0.0.tgz", + "integrity": "sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "longest-streak": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.1.0", + "unist-util-remove-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -4869,6 +6674,588 @@ "node": ">= 8" } }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-math": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-math/-/micromark-extension-math-3.1.0.tgz", + "integrity": "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==", + "license": "MIT", + "dependencies": { + "@types/katex": "^0.16.0", + "devlop": "^1.0.0", + "katex": "^0.16.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -4883,6 +7270,39 @@ "node": ">=8.6" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minim": { + "version": "0.23.8", + "resolved": "https://registry.npmjs.org/minim/-/minim-0.23.8.tgz", + "integrity": "sha512-bjdr2xW1dBCMsMGGsUeqM4eFI60m94+szhxWys+B1ztIt6gWSfeGBdSVCIawezeHYLYn0j6zrsXdQS/JllBzww==", + "license": "MIT", + "dependencies": { + "lodash": "^4.15.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -4906,11 +7326,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/monaco-editor": { + "version": "0.55.1", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", + "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", + "license": "MIT", + "peer": true, + "dependencies": { + "dompurify": "3.2.7", + "marked": "14.0.0" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -4954,6 +7384,15 @@ "dev": true, "license": "MIT" }, + "node_modules/neotraverse": { + "version": "0.6.18", + "resolved": "https://registry.npmjs.org/neotraverse/-/neotraverse-0.6.18.tgz", + "integrity": "sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, "node_modules/next": { "version": "16.1.6", "resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz", @@ -5035,6 +7474,71 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/node-abort-controller": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", + "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", + "license": "MIT" + }, + "node_modules/node-addon-api": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz", + "integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==", + "license": "MIT", + "optional": true, + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch-commonjs": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch-commonjs/-/node-fetch-commonjs-3.3.2.tgz", + "integrity": "sha512-VBlAiynj3VMLrotgwOS3OyECFxas5y7ltLcK4t41lMUZeaK15Ym4QRkqN0EQKAFL42q9i21EPKjzLUPfltR72A==", + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "optional": true, + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", @@ -5046,7 +7550,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -5165,6 +7668,30 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/openapi-path-templating": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/openapi-path-templating/-/openapi-path-templating-2.2.1.tgz", + "integrity": "sha512-eN14VrDvl/YyGxxrkGOHkVkWEoPyhyeydOUrbvjoz8K5eIGgELASwN1eqFOJ2CTQMGCy2EntOK1KdtJ8ZMekcg==", + "license": "Apache-2.0", + "dependencies": { + "apg-lite": "^1.0.4" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/openapi-server-url-templating": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/openapi-server-url-templating/-/openapi-server-url-templating-1.3.0.tgz", + "integrity": "sha512-DPlCms3KKEbjVQb0spV6Awfn6UWNheuG/+folQPzh/wUaKwuqvj8zt5gagD7qoyxtE03cIiKPgLFS3Q8Bz00uQ==", + "license": "Apache-2.0", + "dependencies": { + "apg-lite": "^1.0.4" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -5246,6 +7773,43 @@ "node": ">=6" } }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -5296,7 +7860,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -5341,11 +7904,28 @@ "node": ">= 0.8.0" } }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.4.0", @@ -5353,6 +7933,22 @@ "react-is": "^16.13.1" } }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -5363,6 +7959,12 @@ "node": ">=6" } }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -5384,12 +7986,59 @@ ], "license": "MIT" }, + "node_modules/ramda": { + "version": "0.30.1", + "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.30.1.tgz", + "integrity": "sha512-tEF5I22zJnuclswcZMc8bDIrwRHRzf+NqVEmqg50ShAZMP7MWeR/RGDthfM/p+BlqvF2fXAzpn8i+SJcYD3alw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ramda" + } + }, + "node_modules/ramda-adjunct": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ramda-adjunct/-/ramda-adjunct-5.1.0.tgz", + "integrity": "sha512-8qCpl2vZBXEJyNbi4zqcgdfHtcdsWjOGbiNSEnEBrM6Y0OKOT8UxJbIVGm1TIcjaSu2MxaWcgtsNlKlCk7o7qg==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ramda-adjunct" + }, + "peerDependencies": { + "ramda": ">= 0.30.0" + } + }, + "node_modules/randexp": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/randexp/-/randexp-0.5.3.tgz", + "integrity": "sha512-U+5l2KrcMNOUPYvazA3h5ekF80FHTUG+87SEAmHZmolh1M+i/WyTCxVzmi+tidIa1tM4BSe8g2Y/D3loWDjj+w==", + "license": "MIT", + "dependencies": { + "drange": "^1.0.2", + "ret": "^0.2.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, "node_modules/react": { "version": "19.2.3", "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -5399,7 +8048,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -5407,13 +8055,143 @@ "react": "^19.2.3" } }, + "node_modules/react-immutable-proptypes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/react-immutable-proptypes/-/react-immutable-proptypes-2.2.0.tgz", + "integrity": "sha512-Vf4gBsePlwdGvSZoLSBfd4HAP93HDauMY4fDjXhreg/vg6F3Fj/MXDNyTbltPC/xZKmZc+cjLu3598DdYK6sgQ==", + "license": "MIT", + "dependencies": { + "invariant": "^2.2.2" + }, + "peerDependencies": { + "immutable": ">=3.6.2" + } + }, + "node_modules/react-immutable-pure-component": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/react-immutable-pure-component/-/react-immutable-pure-component-2.2.2.tgz", + "integrity": "sha512-vkgoMJUDqHZfXXnjVlG3keCxSO/U6WeDQ5/Sl0GK2cH8TOxEzQ5jXqDXHEL/jqk6fsNxV05oH5kD7VNMUE2k+A==", + "license": "MIT", + "peerDependencies": { + "immutable": ">= 2 || >= 4.0.0-rc", + "react": ">= 16.6", + "react-dom": ">= 16.6" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, "license": "MIT" }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/react-syntax-highlighter": { + "version": "16.1.0", + "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-16.1.0.tgz", + "integrity": "sha512-E40/hBiP5rCNwkeBN1vRP+xow1X0pndinO+z3h7HLsHyjztbyjfzNWNKuAsJj+7DLam9iT4AaaOZnueCU+Nplg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "highlight.js": "^10.4.1", + "highlightjs-vue": "^1.0.0", + "lowlight": "^1.17.0", + "prismjs": "^1.30.0", + "refractor": "^5.0.0" + }, + "engines": { + "node": ">= 16.20.2" + }, + "peerDependencies": { + "react": ">= 0.14.0" + } + }, + "node_modules/react-syntax-highlighter/node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/react-syntax-highlighter/node_modules/lowlight": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-1.20.0.tgz", + "integrity": "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==", + "license": "MIT", + "dependencies": { + "fault": "^1.0.0", + "highlight.js": "~10.7.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-immutable": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/redux-immutable/-/redux-immutable-4.0.0.tgz", + "integrity": "sha512-SchSn/DWfGb3oAejd+1hhHx01xUoxY+V7TeK0BKqpkLKiQPVFf7DYzEaKmrEVxsWxielKfSK9/Xq66YyxgR1cg==", + "license": "BSD-3-Clause", + "peerDependencies": { + "immutable": "^3.8.1 || ^4.0.0-rc.1" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -5437,6 +8215,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/refractor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/refractor/-/refractor-5.0.0.tgz", + "integrity": "sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/prismjs": "^1.0.0", + "hastscript": "^9.0.0", + "parse-entities": "^4.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", @@ -5458,6 +8252,170 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/rehype-highlight": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/rehype-highlight/-/rehype-highlight-7.0.2.tgz", + "integrity": "sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-to-text": "^4.0.0", + "lowlight": "^3.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-katex": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/rehype-katex/-/rehype-katex-7.0.1.tgz", + "integrity": "sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/katex": "^0.16.0", + "hast-util-from-html-isomorphic": "^2.0.0", + "hast-util-to-text": "^4.0.0", + "katex": "^0.16.0", + "unist-util-visit-parents": "^6.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-math": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/remark-math/-/remark-math-6.0.0.tgz", + "integrity": "sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-math": "^3.0.0", + "micromark-extension-math": "^3.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remarkable": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/remarkable/-/remarkable-2.0.1.tgz", + "integrity": "sha512-YJyMcOH5lrR+kZdmB0aJJ4+93bEojRZ1HGDn9Eagu6ibg7aVZhc3OWbbShRid+Q5eAfsEqWxpe+g5W5nYNfNiA==", + "license": "MIT", + "dependencies": { + "argparse": "^1.0.10", + "autolinker": "^3.11.0" + }, + "bin": { + "remarkable": "bin/remarkable.js" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/remarkable/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/repeat-string": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", + "integrity": "sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -5499,6 +8457,15 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/ret": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.2.2.tgz", + "integrity": "sha512-M0b3YWQs7R3Z917WRQy1HHA7Ba7D8hvZg6UE5mLykJxQVE2ju0IXbGlaHPPlkY+WN7wFP+wUMXmBFA0aV6vYGQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -5554,6 +8521,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -5605,11 +8592,25 @@ "semver": "bin/semver.js" } }, + "node_modules/serialize-error": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-8.1.0.tgz", + "integrity": "sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ==", + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dev": true, "license": "MIT", "dependencies": { "define-data-property": "^1.1.4", @@ -5654,6 +8655,26 @@ "node": ">= 0.4" } }, + "node_modules/sha.js": { + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz", + "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==", + "license": "(MIT AND BSD-3-Clause)", + "dependencies": { + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.0" + }, + "bin": { + "sha.js": "bin.js" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/sharp": { "version": "0.34.5", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", @@ -5735,6 +8756,16 @@ "node": ">=8" } }, + "node_modules/short-unique-id": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/short-unique-id/-/short-unique-id-5.3.2.tgz", + "integrity": "sha512-KRT/hufMSxXKEDSQujfVE0Faa/kZ51ihUcZQAcmP04t00DvPj7Ox5anHke1sJYUtzSuiT/Y5uyzg/W7bBEGhCg==", + "license": "Apache-2.0", + "bin": { + "short-unique-id": "bin/short-unique-id", + "suid": "bin/short-unique-id" + } + }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -5820,6 +8851,22 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, "node_modules/stable-hash": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", @@ -5827,6 +8874,12 @@ "dev": true, "license": "MIT" }, + "node_modules/state-local": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz", + "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==", + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -5954,6 +9007,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -5977,6 +9044,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, "node_modules/styled-jsx": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", @@ -6026,6 +9111,122 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/swagger-client": { + "version": "3.36.1", + "resolved": "https://registry.npmjs.org/swagger-client/-/swagger-client-3.36.1.tgz", + "integrity": "sha512-bcYpeN4P3sOoKi22zsxIlL9lSgouBAmQmL5hH4g5yeOvyTUvq1+OFtGTs0l1C5Dkb0ZN+2vNgp0FBAFulmUklA==", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime-corejs3": "^7.22.15", + "@scarf/scarf": "=1.4.0", + "@swagger-api/apidom-core": "^1.3.0", + "@swagger-api/apidom-error": "^1.3.0", + "@swagger-api/apidom-json-pointer": "^1.3.0", + "@swagger-api/apidom-ns-openapi-3-1": "^1.3.0", + "@swagger-api/apidom-reference": "^1.3.0", + "@swaggerexpert/cookie": "^2.0.2", + "deepmerge": "~4.3.0", + "fast-json-patch": "^3.0.0-1", + "js-yaml": "^4.1.0", + "neotraverse": "=0.6.18", + "node-abort-controller": "^3.1.1", + "node-fetch-commonjs": "^3.3.2", + "openapi-path-templating": "^2.2.1", + "openapi-server-url-templating": "^1.3.0", + "ramda": "^0.30.1", + "ramda-adjunct": "^5.1.0" + } + }, + "node_modules/swagger-ui-react": { + "version": "5.31.0", + "resolved": "https://registry.npmjs.org/swagger-ui-react/-/swagger-ui-react-5.31.0.tgz", + "integrity": "sha512-E/sTgKADThzpVksaGXbhED0pQCYdajiBNOzvSAan+RhV7pdoi2qvdwWhZsIo8nRvHk9UXJ0nkuxrud854ICr7A==", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime-corejs3": "^7.27.1", + "@scarf/scarf": "=1.4.0", + "base64-js": "^1.5.1", + "buffer": "^6.0.3", + "classnames": "^2.5.1", + "css.escape": "1.5.1", + "deep-extend": "0.6.0", + "dompurify": "=3.2.6", + "ieee754": "^1.2.1", + "immutable": "^3.x.x", + "js-file-download": "^0.4.12", + "js-yaml": "=4.1.1", + "lodash": "^4.17.21", + "prop-types": "^15.8.1", + "randexp": "^0.5.3", + "randombytes": "^2.1.0", + "react-copy-to-clipboard": "5.1.0", + "react-debounce-input": "=3.3.0", + "react-immutable-proptypes": "2.2.0", + "react-immutable-pure-component": "^2.2.0", + "react-inspector": "^6.0.1", + "react-redux": "^9.2.0", + "react-syntax-highlighter": "^16.0.0", + "redux": "^5.0.1", + "redux-immutable": "^4.0.0", + "remarkable": "^2.0.1", + "reselect": "^5.1.1", + "serialize-error": "^8.1.0", + "sha.js": "^2.4.12", + "swagger-client": "^3.36.0", + "url-parse": "^1.5.10", + "xml": "=1.0.1", + "xml-but-prettier": "^1.0.1", + "zenscroll": "^4.0.2" + }, + "peerDependencies": { + "react": ">=16.8.0 <20", + "react-dom": ">=16.8.0 <20" + } + }, + "node_modules/swagger-ui-react/node_modules/dompurify": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.6.tgz", + "integrity": "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/swagger-ui-react/node_modules/react-copy-to-clipboard": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/react-copy-to-clipboard/-/react-copy-to-clipboard-5.1.0.tgz", + "integrity": "sha512-k61RsNgAayIJNoy9yDsYzDe/yAZAzEbEgcz3DZMhF686LEyukcE1hzurxe85JandPUG+yTfGVFzuEw3xt8WP/A==", + "license": "MIT", + "dependencies": { + "copy-to-clipboard": "^3.3.1", + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": "^15.3.0 || 16 || 17 || 18" + } + }, + "node_modules/swagger-ui-react/node_modules/react-debounce-input": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/react-debounce-input/-/react-debounce-input-3.3.0.tgz", + "integrity": "sha512-VEqkvs8JvY/IIZvh71Z0TC+mdbxERvYF33RcebnodlsUZ8RSgyKe2VWaHXv4+/8aoOgXLxWrdsYs2hDhcwbUgA==", + "license": "MIT", + "dependencies": { + "lodash.debounce": "^4", + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": "^15.3.0 || 16 || 17 || 18" + } + }, + "node_modules/swagger-ui-react/node_modules/react-inspector": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/react-inspector/-/react-inspector-6.0.2.tgz", + "integrity": "sha512-x+b7LxhmHXjHoU/VrFAzw5iutsILRoYyDq97EDYdFpPLcvqtEzk4ZSZSQjnFPbr5T57tLXnHcqFYoN1pI6u8uQ==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.4 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/tailwindcss": { "version": "4.1.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", @@ -6088,7 +9289,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -6096,6 +9296,20 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/to-buffer": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", + "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==", + "license": "MIT", + "dependencies": { + "isarray": "^2.0.5", + "safe-buffer": "^5.2.1", + "typed-array-buffer": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -6109,6 +9323,52 @@ "node": ">=8.0" } }, + "node_modules/toggle-selection": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz", + "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==", + "license": "MIT" + }, + "node_modules/tree-sitter-json": { + "version": "0.24.8", + "resolved": "https://registry.npmjs.org/tree-sitter-json/-/tree-sitter-json-0.24.8.tgz", + "integrity": "sha512-Tc9ZZYwHyWZ3Tt1VEw7Pa2scu1YO7/d2BCBbKTx5hXwig3UfdQjsOPkPyLpDJOn/m1UBEWYAtSdGAwCSyagBqQ==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-addon-api": "^8.2.2", + "node-gyp-build": "^4.8.2" + }, + "peerDependencies": { + "tree-sitter": "^0.21.1" + }, + "peerDependenciesMeta": { + "tree-sitter": { + "optional": true + } + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/ts-api-utils": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", @@ -6122,6 +9382,18 @@ "typescript": ">=4.8.4" } }, + "node_modules/ts-mixer": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", + "integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==", + "license": "MIT" + }, + "node_modules/ts-toolbelt": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/ts-toolbelt/-/ts-toolbelt-9.6.0.tgz", + "integrity": "sha512-nsZd8ZeNUzukXPlJmTBwUAuABDe/9qtVDelJeT/qW0ow3ZS3BsQJtNkan1802aM9Uf68/Y8ljw86Hu0h5IUW3w==", + "license": "Apache-2.0" + }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", @@ -6167,11 +9439,22 @@ "node": ">= 0.8.0" } }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.3", @@ -6245,13 +9528,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/types-ramda": { + "version": "0.30.1", + "resolved": "https://registry.npmjs.org/types-ramda/-/types-ramda-0.30.1.tgz", + "integrity": "sha512-1HTsf5/QVRmLzcGfldPFvkVsAdi1db1BBKzi7iW3KBUlOICg/nKnFS+jGqDJS3YD8VsWbAh7JiHeBvbsw8RPxA==", + "license": "MIT", + "dependencies": { + "ts-toolbelt": "^9.6.0" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6310,6 +9601,127 @@ "dev": true, "license": "MIT" }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-find-after": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz", + "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-remove-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz", + "integrity": "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unraw": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unraw/-/unraw-3.0.0.tgz", + "integrity": "sha512-08/DA66UF65OlpUDIQtbJyrqTR0jTAlJ+jsnkQ4jxR7+K5g5YG1APZKQSMCE1vqqmD+2pv6+IdEjmopFatacvg==", + "license": "MIT" + }, "node_modules/unrs-resolver": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", @@ -6386,6 +9798,93 @@ "punycode": "^2.1.0" } }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-location": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz", + "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/web-tree-sitter": { + "version": "0.24.5", + "resolved": "https://registry.npmjs.org/web-tree-sitter/-/web-tree-sitter-0.24.5.tgz", + "integrity": "sha512-+J/2VSHN8J47gQUAvF8KDadrfz6uFYVjxoxbKWDoXVsH2u7yLdarCnIURnrMA6uSRkgX3SdmqM5BOoQjPdSh5w==", + "license": "MIT", + "optional": true + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -6473,7 +9972,6 @@ "version": "1.1.20", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", - "dev": true, "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.7", @@ -6501,6 +9999,21 @@ "node": ">=0.10.0" } }, + "node_modules/xml": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz", + "integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==", + "license": "MIT" + }, + "node_modules/xml-but-prettier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/xml-but-prettier/-/xml-but-prettier-1.0.1.tgz", + "integrity": "sha512-C2CJaadHrZTqESlH03WOyw0oZTtoy2uEg6dSDF6YRg+9GnYNub53RRemLpnvtbHDFelxMx4LajiFsYeR6XJHgQ==", + "license": "MIT", + "dependencies": { + "repeat-string": "^1.5.2" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -6521,13 +10034,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zenscroll": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zenscroll/-/zenscroll-4.0.2.tgz", + "integrity": "sha512-jEA1znR7b4C/NnaycInCU6h/d15ZzCd1jmsruqOKnZP6WXQSMH3W2GL+OXbkruslU4h+Tzuos0HdswzRUk/Vgg==", + "license": "Unlicense" + }, "node_modules/zod": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -6544,6 +10062,16 @@ "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/frontend/package.json b/frontend/package.json index dc832c5..62924d5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,9 +9,18 @@ "lint": "eslint" }, "dependencies": { + "@monaco-editor/react": "^4.7.0", + "highlight.js": "^11.11.1", + "katex": "^0.16.28", "next": "16.1.6", "react": "19.2.3", - "react-dom": "19.2.3" + "react-dom": "19.2.3", + "react-markdown": "^10.1.0", + "rehype-highlight": "^7.0.2", + "rehype-katex": "^7.0.1", + "remark-gfm": "^4.0.1", + "remark-math": "^6.0.0", + "swagger-ui-react": "^5.31.0" }, "devDependencies": { "@tailwindcss/postcss": "^4", diff --git a/frontend/src/app/api-docs/page.tsx b/frontend/src/app/api-docs/page.tsx new file mode 100644 index 0000000..84e2943 --- /dev/null +++ b/frontend/src/app/api-docs/page.tsx @@ -0,0 +1,21 @@ +"use client"; + +import dynamic from "next/dynamic"; +import { useMemo } from "react"; + +import { API_BASE } from "@/lib/api"; + +const SwaggerUI = dynamic(() => import("swagger-ui-react"), { ssr: false }); + +export default function ApiDocsPage() { + const specUrl = useMemo(() => `${API_BASE}/api/openapi.json`, []); + + return ( +
+

API 文档(Swagger)

+
+ +
+
+ ); +} diff --git a/frontend/src/app/api/image-cache/route.ts b/frontend/src/app/api/image-cache/route.ts new file mode 100644 index 0000000..c10e83d --- /dev/null +++ b/frontend/src/app/api/image-cache/route.ts @@ -0,0 +1,136 @@ +import { createHash } from "crypto"; +import { promises as fs } from "fs"; +import path from "path"; + +import { NextRequest, NextResponse } from "next/server"; + +export const runtime = "nodejs"; + +const CACHE_DIR = process.env.CSP_IMAGE_CACHE_DIR ?? "/tmp/csp-image-cache"; +const MAX_BYTES = 5 * 1024 * 1024; + +function toArrayBuffer(view: Uint8Array): ArrayBuffer { + return view.buffer.slice( + view.byteOffset, + view.byteOffset + view.byteLength + ) as ArrayBuffer; +} + +function pickExt(urlObj: URL, contentType: string): string { + const fromPath = path.extname(urlObj.pathname || "").toLowerCase(); + if (fromPath && fromPath.length <= 10) return fromPath; + + if (contentType.includes("image/png")) return ".png"; + if (contentType.includes("image/jpeg")) return ".jpg"; + if (contentType.includes("image/webp")) return ".webp"; + if (contentType.includes("image/gif")) return ".gif"; + if (contentType.includes("image/svg+xml")) return ".svg"; + return ".img"; +} + +async function readCachedByKey( + key: string +): Promise<{ data: Uint8Array; contentType: string } | null> { + try { + const files = await fs.readdir(CACHE_DIR); + const hit = files.find((name) => name.startsWith(`${key}.`)); + if (!hit) return null; + const ext = path.extname(hit).toLowerCase(); + let contentType = "application/octet-stream"; + if (ext === ".png") contentType = "image/png"; + else if (ext === ".jpg" || ext === ".jpeg") contentType = "image/jpeg"; + else if (ext === ".webp") contentType = "image/webp"; + else if (ext === ".gif") contentType = "image/gif"; + else if (ext === ".svg") contentType = "image/svg+xml"; + const data = new Uint8Array(await fs.readFile(path.join(CACHE_DIR, hit))); + return { data, contentType }; + } catch { + return null; + } +} + +export async function GET(req: NextRequest) { + const raw = req.nextUrl.searchParams.get("url") ?? ""; + if (!raw) { + return NextResponse.json({ ok: false, error: "missing url" }, { status: 400 }); + } + + let target: URL; + try { + target = new URL(raw); + } catch { + return NextResponse.json({ ok: false, error: "invalid url" }, { status: 400 }); + } + + if (target.protocol !== "http:" && target.protocol !== "https:") { + return NextResponse.json({ ok: false, error: "only http/https allowed" }, { status: 400 }); + } + + await fs.mkdir(CACHE_DIR, { recursive: true }); + + const key = createHash("sha1").update(target.toString()).digest("hex"); + const probe = await readCachedByKey(key); + if (probe) { + const body = new Blob([toArrayBuffer(probe.data)], { type: probe.contentType }); + return new NextResponse(body, { + status: 200, + headers: { + "Content-Type": probe.contentType, + "Cache-Control": "public, max-age=31536000, immutable", + }, + }); + } + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 12000); + + try { + const resp = await fetch(target.toString(), { + signal: controller.signal, + headers: { + "User-Agent": "csp-platform-image-cache/1.0", + }, + cache: "no-store", + }); + + if (!resp.ok) { + return NextResponse.json( + { ok: false, error: `fetch image failed: HTTP ${resp.status}` }, + { status: 502 } + ); + } + + const contentType = (resp.headers.get("content-type") ?? "").toLowerCase(); + if (!contentType.startsWith("image/")) { + return NextResponse.json({ ok: false, error: "url is not an image" }, { status: 400 }); + } + + const data = new Uint8Array(await resp.arrayBuffer()); + if (data.length <= 0 || data.length > MAX_BYTES) { + return NextResponse.json( + { ok: false, error: "image is empty or too large" }, + { status: 400 } + ); + } + + const ext = pickExt(target, contentType); + const finalFile = path.join(CACHE_DIR, `${key}${ext}`); + await fs.writeFile(finalFile, data); + + const body = new Blob([toArrayBuffer(data)], { type: contentType }); + return new NextResponse(body, { + status: 200, + headers: { + "Content-Type": contentType, + "Cache-Control": "public, max-age=31536000, immutable", + }, + }); + } catch (e: unknown) { + return NextResponse.json( + { ok: false, error: `fetch image failed: ${String(e)}` }, + { status: 502 } + ); + } finally { + clearTimeout(timer); + } +} diff --git a/frontend/src/app/auth/page.tsx b/frontend/src/app/auth/page.tsx index 05eecc9..9bb110f 100644 --- a/frontend/src/app/auth/page.tsx +++ b/frontend/src/app/auth/page.tsx @@ -1,38 +1,57 @@ "use client"; +import Link from "next/link"; import { useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; + +import { API_BASE, apiFetch } from "@/lib/api"; +import { saveToken } from "@/lib/auth"; type AuthOk = { ok: true; user_id: number; token: string; expires_at: number }; type AuthErr = { ok: false; error: string }; type AuthResp = AuthOk | AuthErr; -export default function AuthPage() { - const apiBase = useMemo( - () => process.env.NEXT_PUBLIC_API_BASE ?? "http://localhost:8080", - [] - ); +function passwordScore(password: string): { label: string; color: string } { + if (password.length >= 12) return { label: "强", color: "text-emerald-600" }; + if (password.length >= 8) return { label: "中", color: "text-blue-600" }; + return { label: "弱", color: "text-orange-600" }; +} - const [mode, setMode] = useState<"register" | "login">("register"); - const [username, setUsername] = useState( - process.env.NEXT_PUBLIC_TEST_USERNAME ?? "" - ); - const [password, setPassword] = useState( - process.env.NEXT_PUBLIC_TEST_PASSWORD ?? "" - ); +export default function AuthPage() { + const router = useRouter(); + const apiBase = useMemo(() => API_BASE, []); + + const [mode, setMode] = useState<"register" | "login">("login"); + const [username, setUsername] = useState(process.env.NEXT_PUBLIC_TEST_USERNAME ?? ""); + const [password, setPassword] = useState(process.env.NEXT_PUBLIC_TEST_PASSWORD ?? ""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [showPassword, setShowPassword] = useState(false); const [loading, setLoading] = useState(false); const [resp, setResp] = useState(null); + const usernameErr = username.trim().length < 3 ? "用户名至少 3 位" : ""; + const passwordErr = password.length < 6 ? "密码至少 6 位" : ""; + const confirmErr = + mode === "register" && password !== confirmPassword ? "两次密码不一致" : ""; + + const canSubmit = !loading && !usernameErr && !passwordErr && !confirmErr; + async function submit() { + if (!canSubmit) return; setLoading(true); setResp(null); try { - const r = await fetch(`${apiBase}/api/v1/auth/${mode}`, { + const j = await apiFetch(`/api/v1/auth/${mode}`, { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ username, password }), + body: JSON.stringify({ username: username.trim(), password }), }); - const j = (await r.json()) as AuthResp; setResp(j); + if (j.ok) { + saveToken(j.token); + setTimeout(() => { + router.push("/problems"); + }, 350); + } } catch (e: unknown) { setResp({ ok: false, error: String(e) }); } finally { @@ -40,75 +59,140 @@ export default function AuthPage() { } } + const strength = passwordScore(password); + return ( -
-
-

{mode === "register" ? "注册" : "登录"}

-

- API Base: {apiBase} -

+
+
+
+

欢迎回来,开始刷题

+

+ 登录后可提交评测、保存草稿、查看错题本和个人进度。 +

+
+

• 题库按 CSP-J / CSP-S / NOIP 入门组织

+

• 题目页支持本地草稿与试运行

+

• 生成式题解会异步入库,支持多解法

+
+

+ API Base: {apiBase} +

+
-
- - -
+
+
+ + +
-
- - setUsername(e.target.value)} - placeholder="alice" - /> +
+
+ + setUsername(e.target.value)} + placeholder="例如:csp_student" + /> + {usernameErr &&

{usernameErr}

} +
- - setPassword(e.target.value)} - placeholder="password123" - /> +
+
+ + 强度:{strength.label} +
+ setPassword(e.target.value)} + placeholder="至少 6 位" + /> + {passwordErr &&

{passwordErr}

} +
- -
+ {mode === "register" && ( +
+ + setConfirmPassword(e.target.value)} + placeholder="再输入一次密码" + /> + {confirmErr &&

{confirmErr}

} +
+ )} -
-

响应

-
-            {JSON.stringify(resp, null, 2)}
-          
- {resp && resp.ok && ( -

- 你可以把 token 用在后续需要登录的接口里: - Authorization: Bearer {resp.token} -

+ + + +
+ + {resp && ( +
+ {resp.ok + ? "登录成功,正在跳转到题库..." + : `操作失败:${resp.error}`} +
)} -
-
-
+ +

+ 登录后 Token 自动保存在浏览器 localStorage,可直接前往 + + 题库 + + 与 + + 我的 + + 页面。 +

+ + + ); } diff --git a/frontend/src/app/contests/[id]/page.tsx b/frontend/src/app/contests/[id]/page.tsx new file mode 100644 index 0000000..0ecd5c1 --- /dev/null +++ b/frontend/src/app/contests/[id]/page.tsx @@ -0,0 +1,147 @@ +"use client"; + +import Link from "next/link"; +import { useParams } from "next/navigation"; +import { useEffect, useMemo, useState } from "react"; + +import { apiFetch } from "@/lib/api"; +import { readToken } from "@/lib/auth"; + +type Contest = { + id: number; + title: string; + starts_at: number; + ends_at: number; + rule_json: string; +}; + +type Problem = { + id: number; + title: string; + difficulty: number; +}; + +type LeaderboardRow = { + user_id: number; + username: string; + solved: number; + penalty_sec: number; +}; + +type DetailResp = { + contest: Contest; + problems: Problem[]; + registered?: boolean; +}; + +export default function ContestDetailPage() { + const params = useParams<{ id: string }>(); + const contestId = useMemo(() => Number(params.id), [params.id]); + + const [detail, setDetail] = useState(null); + const [board, setBoard] = useState([]); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + const load = async () => { + setLoading(true); + setError(""); + try { + const token = readToken(); + const d = await apiFetch(`/api/v1/contests/${contestId}`, {}, token || undefined); + const b = await apiFetch(`/api/v1/contests/${contestId}/leaderboard`); + setDetail(d); + setBoard(b); + } catch (e: unknown) { + setError(String(e)); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (Number.isFinite(contestId) && contestId > 0) { + void load(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [contestId]); + + const register = async () => { + try { + const token = readToken(); + if (!token) throw new Error("请先登录"); + await apiFetch(`/api/v1/contests/${contestId}/register`, { method: "POST" }, token); + await load(); + } catch (e: unknown) { + setError(String(e)); + } + }; + + return ( +
+

比赛详情 #{contestId}

+ {loading &&

加载中...

} + {error &&

{error}

} + + {detail && ( +
+
+

{detail.contest.title}

+

+ {new Date(detail.contest.starts_at * 1000).toLocaleString()} - {" "} + {new Date(detail.contest.ends_at * 1000).toLocaleString()} +

+
+              {detail.contest.rule_json}
+            
+ + + +

比赛题目

+
    + {detail.problems.map((p) => ( +
  • + #{p.id} {p.title}(难度 {p.difficulty}) + + 去提交 + +
  • + ))} +
+
+ +
+

排行榜

+
+ + + + + + + + + + + {board.map((r, idx) => ( + + + + + + + ))} + +
#用户SolvedPenalty(s)
{idx + 1}{r.username}{r.solved}{r.penalty_sec}
+
+
+
+ )} +
+ ); +} diff --git a/frontend/src/app/contests/page.tsx b/frontend/src/app/contests/page.tsx new file mode 100644 index 0000000..274cf70 --- /dev/null +++ b/frontend/src/app/contests/page.tsx @@ -0,0 +1,58 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useState } from "react"; + +import { apiFetch } from "@/lib/api"; + +type Contest = { + id: number; + title: string; + starts_at: number; + ends_at: number; + rule_json: string; +}; + +export default function ContestsPage() { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + const load = async () => { + setLoading(true); + setError(""); + try { + const data = await apiFetch("/api/v1/contests"); + setItems(data); + } catch (e: unknown) { + setError(String(e)); + } finally { + setLoading(false); + } + }; + void load(); + }, []); + + return ( +
+

模拟竞赛

+ {loading &&

加载中...

} + {error &&

{error}

} + +
+ {items.map((c) => ( + +

{c.title}

+

开始: {new Date(c.starts_at * 1000).toLocaleString()}

+

结束: {new Date(c.ends_at * 1000).toLocaleString()}

+ + ))} +
+
+ ); +} diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index a2dc41e..9b21683 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -8,8 +8,9 @@ @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); - --font-sans: var(--font-geist-sans); - --font-mono: var(--font-geist-mono); + --font-sans: Arial, Helvetica, sans-serif; + --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", + "Courier New", monospace; } @media (prefers-color-scheme: dark) { diff --git a/frontend/src/app/imports/page.tsx b/frontend/src/app/imports/page.tsx new file mode 100644 index 0000000..3133680 --- /dev/null +++ b/frontend/src/app/imports/page.tsx @@ -0,0 +1,260 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; + +import { apiFetch } from "@/lib/api"; + +type ImportJob = { + id: number; + status: string; + trigger: string; + total_count: number; + processed_count: number; + success_count: number; + failed_count: number; + options_json: string; + last_error: string; + started_at: number; + finished_at: number | null; + updated_at: number; + created_at: number; +}; + +type ImportItem = { + id: number; + job_id: number; + source_path: string; + status: string; + title: string; + difficulty: number; + problem_id: number | null; + error_text: string; + started_at: number | null; + finished_at: number | null; + updated_at: number; + created_at: number; +}; + +type LatestResp = { + runner_running: boolean; + job: ImportJob | null; +}; + +type ItemsResp = { + items: ImportItem[]; + page: number; + page_size: number; +}; + +function fmtTs(v: number | null | undefined): string { + if (!v) return "-"; + return new Date(v * 1000).toLocaleString(); +} + +export default function ImportsPage() { + const [loading, setLoading] = useState(false); + const [running, setRunning] = useState(false); + const [error, setError] = useState(""); + const [job, setJob] = useState(null); + const [items, setItems] = useState([]); + const [statusFilter, setStatusFilter] = useState(""); + const [pageSize, setPageSize] = useState(100); + const [clearAllBeforeRun, setClearAllBeforeRun] = useState(true); + + const progress = useMemo(() => { + if (!job || job.total_count <= 0) return 0; + return Math.min(100, Math.floor((job.processed_count / job.total_count) * 100)); + }, [job]); + + const loadLatest = async () => { + const latest = await apiFetch("/api/v1/import/jobs/latest"); + setJob(latest.job ?? null); + setRunning(Boolean(latest.runner_running) || latest.job?.status === "running"); + return latest.job; + }; + + const loadItems = async (jobId: number) => { + const params = new URLSearchParams(); + params.set("page", "1"); + params.set("page_size", String(pageSize)); + if (statusFilter) params.set("status", statusFilter); + const data = await apiFetch(`/api/v1/import/jobs/${jobId}/items?${params.toString()}`); + setItems(data.items ?? []); + }; + + const refresh = async () => { + setLoading(true); + setError(""); + try { + const latestJob = await loadLatest(); + if (latestJob) { + await loadItems(latestJob.id); + } else { + setItems([]); + } + } catch (e: unknown) { + setError(String(e)); + } finally { + setLoading(false); + } + }; + + const runImport = async () => { + setError(""); + try { + await apiFetch<{ started: boolean }>("/api/v1/import/jobs/run", { + method: "POST", + body: JSON.stringify({ clear_all_problems: clearAllBeforeRun }), + }); + await refresh(); + } catch (e: unknown) { + setError(String(e)); + } + }; + + useEffect(() => { + void refresh(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pageSize, statusFilter]); + + useEffect(() => { + const timer = setInterval(() => { + void refresh(); + }, running ? 3000 : 15000); + return () => clearInterval(timer); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [running, pageSize, statusFilter]); + + return ( +
+

题库导入任务(Luogu CSP J/S)

+ +
+
+ + + + + {running ? "运行中" : "空闲"} + +
+

+ 默认按后端配置以 3 线程执行,抓取洛谷 CSP-J/CSP-S/NOIP 标签题;容器重启后会自动触发导入(可通过环境变量关闭)。 +

+
+ + {error &&

{error}

} + +
+

最新任务

+ {!job &&

暂无任务记录

} + {job && ( +
+

+ 任务 #{job.id} · 状态 {job.status} · 触发方式 {job.trigger} +

+

+ 总数 {job.total_count},已处理 {job.processed_count},成功 {job.success_count},失败 {job.failed_count} +

+
+
+
+

+ 进度 {progress}% · 开始 {fmtTs(job.started_at)} · 结束 {fmtTs(job.finished_at)} +

+ {job.last_error &&

最近错误:{job.last_error}

} +
+ )} +
+ +
+
+

任务明细

+ + +
+ +
+ + + + + + + + + + + + + + {items.map((item) => ( + + + + + + + + + + ))} + {items.length === 0 && ( + + + + )} + +
ID路径状态标题难度题目ID错误
{item.id} +
+ {item.source_path} +
+
{item.status} +
+ {item.title || "-"} +
+
{item.difficulty || "-"}{item.problem_id ?? "-"} +
+ {item.error_text || "-"} +
+
+ 暂无明细 +
+
+
+
+ ); +} diff --git a/frontend/src/app/kb/[slug]/page.tsx b/frontend/src/app/kb/[slug]/page.tsx new file mode 100644 index 0000000..69ec8f5 --- /dev/null +++ b/frontend/src/app/kb/[slug]/page.tsx @@ -0,0 +1,75 @@ +"use client"; + +import Link from "next/link"; +import { useParams } from "next/navigation"; +import { useEffect, useMemo, useState } from "react"; + +import { apiFetch } from "@/lib/api"; + +type Article = { + id: number; + slug: string; + title: string; + content_md: string; + created_at: number; +}; + +type DetailResp = { + article: Article; + related_problems: { problem_id: number; title: string }[]; +}; + +export default function KbDetailPage() { + const params = useParams<{ slug: string }>(); + const slug = useMemo(() => params.slug, [params.slug]); + + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + const load = async () => { + setLoading(true); + setError(""); + try { + const detail = await apiFetch(`/api/v1/kb/articles/${slug}`); + setData(detail); + } catch (e: unknown) { + setError(String(e)); + } finally { + setLoading(false); + } + }; + if (slug) void load(); + }, [slug]); + + return ( +
+

知识库文章

+ {loading &&

加载中...

} + {error &&

{error}

} + + {data && ( +
+
+

{data.article.title}

+
{data.article.content_md}
+
+ +
+

关联题目

+
    + {data.related_problems.map((p) => ( +
  • + + #{p.problem_id} {p.title} + +
  • + ))} +
+
+
+ )} +
+ ); +} diff --git a/frontend/src/app/kb/page.tsx b/frontend/src/app/kb/page.tsx new file mode 100644 index 0000000..39bd297 --- /dev/null +++ b/frontend/src/app/kb/page.tsx @@ -0,0 +1,58 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useState } from "react"; + +import { apiFetch } from "@/lib/api"; + +type Article = { + id: number; + slug: string; + title: string; + created_at: number; +}; + +export default function KbListPage() { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + const load = async () => { + setLoading(true); + setError(""); + try { + const data = await apiFetch("/api/v1/kb/articles"); + setItems(data); + } catch (e: unknown) { + setError(String(e)); + } finally { + setLoading(false); + } + }; + void load(); + }, []); + + return ( +
+

学习知识库

+ {loading &&

加载中...

} + {error &&

{error}

} + +
+ {items.map((a) => ( + +

{a.title}

+

+ slug: {a.slug} · {new Date(a.created_at * 1000).toLocaleString()} +

+ + ))} +
+
+ ); +} diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index f7fa87e..ab2da30 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -1,20 +1,13 @@ import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; +import { AppNav } from "@/components/app-nav"; +import "katex/dist/katex.min.css"; +import "highlight.js/styles/github-dark.css"; +import "swagger-ui-react/swagger-ui.css"; import "./globals.css"; -const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], -}); - -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], -}); - export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "CSP 在线学习与竞赛平台", + description: "题库、错题本、模拟竞赛、知识库与在线 C++ 运行", }; export default function RootLayout({ @@ -23,10 +16,9 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - - + + + {children} diff --git a/frontend/src/app/leaderboard/page.tsx b/frontend/src/app/leaderboard/page.tsx new file mode 100644 index 0000000..d1ca2e6 --- /dev/null +++ b/frontend/src/app/leaderboard/page.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import { apiFetch } from "@/lib/api"; + +type Row = { + user_id: number; + username: string; + rating: number; + created_at: number; +}; + +export default function LeaderboardPage() { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + const load = async () => { + setLoading(true); + setError(""); + try { + const data = await apiFetch("/api/v1/leaderboard/global?limit=200"); + setItems(data); + } catch (e: unknown) { + setError(String(e)); + } finally { + setLoading(false); + } + }; + void load(); + }, []); + + return ( +
+

全站排行榜

+ {loading &&

加载中...

} + {error &&

{error}

} + +
+ + + + + + + + + + + {items.map((row, i) => ( + + + + + + + ))} + +
排名用户Rating注册时间
{i + 1}{row.username}{row.rating} + {new Date(row.created_at * 1000).toLocaleString()} +
+
+
+ ); +} diff --git a/frontend/src/app/me/page.tsx b/frontend/src/app/me/page.tsx new file mode 100644 index 0000000..987c061 --- /dev/null +++ b/frontend/src/app/me/page.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import { apiFetch } from "@/lib/api"; +import { readToken } from "@/lib/auth"; + +type Me = { + id: number; + username: string; + rating: number; + created_at: number; +}; + +export default function MePage() { + const [data, setData] = useState(null); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + useEffect(() => { + const load = async () => { + setLoading(true); + setError(""); + try { + const token = readToken(); + if (!token) throw new Error("请先登录"); + const d = await apiFetch("/api/v1/me", {}, token); + setData(d); + } catch (e: unknown) { + setError(String(e)); + } finally { + setLoading(false); + } + }; + void load(); + }, []); + + return ( +
+

我的信息

+ {loading &&

加载中...

} + {error &&

{error}

} + + {data && ( +
+

ID: {data.id}

+

用户名: {data.username}

+

Rating: {data.rating}

+

创建时间: {new Date(data.created_at * 1000).toLocaleString()}

+
+ )} +
+ ); +} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 23d4550..0f438ee 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -1,48 +1,5 @@ -import Link from "next/link"; +import { redirect } from "next/navigation"; -const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? "http://localhost:8080"; - -async function fetchHealth() { - try { - const r = await fetch(`${API_BASE}/api/health`, { cache: "no-store" }); - if (!r.ok) return { ok: false, error: `HTTP ${r.status}` } as const; - return (await r.json()) as { ok: boolean; version?: string }; - } catch (e: unknown) { - return { ok: false, error: String(e) } as const; - } -} - -export default async function Home() { - const health = await fetchHealth(); - - return ( -
-
-

CSP 在线练习平台(MVP)

-

- API Base: {API_BASE} -

- -
-

后端状态

-
-            {JSON.stringify(health, null, 2)}
-          
-
- -
- - 注册 / 登录 - -
- -
- 说明:当前前端仅用于验证注册/登录与基础连通性;题库/提交/比赛/判题会在后续迭代补齐。 -
-
-
- ); +export default function Home() { + redirect("/problems"); } diff --git a/frontend/src/app/problems/[id]/page.tsx b/frontend/src/app/problems/[id]/page.tsx new file mode 100644 index 0000000..96e0e05 --- /dev/null +++ b/frontend/src/app/problems/[id]/page.tsx @@ -0,0 +1,508 @@ +"use client"; + +import Link from "next/link"; +import { useParams } from "next/navigation"; +import { useEffect, useMemo, useState } from "react"; + +import { CodeEditor } from "@/components/code-editor"; +import { MarkdownRenderer } from "@/components/markdown-renderer"; +import { apiFetch } from "@/lib/api"; +import { readToken } from "@/lib/auth"; + +type Problem = { + id: number; + title: string; + statement_md: string; + difficulty: number; + source: string; + statement_url: string; + llm_profile_json: string; + sample_input: string; + sample_output: string; +}; + +type LlmProfile = { + title?: string; + difficulty?: number; + answer?: string; + explanation?: string; + knowledge_points?: string[]; + tags?: string[]; + statement_summary_md?: string; +}; + +type Submission = { + id: number; + status: string; + score: number; + compile_log: string; + runtime_log: string; + created_at: number; +}; + +type RunResult = { + status: string; + time_ms: number; + stdout: string; + stderr: string; + compile_log: string; +}; + +type DraftResp = { + language: string; + code: string; + stdin: string; + updated_at: number; +}; + +type SolutionItem = { + id: number; + problem_id: number; + variant: number; + title: string; + idea_md: string; + explanation_md: string; + code_cpp: string; + complexity: string; + tags_json: string; + source: string; + created_at: number; + updated_at: number; +}; + +type SolutionJob = { + id: number; + problem_id: number; + status: string; + progress: number; + message: string; + created_at: number; + started_at: number | null; + finished_at: number | null; +}; + +type SolutionResp = { + items: SolutionItem[]; + latest_job: SolutionJob | null; + runner_running: boolean; +}; + +const starterCode = `#include +using namespace std; + +int main() { + ios::sync_with_stdio(false); + cin.tie(nullptr); + + return 0; +} +`; + +const defaultRunInput = `1 2\n`; + +export default function ProblemDetailPage() { + const params = useParams<{ id: string }>(); + const id = useMemo(() => Number(params.id), [params.id]); + + const [problem, setProblem] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + const [code, setCode] = useState(starterCode); + const [runInput, setRunInput] = useState(defaultRunInput); + const [contestId, setContestId] = useState(""); + const [submitLoading, setSubmitLoading] = useState(false); + const [runLoading, setRunLoading] = useState(false); + const [draftLoading, setDraftLoading] = useState(false); + const [submitResp, setSubmitResp] = useState(null); + const [runResp, setRunResp] = useState(null); + const [draftMsg, setDraftMsg] = useState(""); + + const [showSolutions, setShowSolutions] = useState(false); + const [solutionLoading, setSolutionLoading] = useState(false); + const [solutionData, setSolutionData] = useState(null); + const [solutionMsg, setSolutionMsg] = useState(""); + + const llmProfile = useMemo(() => { + if (!problem?.llm_profile_json) return null; + try { + const parsed = JSON.parse(problem.llm_profile_json); + return typeof parsed === "object" && parsed !== null ? (parsed as LlmProfile) : null; + } catch { + return null; + } + }, [problem?.llm_profile_json]); + + useEffect(() => { + const load = async () => { + if (!Number.isFinite(id) || id <= 0) return; + setLoading(true); + setError(""); + try { + const data = await apiFetch(`/api/v1/problems/${id}`); + setProblem(data); + } catch (e: unknown) { + setError(String(e)); + } finally { + setLoading(false); + } + }; + void load(); + }, [id]); + + useEffect(() => { + const loadDraft = async () => { + if (!Number.isFinite(id) || id <= 0) return; + const token = readToken(); + if (!token) return; + try { + const draft = await apiFetch(`/api/v1/problems/${id}/draft`, undefined, token); + if (draft.code) setCode(draft.code); + if (draft.stdin) setRunInput(draft.stdin); + setDraftMsg("已自动加载草稿"); + } catch { + // ignore empty draft / unauthorized + } + }; + void loadDraft(); + }, [id]); + + const submit = async () => { + setSubmitLoading(true); + setSubmitResp(null); + setError(""); + try { + const token = readToken(); + if (!token) throw new Error("请先登录后再提交评测"); + + const body: Record = { + language: "cpp", + code, + }; + if (contestId) body.contest_id = Number(contestId); + + const resp = await apiFetch( + `/api/v1/problems/${id}/submit`, + { + method: "POST", + body: JSON.stringify(body), + }, + token + ); + setSubmitResp(resp); + } catch (e: unknown) { + setError(String(e)); + } finally { + setSubmitLoading(false); + } + }; + + const runCode = async () => { + setRunLoading(true); + setRunResp(null); + setError(""); + try { + const resp = await apiFetch("/api/v1/run/cpp", { + method: "POST", + body: JSON.stringify({ code, input: runInput }), + }); + setRunResp(resp); + } catch (e: unknown) { + setError(String(e)); + } finally { + setRunLoading(false); + } + }; + + const saveDraft = async () => { + setDraftLoading(true); + setDraftMsg(""); + setError(""); + try { + const token = readToken(); + if (!token) throw new Error("请先登录后再保存草稿"); + await apiFetch<{ saved: boolean }>( + `/api/v1/problems/${id}/draft`, + { + method: "PUT", + body: JSON.stringify({ language: "cpp", code, stdin: runInput }), + }, + token + ); + setDraftMsg("草稿已保存"); + } catch (e: unknown) { + setError(String(e)); + } finally { + setDraftLoading(false); + } + }; + + const loadSolutions = async () => { + setSolutionLoading(true); + setSolutionMsg(""); + try { + const resp = await apiFetch(`/api/v1/problems/${id}/solutions`); + setSolutionData(resp); + } catch (e: unknown) { + setSolutionMsg(`加载题解失败:${String(e)}`); + } finally { + setSolutionLoading(false); + } + }; + + const triggerSolutions = async () => { + setSolutionLoading(true); + setSolutionMsg(""); + try { + const token = readToken(); + if (!token) throw new Error("请先登录后再触发题解生成"); + await apiFetch<{ started: boolean; job_id: number }>( + `/api/v1/problems/${id}/solutions/generate`, + { + method: "POST", + body: JSON.stringify({ max_solutions: 3 }), + }, + token + ); + setSolutionMsg("题解生成任务已提交,后台异步处理中..."); + await loadSolutions(); + } catch (e: unknown) { + setSolutionMsg(`提交失败:${String(e)}`); + } finally { + setSolutionLoading(false); + } + }; + + useEffect(() => { + if (!showSolutions) return; + void loadSolutions(); + const timer = setInterval(() => { + void loadSolutions(); + }, 5000); + return () => clearInterval(timer); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [showSolutions, id]); + + return ( +
+

题目详情与评测

+ + {loading &&

加载中...

} + {error &&

{error}

} + + {problem && ( +
+
+

{problem.title}

+

+ 难度 {problem.difficulty} · 来源 {problem.source} +

+ {problem.statement_url && ( +

+ + 查看原始题面链接 + +

+ )} + +
+ +
+ + {llmProfile?.knowledge_points && llmProfile.knowledge_points.length > 0 && ( + <> +

知识点考查

+
+ {llmProfile.knowledge_points.map((kp) => ( + + {kp} + + ))} +
+ + )} + +

样例输入

+
{problem.sample_input}
+ +

样例输出

+
{problem.sample_output}
+
+ +
+ + setContestId(e.target.value)} + /> + +
+ + + +
+ + {draftMsg &&

{draftMsg}

} + + +
+ +
+ + +