diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..dc70808 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,48 @@ +name: Deploy docs + +on: + push: + branches: [master] + pull_request: + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # full history for "last updated" timestamps + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run check:locales # every page must exist in both vi and en + - run: npm run build # fails on dead internal links + - uses: actions/configure-pages@v5 + if: github.event_name != 'pull_request' + - uses: actions/upload-pages-artifact@v3 + if: github.event_name != 'pull_request' + with: + path: src/.vitepress/dist + + deploy: + if: github.event_name != 'pull_request' + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..34a93a5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +src/.vitepress/dist/ +src/.vitepress/cache/ +plans/ diff --git a/README.md b/README.md index 14976a7..11b2857 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,41 @@ # LCOJ Documentation -Tài liệu hướng dẫn cho hệ thống [LCOJ](https://github.com/luyencode/lcoj-site). +Tài liệu hướng dẫn cho hệ thống [LCOJ](https://github.com/luyencode/lcoj-site). Truy cập tại . LCOJ được phát triển dựa trên [DMOJ](https://github.com/DMOJ/online-judge) và [VNOJ](https://github.com/VNOI-Admin/OJ). -Truy cập tài liệu tại . +## Chạy ở máy local + +Cần Node.js 18 trở lên. + +```sh +npm install +npm run dev # http://localhost:5173, tự reload khi sửa file +npm run build # build ra src/.vitepress/dist, báo lỗi nếu có link hỏng +npm run check:locales # kiểm tra mỗi trang có đủ bản tiếng Việt và tiếng Anh +npm run preview # xem bản build +``` + +## Cấu trúc + +``` +src/ +├── .vitepress/config.mts # cấu hình chung +├── .vitepress/locales/ # menu, sidebar cho từng ngôn ngữ (vi, en) +├── public/ # ảnh, logo, CNAME +├── index.md # trang chủ tiếng Việt +├── features/ site/ judge/ problem_format/ about/ +└── en/ # bản tiếng Anh, cùng cấu trúc với bản tiếng Việt +``` + +**Mỗi trang phải có đủ hai ngôn ngữ.** Thêm trang mới: + +1. Tạo `src/<đường-dẫn>.md` (tiếng Việt) và `src/en/<đường-dẫn>.md` (tiếng Anh). +2. Thêm link vào sidebar trong cả `src/.vitepress/locales/vi.mts` và `en.mts`. +3. Chạy `npm run check:locales`. CI sẽ báo lỗi nếu một trang chỉ có một ngôn ngữ. + +Tài liệu không dùng ảnh chụp màn hình. Hãy dùng sơ đồ Mermaid (khối ` ```mermaid `), bảng và hộp lưu ý (`::: tip`, `::: warning`). + +## Triển khai + +Mỗi lần push lên `master`, GitHub Actions (`.github/workflows/deploy.yml`) build và đăng lên GitHub Pages. Pull request chỉ build để kiểm tra. diff --git a/docs/.nojekyll b/docs/.nojekyll deleted file mode 100644 index e69de29..0000000 diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index 972e25b..0000000 --- a/docs/README.md +++ /dev/null @@ -1,18 +0,0 @@ -# LCOJ: Hệ thống chấm bài trực tuyến - -Tài liệu này hướng dẫn bạn cài đặt và sử dụng hệ thống LCOJ. - -LCOJ được phát triển dựa trên [DMOJ](https://github.com/DMOJ) và [VNOJ](https://github.com/VNOI-Admin). - -## Nội dung - -- **Hệ thống Judge** - Hướng dẫn cài đặt và cấu hình judge -- **Hệ thống Website** - Cài đặt, quản lý và cấu hình website -- **Định dạng bài tập** - Cách tạo và cấu hình bài tập -- **Ví dụ bài tập** - Các ví dụ thực tế trong thư mục [problem_examples](https://github.com/luyencode/docs/tree/master/problem_examples) - -## Hỗ trợ - -Nếu có thắc mắc, vui lòng tạo issue tại [GitHub Issues](https://github.com/luyencode/lcoj-docker/issues). - -**Cần hỗ trợ cài đặt?** Nếu bạn quá mệt mỏi để tự mình cài đặt, LCOJ sẽ hỗ trợ bạn cài đặt miễn phí. Liên hệ tại [https://luyencode.net/about/#lien-he](https://luyencode.net/about/#lien-he). diff --git a/docs/_sidebar.md b/docs/_sidebar.md deleted file mode 100644 index 5a9a357..0000000 --- a/docs/_sidebar.md +++ /dev/null @@ -1,32 +0,0 @@ -- Hệ thống Website - - [Cài đặt website](site/installation.md) - - [Vận hành hệ thống](site/operations.md) - - [Cập nhật website](site/updating.md) - - [Management Commands](site/management_commands.md) - - [Các định dạng kỳ thi](site/contest_formats.md) - - [Hệ thống phân quyền](site/permission_system.md) - - [Quản lý bài tập](site/managing_problems.md) - - [Hiển thị công thức toán học LaTeX](site/mathoid.md) - - [Hiển thị sơ đồ LaTeX](site/texoid.md) - - [Tạo file PDF cho đề bài](site/pdfoid.md) - - [Chống spam với reCAPTCHA](site/recaptcha.md) - - [SSL proxy cho nội dung người dùng](site/ssl_content_proxy.md) - - [Tải dữ liệu người dùng](site/user_data_download.md) - - [Tải dữ liệu kỳ thi](site/contest_data_download.md) - - [API](site/api.md) - -- Hệ thống Judge - - [Cài đặt judge](judge/setting_up_a_judge.md) - - [Cấu hình judge](judge/judge_configuration.md) - - [Các ngôn ngữ được hỗ trợ](judge/supported_languages.md) - - [Các mã trạng thái](judge/status_codes.md) - -- Định dạng bài tập - - [Cấu trúc bài tập](problem_format/problem_format.md) - - [Custom checker](problem_format/custom_checkers.md) - - [Custom grader](problem_format/custom_graders.md) - - [Generator](problem_format/generator.md) - - [Ví dụ bài tập](problem_format/problem_examples.md) - -- Giới thiệu - - [Giấy phép](about/LICENSE.md) diff --git a/docs/favicon.ico b/docs/favicon.ico deleted file mode 100644 index a70ee26..0000000 Binary files a/docs/favicon.ico and /dev/null differ diff --git a/docs/index.html b/docs/index.html deleted file mode 100644 index 067da31..0000000 --- a/docs/index.html +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - LCOJ Documentation - - - - -
- - - - - - - - - - - - - - diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..252620c --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3802 @@ +{ + "name": "lcoj-docs", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "lcoj-docs", + "devDependencies": { + "markdown-it-task-lists": "^2.1.1", + "mermaid": "^11.12.0", + "vitepress": "^1.6.4", + "vitepress-plugin-mermaid": "^2.0.17" + } + }, + "node_modules/@algolia/abtesting": { + "version": "1.25.0", + "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.25.0.tgz", + "integrity": "sha512-rSTin9Uta23uaewYVQEp8XI9T3iA/zrg0/1G2vhf8oFFDxFL5vybnZ5IQwsVAg4JpKxPX4/WYNKdcfWrZymk7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/autocomplete-core": { + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-core/-/autocomplete-core-1.17.7.tgz", + "integrity": "sha512-BjiPOW6ks90UKl7TwMv7oNQMnzU+t/wk9mgIDi6b1tXpUek7MW0lbNOUHpvam9pe3lVCf4xPFT+lK7s+e+fs7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-plugin-algolia-insights": "1.17.7", + "@algolia/autocomplete-shared": "1.17.7" + } + }, + "node_modules/@algolia/autocomplete-plugin-algolia-insights": { + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-plugin-algolia-insights/-/autocomplete-plugin-algolia-insights-1.17.7.tgz", + "integrity": "sha512-Jca5Ude6yUOuyzjnz57og7Et3aXjbwCSDf/8onLHSQgw1qW3ALl9mrMWaXb5FmPVkV3EtkD2F/+NkT6VHyPu9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-shared": "1.17.7" + }, + "peerDependencies": { + "search-insights": ">= 1 < 3" + } + }, + "node_modules/@algolia/autocomplete-preset-algolia": { + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-preset-algolia/-/autocomplete-preset-algolia-1.17.7.tgz", + "integrity": "sha512-ggOQ950+nwbWROq2MOCIL71RE0DdQZsceqrg32UqnhDz8FlO9rL8ONHNsI2R1MH0tkgVIDKI/D0sMiUchsFdWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-shared": "1.17.7" + }, + "peerDependencies": { + "@algolia/client-search": ">= 4.9.1 < 6", + "algoliasearch": ">= 4.9.1 < 6" + } + }, + "node_modules/@algolia/autocomplete-shared": { + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-shared/-/autocomplete-shared-1.17.7.tgz", + "integrity": "sha512-o/1Vurr42U/qskRSuhBH+VKxMvkkUVTLU6WZQr+L5lGZZLYWyhdzWjW0iGXY7EkwRTjBqvN2EsR81yCTGV/kmg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@algolia/client-search": ">= 4.9.1 < 6", + "algoliasearch": ">= 4.9.1 < 6" + } + }, + "node_modules/@algolia/client-abtesting": { + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.59.0.tgz", + "integrity": "sha512-bm2XN0hCSMYwStSsCBT0/PUB2BDxoyR1Lnub3c392HMEy9bi8PUSW8vR6zltVEKWG2t4PQFWMD5C07bmJxGgPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-analytics": { + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.59.0.tgz", + "integrity": "sha512-XOFPOTa69WuqHR6c5tMgnUUwwqQgNSzMpxmhrgA9KmxRf8WIqEa0cokHJvohk5CYb7CZx0xeSL6Bk2IUJ7Lv7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-common": { + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.59.0.tgz", + "integrity": "sha512-PC8ipLOYFKRTfIUY1J3FJxS6ryzWziaXmIX9/sNMoUR8L+XhF7hX2QAeUI87YVl5zujvlYTSOb+HAIqKXDjyHQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-insights": { + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.59.0.tgz", + "integrity": "sha512-yFNcCMM5fHiyoR0HuxMrzy+VjDcmhFUZTm2IJ2DHwGsVH3B5SEob4zTmeEZ3j/AZqNnmrJOCKl/tJnBg7+84bA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-personalization": { + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.59.0.tgz", + "integrity": "sha512-GYja6HkDt2VrQhWmB2cLx3Z5fDwI9no7q+xwCWcFrTPm5CLH9QZvK0XLO9co1FcNIDxxkMaP3AXM39/XcecEOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-query-suggestions": { + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.59.0.tgz", + "integrity": "sha512-Wofg7bMpWh8N5qDDZs0wy6whc+KMmdNsxgrIGp9Ug2s1Bka0uq7AjyckdxReKPHLA0Q1qH8X/SNh0wn2t2X2Lw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-search": { + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.59.0.tgz", + "integrity": "sha512-fHnALZfbEnODczGk14Y/1YBRApp6UEpZUTexGcMUPzY7RDc7q4HN2Y6jh0KUG8Jo9B+q+wOoQEc3ziR0ErbuFg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@algolia/client-common": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/ingestion": { + "version": "1.59.0", + "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.59.0.tgz", + "integrity": "sha512-Fa38s1mHgoaLCT117sfJ6P78rtxUt93CYxBYpq1VOIcslaF+cH+1h5uikAPsKfxLnsuEDZHiBiaI6eNPTsQMRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/monitoring": { + "version": "1.59.0", + "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.59.0.tgz", + "integrity": "sha512-NyNsRSqM2tF1MX7ZGw/j4rduoEJiQ5wfvbSo/CFVdEzYCjyxbGFRPOZyS/GETJG9rk1TdHOq8NZqKqk/u1fm4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/recommend": { + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.59.0.tgz", + "integrity": "sha512-nXBK2uygWtvbCOffMWqqfjjcEtp9enDKY5/2Pw/Hhw8VVaOyK2ThbGK04bNX/Le+qoK1VHYkodWGVTKfw0Otkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-browser-xhr": { + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.59.0.tgz", + "integrity": "sha512-yb+4afX/zja8QwX0KmV4/ae2kyYkRknsE79CRusYS2U5D8qwn2wq0cn1x12f3tm3F3+5FgmDdbuGdQTuCLSKMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.59.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-fetch": { + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.59.0.tgz", + "integrity": "sha512-Lp52TmpA1QtNmdHzs505Xwf4tgII7RAapkDSF9AAtWIBETcGaTIaXci4Rd9nS6SdaWK1BWTSqAPp5wzh6UN3Ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.59.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-node-http": { + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.59.0.tgz", + "integrity": "sha512-YYHLEs5rC6oRTFwT7bJaKBR9NSFzikeVHGAT1ffATmUQzR8XqyhHXjoieAUoR7fBU6I5cfy1FVeh5GoRgriuzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.59.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@antfu/install-pkg": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-2.1.0.tgz", + "integrity": "sha512-sdg9NxU3zR4Mnawfbc/x6GB5Wf17WYud5qOuEuxXjaKpYpMkISSJEjItGebXJ2bQ4DIcly4NYH23mtkGJjvKUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "package-manager-detector": "^1.8.0", + "tinyexec": "^1.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@braintree/sanitize-url": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz", + "integrity": "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@chevrotain/types": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.2.tgz", + "integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@docsearch/css": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-3.8.2.tgz", + "integrity": "sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@docsearch/js": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/@docsearch/js/-/js-3.8.2.tgz", + "integrity": "sha512-Q5wY66qHn0SwA7Taa0aDbHiJvaFJLOJyHmooQ7y8hlwwQLQ/5WwCcoX0g7ii04Qi2DJlHsd0XXzJ8Ypw9+9YmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@docsearch/react": "3.8.2", + "preact": "^10.0.0" + } + }, + "node_modules/@docsearch/react": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/@docsearch/react/-/react-3.8.2.tgz", + "integrity": "sha512-xCRrJQlTt8N9GU0DG4ptwHRkfnSnD/YpdeaXe02iKfqs97TkZJv60yE+1eq/tjPcVnTW8dP5qLP7itifFVV5eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-core": "1.17.7", + "@algolia/autocomplete-preset-algolia": "1.17.7", + "@docsearch/css": "3.8.2", + "algoliasearch": "^5.14.2" + }, + "peerDependencies": { + "@types/react": ">= 16.8.0 < 19.0.0", + "react": ">= 16.8.0 < 19.0.0", + "react-dom": ">= 16.8.0 < 19.0.0", + "search-insights": ">= 1 < 3" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "search-insights": { + "optional": true + } + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@iconify-json/simple-icons": { + "version": "1.2.96", + "resolved": "https://registry.npmjs.org/@iconify-json/simple-icons/-/simple-icons-1.2.96.tgz", + "integrity": "sha512-/FmCYo/H5rIywTLV/P6OYsr0rg/31vuc8jYq0QnkOZl6m7PWIs+h5oAnSTT2cqRhOsMaKqbXcdkka6tnZ1WiUA==", + "dev": true, + "license": "CC0-1.0", + "dependencies": { + "@iconify/types": "*" + } + }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@iconify/utils": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.7.tgz", + "integrity": "sha512-JZHlwdID+dy+lTgbYC8NEC4zeugqeYsc6jewvzb4c58kHauJn+X7rNwQjxz5p2qSjqaEeQoLkCIQ9v/H4PK0/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@antfu/install-pkg": "^2.0.1", + "@iconify/types": "^2.0.0", + "import-meta-resolve": "^4.2.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@mermaid-js/mermaid-mindmap": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@mermaid-js/mermaid-mindmap/-/mermaid-mindmap-9.3.0.tgz", + "integrity": "sha512-IhtYSVBBRYviH1Ehu8gk69pMDF8DSRqXBRDMWrEfHoaMruHeaP2DXA3PBnuwsMaCdPQhlUUcy/7DBLAEIXvCAw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@braintree/sanitize-url": "^6.0.0", + "cytoscape": "^3.23.0", + "cytoscape-cose-bilkent": "^4.1.0", + "cytoscape-fcose": "^2.1.0", + "d3": "^7.0.0", + "khroma": "^2.0.0", + "non-layered-tidy-tree-layout": "^2.0.2" + } + }, + "node_modules/@mermaid-js/mermaid-mindmap/node_modules/@braintree/sanitize-url": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-6.0.4.tgz", + "integrity": "sha512-s3jaWicZd0pkP0jf5ysyHUI/RE7MHos6qlToFcGWXVp+ykHOy77OUMrfbgJ9it2C5bow7OIQwYYaHjk9XlBQ2A==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@mermaid-js/parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.2.1.tgz", + "integrity": "sha512-n12NohV3mrUyUL2o93IgG/ifeW9FTyeJn3zDxkhwa8MJ9Fxg3HQMlA3RiGmD/3UnJvheztkjjQAjA2T4LmUcpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@chevrotain/types": "~11.1.2" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.3.tgz", + "integrity": "sha512-w3Jnvi1ocaVm/c7yVPpfB98XeSRBMyzp6njL5MVVbGyXjpmUkN+s6Hp4t0PqhGCCaI1ZHMKXt/w0lA1RCaLVcw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.3.tgz", + "integrity": "sha512-uI/ESiaIbbRYAEhzy8PCUWDp1hB0bjAqM06mW9flOoNO4Q8DQpeoREhBR5Hegfl+wpXiguyJv6XSPzEN7OxyHQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.3.tgz", + "integrity": "sha512-oxhrd1jmXLwWZ83eQYDXxuqRdkqkzrjR3JobKeuUyfdNZo11FuQIvqEOZhyIT7OBHxXoGslDDjN0cQcM6T0TqQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.3.tgz", + "integrity": "sha512-7/YiIMghVE8DrxKvNdorAaJVdriOFgOIpdStnPx8ppx5zfTwC3jBCSEAIzB7JD5404m65THl6H93UTTVUvypmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.3.tgz", + "integrity": "sha512-GXFZRRoMAytaI5z6N3Zhfw0WL18Q0M8r95D5hlC4GqE/lGk8pbSJNUBoOWDfbm6dTciqHj2nU87tI5f6XhQiOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.3.tgz", + "integrity": "sha512-77W+8X3ddYgPxUpB8nZFQs2Mq+wc4HVlcSRtApXLjYBcnPMkttrSnU8VwKQjeWYhMsITHFs5cWBQ8vz1Q+5RHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.3.tgz", + "integrity": "sha512-FVkwK+iUC+mq+GipVK46rRVticfAPtvPUNlqlGXUDxdVk/UGjQiiiUVPUrEXdSpU2ufU0XxLGyTqDtBidDOVmg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.3.tgz", + "integrity": "sha512-+aGU1t3398yQOVj1Bz8o3e+KtswxAPvO+mtxtNdfXYMkXIHu7XhhkCD7/DEH9q8tF8uhDnMWvfpUKI8y1sZJsg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.3.tgz", + "integrity": "sha512-cR0kjpRXR2KJ2oQK8E2KTPtphs+b9hZ8IhTZubNryt/RsqgdOZBQ2Zq0q5UedtiIi0rs3jVhJh55RE1ZHUVGUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.3.tgz", + "integrity": "sha512-y1RYi4Q3/9ByVWSSt9kX2ustE0B7kFYbJ6zZdVZVyqopZs3yhCTwRfrjIX4vezUJInma/Gs6BOFDJg7yZmJ0IQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.3.tgz", + "integrity": "sha512-DNhEA5viIj3Z5bZLE4z4oV8N5ozWqDwyt7T6KG7VdLDJ0nW+rNOYlphBl4/3HQkK75qipPLsVOfStHHOwN9WSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.3.tgz", + "integrity": "sha512-17gQCqrIpXBX2Cmi9/TygnVOqGbzsba/iaqcYSL8FY7lNugg+7AiYNs5c5nKWD+NRQha36Sa0CqkJqH4XVHwnQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.3.tgz", + "integrity": "sha512-6LwVnZRIyINpdku/yOcI8Tm9YqLmhHK5emmlOOnW9tO0SYEm1FmKPcsSAGp0NBlqR2P04xaND4jvN6sTHqhq8A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.3.tgz", + "integrity": "sha512-xMUqkTXlEUtI/p5AAukMwBRr1enU3efsTeF+bskeFfk8t1C9rcC8sLREcZXmTfAXEbvRdJVSonVJez3TMlbR3w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.3.tgz", + "integrity": "sha512-S3E94co9F9WRRqEaUoQZ38K1gCz6KiM+nL7/3ijq7fDGF3OznjS5TasgYITlvl27GQKtu4lOAOsr5MFwkijvOA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.3.tgz", + "integrity": "sha512-1QtRDwG42x5BJI3s9mxu5rEjDnfbSnk20HQ9/ylTAYnSwYwxMVb+Vgu34wzzTQ7ogqBybebgQNUDAvZVQ38DbA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.3.tgz", + "integrity": "sha512-BQhejF6ZXOpxbngiNTP12GCGQeaDVL2QXGeBVViKIYzFHM5RKxTxwUMB1fr1BeNFphFMpnRqC5QSXFSa4z6UQw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.3.tgz", + "integrity": "sha512-SXagRwnI2Wlwlitllu59UK/nGVbD1CKPcNqDplHwIC4BqJcpXFjD32d1R/RbuISa95HdQrZM3/7v4bKiowFaLA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.3.tgz", + "integrity": "sha512-2IPozoEALRCziGqE8O9KMK60PMu5TS1huv4fwoeCexj+WjmcwFtX9CTOVbfXCUqcELAubEwRFPYlzb/WvwY2HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.3.tgz", + "integrity": "sha512-AoxqosUHT9IX54hFn2TiN6A7d6ZKTtE6pd2bqWtqkkNJ6HJGaU6FRouGX8L1O7R/ZwsnCnpQrHzb4pDEx+UHRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.3.tgz", + "integrity": "sha512-d+CaftKgmkFBzCwezMqqy1d0QNNYugqLCMcYVQWBy5SS2YfeMP8Q8ripkgx9O8IyBXXLHrJ+aaCV4U96usv6Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.3.tgz", + "integrity": "sha512-xXlDF6nR1eOuXbdDy5Hl5fmtY7teUDevF/k0O7IPoZe4Tpmdv+lgdE5JRsnhQtt37ql9P0VF2kAN9a0OCZdo+Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.3.tgz", + "integrity": "sha512-YtXAgLN+JP7Ay6qG3eWhc7IHMQPzLc8r3uvhAvlJIoCz/4Q32+Bl9Fmnywidh8v1GOIMmymjovfqY9ETAtysvA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.3.tgz", + "integrity": "sha512-WuWtSJRNo549vzcfZyEgfqb6zeSgn1F+UE5kQ+BCjzz0W4MGCjntUHkZVc1VRuAM7+ULaSyhiPxD1spyewFvkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.3.tgz", + "integrity": "sha512-+lIKX7O0+IGe7WuhATaAMMeT7B76vfhXH/l9wLQL+nvyhbw2ohYCKIdWL56JfDu75CWt5oKRP4QFH/jkMtBquA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@shikijs/core": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-2.5.0.tgz", + "integrity": "sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/engine-javascript": "2.5.0", + "@shikijs/engine-oniguruma": "2.5.0", + "@shikijs/types": "2.5.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.4" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-2.5.0.tgz", + "integrity": "sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "2.5.0", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^3.1.0" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-2.5.0.tgz", + "integrity": "sha512-pGd1wRATzbo/uatrCIILlAdFVKdxImWJGQ5rFiB5VZi2ve5xj3Ax9jny8QvkaV93btQEwR/rSz5ERFpC5mKNIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "2.5.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "node_modules/@shikijs/langs": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-2.5.0.tgz", + "integrity": "sha512-Qfrrt5OsNH5R+5tJ/3uYBBZv3SuGmnRPejV9IlIbFH3HTGLDlkqgHymAlzklVmKBjAaVmkPkyikAV/sQ1wSL+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "2.5.0" + } + }, + "node_modules/@shikijs/themes": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-2.5.0.tgz", + "integrity": "sha512-wGrk+R8tJnO0VMzmUExHR+QdSaPUl/NKs+a4cQQRWyoc3YFbUzuLEi/KWK1hj+8BfHRKm2jNhhJck1dfstJpiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "2.5.0" + } + }, + "node_modules/@shikijs/transformers": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/transformers/-/transformers-2.5.0.tgz", + "integrity": "sha512-SI494W5X60CaUwgi8u4q4m4s3YAFSxln3tzNjOSYqq54wlVgz0/NbbXEb3mdLbqMBztcmS7bVTaEd2w0qMmfeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/core": "2.5.0", + "@shikijs/types": "2.5.0" + } + }, + "node_modules/@shikijs/types": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-2.5.0.tgz", + "integrity": "sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.4.tgz", + "integrity": "sha512-UHYId5WTCx4L4YNel7NU00XUXXgvgpgZOvp10PuvsQENjMDXhh2RyFc0KBjO7B45ne4Ha1yVH7ii0vnzKkuzWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.12", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.12.tgz", + "integrity": "sha512-Qe/KWYhEiIIxGs7HrAAjMfShxKldx19SJtr5zu53f3afPsdZNz7HHtdTLXo/kqeiWNXVycI24kSnfzBYkTzpgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/hast": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/markdown-it": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.2.0.tgz", + "integrity": "sha512-NoQ2yGlLWj4wpxMs+TYmRKk3thDrQ97agr7sFqfLsAlvoS8SNQuTrlObhFqG9iugdTtgOE9jpJ6FNM4ZGsa5xQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz", + "integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@upsetjs/venn.js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@upsetjs/venn.js/-/venn.js-2.0.0.tgz", + "integrity": "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "d3-selection": "^3.0.0", + "d3-transition": "^3.0.1" + } + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.43.tgz", + "integrity": "sha512-zdiLhnbe1QQqgDT8xZMpNmyqZ3qlI+/Q/FHQco57Kwl/b05HhCzN6eVGN9QU9rbga4CrS0H5SYY8VGHZCt/1Hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/shared": "3.5.43", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.43.tgz", + "integrity": "sha512-PEZoAk3NQmsn/ejMzSOCyTYqwGqczrWm70PuhBKjjv1+TCoQAaO/zOqNwjV+honlNstT5ILxtc+8r8UUfj+iEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.43", + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.43.tgz", + "integrity": "sha512-FCbrG3XNCRl+js3huuKx4IVHBLTvMkJhVepjbxSPu1gn4yWLaYtGNQdjJGZaMytXB6qb76qQDDmDSLy/vkmleQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/compiler-core": "3.5.43", + "@vue/compiler-dom": "3.5.43", + "@vue/compiler-ssr": "3.5.43", + "@vue/shared": "3.5.43", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.28", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.43.tgz", + "integrity": "sha512-GF62orf7KiJX9RqrHNGrYBudsQGD0OhJ5nDs90O8UiDuS40+YMYomiXu6w6EuvtXuRDc3MSNis3EaaSKAVSWpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.43", + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/devtools-api": { + "version": "7.7.10", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.10.tgz", + "integrity": "sha512-KxtEpUOOpFz/qOGRrAwA36QF7DqIA+FXgCYit9mk9wjbaZt0sXOFz81ElOZtKA4HbWHUdwNjZHBFsFFyp5BZiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^7.7.10" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "7.7.10", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.10.tgz", + "integrity": "sha512-3WNi2Kq4tbpVbmhml7RiphmAt0279oh3fKNeWMQIrltfX8Q91b4i5PL8DtyNKdwmcsGrV4fg+erwWOmD05CLIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^7.7.10", + "birpc": "^2.3.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^1.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "7.7.10", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.10.tgz", + "integrity": "sha512-wOPslzB8vTvpxwdaOcR2qAbwmuSP0L+rhpoC6Cf56V3Jip+HWb7PQQXOUPgBNQARpXsbQX/+mvi8kKucmBGRwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "rfdc": "^1.4.1" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.43.tgz", + "integrity": "sha512-G/c9GyOZNI2jVaaS6OX1EF1SSFSv7H0ERqNTl4+DTFMlZmB5eVAB53aLQNam/7NL2NPtaDD7RdVrzf8uJzMuOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.43.tgz", + "integrity": "sha512-hU6U6VnVhBGQDpvlnnDlIB8ZGJBiOcgk2lh/0InltHiz3D8oSkluvuvY+do1G2H3+udeKFsmaBlgVYP7gXQzEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.43", + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.43.tgz", + "integrity": "sha512-Bb2Jc0YjjJdMt1SJmb9b2L/IWd3I8lIT9x9eS/xvvP9CiVgna0ffua74xKRmt4/uSJ+0r4iN8ex1jrqkhQGWQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.43", + "@vue/runtime-core": "3.5.43", + "@vue/shared": "3.5.43", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.43.tgz", + "integrity": "sha512-l2Ygjv9NehV94PSBxNWsAHC0j/eIIKbn92mBuWXAPGLnn6HfJ6MH5ubsd+Nk0YoZ5FRuxWI1P2VSoh+dbPQhCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.43", + "@vue/runtime-dom": "3.5.43", + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.43.tgz", + "integrity": "sha512-uksS7YGMR5NZyr4JNq0Rp+QyLns0ueaz20KwzIPW9R0LH1Vnt4E+XUM29PNseEbf1www2gOuhuDi5AKOIXag9Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-12.8.2.tgz", + "integrity": "sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "12.8.2", + "@vueuse/shared": "12.8.2", + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/integrations": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/integrations/-/integrations-12.8.2.tgz", + "integrity": "sha512-fbGYivgK5uBTRt7p5F3zy6VrETlV9RtZjBqd1/HxGdjdckBgBM4ugP8LHpjolqTj14TXTxSK1ZfgPbHYyGuH7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vueuse/core": "12.8.2", + "@vueuse/shared": "12.8.2", + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "async-validator": "^4", + "axios": "^1", + "change-case": "^5", + "drauu": "^0.4", + "focus-trap": "^7", + "fuse.js": "^7", + "idb-keyval": "^6", + "jwt-decode": "^4", + "nprogress": "^0.2", + "qrcode": "^1.5", + "sortablejs": "^1", + "universal-cookie": "^7" + }, + "peerDependenciesMeta": { + "async-validator": { + "optional": true + }, + "axios": { + "optional": true + }, + "change-case": { + "optional": true + }, + "drauu": { + "optional": true + }, + "focus-trap": { + "optional": true + }, + "fuse.js": { + "optional": true + }, + "idb-keyval": { + "optional": true + }, + "jwt-decode": { + "optional": true + }, + "nprogress": { + "optional": true + }, + "qrcode": { + "optional": true + }, + "sortablejs": { + "optional": true + }, + "universal-cookie": { + "optional": true + } + } + }, + "node_modules/@vueuse/metadata": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-12.8.2.tgz", + "integrity": "sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-12.8.2.tgz", + "integrity": "sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/algoliasearch": { + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.59.0.tgz", + "integrity": "sha512-wUXzaeI7B526W4y1gFg3lcxgDZ67XSgRJIiellYWOas/pLpO7rOtxm9Gr2E/C8aiSDXIgx1q5TdSsvK67Uakqw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@algolia/abtesting": "1.25.0", + "@algolia/client-abtesting": "5.59.0", + "@algolia/client-analytics": "5.59.0", + "@algolia/client-common": "5.59.0", + "@algolia/client-insights": "5.59.0", + "@algolia/client-personalization": "5.59.0", + "@algolia/client-query-suggestions": "5.59.0", + "@algolia/client-search": "5.59.0", + "@algolia/ingestion": "1.59.0", + "@algolia/monitoring": "1.59.0", + "@algolia/recommend": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "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==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/copy-anything": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.1.0.tgz", + "integrity": "sha512-ufbM3smX/Jbnpk5wcQjzd1MgBpzmqfNETUAyZNrGwU9foRlyHoGzMMBBCRzEhQLBjZfFDE1W2ufPXX2vdWkV8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/cose-base": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", + "integrity": "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==", + "dev": true, + "license": "MIT", + "dependencies": { + "layout-base": "^1.0.0" + } + }, + "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/cytoscape": { + "version": "3.34.3", + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.34.3.tgz", + "integrity": "sha512-yfYGhRcGAntq6YBD583j4n0Eg3jIxvWmZtz/5uz9UYkeIStSlMxuUja+ec5j3iBD8nv1rwaOAYMW09tBdkSeaQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/cytoscape-cose-bilkent": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cytoscape-cose-bilkent/-/cytoscape-cose-bilkent-4.1.0.tgz", + "integrity": "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cose-base": "^1.0.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz", + "integrity": "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cose-base": "^2.2.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/cose-base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-2.2.0.tgz", + "integrity": "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "layout-base": "^2.0.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/layout-base": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-2.0.1.tgz", + "integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==", + "dev": true, + "license": "MIT" + }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "dev": true, + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "dev": true, + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-sankey": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", + "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1 - 2", + "d3-shape": "^1.2.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/d3-sankey/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-sankey/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "dev": true, + "license": "ISC" + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "dev": true, + "license": "ISC", + "peer": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dagre-d3-es": { + "version": "7.0.14", + "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz", + "integrity": "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "d3": "^7.9.0", + "lodash-es": "^4.17.21" + } + }, + "node_modules/dayjs": { + "version": "1.11.23", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz", + "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/delaunator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", + "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dompurify": { + "version": "3.4.15", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.15.tgz", + "integrity": "sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw==", + "dev": true, + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/emoji-regex-xs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz", + "integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-toolkit": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz", + "integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==", + "dev": true, + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks", + "tests/types", + "tests/browser-compat" + ] + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastdom": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/fastdom/-/fastdom-1.0.12.tgz", + "integrity": "sha512-LB+xjSTEbjHE1cWsxu+tN2Xqr1kpi+V9aADI7sVM5ZMaXyYGPHULQMzpJMYqOTULK/73pUkWVzzObFRBkPr+hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "strictdom": "^1.0.1" + } + }, + "node_modules/focus-trap": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.8.0.tgz", + "integrity": "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "tabbable": "^6.4.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/hachure-fill": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz", + "integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==", + "dev": true, + "license": "MIT" + }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/katex": { + "version": "0.16.47", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "dev": true, + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/khroma": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz", + "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==", + "dev": true + }, + "node_modules/layout-base": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz", + "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mark.js": { + "version": "8.11.1", + "resolved": "https://registry.npmjs.org/mark.js/-/mark.js-8.11.1.tgz", + "integrity": "sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/markdown-it-task-lists": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/markdown-it-task-lists/-/markdown-it-task-lists-2.1.1.tgz", + "integrity": "sha512-TxFAc76Jnhb2OUu+n3yz9RMu4CwGfaT788br6HhEDlvWfdeJcLUsxk1Hgw2yJio0OXsxv7pyIPmvECY7bMbluA==", + "dev": true, + "license": "ISC" + }, + "node_modules/marked": { + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz", + "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", + "dev": true, + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "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==", + "dev": true, + "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/mermaid": { + "version": "11.17.2", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.17.2.tgz", + "integrity": "sha512-V6K3C8EBdEsPFZXSKMJe6ppQOENxuHARr9GvHX4hh47lAbhMRD9qf4oEK7LoaRQxULMa80/qt5gHO73aCleBBg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@braintree/sanitize-url": "^7.1.2", + "@iconify/utils": "^3.0.2", + "@mermaid-js/parser": "^1.2.1", + "@types/d3": "^7.4.3", + "@upsetjs/venn.js": "^2.0.0", + "cytoscape": "^3.34.0", + "cytoscape-cose-bilkent": "^4.1.0", + "cytoscape-fcose": "^2.2.0", + "d3": "^7.9.0", + "d3-sankey": "^0.12.3", + "dagre-d3-es": "7.0.14", + "dayjs": "^1.11.21", + "dompurify": "^3.3.3", + "es-toolkit": "^1.45.1", + "fastdom": "1.0.12", + "katex": "^0.16.47", + "khroma": "^2.1.0", + "marked": "^16.3.0", + "roughjs": "^4.6.6", + "stylis": "^4.3.6", + "ts-dedent": "^2.2.0", + "uuid": "^11.1.0 || ^12 || ^13 || ^14.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==", + "dev": true, + "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-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==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "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==", + "dev": true, + "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-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "dev": true, + "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==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/minisearch": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/minisearch/-/minisearch-7.2.0.tgz", + "integrity": "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==", + "dev": true, + "license": "MIT" + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/non-layered-tidy-tree-layout": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/non-layered-tidy-tree-layout/-/non-layered-tidy-tree-layout-2.0.2.tgz", + "integrity": "sha512-gkXMxRzUH+PB0ax9dUN0yYF0S25BqeAYqhgMaLUFmpXLEk7Fcu8f4emJuOAY0V8kjDICxROIKsTAKsV/v355xw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/oniguruma-to-es": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-3.1.1.tgz", + "integrity": "sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex-xs": "^1.0.0", + "regex": "^6.0.1", + "regex-recursion": "^6.0.2" + } + }, + "node_modules/package-manager-detector": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.8.0.tgz", + "integrity": "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-data-parser": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz", + "integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==", + "dev": true, + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/points-on-curve": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", + "integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==", + "dev": true, + "license": "MIT" + }, + "node_modules/points-on-path": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/points-on-path/-/points-on-path-0.2.1.tgz", + "integrity": "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-data-parser": "0.1.0", + "points-on-curve": "0.2.0" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/preact": { + "version": "10.29.8", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.8.tgz", + "integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + }, + "peerDependencies": { + "preact-render-to-string": ">=5" + }, + "peerDependenciesMeta": { + "preact-render-to-string": { + "optional": true + } + } + }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "dev": true, + "license": "MIT" + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/robust-predicates": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", + "dev": true, + "license": "Unlicense" + }, + "node_modules/rollup": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.3.tgz", + "integrity": "sha512-1i2XreiAoMMXuPGD6Msj2xWrMMkHojNRKivInxGQcg7/1KuPuYlfUutLyh4drnOxUTHX9cHI4wFoat8D/NKaBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.3", + "@rollup/rollup-android-arm64": "4.63.3", + "@rollup/rollup-darwin-arm64": "4.63.3", + "@rollup/rollup-darwin-x64": "4.63.3", + "@rollup/rollup-freebsd-arm64": "4.63.3", + "@rollup/rollup-freebsd-x64": "4.63.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.3", + "@rollup/rollup-linux-arm-musleabihf": "4.63.3", + "@rollup/rollup-linux-arm64-gnu": "4.63.3", + "@rollup/rollup-linux-arm64-musl": "4.63.3", + "@rollup/rollup-linux-loong64-gnu": "4.63.3", + "@rollup/rollup-linux-loong64-musl": "4.63.3", + "@rollup/rollup-linux-ppc64-gnu": "4.63.3", + "@rollup/rollup-linux-ppc64-musl": "4.63.3", + "@rollup/rollup-linux-riscv64-gnu": "4.63.3", + "@rollup/rollup-linux-riscv64-musl": "4.63.3", + "@rollup/rollup-linux-s390x-gnu": "4.63.3", + "@rollup/rollup-linux-x64-gnu": "4.63.3", + "@rollup/rollup-linux-x64-musl": "4.63.3", + "@rollup/rollup-openbsd-x64": "4.63.3", + "@rollup/rollup-openharmony-arm64": "4.63.3", + "@rollup/rollup-win32-arm64-msvc": "4.63.3", + "@rollup/rollup-win32-ia32-msvc": "4.63.3", + "@rollup/rollup-win32-x64-gnu": "4.63.3", + "@rollup/rollup-win32-x64-msvc": "4.63.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/roughjs": { + "version": "4.6.6", + "resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.6.tgz", + "integrity": "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hachure-fill": "^0.5.2", + "path-data-parser": "^0.1.0", + "points-on-curve": "^0.2.0", + "points-on-path": "^0.2.1" + } + }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/search-insights": { + "version": "2.17.3", + "resolved": "https://registry.npmjs.org/search-insights/-/search-insights-2.17.3.tgz", + "integrity": "sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/shiki": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-2.5.0.tgz", + "integrity": "sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/core": "2.5.0", + "@shikijs/engine-javascript": "2.5.0", + "@shikijs/engine-oniguruma": "2.5.0", + "@shikijs/langs": "2.5.0", + "@shikijs/themes": "2.5.0", + "@shikijs/types": "2.5.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "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==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/speakingurl": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", + "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strictdom": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/strictdom/-/strictdom-1.0.1.tgz", + "integrity": "sha512-cEmp9QeXXRmjj/rVp9oyiqcvyocWab/HaoN4+bwFeZ7QzykJD6L3yD4v12K1x0tHpqRqVpJevN3gW7kyM39Bqg==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "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/stylis": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz", + "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", + "dev": true, + "license": "MIT" + }, + "node_modules/superjson": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", + "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "copy-anything": "^4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tabbable": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.5.0.tgz", + "integrity": "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "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==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/ts-dedent": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.3.0.tgz", + "integrity": "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.10" + } + }, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/uuid": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.2.tgz", + "integrity": "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.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==", + "dev": true, + "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/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vitepress": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/vitepress/-/vitepress-1.6.4.tgz", + "integrity": "sha512-+2ym1/+0VVrbhNyRoFFesVvBvHAVMZMK0rw60E3X/5349M1GuVdKeazuksqopEdvkKwKGs21Q729jX81/bkBJg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@docsearch/css": "3.8.2", + "@docsearch/js": "3.8.2", + "@iconify-json/simple-icons": "^1.2.21", + "@shikijs/core": "^2.1.0", + "@shikijs/transformers": "^2.1.0", + "@shikijs/types": "^2.1.0", + "@types/markdown-it": "^14.1.2", + "@vitejs/plugin-vue": "^5.2.1", + "@vue/devtools-api": "^7.7.0", + "@vue/shared": "^3.5.13", + "@vueuse/core": "^12.4.0", + "@vueuse/integrations": "^12.4.0", + "focus-trap": "^7.6.4", + "mark.js": "8.11.1", + "minisearch": "^7.1.1", + "shiki": "^2.1.0", + "vite": "^5.4.14", + "vue": "^3.5.13" + }, + "bin": { + "vitepress": "bin/vitepress.js" + }, + "peerDependencies": { + "markdown-it-mathjax3": "^4", + "postcss": "^8" + }, + "peerDependenciesMeta": { + "markdown-it-mathjax3": { + "optional": true + }, + "postcss": { + "optional": true + } + } + }, + "node_modules/vitepress-plugin-mermaid": { + "version": "2.0.17", + "resolved": "https://registry.npmjs.org/vitepress-plugin-mermaid/-/vitepress-plugin-mermaid-2.0.17.tgz", + "integrity": "sha512-IUzYpwf61GC6k0XzfmAmNrLvMi9TRrVRMsUyCA8KNXhg/mQ1VqWnO0/tBVPiX5UoKF1mDUwqn5QV4qAJl6JnUg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "@mermaid-js/mermaid-mindmap": "^9.3.0" + }, + "peerDependencies": { + "mermaid": "10 || 11", + "vitepress": "^1.0.0 || ^1.0.0-alpha" + } + }, + "node_modules/vue": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.43.tgz", + "integrity": "sha512-o5qZoksdnjIKvW1srZ3ab7pcDNYAerBjRe54D0LBLfRdCYFrSgBHVXokMas35czQc0//lmx4/tuY4ZNQ+Rf2Ng==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@vue/compiler-dom": "3.5.43", + "@vue/compiler-sfc": "3.5.43", + "@vue/runtime-dom": "3.5.43", + "@vue/server-renderer": "3.5.43", + "@vue/shared": "3.5.43" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "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==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..b6c94ed --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "lcoj-docs", + "private": true, + "type": "module", + "scripts": { + "dev": "vitepress dev src", + "build": "vitepress build src", + "check:locales": "node scripts/check-locales.mjs", + "preview": "vitepress preview src" + }, + "devDependencies": { + "markdown-it-task-lists": "^2.1.1", + "mermaid": "^11.12.0", + "vitepress": "^1.6.4", + "vitepress-plugin-mermaid": "^2.0.17" + } +} diff --git a/scripts/check-locales.mjs b/scripts/check-locales.mjs new file mode 100644 index 0000000..5212b9e --- /dev/null +++ b/scripts/check-locales.mjs @@ -0,0 +1,29 @@ +// Fails if a page exists in only one locale: src/.md (vi) must have src/en/.md (en), and vice versa. +import { readdirSync, statSync } from 'node:fs' +import { join, relative } from 'node:path' + +const SRC = new URL('../src/', import.meta.url).pathname +const EN = join(SRC, 'en') + +function pages(dir, skip = []) { + const out = [] + for (const name of readdirSync(dir)) { + const full = join(dir, name) + if (name.startsWith('.') || name === 'public' || skip.includes(full)) continue + if (statSync(full).isDirectory()) out.push(...pages(full, skip)) + else if (name.endsWith('.md')) out.push(full) + } + return out +} + +const vi = new Set(pages(SRC, [EN]).map((p) => relative(SRC, p))) +const en = new Set(pages(EN).map((p) => relative(EN, p))) + +const missingEn = [...vi].filter((p) => !en.has(p)) +const missingVi = [...en].filter((p) => !vi.has(p)) + +for (const p of missingEn) console.error(`missing English page: src/en/${p}`) +for (const p of missingVi) console.error(`missing Vietnamese page: src/${p}`) + +if (missingEn.length || missingVi.length) process.exit(1) +console.log(`locales in sync: ${vi.size} pages in vi and en`) diff --git a/src/.vitepress/config.mts b/src/.vitepress/config.mts new file mode 100644 index 0000000..f38a65e --- /dev/null +++ b/src/.vitepress/config.mts @@ -0,0 +1,133 @@ +import { defineConfig } from 'vitepress' +import { withMermaid } from 'vitepress-plugin-mermaid' +import taskLists from 'markdown-it-task-lists' +import { vi } from './locales/vi.mts' +import { en } from './locales/en.mts' + +const SITE_URL = 'https://docs.luyencode.net' +const OG_IMAGE = `${SITE_URL}/og_logo.png` + +// 'features/quiz.md' -> '/features/quiz', 'en/index.md' -> '/en/' +const pageUrl = (relativePath: string) => + '/' + relativePath.replace(/(^|\/)index\.md$/, '$1').replace(/\.md$/, '') + +export default withMermaid( + defineConfig({ + title: 'LCOJ Docs', + description: 'Tài liệu hệ thống chấm bài trực tuyến LCOJ', + cleanUrls: true, + lastUpdated: true, + sitemap: { hostname: SITE_URL }, + // Icons and social metadata match luyencode.net (icon set from lcoj-site resources/icons) + head: [ + ['link', { rel: 'icon', href: '/favicon.ico' }], + ['link', { rel: 'icon', type: 'image/png', sizes: '16x16', href: '/favicon-16x16.png' }], + ['link', { rel: 'icon', type: 'image/png', sizes: '32x32', href: '/favicon-32x32.png' }], + ['link', { rel: 'icon', type: 'image/png', sizes: '96x96', href: '/favicon-96x96.png' }], + ['link', { rel: 'icon', type: 'image/png', sizes: '192x192', href: '/android-chrome-192x192.png' }], + ['link', { rel: 'apple-touch-icon', sizes: '180x180', href: '/apple-touch-icon-180x180.png' }], + ['meta', { name: 'theme-color', content: '#231F20' }], + ['meta', { property: 'og:type', content: 'website' }], + ['meta', { property: 'og:site_name', content: 'LCOJ: Luyện Code Online Judge' }], + ['meta', { property: 'og:image', content: OG_IMAGE }], + ['meta', { property: 'og:image:type', content: 'image/png' }], + ['meta', { property: 'og:image:width', content: '1200' }], + ['meta', { property: 'og:image:height', content: '675' }], + ['meta', { name: 'twitter:card', content: 'summary_large_image' }], + ['meta', { name: 'twitter:site', content: '@nguyenvanhieuvn' }], + ['meta', { name: 'twitter:creator', content: '@nguyenvanhieuvn' }], + ['meta', { name: 'twitter:image', content: OG_IMAGE }], + // Old Docsify links look like /#/site/installation?id=buoc-2 — send them to /site/installation#buoc-2 + [ + 'script', + {}, + `(function(){var h=location.hash;if(h.indexOf('#/')!==0)return;` + + `var m=h.slice(1).match(/^([^?]*)(?:\\?id=(.*))?$/);` + + `var p=m[1].replace(/\\.md$/,'').replace(/\\/README$/i,'/');` + + `location.replace(p+(m[2]?'#'+m[2]:''));})();`, + ], + ], + + // Per-page social/SEO tags: title, description, canonical URL, locale and the vi/en alternate + transformHead({ pageData, title, description, content }) { + if (pageData.isNotFound) return [] + // Pages without a frontmatter description get the first paragraph of the page instead + if (!pageData.frontmatter.description) { + const p = content.split('class="vp-doc')[1]?.match(/

([\s\S]*?)<\/p>/)?.[1] + const text = p + ?.replace(/<[^>]+>/g, '') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&/g, '&') + .replace(/\s+/g, ' ') + .trim() + if (text) description = text.length > 160 ? text.slice(0, 157).trimEnd() + '…' : text + } + const path = pageUrl(pageData.relativePath) + const isEn = path.startsWith('/en/') + const viPath = isEn ? path.slice(3) || '/' : path + const enPath = isEn ? path : '/en' + path + const url = SITE_URL + path + return [ + ['meta', { name: 'description', content: description }], + ['link', { rel: 'canonical', href: url }], + ['link', { rel: 'alternate', hreflang: 'vi', href: SITE_URL + viPath }], + ['link', { rel: 'alternate', hreflang: 'en', href: SITE_URL + enPath }], + ['link', { rel: 'alternate', hreflang: 'x-default', href: SITE_URL + viPath }], + ['meta', { property: 'og:url', content: url }], + ['meta', { property: 'og:locale', content: isEn ? 'en_US' : 'vi_VN' }], + ['meta', { property: 'og:locale:alternate', content: isEn ? 'vi_VN' : 'en_US' }], + ['meta', { property: 'og:title', content: title }], + ['meta', { property: 'og:description', content: description }], + ['meta', { property: 'og:image:alt', content: title }], + ['meta', { name: 'twitter:title', content: title }], + ['meta', { name: 'twitter:description', content: description }], + ] + }, + + locales: { + root: { label: 'Tiếng Việt', lang: 'vi-VN', ...vi }, + en: { label: 'English', lang: 'en-US', link: '/en/', ...en }, + }, + + themeConfig: { + logo: '/logo.png', + siteTitle: false, + socialLinks: [{ icon: 'github', link: 'https://github.com/luyencode/lcoj-docker' }], + search: { + provider: 'local', + options: { + locales: { + root: { + translations: { + button: { buttonText: 'Tìm kiếm', buttonAriaLabel: 'Tìm kiếm' }, + modal: { + noResultsText: 'Không tìm thấy kết quả', + resetButtonTitle: 'Xóa', + footer: { selectText: 'chọn', navigateText: 'di chuyển', closeText: 'đóng' }, + }, + }, + }, + }, + }, + }, + }, + + markdown: { + languageAlias: { env: 'dotenv', cron: 'shellscript' }, + // Render "- [ ] item" checklists (used in "Before you start" sections) as checkboxes + config: (md) => md.use(taskLists, { label: true }), + }, + + vite: { + // Mermaid is large on its own; it's loaded lazily, only on pages with diagrams + build: { chunkSizeWarningLimit: 2000 }, + // Pre-bundle mermaid so its CommonJS deps (e.g. fastdom) get ESM interop in `vitepress dev` + optimizeDeps: { include: ['mermaid'] }, + }, + + mermaid: {}, + }), +) diff --git a/src/.vitepress/locales/en.mts b/src/.vitepress/locales/en.mts new file mode 100644 index 0000000..b3bab91 --- /dev/null +++ b/src/.vitepress/locales/en.mts @@ -0,0 +1,82 @@ +import type { DefaultTheme, LocaleSpecificConfig } from 'vitepress' + +export const en: LocaleSpecificConfig = { + description: 'Documentation for the LCOJ online judge', + themeConfig: { + nav: [ + { text: 'Features', link: '/en/features/quiz' }, + { text: 'Website', link: '/en/site/installation' }, + { text: 'Judge', link: '/en/judge/setting_up_a_judge' }, + { text: 'Problems', link: '/en/problem_format/problem_format' }, + { text: 'luyencode.net', link: 'https://luyencode.net' }, + ], + sidebar: { + '/en/': [ + { + text: 'Features', + items: [ + { text: 'Taking a quiz', link: '/en/features/quiz' }, + { text: 'Creating quizzes', link: '/en/features/quiz_authoring' }, + { text: 'Exam library', link: '/en/features/library' }, + { text: 'URL shortener', link: '/en/features/url_shortener' }, + ], + }, + { + text: 'Website', + items: [ + { text: 'Installing the website', link: '/en/site/installation' }, + { text: 'Operating the system', link: '/en/site/operations' }, + { text: 'Updating the website', link: '/en/site/updating' }, + { text: 'Configuring uWSGI', link: '/en/site/uwsgi' }, + { text: 'Management commands', link: '/en/site/management_commands' }, + { text: 'Contest formats', link: '/en/site/contest_formats' }, + { text: 'Permission system', link: '/en/site/permission_system' }, + { text: 'Managing problems', link: '/en/site/managing_problems' }, + { text: 'Rendering LaTeX math', link: '/en/site/mathoid' }, + { text: 'Rendering LaTeX diagrams', link: '/en/site/texoid' }, + { text: 'Generating problem PDFs', link: '/en/site/pdfoid' }, + { text: 'Fighting spam with reCAPTCHA', link: '/en/site/recaptcha' }, + { text: 'SSL proxy for user content', link: '/en/site/ssl_content_proxy' }, + { text: 'User data download', link: '/en/site/user_data_download' }, + { text: 'Contest data download', link: '/en/site/contest_data_download' }, + { text: 'API', link: '/en/site/api' }, + ], + }, + { + text: 'Judge', + items: [ + { text: 'Setting up a judge', link: '/en/judge/setting_up_a_judge' }, + { text: 'Judge configuration', link: '/en/judge/judge_configuration' }, + { text: 'Supported languages', link: '/en/judge/supported_languages' }, + { text: 'Status codes', link: '/en/judge/status_codes' }, + ], + }, + { + text: 'Problem format', + items: [ + { text: 'Problem structure', link: '/en/problem_format/problem_format' }, + { text: 'Custom checkers', link: '/en/problem_format/custom_checkers' }, + { text: 'Custom graders', link: '/en/problem_format/custom_graders' }, + { text: 'Generators', link: '/en/problem_format/generator' }, + { text: 'Problem examples', link: '/en/problem_format/problem_examples' }, + ], + }, + { + text: 'About', + items: [{ text: 'License', link: '/en/about/LICENSE' }], + }, + ], + }, + editLink: { + pattern: 'https://github.com/luyencode/docs/edit/master/src/:path', + text: 'Edit this page on GitHub', + }, + lastUpdated: { text: 'Last updated' }, + footer: { + message: + 'LCOJ is built on DMOJ and VNOJ (AGPL-3.0).', + copyright: + 'Need help? GitHub Issues · behitek.com · Contact', + }, + }, +} diff --git a/src/.vitepress/locales/vi.mts b/src/.vitepress/locales/vi.mts new file mode 100644 index 0000000..6da497b --- /dev/null +++ b/src/.vitepress/locales/vi.mts @@ -0,0 +1,86 @@ +import type { DefaultTheme, LocaleSpecificConfig } from 'vitepress' + +export const vi: LocaleSpecificConfig = { + description: 'Tài liệu hệ thống chấm bài trực tuyến LCOJ', + themeConfig: { + nav: [ + { text: 'Tính năng', link: '/features/quiz' }, + { text: 'Website', link: '/site/installation' }, + { text: 'Judge', link: '/judge/setting_up_a_judge' }, + { text: 'Bài tập', link: '/problem_format/problem_format' }, + { text: 'luyencode.net', link: 'https://luyencode.net' }, + ], + sidebar: [ + { + text: 'Tính năng', + items: [ + { text: 'Làm bài trắc nghiệm', link: '/features/quiz' }, + { text: 'Tạo bài trắc nghiệm', link: '/features/quiz_authoring' }, + { text: 'Thư viện đề thi', link: '/features/library' }, + { text: 'Rút gọn liên kết', link: '/features/url_shortener' }, + ], + }, + { + text: 'Hệ thống Website', + items: [ + { text: 'Cài đặt website', link: '/site/installation' }, + { text: 'Vận hành hệ thống', link: '/site/operations' }, + { text: 'Cập nhật website', link: '/site/updating' }, + { text: 'Cấu hình uWSGI', link: '/site/uwsgi' }, + { text: 'Management Commands', link: '/site/management_commands' }, + { text: 'Các định dạng kỳ thi', link: '/site/contest_formats' }, + { text: 'Hệ thống phân quyền', link: '/site/permission_system' }, + { text: 'Quản lý bài tập', link: '/site/managing_problems' }, + { text: 'Hiển thị công thức toán học LaTeX', link: '/site/mathoid' }, + { text: 'Hiển thị sơ đồ LaTeX', link: '/site/texoid' }, + { text: 'Tạo file PDF cho đề bài', link: '/site/pdfoid' }, + { text: 'Chống spam với reCAPTCHA', link: '/site/recaptcha' }, + { text: 'SSL proxy cho nội dung người dùng', link: '/site/ssl_content_proxy' }, + { text: 'Tải dữ liệu người dùng', link: '/site/user_data_download' }, + { text: 'Tải dữ liệu kỳ thi', link: '/site/contest_data_download' }, + { text: 'API', link: '/site/api' }, + ], + }, + { + text: 'Hệ thống Judge', + items: [ + { text: 'Cài đặt judge', link: '/judge/setting_up_a_judge' }, + { text: 'Cấu hình judge', link: '/judge/judge_configuration' }, + { text: 'Các ngôn ngữ được hỗ trợ', link: '/judge/supported_languages' }, + { text: 'Các mã trạng thái', link: '/judge/status_codes' }, + ], + }, + { + text: 'Định dạng bài tập', + items: [ + { text: 'Cấu trúc bài tập', link: '/problem_format/problem_format' }, + { text: 'Custom checker', link: '/problem_format/custom_checkers' }, + { text: 'Custom grader', link: '/problem_format/custom_graders' }, + { text: 'Generator', link: '/problem_format/generator' }, + { text: 'Ví dụ bài tập', link: '/problem_format/problem_examples' }, + ], + }, + { + text: 'Giới thiệu', + items: [{ text: 'Giấy phép', link: '/about/LICENSE' }], + }, + ], + editLink: { + pattern: 'https://github.com/luyencode/docs/edit/master/src/:path', + text: 'Sửa trang này trên GitHub', + }, + lastUpdated: { text: 'Cập nhật lần cuối' }, + docFooter: { prev: 'Trang trước', next: 'Trang sau' }, + outline: { level: [2, 3], label: 'Trên trang này' }, + returnToTopLabel: 'Lên đầu trang', + sidebarMenuLabel: 'Menu', + darkModeSwitchLabel: 'Giao diện', + langMenuLabel: 'Ngôn ngữ', + footer: { + message: + 'LCOJ được phát triển dựa trên DMOJVNOJ (AGPL-3.0).', + copyright: + 'Cần hỗ trợ? GitHub Issues · behitek.com · Liên hệ', + }, + }, +} diff --git a/src/.vitepress/theme/custom.css b/src/.vitepress/theme/custom.css new file mode 100644 index 0000000..9131306 --- /dev/null +++ b/src/.vitepress/theme/custom.css @@ -0,0 +1,27 @@ +/* LCOJ brand colors, taken from lcoj-site resources/vars-default.scss ($color_link*) */ +:root { + --vp-c-brand-1: #1958c1; + --vp-c-brand-2: #0645ad; + --vp-c-brand-3: #1958c1; + --vp-c-brand-soft: rgba(25, 88, 193, 0.14); +} + +.dark { + --vp-c-brand-1: #7ea6e8; + --vp-c-brand-2: #a3c0f0; + --vp-c-brand-3: #1958c1; + --vp-c-brand-soft: rgba(126, 166, 232, 0.16); +} + +.vp-doc .task-list-item { + list-style: none; +} + +.vp-doc .task-list-item input { + margin: 0 0.5em 0 -1.4em; + vertical-align: middle; +} + +.VPNavBarTitle .logo { + height: 32px; +} diff --git a/src/.vitepress/theme/index.ts b/src/.vitepress/theme/index.ts new file mode 100644 index 0000000..42fe9a9 --- /dev/null +++ b/src/.vitepress/theme/index.ts @@ -0,0 +1,4 @@ +import DefaultTheme from 'vitepress/theme' +import './custom.css' + +export default DefaultTheme diff --git a/docs/about/LICENSE.md b/src/about/LICENSE.md similarity index 100% rename from docs/about/LICENSE.md rename to src/about/LICENSE.md diff --git a/src/en/about/LICENSE.md b/src/en/about/LICENSE.md new file mode 100644 index 0000000..61b6630 --- /dev/null +++ b/src/en/about/LICENSE.md @@ -0,0 +1,108 @@ +# License + +LCOJ is released under the **GNU Affero General Public License v3.0 (AGPL-3.0)**. + +## Summary + +AGPL-3.0 is an open-source license that allows you to: + +✅ **Permissions:** +- Commercial use +- Modification +- Distribution +- Patent use +- Private use + +❌ **Conditions:** +- Disclose source code when distributing +- Use the same license (AGPL-3.0) +- State your changes +- **Important:** If you run the software on a server and users access it over a network, you must provide the source code + +⚠️ **Limitations:** +- No warranty +- No liability + +## Comparison with the GPL + +The AGPL differs from the GPL in one respect: **if you run the software on a server and let others access it over a network, you must provide the source code.** + +The GPL only requires you to provide the source code when you distribute the software. + +## Origins + +LCOJ is based on: +- [DMOJ](https://github.com/DMOJ/online-judge) - AGPL-3.0 +- [VNOJ](https://github.com/VNOI-Admin/OJ) - AGPL-3.0 + +## Read the full text + +Full license text: [GNU AGPL-3.0](https://www.gnu.org/licenses/agpl-3.0.en.html) + +--- + +# GNU AFFERO GENERAL PUBLIC LICENSE + +*Version 3, 19 November 2007* + +*Copyright © 2007 Free Software Foundation, Inc. * + +Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. + +## Preamble + +The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software. + +The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. + +When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. + +Developers that use our General Public Licenses protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License which gives you legal permission to copy, distribute and/or modify the software. + +A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to incorporate. Many developers of free software are heartened and encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public. + +The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available to the community. It requires the operator of a network server to provide the source code of the modified version running there to the users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version. + +An older license, called the Affero General Public License and published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license. + +The precise terms and conditions for copying, distribution and modification follow. + +## TERMS AND CONDITIONS + +### 0. Definitions + +"This License" refers to version 3 of the GNU Affero General Public License. + +"Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. + +"The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. + +To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. + +A "covered work" means either the unmodified Program or a work based on the Program. + +To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. + +To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. + +An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. + +### 1. Source Code + +The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. + +A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. + +The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. + +The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. + +The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. + +The Corresponding Source for a work in source code form is that same work. + +*[Full license text continues...]* + +--- + +**Note:** This is only an unofficial summary. Please read the full license text to fully understand your rights and obligations. diff --git a/src/en/features/library.md b/src/en/features/library.md new file mode 100644 index 0000000..76708e4 --- /dev/null +++ b/src/en/features/library.md @@ -0,0 +1,474 @@ +# Exam library + +The exam library (`/library/`) is where LCOJ keeps official exam papers as PDFs: provincial and national olympiads, the Tin học trẻ contest, entrance exams for specialised computer science classes, and more. Readers flip through each paper in the browser with a book-style viewer (the flipbook). If an exam is linked to a contest, one click takes readers to that contest, where their submissions are graded automatically. + +This page has two parts: + +- **[Part 1 – For readers](#readers)**: finding, filtering and reading exams in the flipbook. +- **[Part 2 – For admins](#admins)**: adding exams, uploading PDFs, scheduling publication, managing categories, storage and troubleshooting. + +```mermaid +flowchart TD + A["Admin adds exam + PDF"] --> B{"Publicly visible?"} + B -- "No" --> H["Hidden (404)"] + B -- "Yes" --> C{"Publish time reached?"} + C -- "Not yet" --> H + C -- "Yes" --> D["Listed in /library/"] + D --> E["Exam page /library/slug"] + E --> F["Read in flipbook"] + E --> G["Take the test (if contest)"] +``` + +--- + +## Part 1 – For readers {#readers} + +### 1.1. Finding exams {#browse} + +⏱ 2 min · 👤 Students, teachers, guests · 🔑 No login required + +#### Before you start + +- The library is public. You don't need an account. +- Open `/library/` directly (for example `https://luyencode.net/library/`). LCOJ's default navigation bar has **no** Library entry. Whether one exists depends on your admins. + +#### Steps + +1. Open `/library/`. The page header reads **"Exam library"** and shows four figures: "published exams", "exam types", "provinces and cities" and "graded automatically". +2. Type a keyword into **"Search by exam title or description..."** and click **"Search"**. The search checks both titles and descriptions and ignores case. +3. Pick a category tab below the search box, for example "HSG Tỉnh/TP" or "Đề vào 10 chuyên". The **"All"** tab shows every exam. Each tab shows how many exams it holds. Categories with no published exams have no tab. +4. Pick a province in the **"All provinces"** dropdown. The list reloads as soon as you choose. +5. Pick a year in the **"All years"** dropdown. It only lists years that have exams. +6. Check the result count next to the filters (for example "12 exams found"). Click **"Clear filters"** to reset everything. +7. Scroll to the bottom to change pages. Each page shows 12 exams, newest (by publish date) first. + +All filters combine: search, category, province and year can be used together, and they stay applied when you change pages. + +::: tip Sharing a filtered list +Filters are stored in the page address, so you can send the link to others: + +| Parameter | Meaning | Example | +|---|---|---| +| `q` | Keyword matched against titles and descriptions | `?q=tin học trẻ` | +| `category` | Category slug | `?category=hsg-tinh-tp` | +| `province` | Province code | `?province=ha_noi` | +| `year` | Exam year (must be a number; anything else is ignored) | `?year=2024` | + +Example: `/library/2?category=de-vao-10-chuyen&province=tp_ho_chi_minh` is page 2 of the specialised-class entrance exams for Ho Chi Minh City. +::: + +#### Reading an exam card + +Each exam in the list is a card with: + +- A coloured category badge and the exam year. +- The exam title. Click the title (or anywhere on the card) to open the exam page. +- A province chip, plus a **"Full statement in PDF"** chip when the exam has a PDF. +- A description excerpt of up to 140 characters. +- A **"Take the test now"** button if the exam has a practice contest, or a **"Statement only"** label if it doesn't. + +#### Verify + +- The "N exams found" count matches the number of cards across all pages. +- The tab you picked is highlighted, and the address now contains `category=…`. + +#### Troubleshooting + +| Symptom | Fix | +|---|---| +| "No exams found." | Click "Clear filters", or try a shorter keyword. | +| The category you need has no tab | That category has no publicly listed exams yet. | +| The year dropdown doesn't list the year you want | No public exam from that year exists yet. | +| An exam you know exists doesn't show up | It may be hidden or scheduled for later. Contact an admin. | + +#### Next steps + +Open an exam to read it: see [1.2. Reading an exam in the flipbook](#flipbook). + +### 1.2. Reading an exam in the flipbook {#flipbook} + +⏱ 5 min · 👤 Students, teachers, guests · 🔑 No login required + +#### Before you start + +- Use a modern browser with JavaScript enabled (current Chrome, Edge, Firefox or Safari). +- Exam pages live at `/library/`, for example `/library/hsg-ha-noi-2024`. + +#### Steps + +1. Click an exam card in the library to open the exam page. +2. Check the header: category, province, year and the "Published on …" line. +3. Scroll to the flipbook. The viewer only starts loading once it is about to scroll into view, and shows **"Loading document…"** in the meantime. Page 1 appears first and the other pages are processed one by one in the background. +4. Turn pages by clicking the left or right edge of a page, or by dragging a page corner as you would with a real book. On phones, swipe. +5. Use the toolbar in the top-right corner of the viewer: + + | Button (icon) | What it does | + |---|---| + | Magnifier with minus | Zoom out in 0.25× steps, down to 1× | + | Magnifier with plus | Zoom in in 0.25× steps, up to 2.5×. While zoomed in, the viewer gets scrollbars so you can reach the hidden parts | + | Speaker | Turn the page-flip sound on or off. Your browser remembers the choice for next time | + | Expand arrows | Fullscreen view on a dark background. Click it again or press `Esc` to leave | + +6. Read the description below the flipbook, if there is one. +7. If the exam has a contest, click **"Take the test now"** (in the header, or in the **"Ready to try it yourself?"** box at the bottom) to enter the contest, submit solutions and get results. +8. Click **"Back to library"** to return to the list. + +::: details How does the flipbook look on desktop and on phones? +- **Wide desktop screens**: two pages side by side, like an open book. The first and last pages (the covers) are shown alone and centred. +- **Narrow screens** (viewer narrower than about 600px, typically phones): one page at a time is preferred. +- The book is sized to fit the window height. Entering or leaving fullscreen rebuilds it to fit the new screen. +- The flipbook has **no keyboard shortcuts**. Use the mouse, touch and the toolbar buttons. +- The toolbar tooltips ("Zoom out", "Zoom in", "Toggle sound", "Fullscreen") are currently English-only, even on the Vietnamese interface. +::: + +::: warning No download button +Exam pages have no download button. A **"Download PDF"** link only appears when JavaScript is disabled or the viewer libraries fail to load. If the PDF itself can't be read, the flipbook shows a **"Could not load preview — download PDF"** link that opens the file directly. +::: + +#### Verify + +- Page 1 of the exam is visible, with the toolbar in the top-right corner. +- Turning a page plays an animation and a flip sound (unless muted). + +#### Troubleshooting + +| Symptom | Fix | +|---|---| +| Stuck on "Loading document…" | The file is large or the network is slow. Wait a little longer, or reload the page. | +| A "Download PDF" link instead of the book | The browser couldn't load the viewer libraries (JavaScript disabled or blocked, or a very old browser). Use the link to open the PDF, or switch browsers. | +| "Could not load preview — download PDF" | The PDF couldn't be read. Use the link to open it directly, then tell an admin. | +| The exam page returns 404 | The exam is hidden, not yet published, or the address is wrong. | +| The fullscreen button does nothing | Some mobile browsers (for example Safari on iPhone) don't let part of a page go fullscreen. Rotate the phone or pinch-zoom instead. | +| No flip sound | Check the speaker button in the toolbar and your device volume. | +| The exam page has no flipbook | The exam has no PDF yet, only a description. | +| "Take the test now" shows an error or 404 | The linked contest may be private and you can't see it. Contact an admin. | + +#### Next steps + +- Practise in the linked contest with "Take the test now". +- Go back to `/library/` to find exams from the same category, province or year. + +--- + +## Part 2 – For admins {#admins} + +All library management happens in the Django admin (`/admin/`), under the **Online Judge** section: + +| Admin entry (en) | Admin entry (vi) | Address | Model | +|---|---|---|---| +| Resources | Tài nguyên | `/admin/judge/examstatement/` | `ExamStatement` (one exam) | +| Exam categories | Danh mục đề thi | `/admin/judge/examcategory/` | `ExamCategory` (a category) | + +::: warning Confusing entry name +In the admin, exams are listed as **"Resources"** (Vietnamese: "Tài nguyên"), not "Exams". +::: + +### 2.1. Granting library permissions {#permissions} + +⏱ 5 min · 👤 System admins · 🔑 Superuser + +#### Before you start + +- People who manage the library need **staff status** to reach `/admin/`. +- The library has no custom permissions. It uses Django's four default permissions per model. See the [permission system](/en/site/permission_system) page. + +#### Steps + +1. Go to `/admin/auth/group/` and create a group, for example "Library editors". +2. Give the group the permissions it needs: + + | Permission (codename) | Allows | + |---|---| + | `judge.view_examstatement` / `judge.add_examstatement` / `judge.change_examstatement` / `judge.delete_examstatement` | View / add / change / delete exams | + | `judge.view_examcategory` / `judge.add_examcategory` / `judge.change_examcategory` / `judge.delete_examcategory` | View / add / change / delete categories | + +3. Add the users to the group. +4. Make sure their accounts have staff status. + +::: tip +Exam editors usually only need the exam permissions, plus `judge.view_examcategory` to pick a category. Keep category change/delete for whoever manages the library as a whole. +::: + +#### Verify + +- The user can log in to `/admin/` and sees "Resources" under Online Judge. + +#### Troubleshooting + +| Symptom | Fix | +|---|---| +| Can't open `/admin/` | Turn on staff status for the account. | +| "Resources" is missing | The user lacks `view`/`change` permission on `examstatement`. | +| The "Contest" field can't find the contest to link | It only lists contests that the editor can see. Give them access to that contest (for example `see_private_contest`). | + +#### Next steps + +[2.2. Adding an exam](#add-exam). + +### 2.2. Adding an exam {#add-exam} + +⏱ 5–10 min · 👤 Admins, exam editors · 🔑 `judge.add_examstatement` + +#### Before you start + +- Have the exam PDF ready. It must have a `.pdf` extension and be **at most 5 MB** (`PDF_STATEMENT_MAX_FILE_SIZE = 5242880`). +- If you want a "Take the test now" button, create the practice contest first. +- Check that a suitable category exists (see [2.4](#categories)). + +#### Steps + +1. Go to `/admin/judge/examstatement/` and click the add button. +2. Enter the **Title** (up to 100 characters). **Slug** fills itself in from the title, without diacritics. +3. Adjust the **Slug** if needed. It must be unique and at most 50 characters, and it becomes the exam page address `/library/`. +4. Pick a **Category** (required). +5. Pick a **Province** if the exam belongs to one. Otherwise leave it empty. +6. Enter the exam **Year** (optional, from 1990 up to next year). +7. Write a **Description** in Markdown (optional). +8. Search for the practice contest in the **Contest** field (type its key or name). +9. Tick or untick **Publicly visible**. +10. Set **Publish on** with the date/time picker. Leave it empty to use the moment you save. +11. Choose the file in **PDF file**. +12. Save. + +Field reference: + +| Field (en) | Field (vi) | Required | Meaning | +|---|---|---|---| +| Title | Tiêu đề | Yes | Exam name, shown on the card, the exam page and the browser tab. Up to 100 characters. | +| Slug | Slug | Yes | Exam page address `/library/`. Unique, up to 50 characters, only unaccented letters, digits, `-` and `_`. Pre-filled from the title. | +| Category | Nhóm | Yes | Exam category: decides the tab and the coloured badge. | +| Province | Tỉnh/thành phố | No | Chosen from a fixed list of provinces and cities. Used by the province filter. | +| Year | Năm | No | From 1990 up to next year. Used by the year filter. | +| Description | Mô tả | No | Markdown. Shown below the flipbook, trimmed to a 140-character excerpt on the card, included in search, and its first paragraph becomes the SEO description. | +| Contest | Contest | No | Practice contest. When set, the "Take the test now" button appears. If the contest is deleted, the link is cleared automatically. | +| Publicly visible | Hiển thị công khai | — | On by default. When off, the exam disappears from the list and its page returns 404. | +| Publish on | Publish on | No | Publication date and time. Before it, the exam is hidden as if it were not visible. Also used for ordering (newest first) and for the "Published on …" line. | +| PDF file | Tệp PDF | No | Uploaded file (`.pdf`, ≤ 5 MB). Saving with a new file replaces the exam's PDF. | +| PDF URL | Đường dẫn PDF | — | Read-only. Filled in automatically after upload, in the form `/pdf/.pdf`. | + +::: warning No external PDF links, no PDF removal +**PDF URL** is read-only, so you can't point an exam at a PDF hosted elsewhere, and you can't remove an exam's PDF from the admin. To replace a PDF, upload a new file. The old file stays on disk. +::: + +::: tip Scheduling an exam +To publish an exam at a set time (for example right after the real exam ends), leave **Publicly visible** on and set **Publish on** to that time. The exam appears by itself when the time comes. Nothing else to do. +::: + +::: details Built-in SEO +- The list page title follows the active filters (for example "Đề vào 10 chuyên - Hà Nội - year 2024 | Exam library"), and its description includes the number of matching exams. +- Search result pages (`?q=`) are marked `noindex, follow`. Pages filtered by category, province or year stay indexable. +- Both page types carry schema.org data (JSON-LD): `CollectionPage`/`ItemList` for the list, `LearningResource` for an exam page, plus `BreadcrumbList`. +- The SEO description of an exam page comes from the first paragraph of its **Description** (or the title if there is none). The share image is the first image in the description. The result is **cached for 24 hours**, so after you edit a description the meta tags may take up to a day to update. +- No library pages are in `sitemap.xml` yet. +::: + +#### Verify + +1. Open `/library/`. The new exam is at the top of the list (once its publish time has passed). +2. Its card shows "Full statement in PDF", and "Take the test now" if a contest is linked. +3. Open the exam page. The flipbook loads page 1. +4. Open `/pdf/.pdf` (taken from the PDF URL field). The browser should display the file. + +#### Troubleshooting + +| Symptom | Fix | +|---|---| +| "File size is too big! Maximum file size is …" | Compress the PDF (lower image resolution) below 5 MB. The limit is `PDF_STATEMENT_MAX_FILE_SIZE`. | +| File extension error | Only `.pdf` files are accepted. | +| "Exam year must be between 1990 and …" | Enter a year within the allowed range. | +| Slug already exists | Change the slug, for example by adding the year or province. | +| Saved, but the exam isn't in `/library/` | Check that **Publicly visible** is on and **Publish on** isn't in the future. | +| The exam page has no flipbook | No PDF was uploaded (the PDF URL field is empty). | + +#### Next steps + +- [2.3. Editing, hiding or removing an exam](#edit-exam) +- [2.5. PDF storage and backups](#storage) + +### 2.3. Editing, hiding or removing an exam {#edit-exam} + +⏱ 2 min · 👤 Admins, exam editors · 🔑 `judge.change_examstatement` (delete: `judge.delete_examstatement`) + +#### Before you start + +- The admin exam list shows title, category, province, year, contest, visibility and publish date. It can be filtered by category, province, year and visibility, browsed by publish date, and searched by title and description. + +#### Steps + +1. Go to `/admin/judge/examstatement/` and find the exam. +2. Click its title to open the form. +3. To hide it for now: untick **Publicly visible**, then save. +4. To replace the PDF: choose a new file in **PDF file**, then save. +5. To remove it for good: use the delete button in the form and confirm. + +::: warning Changing the slug breaks old links +The slug is the exam page address. Changing it makes every link shared earlier return 404. +::: + +#### Verify + +- A hidden or deleted exam is gone from `/library/`, and `/library/` returns 404. +- After replacing the PDF, the PDF URL field shows a new file name. + +#### Troubleshooting + +| Symptom | Fix | +|---|---| +| The flipbook still shows the old PDF | Do a hard reload (Ctrl+F5). The new file has a new address, so only the HTML page needs reloading. | +| The SEO description is still the old text | It is cached for 24 hours. Wait, or clear the Redis cache. | + +#### Next steps + +[2.4. Managing categories](#categories). + +### 2.4. Managing categories {#categories} + +⏱ 3 min · 👤 Admins · 🔑 `judge.add_examcategory` / `judge.change_examcategory` + +#### Before you start + +After migrations, LCOJ ships with 8 categories: + +| Order | Name | Slug | +|---|---|---| +| 0 | HSG Tỉnh/TP | `hsg-tinh-tp` | +| 1 | HSG Quốc Gia | `hsg-quoc-gia` | +| 2 | Chọn đội tuyển quốc gia | `chon-doi-tuyen-quoc-gia` | +| 3 | Olympic quốc tế | `olympic-quoc-te` | +| 4 | Đề thi thử | `de-thi-thu` | +| 5 | Đề vào 10 chuyên | `de-vao-10-chuyen` | +| 6 | ICPC/OLP | `icpc-olp` | +| 7 | Khác | `khac` | + +#### Steps + +1. Go to `/admin/judge/examcategory/`. +2. Click the add button, or click an existing category to edit it. +3. Enter the **Name** (unique, up to 40 characters). In the Vietnamese admin this field is mistranslated as "Tên người dùng". +4. Check the **Slug** (pre-filled from the name, unique, up to 50 characters). It is the `?category=` value in page addresses. +5. Set **Order**: lower numbers come first. Categories with the same order are sorted by name. +6. Save. + +::: tip Badge colours +Badge colours are assigned automatically from each category's position in the sort order (a 7-colour palette, repeating). Reordering categories changes their colours. +::: + +#### Verify + +- The category tab appears on `/library/` in the right order, **once the category has at least one public exam**. + +#### Troubleshooting + +| Symptom | Fix | +|---|---| +| A category can't be deleted | It still has exams (the link is protected). Move them to another category or delete them first. | +| A new category has no tab | It has no public exams yet. | +| An old `?category=` link no longer filters correctly | The category slug was changed. Update the link. | + +#### Next steps + +[2.5. PDF storage and backups](#storage). + +### 2.5. PDF storage and backups {#storage} + +⏱ 5 min · 👤 Server operators · 🔑 Access to the Docker host + +#### Before you start + +How a PDF travels: + +```mermaid +flowchart LR + U["Uploaded PDF"] --> S["site: /media/pdf/uuid.pdf"] + S --> H["Host: dmoj/media/pdf/"] + H --> N["nginx: location /pdf"] + N --> B["Browser: /pdf/uuid.pdf"] +``` + +- The file is renamed to `.pdf` and saved in `MEDIA_ROOT/pdf/`, which is `/media/pdf/` inside the `site` container (`PDF_STATEMENT_UPLOAD_MEDIA_DIR = 'pdf'`). +- `/media/` is the host directory `dmoj/media/` (a bind mount in `docker-compose.yml`), shared by `site` and `nginx`. +- nginx serves the files directly at `/pdf/…` (`location /pdf { root /media/; }`). The exam stores the relative address `/pdf/.pdf` (`PDF_STATEMENT_UPLOAD_URL_PREFIX = '/pdf'`), on the same domain as the page, so CORS doesn't come into play. +- Problem statement PDFs share this same directory. + +#### Steps + +1. Back up `dmoj/media/pdf/` together with the database. With only the database, the exams survive but their PDFs are lost. With only the directory, the PDFs survive but you can't tell which file belongs to which exam. +2. When moving to a new server, copy `dmoj/media/` over before running `docker compose up -d`. + +::: warning Old PDFs are never deleted +Replacing a PDF or deleting an exam does **not** delete the file on disk, so `dmoj/media/pdf/` keeps growing. If you clean it up, first check each file against the exams' `pdf_url` column (and problem PDFs). +::: + +#### Verify + +```sh +cd lcoj-docker/dmoj +ls -lh media/pdf/ | tail +curl -I http://localhost:${NGINX_PORT:-8071}/pdf/.pdf # expect 200, Content-Type: application/pdf +``` + +#### Next steps + +- See [Operating LCOJ](/en/site/operations) for backups. + +### 2.6. Flipbook libraries (PDF.js, StPageFlip) {#static-assets} + +⏱ 10 min · 👤 Server operators · 🔑 Access to the Docker host and repositories + +#### Before you start + +The flipbook uses these static files: + +| File | Source | +|---|---| +| `lcoj/pdfjs/pdfjs-init.js`, `pdf.min.js`, `pdf.worker.min.js` (PDF.js) | Submodule `resources/lcoj` → [luyencode/lcoj-static](https://github.com/luyencode/lcoj-static) | +| `lcoj/pageflip/page-flip.browser.js` (StPageFlip) | Same `resources/lcoj` submodule | +| `flipbook.js`, `flipbook.scss`, `page-flip.mp3` | Directly in lcoj-site's `resources/` | + +These libraries are **not** part of the lcoj-site repository and are not loaded from a CDN. They live in a separate submodule and are served from `/static/`. + +#### Steps + +1. After cloning or updating, fetch all submodules (including the ones nested inside `dmoj/repo`): + + ```sh + cd lcoj-docker + git submodule update --init --recursive + ``` + +2. Check that the files are there: + + ```sh + ls dmoj/repo/resources/lcoj/pdfjs dmoj/repo/resources/lcoj/pageflip + ``` + +3. Copy the static files to the `assets` volume so nginx can serve them: + + ```sh + cd dmoj + ./scripts/copy_static + ``` + +4. If you changed the nginx config, restart nginx: `docker compose restart nginx`. + +#### Verify + +- Open `/static/lcoj/pdfjs/pdf.min.js` and `/static/lcoj/pageflip/page-flip.browser.js` in a browser. They should return JavaScript, not a 404. +- Open an exam page with a PDF. The flipbook shows page 1. + +#### Troubleshooting + +| Symptom | Fix | +|---|---| +| After about 8 seconds the flipbook shows only a "Download PDF" link | PDF.js or StPageFlip failed to load. Check `/static/lcoj/...` for 404s, then repeat steps 1 and 3. | +| "Could not load preview — download PDF" | PDF.js loaded but couldn't read the file. Open the browser console and look for `[flipbook] failed to load`. Check that `/pdf/.pdf` returns 200 and that the file isn't corrupt. | +| `/pdf/.pdf` returns 404 | The file isn't in `dmoj/media/pdf/`, or nginx lacks `location /pdf`. Check the `./media/:/media/` mount on both `site` and `nginx`. | +| The console reports a failed load of `pdf.worker.min.js` | The worker is loaded from the same folder as `pdfjs-init.js`. Make sure `pdf.worker.min.js` was copied to `/static/lcoj/pdfjs/`. | +| The flipbook is blank or very slow for long files | Every page is rendered to an image in the browser, one at a time. Long or image-heavy PDFs use a lot of memory, especially on phones. Optimise the PDF (lower image resolution, drop unneeded pages). | +| CORS errors in the console | These only happen when the PDF is on another domain. The default setup (PDF under `/pdf/` on the same domain) avoids them. Check `MEDIA_URL`/`SITE_FULL_URL` and whether a front proxy (Cloudflare) redirects to another domain. | +| Upload fails with 413 | The request exceeded nginx's `client_max_body_size 64M`. This can't happen with files ≤ 5 MB. | + +#### Next steps + +- [Installing LCOJ](/en/site/installation) +- [Operating LCOJ](/en/site/operations) diff --git a/src/en/features/quiz.md b/src/en/features/quiz.md new file mode 100644 index 0000000..567595a --- /dev/null +++ b/src/en/features/quiz.md @@ -0,0 +1,250 @@ +# Taking a quiz + +> How to find a quiz on LCOJ, take it, submit it, and read your result and the ranking. +> +> ⏱ ~10 min · 👤 Students and learners · 🔑 A signed-in LCOJ account + +## Before you start + +- [ ] You are **signed in**. Guests can browse the quiz list and quiz pages, but must sign in to start an attempt. +- [ ] You use an up-to-date desktop browser (Chrome, Edge, Firefox…) on a stable connection. Answers are saved to the server as you pick them. +- [ ] If the quiz is private to an **organization** (class, school, club), you are a member of that organization. +- [ ] You have enough time. On timed quizzes the clock starts when you press start and **does not pause**, even if you close the tab. + +## Attempt lifecycle + +```mermaid +stateDiagram-v2 + state "Not started" as idle + state "In progress" as doing + state "Time is up" as timeout + state "Submitted and graded" as graded + state "Result and ranking" as result + [*] --> idle + idle --> doing: Start + doing --> doing: Autosave + doing --> graded: Submit + doing --> timeout: Time runs out + timeout --> graded: Auto-submit or closed after 30 s + graded --> result + result --> [*] +``` + +Each time you press start you create an **attempt**. It is graded automatically the moment it is submitted, and your best score is used for the ranking. + +## Find a quiz + +1. Click **Quizzes** in the top menu (on luyencode.net the menu item is labelled **Trắc nghiệm**), or open `https://luyencode.net/quizzes/` directly. +2. Pick a status tab: **All**, **Upcoming**, **Ongoing** or **Past**. A tab only appears when it has quizzes in it. +3. To search by name or code, type in **Search quizzes...** and click **Go** (or press Enter). +4. Tick **Hide attempted** to hide quizzes you have already submitted at least once. +5. Click a quiz code or name to open its page. + +The list has these columns: + +| Column | Meaning | +|---|---| +| **Code** | The quiz code, also part of the URL: `/quizzes/` | +| **Quiz** | Quiz name, open/close times (or **Always open**) and a countdown | +| **Questions** | Number of questions | +| **Participants** | Number of people with at least one submitted attempt | +| **Your best** | Your best score, or `—` if you have not submitted yet | + +::: tip +The list shows 50 quizzes per page. You only see quizzes you are allowed to open. Organization-private quizzes for organizations you have not joined are hidden. +::: + +## Read the quiz page + +The page at `/quizzes/` shows: + +| Item | Meaning | +|---|---| +| **Questions** | Number of questions | +| **Points** | Maximum total score | +| **Time limit** | **Minutes** per attempt. `∞` means unlimited | +| **Max attempts** | How many attempts you may submit. `∞` means unlimited | +| **Start:** / **End:** | The window in which the quiz is open. **Not set — opens immediately** and **Not set — never closes** mean no limit on that side | +| Banner | **Starting in …** (not open yet), **Ends in …** (open), or a notice that the quiz has closed | +| **Your attempts** | Your previous attempts with their score, or **in progress** | + +The action bar changes with the situation: + +| You see | Meaning | +|---|---| +| **Start quiz** (with "N attempt(s) remaining") | You can start a new attempt | +| **Resume attempt** | You have an unfinished attempt. Click to return to it | +| **Quiz not started yet.** | The quiz has not opened yet | +| **Quiz is closed.** | The end time has passed | +| **No attempts remaining.** | You have used all your attempts | + +::: info +The quiz page does **not** tell you in advance how results are shown (score only, correctness, or full answers). You find out after your first submission. See [Read your result](#read-your-result). +::: + +## Start an attempt + +1. On the quiz page, click **Start quiz**. +2. If integrity monitoring is on, the **Academic Integrity Notice** dialog appears. Read the rules and click **I understand, start →**. Click **Cancel** if you are not ready; no attempt is created yet. +3. The quiz page opens. On a timed quiz the countdown is already running. + +::: warning The clock never stops +Time counts from the moment you start. Closing the tab, shutting down the computer or losing the connection does **not** pause it. +::: + +Questions and answer choices may be shuffled, depending on the teacher's settings. The order is fixed for your attempt, so reloading the page keeps it. + +## Integrity monitoring + +When the teacher enables **integrity monitoring**, the quiz page: + +- Overlays a **watermark** repeating your username and the attempt start time. +- **Blocks copying** and **disables right-click**. +- Shows a short message for about 4 seconds whenever it records an event (for example `⚠ Tab switch detected. This has been recorded.`). + +Recorded events: + +| Event (as the teacher sees it) | Recorded when | +|---|---| +| **Tab switch** | The quiz tab becomes hidden: switching tabs, minimizing the browser | +| **Window blur** | The browser window loses focus: clicking another app, Alt+Tab | +| **DevTools opened** | The page viewport is more than 160 px smaller than the window (a sign of open DevTools) | +| **PrintScreen key** | Pressing the PrintScreen key | +| **Copy attempt** | Trying to copy (Ctrl+C…) | + +The teacher sees the **event type** and **time**. Nothing is screenshotted, and nothing you do outside the tab is recorded. + +::: tip Events do not change your score +As the dialog says, these events do **not** affect your score. They are information for the teacher to review. +::: + +::: details Why was "DevTools opened" recorded when I never opened DevTools? +Detection compares the window size with the page viewport. An open browser side panel, some extensions or an unusual zoom level can create the same gap. Close side panels before starting. Each event type is recorded at most once every 5 seconds. +::: + +## Answer each question type + +Use **Previous** / **Next** to move between questions, or click a question number in the **Questions** box in the sidebar. Answered questions are highlighted on that map, and the progress bar at the top shows how far you are. + +| Type (UI name) | How to answer | Grading | +|---|---|---| +| **Multiple Choice** | Pick one radio button | The right choice earns full points; anything else earns 0 | +| **Multiple Answer** | Tick one or more checkboxes | Depends on the teacher's strategy: either all-or-nothing or partial credit | +| **True/False** | Pick **True** or **False** | Full points or 0 | +| **Short Answer** | Type in **Type your answer...** | Full points if it matches an accepted answer, otherwise 0 | + +Keyboard shortcuts (when you are not typing in a short-answer box): + +| Key | Action | +|---|---| +| / | Previous / next question | +| 19 | Select (or toggle, for multiple-answer questions) choice N | + +### How are short answers matched? + +- Spaces **at the start and end** of your answer are ignored. Spaces **in the middle** count. +- Your **whole** answer must match one of the teacher's accepted answers; containing it is not enough. For example, an accepted `42` does not accept `x = 42`. +- Whether **upper/lower case** matters is decided by the teacher for each question. Type exactly what the question asks for. +- An empty box counts as unanswered and scores 0. + +::: tip +If the question says nothing about format, keep your answer minimal: just the number or keyword, with no units or punctuation. +::: + +## Autosave + +- **Multiple choice, multiple answer and true/false** answers are saved as soon as you click. +- **Short answers** are saved about 0.8 seconds after you stop typing. +- If a save fails (connection lost), the sidebar shows `Save failed — retrying…` and the browser retries every 3 seconds. Do not close the tab while you see this. +- Clearing all choices of a question is saved too, and the question becomes unanswered again. + +Because answers live on the server, you can reload the page or reopen it on another device without losing work: click **Resume attempt** on the quiz page. + +## Time limits, time-outs and closed tabs + +| Situation | What happens | +|---|---| +| The quiz has a time limit | The **Time** box turns amber under 5 minutes and red under 1 minute. At 0 the browser **submits automatically** | +| Your connection is slow right at the deadline | The server still accepts answers that arrive within **30 seconds** after the deadline | +| You close the tab with time left | The attempt keeps running. Reopen the quiz page and click **Resume attempt** | +| You close the tab and time runs out | After the deadline + 30 seconds the attempt is **closed**: the answers you saved are still graded. Closing happens when you next open the quiz page | +| The quiz has both an end time and a time limit | You get your full time limit, **even if** the end time passes meanwhile | +| The quiz has an end time but no time limit | The attempt closes exactly at the end time, with no 30-second grace and no automatic submit in the browser | +| No time limit and no end time | The attempt stays open until you submit it | + +::: warning +An abandoned attempt is only closed when you come back to the quiz page. Until then it has no score and is not on the ranking. If you closed the tab by accident, reopen the quiz page as soon as you can. +::: + +## Submit + +1. On the last question click **Review & Submit**, or click **Submit quiz** in the sidebar at any time. +2. The browser asks **Submit the quiz now?**. If some questions are blank, it first says "You have N unanswered question(s)." Confirm to submit, or cancel to keep working. +3. The attempt is graded immediately and you are taken to the result page. + +::: danger +A submitted attempt cannot be changed. To improve your score, start a new attempt (if you have any left). +::: + +## Read your result + +The result page (`/quizzes//attempt//result`) always shows **Score: X / Y**. The rest depends on the mode the teacher chose: + +| Mode | What you see | +|---|---| +| **Score only** | Total score, each question and **Your answer**. No right/wrong marks, no answer key | +| **Show correctness** (no answer key) | Adds green/red colouring and per-question points (for example `(0.5 / 1)`), but no correct answers | +| **Show correct answers and explanations** | Every choice: ✓ marks correct choices, ✗ marks wrong choices you picked, a **Your answer** tag, a **Missed** tag on correct choices you did not pick, a **Why?** toggle with per-choice explanations, the **Correct answer** (or **Accepted patterns**) for true/false and short-answer questions, and the question's overall explanation | + +Blank questions show **(no answer)**. + +You can always review old attempts: on the quiz page, in **Your attempts**, click **view**. + +## Ranking + +Click **Ranking** on the quiz page or the result page (`/quizzes//ranking`). The rules: + +1. Each person has **one row**: their best attempt. +2. **Higher** score ranks first. +3. On equal scores, the **faster** attempt (time from start to submission) ranks first. +4. If still tied, the **earlier submission** ranks first. + +Only **submitted** attempts count. Your own row is highlighted in yellow. The **Time** column is the duration of the attempt used for ranking. + +## Attempt limits + +- **Max attempts** counts **submitted** attempts only. An unfinished attempt does not use up a new one. +- You can have only **one** unfinished attempt at a time. Pressing start while one exists takes you back to it. +- When you run out, the start button disappears and you see **No attempts remaining.** + +## Organization-private quizzes + +Some quizzes are only for members of one or more organizations. If you are not a member, the quiz is missing from the list and opening its link directly shows **404**. Join the organization (see **Organizations** on LCOJ) or ask your teacher. + +## Verify + +After submitting, check that: + +- [ ] The quiz page has a new row under **Your attempts** with a score (no longer **in progress**). +- [ ] The **Your best** column on `/quizzes/` shows your best score. +- [ ] Your name is on the **Ranking**. + +## FAQ + +| Situation | What to do | +|---|---| +| You see `Save failed — retrying…` | Your connection is unstable. Keep the tab open; the browser retries every 3 seconds. Answers saved earlier are safe | +| You closed the tab or the computer shut down | Reopen the quiz page. With time left, click **Resume attempt**. With time up, the attempt is closed with your saved answers | +| Time ran out while answering | The quiz submits itself. All saved answers (including those arriving within the 30-second grace) are graded | +| No correct answers after submitting | The teacher chose **Score only** or **Show correctness**. This is a setting, not a bug | +| No start button | Check that you are signed in, the quiz has opened (**Quiz not started yet.**), it has not closed (**Quiz is closed.**), and you have attempts left | +| The quiz link shows 404 | The quiz is hidden, or private to an organization you are not in | +| The ranking does not show my latest score | The ranking uses your **best** attempt, not your latest | +| A correct short answer was marked wrong | Check case, spaces in the middle and extra units. If you are still sure, tell the teacher: they can fix the answer key and regrade | +| An event was recorded unfairly | Events do not change your score. Explain to the teacher if needed | +| The end time has passed. Can I keep going? | Only if you already have an unfinished attempt **and** the quiz has a time limit that your attempt has not used up. You cannot start a new attempt after the end time | + +## Next steps + +- [Creating and managing quizzes](/en/features/quiz_authoring): for teachers who want to write their own quizzes. +- [Permission system](/en/site/permission_system): how permissions work on LCOJ. diff --git a/src/en/features/quiz_authoring.md b/src/en/features/quiz_authoring.md new file mode 100644 index 0000000..6425341 --- /dev/null +++ b/src/en/features/quiz_authoring.md @@ -0,0 +1,614 @@ +# Creating and managing quizzes + +> How to build a question bank, bulk-import questions from Excel/JSON, create and schedule quizzes, then review attempts, integrity events and regrade. +> +> ⏱ ~30 min · 👤 Teachers, problem setters, administrators · 🔑 `quiz.edit_own_quiz` or `quiz.edit_all_quiz` + +## Before you start + +- [ ] Your account has `edit_own_quiz` or `edit_all_quiz` (see [Permissions](#permissions-and-how-to-grant-them)). With either one, the site's floating toolbar shows extra **Quiz** buttons. +- [ ] You have read [Taking a quiz](/en/features/quiz) so you know what students see. +- [ ] For bulk import: Microsoft Excel, LibreOffice or Google Sheets (exporting `.xlsx`), or a text editor for JSON. +- [ ] If the quiz is for a class: the class's organization already exists on LCOJ. + +## Workflow overview + +```mermaid +flowchart TD + I["Import XLSX / JSON"] --> B["Question bank"] + N["Write questions"] --> B + B --> Q["Create quiz"] + Q --> T["Trial run: authors, testers"] + T --> P["Publish, schedule"] + P --> S["Students take it"] + S --> R["Review attempts, events"] + R --> G["Fix key, regrade"] +``` + +Two key ideas: + +- **Questions** live in the **question bank** and can be reused across quizzes. +- A **quiz** picks questions from the bank and gives each one **points** and an **order**. Points belong to the quiz, not the question. + +## Permissions and how to grant them + +| Permission (codename) | Display name | Allows | +|---|---|---| +| `quiz.edit_own_quiz` | Edit own quizzes and questions | Open the question bank, create questions and quizzes, import/export. Edit only questions and quizzes where you are an **author** or **curator** | +| `quiz.edit_all_quiz` | Edit all quizzes and questions | Everything above, plus view and edit **every** question and quiz | + +Superusers automatically have both. + +Per-object roles: + +| Role | Can | +|---|---| +| Author | Whoever creates the object is added automatically. Can edit if they have `edit_own_quiz` | +| Curator | Edit like an author, if they also have `edit_own_quiz` | +| Tester | View and take the quiz while it is **hidden**. Cannot edit | +| `edit_all_quiz` holder | Edit everything | + +### Grant access to a teacher (for administrators) + +**Option 1: through a group (recommended for several teachers)** + +1. Go to `/admin/auth/group/` and create a group, for example `Quiz Teachers`. +2. In the permission list's filter box, type `quiz`. +3. Pick **Quiz | quiz question | Edit own quizzes and questions** (`edit_own_quiz`) and click the arrow to add it. +4. Save the group. +5. Go to `/admin/auth/user/`, open the teacher's account, add the group under **Groups** and save. + +**Option 2: directly on one user** + +1. Go to `/admin/auth/user/` and open the account. +2. Under **User permissions**, filter by `quiz` and add `edit_own_quiz` (or `edit_all_quiz` for a head teacher or administrator). +3. Save. + +::: warning Vietnamese permission name is mistranslated +In the Vietnamese UI, `edit_all_quiz` shows as "Chỉnh sửa toàn bộ tổ chức" ("edit all organizations"). That is wrong: the permission means **edit all quizzes and questions** and has nothing to do with organizations. Go by the codename. +::: + +Teachers do **not** need `is_staff` or admin access: everything on this page works in the web UI. Admin access is only needed for [Django admin](#django-admin). + +See also: [Permission system](/en/site/permission_system). + +## Entry points + +Users with the permission see three **Quiz** buttons on the floating site toolbar: + +| Toolbar button | URL | Use it to | +|---|---|---| +| **Question Bank** | `/quizzes/questions/` | Browse, filter, create and export questions | +| **Import Quiz** | `/quizzes/import/` | Import questions from XLSX/JSON | +| **Manage Quizzes** | `/quizzes/` | The quiz list (includes your hidden quizzes) | + +Other URLs: + +| URL | Page | +|---|---| +| `/quizzes/questions/new` | New question | +| `/quizzes/questions//edit` | Edit a question | +| `/quizzes/import/template` | Download `quiz-template.xlsx` | +| `/quizzes/new` | **Create a quiz** | +| `/quizzes/` | Quiz page (students see this too) | +| `/quizzes//edit` | Edit a quiz | +| `/quizzes//attempts` | Attempts, integrity events, regrade | +| `/quizzes//ranking` | Ranking | + +::: tip There is no "New quiz" button +The UI currently has no link to the quiz creation page. Type `/quizzes/new` into the address bar, or create the quiz while importing (option **Also create a quiz from these questions**). +::: + +## Question bank + +### Browse and filter + +1. Open **Question Bank** (`/quizzes/questions/`). +2. Filter with **Search...** (matches code, title and body), **All types**, **All categories**, **All levels**, then click **Filter**. + +You see questions you authored or curate, plus every question marked **Public** = **Yes**. `edit_all_quiz` holders see everything. + +### Create a question + +1. In the question bank, click **New question**. +2. Enter a **Code** (lowercase letters and digits, for example `cpploop1`). +3. Pick a card under **Question Type**: **Multiple Choice**, **Multiple Answer**, **True / False** or **Short Answer**. +4. Enter a **Title** (a short name to find the question in the bank; students do not see it). +5. Write the **Question Text**. Switch to the **Preview** tab to check the rendering. +6. Set the answer: + - Multiple Choice / Multiple Answer: fill in **Choice text**, click **Add choice** if needed (2 to 6 choices), tick the **Correct** column for the right choice(s). + - True / False: pick **True** or **False**. + - Short Answer: enter a pattern under **Correct Answer — Regex Patterns**; click **Add pattern** for more. +7. (Optional) Fill in **Overall Explanation**, **Category**, **Difficulty**, **Shuffle choices**, **Public in bank**. +8. Click **Save question**. You become the question's author automatically. + +### Question fields + +| Field | Required | Meaning | +|---|---|---| +| **Code** | Yes | Unique site-wide, only `a-z` and `0-9`, up to 32 characters. No underscores (the Vietnamese error message saying `^[a-z0-9_]+$` is wrong) | +| **Question Type** | Yes | Multiple Choice (MC), Multiple Answer (MA), True/False (TF), Short Answer (SA) | +| **Title** | Yes | Up to 200 characters, only used in the bank | +| **Question Text** | Yes | Markdown with math | +| **Choices** | MC/MA | 2–6 choices; each may have its own **Explanation** (shown behind a **Why?** toggle in full result mode) | +| **Correct Answer** | Yes | See [Question types and grading](#question-types-and-grading) | +| **Display answer** | No | SA only: a readable answer shown on the result page. If blank, students see the raw regex patterns | +| **Overall Explanation** | No | Markdown, shown on the result page in full feedback mode | +| **Category** | No | An existing category. Categories are created in admin (`/admin/quiz/quizcategory/`) or automatically on import | +| **Difficulty** | Yes | Easy / Medium / Hard (default Easy) | +| **MA Grading Strategy** | MA | See the strategy table below | +| **Shuffle choices** | No | Each student sees the choices in a different order (MC/MA) | +| **Public in bank** | No | Every quiz editor can see this question and use it in their quizzes (but not edit it) | + +A **question's** authors and curators can only be changed in [Django admin](#django-admin). + +### Writing content: Markdown and math + +Question text, choices and explanations all support Markdown: `**bold**`, `*italic*`, `` `code` ``, highlighted code blocks, tables, lists. Math is rendered by MathJax: + +````markdown +Compute ~S = \sum_{i=1}^{n} i~ for ~n = 100~. + +$$ +\frac{n(n+1)}{2} +$$ + +```cpp +for (int i = 1; i <= n; i++) s += i; +``` +```` + +- Inline math: `~...~` +- Display math: `$$...$$` + +::: warning +The header note in the XLSX template suggests `$math$`. The syntax the site uses is `~...~` (inline) and `$$...$$` (display), the same as in programming problem statements. +::: + +## Question types and grading + +Each question yields a **ratio** from 0 to 1. The question's score = ratio × the question's points in the quiz, rounded to 2 decimals. A blank answer scores 0. + +| Type | Student does | Grading | +|---|---|---| +| **Multiple Choice** (MC) | Picks 1 choice | Right: 1, wrong: 0 | +| **True / False** (TF) | Picks True or False | Right: 1, wrong: 0 | +| **Multiple Answer** (MA) | Picks several choices | Depends on the strategy | +| **Short Answer** (SA) | Types text | Matches any pattern: 1, otherwise 0 | + +### Multiple-answer strategies + +Notation: **C** = number of correct choices, **W** = number of wrong choices in the question; the student picked **c** correct and **w** wrong choices. + +| Strategy (UI label) | Value in import files | Formula | +|---|---|---| +| **All or nothing** (default) | `all_or_nothing` / `All or nothing` | 1 if the selection equals the correct set **exactly**, else 0 | +| **Partial credit with penalty** | `partial_credit` / `Partial credit` | max(0, c/C − w/W) | +| **Right minus wrong** | `right_minus_wrong` / `Right minus wrong` | max(0, (c − w)/C) | +| **Correct only, no penalty** | `correct_only` / `Correct only` | c/C | + +**Example.** A question with 5 choices A–E, correct answers **A, C, D** (C = 3, W = 2), worth **3 points**: + +| Student picks | c | w | All or nothing | Partial credit | Right minus wrong | Correct only | +|---|---|---|---|---|---|---| +| A, C, D | 3 | 0 | **3** | **3** | **3** | **3** | +| A, C, B | 2 | 1 | 0 | 0.5 | 1 | 2 | +| A, C, D, B | 3 | 1 | 0 | 1.5 | 2 | 3 | +| All 5 | 3 | 2 | 0 | 0 | 1 | 3 | +| A only | 1 | 0 | 0 | 1 | 1 | 1 | + +::: danger "Correct only" can be gamed +With **Correct only, no penalty**, a student who ticks **every** choice gets full points and the question is even marked correct. Use it for practice quizzes only. +::: + +An MA question is marked "correct" (green) only when its ratio is 1. + +### Short answer: regex patterns + +Each pattern is compared with the student's **whole** answer (leading and trailing spaces removed) using Python's `re.fullmatch`. Matching **any one** pattern is enough. + +| Pattern | Accepts | Rejects | +|---|---|---| +| `42` | `42`, ` 42 ` | `42.0`, `x = 42` | +| `(?i)python` | `Python`, `PYTHON` | `python3` | +| `def` | `def` | `Def` (**case-sensitive** by default) | +| `\d+` | `7`, `2024` | `12a` | +| `3\.14` | `3.14` | `3x14` (without `\`, `.` matches any character) | +| `(?i)o\(n log n\)` | `O(n log n)`, `o(N LOG N)` | `O(nlogn)` | + +::: warning Do not put `|` inside a pattern +Patterns are split on the `|` character, both in the form and in XLSX. So `(?i)(true|yes)` (even though the form's own help suggests it) is split into `(?i)(true` and `yes)` and rejected with "Invalid regex". Write **each alternative as its own pattern**: `(?i)true` and `(?i)yes`. `3|three` still works because it splits into the two valid patterns `3` and `three`. +::: + +Also fill in **Display answer** (for example `Paris`) so students do not have to read regexes on the result page. + +## Bulk import + +### Import steps + +1. Click **Import Quiz** on the toolbar, or open `/quizzes/import/`. +2. Under **XLSX or JSON file**, choose the file. Files ending in `.json` are read as JSON; anything else is read as XLSX. +3. (Optional) Tick **Also create a quiz from these questions**, then fill in **Quiz code** and **Quiz name**. +4. Click **Upload and preview**. +5. Review the **Preview**: each question is a box **Row N: [type] title**. Red boxes with a list of errors are invalid rows. **These categories will be created** lists categories that do not exist yet. +6. With no errors, click **Confirm import**. With errors, fix the file and upload again. +7. **Imported N questions.** means you are done. You land on the question bank, or on the quiz edit page if you chose to create a quiz. + +::: info All-or-nothing import +One bad row means **nothing** is imported ("Fix the errors above and re-upload. Nothing was imported."). On confirm, everything is written in one transaction: if a code conflict appears, the whole import is rolled back. +::: + +A quiz created during import gets default settings: **hidden**, no time limit, unlimited attempts, full feedback, integrity monitoring **on**. Questions are ordered as in the file, with points taken from the points column/field. Open the edit page and finish the settings before publishing. + +### XLSX format + +Download the template from `/quizzes/import/template` (the **Download XLSX template** link in the question bank). It has drop-down lists for **Type**, **Level**, **Shuffle Choices** and **MA Strategy**, and a note on every header cell. + +General rules: + +- Row 1 is the header and is **ignored**. Data starts on row 2; empty rows are skipped. +- Columns are read **by position** (A, B, C…), not by name. **Do not insert, delete or reorder columns.** +- Only the active sheet is read. +- Delete the 20 sample Python questions in the template before importing. If those codes already exist on the site, the import fails with duplicate codes. +- Format column Q (Correct Answer) as **Text** before typing. With a comma decimal separator (for example a Vietnamese regional setting), Excel may turn `1,3` into the number `1.3`, and the MA row will fail. + +| Column | Header | Content | +|---|---|---| +| A | Code | Required. `a-z0-9`, up to 32 characters (lowercased automatically) | +| B | Type | `Multiple Choice`, `Multiple Answer`, `True/False`, `Short Answer` (or `MC`, `MA`, `TF`, `SA`) | +| C | Title | Required | +| D | Question | Required. Markdown body | +| E, G, I, K, M, O | Choice 1 … Choice 6 | Choices (MC/MA need at least 2). Leave unused cells empty | +| F, H, J, L, N, P | Explanation 1 … Explanation 6 | Per-choice explanation (optional) | +| Q | Correct Answer | MC: choice number, **1-based** (`2`). MA: comma-separated (`1,3`). TF: `True`/`False` (also `1`/`0`, `đúng`/`dung`/`sai`). SA: regex patterns separated by `\|` | +| R | Points | Points in the quiz created alongside. Empty = 1. Not negative | +| S | Category | Category (see note below) | +| T | Level | `Easy`, `Medium`, `Hard`. Empty = Easy | +| U | Explanation | Overall explanation | +| V | Shuffle Choices | `Yes` to shuffle choices (also `true`, `1`, `x`, `có`) | +| W | MA Strategy | `All or nothing`, `Partial credit`, `Right minus wrong`, `Correct only`. Empty = All or nothing | +| X | Answer Display | SA only: the answer shown to students | + +::: warning Category column +The value is used **verbatim as the category slug**. If that slug does not exist, a category is created with a name derived from the slug (`-` replaced by spaces, words capitalised). Use slugs like `cpp-basics`, not `C++ basics`. +::: + +**Minimal example** with 4 questions, one of each type. The table is transposed: each table column is one Excel row; unlisted cells stay empty. + +| Excel column | Row 2 | Row 3 | Row 4 | Row 5 | +|---|---|---|---|---| +| A · Code | `cppmc1` | `cppma1` | `cpptf1` | `cppsa1` | +| B · Type | `Multiple Choice` | `Multiple Answer` | `True/False` | `Short Answer` | +| C · Title | `Type of 7/2` | `Integer types` | `Array index` | `Value of 7%3` | +| D · Question | `` In C++, what is `7/2`? `` | `Pick the integer types:` | `C++ arrays start at index 0.` | `` What is `7 % 3`? `` | +| E · Choice 1 | `3.5` | `int` | | | +| F · Explanation 1 | `That is floating-point division.` | | | | +| G · Choice 2 | `3` | `double` | | | +| I · Choice 3 | `4` | `long long` | | | +| Q · Correct Answer | `2` | `1,3` | `True` | `1` | +| R · Points | `1` | `2` | `1` | `1` | +| S · Category | `cpp-basics` | `cpp-basics` | `cpp-basics` | `cpp-basics` | +| T · Level | `Easy` | `Medium` | `Easy` | `Easy` | +| U · Explanation | `Dividing two integers gives an integer.` | | | | +| V · Shuffle Choices | `Yes` | `Yes` | | | +| W · MA Strategy | | `Partial credit` | | | +| X · Answer Display | | | | `1` | + +::: tip The web importer ignores Answer Display +The `/quizzes/import/` page currently does **not** save column X (Answer Display). Fill in **Display answer** by editing the question after import, or import through admin (`/admin/quiz/quizquestion/import/`), which does save it. +::: + +### JSON format + +A JSON file is **an array** of question objects, UTF-8 encoded. + +| Field | Required | Type and values | +|---|---|---| +| `code` | Yes | String, `a-z0-9`, up to 32 characters | +| `type` | Yes | `"MC"`, `"MA"`, `"TF"`, `"SA"` | +| `title` | Yes | String | +| `content` | Yes | Markdown string | +| `choices` | MC/MA | Array of strings, or of objects `{"text": "...", "explanation": "..."}`. At least 2 | +| `correct` | Yes | MC: integer, **0-based** index. MA: non-empty array of 0-based indices. TF: `true` / `false` (boolean). SA: see below | +| `points` | No | Number ≥ 0, default `1` | +| `category` | No | Category slug | +| `level` | No | `"easy"`, `"medium"`, `"hard"` (lowercase), default `"easy"` | +| `explanation` | No | Overall explanation | +| `shuffle` | No | Boolean, shuffle choices | +| `ma_strategy` | No | `"all_or_nothing"` (default), `"partial_credit"`, `"right_minus_wrong"`, `"correct_only"` | + +For SA, `correct` is a string or an array whose items are: + +- **Strings**, e.g. `"Paris"`: matched **literally**, **case-insensitive**, **not** as a regex. +- **Objects** `{"text": "...", "case_sensitive": false, "is_regex": false}`: set `is_regex: true` to use a (full-match) regex and `case_sensitive: true` to make it case-sensitive. + +::: warning JSON differs from XLSX and the form +- MC/MA indices are **0-based** in JSON but **1-based** in XLSX and the form. +- SA strings in JSON are **plain text, case-insensitive**; in XLSX and the form each pattern is a **case-sensitive regex**. +- JSON has no `answer_display` field. +- If you later open a JSON-imported SA question in the form and save it, its answers are converted to case-sensitive regex patterns. Check the patterns before saving. +::: + +**Complete example** (4 questions): + +```json +[ + { + "code": "cppmc1", + "type": "MC", + "title": "Type of 7/2", + "content": "In C++, what is `7/2`?", + "choices": [ + {"text": "3.5", "explanation": "That is floating-point division."}, + {"text": "3", "explanation": "Dividing two integers gives an integer."}, + "4" + ], + "correct": 1, + "points": 1, + "category": "cpp-basics", + "level": "easy", + "explanation": "Integer division drops the fractional part.", + "shuffle": true + }, + { + "code": "cppma1", + "type": "MA", + "title": "Integer types", + "content": "Pick the integer types:", + "choices": ["int", "double", "long long"], + "correct": [0, 2], + "points": 2, + "level": "medium", + "ma_strategy": "partial_credit" + }, + { + "code": "cpptf1", + "type": "TF", + "title": "Array index", + "content": "C++ arrays start at index 0.", + "correct": true + }, + { + "code": "cppsa1", + "type": "SA", + "title": "Binary search complexity", + "content": "What is the time complexity of binary search?", + "correct": ["O(log n)", {"text": "o\\(\\s*log\\s*n\\s*\\)", "is_regex": true}] + } +] +``` + +### Common import errors + +Per-row error messages are in English: + +| Message | Cause | Fix | +|---|---|---| +| `Question code is required and must match ^[a-z0-9]+$` | Missing code, or uppercase, underscores, spaces | Use lowercase letters and digits | +| `Duplicate code … in this file` | Two rows share a code | Rename one | +| `Code … already exists in the question bank` | The code already exists on the site | Change it (codes are unique site-wide) | +| `MC correct answer out of range 1-N` | The answer number exceeds the number of choices | Check Correct Answer (1-based) | +| `MC correct must be a 0-based choice index` | JSON: `correct` is not a valid integer | Use a 0-based index | +| `TF correct answer must be true or false` | Unrecognised TF value | Use `True` / `False` | +| `Invalid regex '…'` | Bad SA pattern, often a `\|` inside it | Split into several patterns, escape special characters | +| `Level must be one of …` | JSON: `level` capitalised or misspelled | Use `easy`/`medium`/`hard` | +| `MA strategy must be one of …` | Unknown strategy name | Use a value from the table | +| `Cannot read XLSX file` / `Invalid JSON file` | Corrupt or wrong file format | Re-save as `.xlsx`, or validate the JSON | +| `JSON root must be a list of question objects` | JSON does not start with `[` | Wrap the questions in an array | +| **No pending import - upload a file first.** | Confirm clicked twice, or the session expired | Upload the file again | + +## Export questions + +1. In the **Question Bank**, tick the checkbox of each question to export. +2. Click **Export selected to XLSX**. You get `quiz-questions.xlsx` in the same format as the import file. + +::: info Export limitations +- The **Points** column is always `1`, because points belong to quizzes, not questions. +- The **Answer Display** column is empty. +- Re-importing the exported file reports duplicate codes. Change the codes first if you want copies. +::: + +## Create a quiz + +1. Open `/quizzes/new`. +2. Fill in the settings (see the table below). Only the **Quiz code** and **Quiz name** are required. +3. Under **Questions**, click **+ Add question**, type to search (by code, title or body; results look like `[MC] code: title`) and pick one. +4. Enter its **Points** (default 1, not negative). +5. Repeat steps 3–4 for each question. Drag the **⠿** handle to reorder; click **✕** to remove a question. +6. Click **Save quiz**. **Quiz saved.** appears and you stay on the edit page `/quizzes//edit`. You are the quiz's author automatically. + +::: warning New quizzes are hidden +Until you tick **Publicly visible**, only authors, curators, testers and `edit_all_quiz` holders can see the quiz. +::: + +### Quiz settings + +| Field | Meaning | +|---|---| +| **Quiz code** | `a-z0-9`, up to 32 characters, unique. **Cannot be changed** after creation | +| **Quiz name** | Up to 100 characters | +| **Description** | Markdown, shown on the quiz page | +| **Time limit (minutes)** | Minutes per attempt. Empty = unlimited | +| **Maximum attempts** | Maximum submitted attempts per student. Empty = unlimited | +| **Shuffle questions** | Shuffle the question order for each attempt | +| **Result feedback** | What students see after submitting (see below) | +| **Integrity monitoring** | Warning dialog, watermark, copy blocking and event logging. **On** by default | +| **Start time** | Nobody can start before this. Empty = open now | +| **End time** | Nobody can start a new attempt after this. Empty = never closes. Must be after the start time | +| **Publicly visible** | Students can see and take the quiz | +| **Private to organizations** | Only members of the organizations below can see it | +| **Organizations** | The allowed organizations | +| **Curators** | Co-managers (need `edit_own_quiz`) | +| **Testers** | Can take the quiz while it is hidden | + +Start and end times are interpreted in your account's time zone. + +::: info Vietnamese labels +Several Vietnamese labels on this form are mistranslated: **Time limit** shows as "Giới hạn thời gian (giây):" (says *seconds*, but the unit is **minutes**), **Maximum attempts** as "Số thành viên tối đa", **Shuffle questions** as "Lời giải", **Result feedback** as "Phản hồi từ trình chấm". The Vietnamese page lists them all. +::: + +**Result feedback** modes: + +| Option | Students see after submitting | +|---|---| +| **Score only** | Total score and their own answers. No right/wrong marks, no key | +| **Show correctness (no answer key)** | Right/wrong colouring and per-question points, no key | +| **Show correct answers and explanations** | The key, per-choice explanations and the overall explanation. **Default** | + +::: warning Results appear right after submission +There is no "reveal answers after the quiz closes" option. In full mode, an early finisher sees the key immediately and can share it. For exams, use **Score only** or **Show correctness** during the exam, then switch to full mode after the end time. The change applies to all past attempts at once. +::: + +::: tip Combining an end time with a time limit +A timed attempt always gets its full time, **even past the end time**. To make everyone finish by a fixed moment, have students start at least one time limit before the end. For example, a 45-minute quiz closing at 10:00 should be started before 9:15. +::: + +### Who can see the quiz? + +| Publicly visible | Private to organizations | Who can see and take it | +|---|---|---| +| No | (any) | Authors, curators, testers, `edit_all_quiz` holders | +| Yes | No | Everyone (guests can view; sign-in required to take) | +| Yes | Yes | Members of the selected organizations, plus the people in the first row | + +::: warning +To restrict a quiz to a class, tick **both** **Publicly visible** and **Private to organizations**, then choose the organizations. Ticking only **Private to organizations** without making it public leaves it **invisible** to students. +::: + +## Preview a quiz + +- **Single question:** in the question editor, use the **Preview** tab on the body and explanation fields. +- **Whole quiz:** while the quiz is hidden, authors and curators can take it themselves. Add colleagues as **Testers** so they can try it without edit rights. + +::: info +Trial attempts are real attempts: they show up in the attempts list and **on the ranking**. Attempts can only be deleted in Django admin (`/admin/quiz/quizattempt/`). +::: + +## Clone a quiz + +1. On the quiz page or edit page, click **Clone quiz**. +2. You are taken to the copy's edit page. +3. Rename it (default `Copy of `), reset the schedule and save. + +The copy has: + +- **Code** = old code + the first free digit from 2 to 9 (for example `midterm` → `midterm2`). If all 8 are taken you get "Could not generate a unique code for the clone. Rename the original quiz first." +- The same description, time limit, attempts, shuffle, feedback mode, integrity setting, organization settings, curators, testers, question list, points and order. +- It is **always hidden**, has **no schedule**, **you** are its only author, and it has no attempts. + +The copy **shares** questions with the original; questions are not duplicated. Editing a question affects both quizzes. + +## Review attempts and integrity events + +1. On the quiz page, click **All attempts** (or **Attempts & regrade** on the edit page). URL: `/quizzes//attempts`. +2. The table lists every attempt, newest first: **User**, **Started**, **Status** (**submitted** / **in progress**), **Score**, **Violations**. +3. Click the **⚠ N** badge in **Violations** to open the log: time and event type, plus the total ("N violation(s) total"). +4. Click **view** to open that attempt's result page. Teachers always see **full** feedback, regardless of the quiz setting. + +Event types: + +| Type | UI label | Meaning | +|---|---|---| +| `tab_switch` | Tab switch | The quiz tab became hidden | +| `window_blur` | Window blur | The browser window lost focus | +| `devtools` | DevTools opened | Window more than 160 px larger than the viewport (heuristic) | +| `print_screen` | PrintScreen key | PrintScreen pressed | +| `copy_attempt` | Copy attempt | Tried to copy content | + +::: warning Read events with care +- Events do **not** affect scores; they are signals to review. +- DevTools detection is a heuristic: a browser side panel or zoom level can trigger it. +- Each type is recorded at most once every 5 seconds. A student with JavaScript disabled or a second device leaves no trace. +::: + +::: info Abandoned attempts +An attempt a student abandons stays **in progress** until that student opens the quiz page again; only then is it closed and graded. Until then it is not on the ranking and is not regraded. +::: + +## Regrade + +Regrading recomputes the score of **every submitted attempt** using the **current** answer key and points. + +1. Fix the question's answer key, or change the points in the quiz, and save. +2. Open `/quizzes//attempts`. +3. Click **Regrade all attempts** and confirm **Regrade all submitted attempts?**. +4. **Regraded N attempts.** means you are done. The ranking updates immediately. + +In-progress attempts do not need regrading: they are graded with the new key when submitted. + +## Editing a quiz that already has attempts + +Each attempt's question and choice order is **frozen when it starts**, and answers are stored as choice **indices**. So: + +| Change | Safe? | Notes | +|---|---|---| +| Name, description, testers, curators | ✅ | | +| Public, organizations | ✅ | Takes effect immediately | +| Result feedback | ✅ | Applies immediately to **all** past attempts | +| Integrity monitoring | ✅ | Applies to pages loaded after saving | +| Extending the end time | ✅ | Students who used all attempts still cannot take more | +| Maximum attempts | ✅ | Takes effect immediately | +| Time limit | ⚠️ | Deadlines of **in-progress** attempts are recalculated immediately | +| Question points | ⚠️ | Old scores stay until you **regrade** | +| Answer key, SA patterns | ⚠️ | Needs a **regrade** | +| Wording of choices or explanations | ⚠️ | Safe if you **do not change meaning or order** | +| Adding a question | ⚠️ | Old attempts do not contain it, but the displayed maximum (`X / Y`) grows for everyone | +| Removing a question | ❌ | Old attempts still show it; on regrade it scores 0 | +| Reordering, adding or deleting choices | ❌ | Stored answers point to the wrong choices | +| Changing the question type | ❌ | Stored answers become invalid | +| Shuffle questions, shuffle choices | ✅ | Only affect new attempts | + +::: danger Shared questions +A question can belong to several quizzes. Editing it changes **every** quiz that uses it. For substantial changes, create a new question with a new code and swap it into the quiz. +::: + +## Django admin + +Administrators (accounts with `is_staff` and the relevant model permissions) can manage everything in admin, under the **Quiz** group: + +| Admin page | Use it for | +|---|---| +| `/admin/quiz/quiz/` | Quiz **authors**, the inline question list (with an **order** column), the **Clone selected quizzes** action | +| `/admin/quiz/quizquestion/` | Question **authors** and **curators**; the **Import questions** button (`/admin/quiz/quizquestion/import/`) imports XLSX/JSON **including Answer Display** | +| `/admin/quiz/quizcategory/` | Create and edit categories | +| `/admin/quiz/quizattempt/` | Inspect each attempt's answers, delete trial attempts | +| `/admin/quiz/quizviolation/` | Browse all integrity events by type | + +On the web quiz edit page, staff also see the link **Edit quiz in admin panel for more options**. + +::: warning +- Admin does **not** check authorship: anyone with change permission in admin can edit **every** quiz. +- In admin, `choices` and `correct_answers` are raw JSON. `choices` looks like `[{"text": "...", "explanation": "..."}]`. `correct_answers`: MC is a 0-based index, TF is `0` (True) or `1` (False), MA is an array of 0-based indices, SA is an array of regex patterns. Mistakes break grading, so prefer the web form. +::: + +## Verify + +- [ ] The quiz appears at `/quizzes/` when signed in as a student (or as an organization member, for organization-private quizzes). +- [ ] The quiz page shows the right number of questions, total points, time limit, attempts and schedule. +- [ ] A tester can finish and submit, and the result page uses the right feedback mode. +- [ ] `/quizzes//attempts` lists that attempt; with monitoring on, switching tabs during the trial produces a **Tab switch** event. + +## Troubleshooting + +| Symptom | Fix | +|---|---| +| No **Quiz** buttons on the toolbar; `/quizzes/questions/` returns 404 | The account lacks `edit_own_quiz` / `edit_all_quiz`. Ask an administrator | +| Cannot find a button to create a quiz | Open `/quizzes/new` directly | +| Students cannot see the quiz | **Publicly visible** is not ticked, or the quiz is organization-private and the student is not a member | +| Students see **Quiz not started yet.** | Check **Start time** and the time zone | +| Students submitted after the end time | Those are timed attempts started before the end time; they get their full time | +| Editing someone else's question returns 404 | You are not its author or curator. **Public** questions can be used but not edited | +| No categories to choose from | Create them in `/admin/quiz/quizcategory/`, or set them in the Category column when importing | +| Saving an SA question says `Invalid regex` | A pattern contains `\|` or an unclosed bracket. Split it into several patterns; escape `.`, `(`, `)`, `+`, `*` | +| A correct short answer was marked wrong | The pattern is case-sensitive (add `(?i)`) or has unescaped special characters. Fix it, then **regrade** | +| Scores did not change after fixing the key | You have not clicked **Regrade all attempts** | +| Import reports duplicate codes | Question codes are unique site-wide. Rename them, or remove the sample rows | +| Answer Display was not saved | The web importer ignores that column. Edit the question after import, or import through admin | +| Form says "End time must be after start time." | The end time must be later than the start time | +| Clone says it cannot generate a code | `2` … `9` all exist. Rename the original or create a new quiz | +| A student's attempt is stuck **in progress** | The student abandoned it. It closes when that student reopens the quiz page | + +## Next steps + +- [Taking a quiz](/en/features/quiz): the student experience. +- [Permission system](/en/site/permission_system): granting permissions to teachers and groups. diff --git a/src/en/features/url_shortener.md b/src/en/features/url_shortener.md new file mode 100644 index 0000000..d315144 --- /dev/null +++ b/src/en/features/url_shortener.md @@ -0,0 +1,334 @@ +# URL shortener + +LCOJ ships with a built-in URL shortener: you assign a **short code** (for example `hsg2026`) to a long address, then share a link such as `https:///hsg2026`. It is handy for handing out contest links, registration forms or materials on slides, posters and group chats. + +This page has three parts, for three audiences: + +| Part | For | Covers | +|---|---|---| +| [1. Creating and managing short links](#_1-creating-and-managing-short-links) | Staff with the permissions | Create, edit, deactivate, delete links; view access counts | +| [2. Granting access](#_2-granting-access) | Admins | Grant the 4 `urlshortener.*` permissions in the admin site | +| [3. Using a dedicated short domain](#_3-using-a-dedicated-short-domain) | Server operators | Enable the middleware, set `URLSHORTENER_DOMAIN`, `ALLOWED_HOSTS`, route the domain | + +::: warning Short links only work once a dedicated domain is configured +Redirects for `/` are handled **only** on the dedicated short domain (Part 3). The main domain (for example `luyencode.net`) has no route for `/`, so `https://luyencode.net/hsg2026` returns 404. + +LCOJ's default configuration (`dmoj/config/local_settings.py`) does **not** set `URLSHORTENER_DOMAIN` and does **not** add `URLShortenerMiddleware` to `MIDDLEWARE`. In that state you can still create and manage links, but the copied link is just the relative path `/` and does not work. Ask your operator to complete Part 3 first. +::: + +## How it works + +```mermaid +sequenceDiagram + participant V as Visitor + participant CF as DNS / Tunnel + participant N as nginx + participant S as site (Django) + participant DB as MariaDB + V->>CF: GET s.example.com/hsg2026 + CF->>N: Host s.example.com + N->>S: uwsgi_pass site:8000 + S->>S: Middleware matches Host + S->>DB: Look up code hsg2026 + DB-->>S: Original URL, status + S->>DB: Increment access count + S-->>V: 302 to original URL +``` + +- The middleware compares the request's Host with `URLSHORTENER_DOMAIN`. If they match, the request is routed with a separate URL table, `urlshortener.urls_redirect`, which has exactly one route: `/`. +- Code found and link active: the counter is incremented, the access time recorded, and a **302** (temporary redirect) to the original URL is returned. +- Code not found, or link inactive: **404**. +- Visitors do **not** need to be logged in. + +--- + +## 1. Creating and managing short links + +⏱ ~2 min · 👤 Staff with the permissions · 🔑 `urlshortener.view_urlshortener`, `urlshortener.add_urlshortener`, `urlshortener.change_urlshortener`, `urlshortener.delete_urlshortener` + +### Before you start + +- You are logged in and have been granted the permissions (see [Part 2](#_2-granting-access)). Anonymous users are sent to the login page; logged-in users without the permission get a 403 error. +- The management pages live at **`/shorteners/`** on the main domain (for example `https://luyencode.net/shorteners/`). There is **no** menu or navbar link to them, so bookmark the address. +- For links to actually work, the short domain must already be configured (Part 3). + +Management pages: + +| Address | Purpose | Required permission | +|---|---|---| +| `/shorteners/` | List of all links, 20 per page | `view_urlshortener` | +| `/shorteners/create/` | Create a link | `add_urlshortener` | +| `/shorteners//` | View one link's details | `view_urlshortener` | +| `/shorteners//edit/` | Edit a link | `change_urlshortener` | +| `/shorteners//delete/` | Delete a link | `delete_urlshortener` | + +::: tip UI labels +Most labels of this feature have no Vietnamese translation yet, so they appear in English even when the site is in Vietnamese. This page quotes the English UI labels; where a Vietnamese translation exists, the Vietnamese page quotes that instead. +::: + +### Create a link + +1. Open `/shorteners/` and click the **Create New** tab (the tab next to it is **List**). +2. Fill in the form: + + | Field | Required | Meaning and rules | + |---|---|---| + | **Original URL** | Yes | The full destination address. Must be a valid URL including `https://` (for example `https://luyencode.net/contest/hsg2026`). Entering `luyencode.net/...` without a scheme is rejected. | + | **Short code** | Yes | The code that appears after the `/` in the link. Only unaccented Latin letters, digits, hyphens `-` and underscores `_`; at most 50 characters; must be **unique**. No spaces, Vietnamese diacritics or characters such as `@`, `!`, `.`, `/`. | + | **Is active** | No (on by default) | On: the link redirects normally. Off: visitors get a 404, but the link is kept so you can re-enable it later. | + + The shuffle 🔀 button to the right of **Short code** fills in a random 8-character alphanumeric code. It is not checked for uniqueness until you save. +3. Click **Create**. +4. You land on the link's detail page. The box at the top shows the full short link; click **Copy** to copy it. + +::: tip Choosing a short code +- Prefer codes that are easy to read and type: `hsg2026`, `dang-ky-k10`, `slide_buoi3`. +- Do not rely on letter case to tell two codes apart (`HSG` vs `hsg`); it is confusing to read and the database may treat them as duplicates. +- Avoid the code `create`: its detail page `/shorteners/create/` is shadowed by the create page (the link itself still redirects normally). +::: + +### View the list and statistics + +The **List** page (`/shorteners/`) shows **every** link in the system, newest first. Links are not owned by their creator; anyone with the view permission sees all of them. Columns: + +| Column | Content | +|---|---| +| **Short URL** | The short code, with a 📋 icon to copy the full link | +| **Original URL** | The original URL (truncated to the first 50 characters) | +| **Accesses** | Number of accesses | +| **Status** | `Active` or `Inactive` (inactive links are shown faded) | +| **Created** | Creation time | +| **Actions** | View 👁, edit ✏️, delete 🗑 | + +The detail page also shows **Last Accessed**: the most recent access time, only shown once there has been at least one access. + +::: details How are accesses counted? +- Every request to an active link that is successfully redirected adds 1 to **Access Count** and updates **Last Accessed**. +- Visitors and bots are not de-duplicated: opening the link 10 times counts 10 accesses. Link-preview crawlers (Zalo, Messenger, Discord…) may be counted too. +- Requests to inactive links or unknown codes are **not** counted. +- There are no per-day, per-referrer or per-country statistics. +::: + +### Edit, deactivate or delete a link + +1. In the **List**, click the ✏️ icon (or the **Edit** button on the detail page). +2. Change the fields as when creating, then click **Save**. + - Changing **Original URL**: the short link stays the same; only its destination changes. Useful when you need a new destination without redistributing the link. + - Changing **Short code**: the old link **stops working** immediately (404); old codes are not kept. + - Unticking **Is active**: temporarily disables the link, keeping its statistics. +3. To delete permanently, click 🗑 (or **Delete** on the detail page). The confirmation page shows the code, original URL, access count and creation date; click **Delete** to confirm. Deletion **cannot be undone**, and you are taken back to the **List**. + +::: warning +Short links do not expire: they keep working until they are deactivated or deleted. When a contest or event is over, deactivate the link if you do not want it used any more. +::: + +### What visitors see + +- Active link: the browser goes straight to the original URL (HTTP 302), with no intermediate page. +- Inactive link or wrong code: a 404 error page. +- Query strings are **not** forwarded: `https:///hsg2026?ref=fb` still goes to the exact original URL; `?ref=fb` is dropped. +- A trailing `/` is **not** accepted: `/hsg2026/` returns 404; use `/hsg2026`. + +### Verify + +1. On the detail page, click **Copy** and paste the link into a private window (not logged in). +2. The browser should open the original URL. +3. Reload the detail page: **Access Count** has increased and **Last Accessed** appears. + +### Troubleshooting + +| Symptom | Fix | +|---|---| +| `/shorteners/` redirects to the login page | Log in first. | +| 403 when opening the list, creating, editing or deleting | Missing the matching permission; ask an admin (Part 2). | +| Creation succeeds but is followed by a 403 | You have `add` but not `view`: after saving you are sent to the detail page, which needs `view_urlshortener`. The link has still been created. | +| The copied link is just `/hsg2026` (no domain) | `URLSHORTENER_DOMAIN` is not configured; tell your operator (Part 3). | +| The link returns 404 | Check that the link is `Active`; the code is typed correctly; there is no trailing `/`; the link is opened on the short domain, not the main domain; the short domain is fully configured (Part 3). | +| Error on **Short code** when saving | The code already exists, contains disallowed characters, or is longer than 50 characters. | +| Error on **Original URL** | Missing `http://` / `https://`, or a malformed URL. | +| After changing the code, the old link printed on a poster returns 404 | Change the code back. If you want an additional code, create a separate link pointing to the same destination. | + +--- + +## 2. Granting access + +⏱ ~3 min · 👤 Admin (superuser, or anyone allowed to edit Users/Groups in the admin site) · 🔑 permission to change `auth.User` or `auth.Group` + +The feature uses the 4 default permissions Django creates for the `URLShortener` model: + +| Permission | Allows | +|---|---| +| `urlshortener.view_urlshortener` | View the list and detail pages | +| `urlshortener.add_urlshortener` | Create links | +| `urlshortener.change_urlshortener` | Edit links (including activating/deactivating) | +| `urlshortener.delete_urlshortener` | Delete links | + +::: tip Grant all 4 permissions together +The **Edit**/**Delete** buttons are always shown to anyone who can view, and after creating a link the user is sent to the detail page (which needs the view permission). Granting permissions piecemeal easily leads to confusing 403 errors. For someone who only needs to see statistics, grant just `view_urlshortener`. +::: + +::: details The model has no page in the Django admin +The `urlshortener` app does not register its model with the Django admin, so you will **not** find a "URL shortener" section under `/admin/` for managing links. The admin is only used to grant permissions; all link operations go through `/shorteners/`. Superusers automatically have every permission. +::: + +### Before you start + +- You can log in to `/admin/` with permission to edit users or groups. +- The `urlshortener` migrations have been applied (`./scripts/migrate`), so the 4 permissions exist in the database. + +### Option A: grant via a group (recommended) + +1. Go to `/admin/auth/group/`, open an existing group or create a new one (for example `Link Managers`). +2. In the permissions selector, type `URL shortener` to filter. The permissions appear as `URL Shortener | URL shortener | Can view URL shortener`, and likewise for `add`, `change`, `delete`. +3. Move the permissions you want into the chosen column, then save. +4. Open `/admin/auth/user/`, pick the user, add them to that group in the groups field, then save. + +### Option B: grant directly to one user + +1. Go to `/admin/auth/user/` and open the user. +2. In the user permissions field, type `urlshortener` to filter. LCOJ's user page shows permissions as `urlshortener.view_urlshortener | Can view URL shortener`. +3. Select the permissions you want, then save. + +See [Permission system](/en/site/permission_system) for more on how permissions work. + +### Verify + +1. Log in with the account you just granted (or ask its owner to try). +2. Open `/shorteners/`: you should see the **List** page instead of a 403. +3. Click **Create New**: you should see the create form (if `add_urlshortener` was granted). + +You can also check from the Django shell: + +```sh +./scripts/manage.py shell -c "from django.contrib.auth.models import User; u = User.objects.get(username='the_username'); print(sorted(p for p in u.get_all_permissions() if p.startswith('urlshortener.')))" +``` + +### Troubleshooting + +| Symptom | Fix | +|---|---| +| No `urlshortener` permissions in the list | Run `./scripts/migrate` to create the table and permissions, then reload the admin page. | +| Permissions granted but the user still gets 403 | Double-check the account; that the user is in the group; that the group has the permission needed for that action (see the table above). | +| The user can create but gets 403 after clicking **Create** | Also grant `view_urlshortener`. | +| No list of links anywhere in the admin | By design: manage links at `/shorteners/`. | + +--- + +## 3. Using a dedicated short domain + +⏱ ~15 min · 👤 Server operator · 🔑 access to the server, `dmoj/repo/dmoj/local_settings.py` and DNS / Cloudflare Tunnel configuration + +This part configures a dedicated domain, for example `s.example.com`, so that `https://s.example.com/` redirects to the original URL. In the examples below, replace `s.example.com` with your real domain. + +### How the middleware decides + +`urlshortener.middleware.URLShortenerMiddleware` does exactly one thing: if `URLSHORTENER_DOMAIN` is set **and** `request.get_host()` is **exactly equal** to it, the request is routed with `urlshortener.urls_redirect` instead of the main URL table. + +```mermaid +flowchart TD + A["Request reaches site"] --> B{"URLSHORTENER_DOMAIN set?"} + B -- No --> M["Main URL table (luyencode.net)"] + B -- Yes --> C{"Host == URLSHORTENER_DOMAIN?"} + C -- No --> M + C -- Yes --> R["urls_redirect: only /short-code"] + R --> D{"Code exists and active?"} + D -- Yes --> E["302 to original URL, count access"] + D -- No --> F["404"] +``` + +Consequences: + +- The comparison is an **exact string match** on Host, port included. For example `s.example.com:8071` does **not** match `s.example.com`. +- On the short domain there is **only** `/`. The home page `/`, `/shorteners/`, `/admin/`… all return 404. Manage links on the main domain. +- `URLSHORTENER_DOMAIN` is also used to build the link shown in the management pages: if the value has no scheme, LCOJ prepends `https://` (for example `s.example.com` → `https://s.example.com/hsg2026`). However, because the middleware matches against Host, the value **must be a bare domain** (no scheme, no port). If you write `https://s.example.com`, the displayed link still looks right but the redirect will never trigger. + +### Before you start + +- LCOJ is running fine on the main domain (see [Installation](/en/site/installation)). +- You control DNS for the short domain, and the Cloudflare Tunnel (if used, as in production). +- In `docker-compose.yml`, nginx is published on the host at port `${NGINX_PORT:-8071}` (default `8071`). The main domain's tunnel points at this port. +- You know the live config file is `dmoj/repo/dmoj/local_settings.py` (ignored by git). `./scripts/initialize` copies it from `dmoj/config/local_settings.py`. + +### Steps + +1. **Point the domain at nginx.** Add a public hostname `s.example.com` in the Cloudflare Tunnel, pointing to the **same nginx service** as the main domain (`http://:8071`). Do not override the Host header: Django must receive `Host: s.example.com`. + +2. **(Optional) Declare it in nginx.** `dmoj/nginx/conf.d/nginx.conf` has a single `server` block (`listen 80`, `server_name luyencode.net;`), so it is the default server and already accepts any Host. Requests for `s.example.com` therefore already reach `site` through `uwsgi_pass site:8000` (with `include uwsgi_params`, so Host is forwarded). To make it explicit, add the domain to `server_name`: + + ```nginx + server_name luyencode.net s.example.com; + ``` + + Then run `docker compose restart nginx`. + +3. **Configure Django.** Append to `dmoj/repo/dmoj/local_settings.py`: + + ```python + # Short domain: bare domain only, no scheme, no port + URLSHORTENER_DOMAIN = 's.example.com' + + # Django rejects Hosts not in ALLOWED_HOSTS (400 error) + ALLOWED_HOSTS = [HOST, URLSHORTENER_DOMAIN] + + # The middleware is not enabled in dmoj/settings.py + MIDDLEWARE += ('urlshortener.middleware.URLShortenerMiddleware',) + ``` + + - `local_settings.py` is `exec`-ed at the end of `dmoj/settings.py`, so `HOST` is available and `MIDDLEWARE += (...)` works (`MIDDLEWARE` is a tuple). + - Appending the middleware at the end is enough, because the URL table is chosen only after every middleware has run its request phase. + - `URLSHORTENER_DOMAIN` is **not** read from environment variables; it must be set in `local_settings.py`. + - Do not edit `dmoj/settings.py`. + +4. **Save it in the template too.** Copy the same lines into `dmoj/config/local_settings.py`, so a later `./scripts/initialize` does not lose the configuration. + +5. **Restart:** + + ```sh + cd lcoj-docker/dmoj + docker compose restart site celery + ``` + +### Verify + +Create a test link (Part 1), for example code `test123` pointing to `https://luyencode.net/`, then: + +```sh +# 1. Hit nginx directly on the server, faking the short domain's Host +curl -sI -H 'Host: s.example.com' http://localhost:8071/test123 +# Expect: HTTP/1.1 302 Found and Location: https://luyencode.net/ + +# 2. Over the Internet (DNS / Tunnel) +curl -sI https://s.example.com/test123 +# Expect: 302 with the same Location + +# 3. Unknown code +curl -sI https://s.example.com/does-not-exist +# Expect: 404 + +# 4. The main domain is unaffected +curl -sI https://luyencode.net/ +# Expect: 200 as before +``` + +Finally, open the link's detail page: the link box should show `https://s.example.com/test123`, and **Access Count** has increased. + +### Troubleshooting + +| Symptom | Fix | +|---|---| +| `400 Bad Request` on the short domain | The domain is not in `ALLOWED_HOSTS`, or `site` has not been restarted. Look for `DisallowedHost` in `docker compose logs site`. | +| The short domain shows the main site's home page or 404 | The middleware is not running: `MIDDLEWARE += ('urlshortener.middleware.URLShortenerMiddleware',)` is missing; or `URLSHORTENER_DOMAIN` differs from the real Host (has a scheme, a port, a typo, different case). | +| `curl -H 'Host: ...'` against `localhost:8071` works, but not over the Internet | Check that the tunnel/DNS public hostname points to nginx and does not override the Host header. | +| The link shown in the management pages is still `/code` | `URLSHORTENER_DOMAIN` was not loaded: check you edited the right file, `dmoj/repo/dmoj/local_settings.py`, and restarted. | +| `/code/` (trailing `/`) returns 404 | By design; use `/code`. | +| Configuration lost after re-running `./scripts/initialize` | Copy the configuration into `dmoj/config/local_settings.py` (step 4). | + +See [Operations](/en/site/operations) for more operational commands. + +--- + +## Next steps + +- [Permission system](/en/site/permission_system): the other permissions in LCOJ and how to organize groups. +- [Operations](/en/site/operations): restarting services, reading logs, checking status. +- [Installation](/en/site/installation): reinstall, or set up a staging environment to test the short domain before applying it in production. diff --git a/src/en/index.md b/src/en/index.md new file mode 100644 index 0000000..e354889 --- /dev/null +++ b/src/en/index.md @@ -0,0 +1,43 @@ +--- +layout: home +title: LCOJ Documentation +titleTemplate: ":title - LCOJ: Luyện Code Online Judge" +description: Documentation for LCOJ (Luyện Code Online Judge). Guides for installing and operating the site, writing problems, running contests and taking quizzes, in English and Vietnamese. + +hero: + name: LCOJ + text: Online judge + tagline: Guides for installing, operating and using LCOJ, built on DMOJ and VNOJ. + image: + src: /logo.png + alt: LCOJ + actions: + - theme: brand + text: Install the website + link: /en/site/installation + - theme: alt + text: Problem structure + link: /en/problem_format/problem_format + +features: + - title: Features + details: Quizzes, the exam library and the URL shortener, for students, teachers and admins. + link: /en/features/quiz + - title: Website + details: Install, operate, update and configure the LCOJ website with Docker. + link: /en/site/installation + - title: Judge + details: Set up and configure judges, supported languages and status codes. + link: /en/judge/setting_up_a_judge + - title: Problem format + details: How to create problems and write checkers, graders and generators, with real examples. + link: /en/problem_format/problem_format +--- + +## Support + +If you have questions, open an issue on [GitHub Issues](https://github.com/luyencode/lcoj-docker/issues). + +**Need help with installation?** If you'd rather not set it up yourself, the LCOJ team will help you install it for free. Get in touch at [behitek.com](https://behitek.com) or [luyencode.net/about/#lien-he](https://luyencode.net/about/#lien-he). + +Real problem examples are in the [problem_examples](https://github.com/luyencode/docs/tree/master/problem_examples) folder. diff --git a/src/en/judge/judge_configuration.md b/src/en/judge/judge_configuration.md new file mode 100644 index 0000000..c7dfde9 --- /dev/null +++ b/src/en/judge/judge_configuration.md @@ -0,0 +1,102 @@ +# Judge configuration + +The judge is configured through a YAML file that specifies programming languages, problem directories, and other settings. + +Sample configuration file: [judge_conf.yml](https://github.com/luyencode/docs/blob/master/sample_files/judge_conf.yml) + +## Configuration file structure + +### ID - Judge name + +The judge's display name. It must match the name created on the site: + +```yaml +id: judge1 +``` + +### Key - Authentication key + +The secret key the judge uses to connect to the bridge. It must match the key on the site: + +```yaml +key: your_secret_key_here +``` + +### Problem storage - Problem directories + +A list of directories containing problems. Each problem directory must contain an `init.yml` file: + +```yaml +problem_storage_globs: + - /problems/* + - /problems/archive/** +``` + +**Examples:** +- `/problems/*` - Matches all direct subdirectories of `/problems` + - Matches: `/problems/bai1`, `/problems/bai2` + - Does not match: `/problems/folder/bai3` + +- `/problems/archive/**` - Matches all subdirectories (including nested ones) + - Matches: `/problems/archive/2023/bai1`, `/problems/archive/bai2` + +- `/problems/year20[0-9][0-9]` - Matches directories by pattern + - Matches: `/problems/year2023`, `/problems/year2024` + +### Runtimes - Programming languages + +Configure the supported programming languages: + +```yaml +runtime: + python3: /usr/bin/python3 + gcc: /usr/bin/gcc + g++: /usr/bin/g++ +``` + +**Notes:** +- Most languages are detected automatically with the `dmoj-autoconf` command +- Manual configuration is only needed if the program is not on your `$PATH` + +## Complete configuration file + +A complete `judge.yml` example: + +```yaml +id: judge1 +key: my_secret_authentication_key + +problem_storage_globs: + - /problems/* + +runtime: + python3: /usr/bin/python3 + python2: /usr/bin/python2 + gcc: /usr/bin/gcc + g++: /usr/bin/g++ + java: /usr/bin/java +``` + +## Automatic language detection + +To automatically detect the languages available on the system: + +```sh +dmoj-autoconf > judge.yml +``` + +Then edit `judge.yml` to add `id`, `key`, and `problem_storage_globs`. + +## Verifying the configuration + +After editing the configuration file, restart the judge: + +```sh +docker restart judge +``` + +Check the logs to make sure there are no errors: + +```sh +docker logs judge +``` diff --git a/src/en/judge/setting_up_a_judge.md b/src/en/judge/setting_up_a_judge.md new file mode 100644 index 0000000..4d320d6 --- /dev/null +++ b/src/en/judge/setting_up_a_judge.md @@ -0,0 +1,219 @@ +# Setting up a judge + +This guide walks you through installing a judge (the grading system) and connecting it to the site. Only Linux (including WSL) is supported; Windows is not. + +**Prerequisites:** The site must already be installed and the bridge must be running. + +## Configuring the site + +### Step 1: Add a new judge + +Go to the admin page at `/admin/judge/` and add a new judge: +- Give the judge a name +- Create an authentication key — you can use the `Regenerate` button to generate one automatically + +### Step 2: Check the connection address + +In `local_settings.py`, find `BRIDGED_JUDGE_ADDRESS`. This is the address the judge will connect to: +- Default: `localhost:9999` +- If the judge runs on a different machine, replace `localhost` with the actual IP address +- **Important:** Make sure this port is open + +### Step 3: Check that the bridge is running + +Run the following command to check: + +```sh +supervisorctl status +``` + +You should see a line like this: +``` +bridged RUNNING pid , uptime +``` + +## Installing the judge + +We recommend installing the judge with Docker, since it is simple and easy to manage. + +### Using the prebuilt Docker image + +LCOJ uses the `tier3` Docker image, which supports the largest number of programming languages: +- Python 2/3 +- C/C++ (GCC) +- Java 8 +- Pascal +- And several other languages + +See the full list on the [runtimes page](https://luyencode.net/runtimes). + +### Building from source + +To build the Docker image yourself: + +```sh +git clone --recursive https://github.com/luyencode/judge-server.git +cd judge/.docker +make judge-tier3 +``` + +### Running the judge + +#### Preparation + +Create a `judge.yml` configuration file: + +```yaml +id: +key: +problem_storage_globs: + - /problems/* +``` + +**Notes:** +- `id` must match the judge name you created on the site +- `key` must match the authentication key you created on the site +- The `/problems` directory contains the problem data + +#### Starting the judge + +```sh +docker run \ + --name judge \ + --network="host" \ + -v /mnt/problems:/problems \ + --cap-add=SYS_PTRACE \ + -d \ + --restart=always \ + luyencode/judge-tier3:latest \ + run -p 9999 -c /problems/judge.yml localhost -A 0.0.0.0 -a 12345 +``` + +**Parameters:** +- `--name judge`: Container name +- `-v /mnt/problems:/problems`: Mounts the problems directory from the host into the container +- `-p 9999`: Port used to connect to the bridge (must match `BRIDGED_JUDGE_ADDRESS`) +- `-a 12345`: The judge's API port + +**Notes on ports:** +- If you changed the port in `BRIDGED_JUDGE_ADDRESS`, change `-p 9999` to match +- If you run multiple judges, each judge needs: + - A distinct container name (`--name`) + - Its own configuration file (`judge.yml`) + - A distinct API port (`-a`) + +### Running multiple judges + +To increase grading capacity, you can run several judges at the same time: + +**Judge 1:** +```sh +docker run --name judge1 -v /mnt/problems:/problems --cap-add=SYS_PTRACE -d --restart=always --network="host" luyencode/judge-tier3:latest run -p 9999 -c /problems/judge1.yml localhost -A 0.0.0.0 -a 12345 +``` + +**Judge 2:** +```sh +docker run --name judge2 -v /mnt/problems:/problems --cap-add=SYS_PTRACE -d --restart=always --network="host" luyencode/judge-tier3:latest run -p 9999 -c /problems/judge2.yml localhost -A 0.0.0.0 -a 12346 +``` + +Each judge needs its own configuration file (`judge1.yml`, `judge2.yml`) with a different `id`. + +## Verification + +After starting the judge, check the site's admin page (`/admin/judge/`). The judge shows as "online" if it connected successfully. + +## Troubleshooting + +**The judge cannot connect:** +- Check that the bridge is running +- Check that the port is open +- Check that `id` and `key` in `judge.yml` match the site + +**The judge keeps disconnecting:** +- Check the network connection +- Check the judge's logs: `docker logs judge` + +**The judge does not pick up test data for new problems:** + +This is the most common issue, usually caused by an incorrect path to the problems directory. + +**Causes:** +- Incorrect volume mount path +- The problems directory is empty or not accessible +- Incorrect problem directory structure + +**How to check:** + +1. Check the problems directory inside the container: + +```sh +docker exec judge ls -la /problems +``` + +You should see a list of problem directories. For example: +``` +drwxr-xr-x 2 root root 4096 Jan 01 00:00 aplusb +drwxr-xr-x 2 root root 4096 Jan 01 00:00 hello +-rw-r--r-- 1 root root 123 Jan 01 00:00 judge.yml +``` + +2. Check the structure of a specific problem: + +```sh +docker exec judge ls -la /problems/aplusb +``` + +It must contain these files: +``` +-rw-r--r-- 1 root root 100 Jan 01 00:00 init.yml +-rw-r--r-- 1 root root 10 Jan 01 00:00 1.in +-rw-r--r-- 1 root root 10 Jan 01 00:00 1.out +``` + +3. Check access permissions: + +```sh +docker exec judge cat /problems/aplusb/init.yml +``` + +If you see a "Permission denied" error, fix the permissions: + +```sh +sudo chmod -R 755 /mnt/problems +``` + +**How to fix:** + +If the directory is empty or incorrect, double-check the `docker run` command: + +```sh +# Wrong - mounts the wrong directory +docker run -v /wrong/path:/problems ... + +# Right - mounts the directory that contains the problems +docker run -v /mnt/problems:/problems ... +``` + +After fixing it, restart the judge: + +```sh +docker stop judge +docker rm judge +# Rerun the docker run command with the correct path +``` + +**Check that the judge has loaded the problems:** + +View the judge's logs: + +```sh +docker logs judge | grep "problem" +``` + +You should see lines like these: +``` +[INFO] Loaded problem: aplusb +[INFO] Loaded problem: hello +``` + +If you don't, the judge has not loaded the problems. diff --git a/src/en/judge/status_codes.md b/src/en/judge/status_codes.md new file mode 100644 index 0000000..ee7de61 --- /dev/null +++ b/src/en/judge/status_codes.md @@ -0,0 +1,78 @@ +# Status codes + +This page lists all the status codes (verdicts) you may get when submitting on LCOJ. + +**Note:** A single test case can have multiple errors, but only the one with the highest priority is displayed. The status codes below are listed in increasing order of priority. + +## AC - Accepted + +Your program is correct! Sometimes the judge includes additional feedback. + +## WA - Wrong Answer + +Your program ran without errors, but its output is incorrect. Details about the error may be included. + +## IR - Invalid Return + +Your program exited with an error (a non-zero exit code), meaning it crashed: +- Python: usually includes the exception name, such as `NameError` or `IndexError` +- Java: usually includes the exception name, such as `java.lang.NullPointerException` + +## RTE - Runtime Error + +Your program encountered an error during execution. This usually happens with C/C++. + +### Common RTE errors: + +| Message | Cause | +|-----------|-------------| +| `segmentation fault`, `bus error` | Invalid memory access. Usually caused by out-of-bounds array access, dereferencing a NULL pointer, or running out of memory | +| `floating point exception` | Invalid arithmetic operation, for example division by zero | +| `killed` | The program was terminated by the system (cause unknown) | +| `{} syscall disallowed` | The program attempted a system call that is not allowed. If you get this error without doing anything unusual, please [report a bug](https://github.com/luyencode/judge-server/issues) | +| `std::bad_alloc` | Memory could not be allocated (C++) | +| `failed initializing` | Too much memory used for global variables. For example, `int arr[10000][10000]` takes 381MB, exceeding the 64MB limit | + +**Common error examples:** + +```cpp +// Segmentation fault - Array index out of bounds +int a[100]; +a[1000] = 5; // Error! + +// Floating point exception - Division by zero +int x = 10 / 0; // Error! + +// Failed initializing - Array too large +int arr[100000][100000]; // Error if the memory limit is small! +``` + +## OLE - Output Limit Exceeded + +Your program printed too much output (usually > 256MB). Some problems may have a different limit. + +**Common causes:** +- An infinite printing loop +- Printing too much debug output + +## MLE - Memory Limit Exceeded + +Your program used too much memory. This is sometimes reported as an RTE with `segmentation fault` or `std::bad_alloc`. + +**How to fix:** +- Reduce array sizes +- Optimize the algorithm to use less memory +- Free memory that is no longer needed + +## TLE - Time Limit Exceeded + +Your program ran too long and exceeded the time limit. + +**How to fix:** +- Optimize the algorithm (reduce its complexity) +- Remove unnecessary loops +- Use more efficient data structures + +## IE - Internal Error + +An error on the judge's side, or an incorrectly configured problem. If you get this error, please notify an admin. diff --git a/src/en/judge/supported_languages.md b/src/en/judge/supported_languages.md new file mode 100644 index 0000000..9474f54 --- /dev/null +++ b/src/en/judge/supported_languages.md @@ -0,0 +1,41 @@ +# Supported languages + +LCOJ can judge submissions in [56 programming languages](https://github.com/luyencode/judge-server/tree/master/dmoj/executors): + +Ada, Assembly (x64/x86), AWK, Brain\*\*\*\*, +C (Clang/GCC), C#, C++14 (Clang/GCC), +C++03/11/17/20, C11, COBOL, D, Dart, F#, Forth, +Fortran, Go, Groovy, Haskell, INTERCAL, +Java 8/latest, Kotlin, Lean 4, Lisp, LLVM IR, Lua, NASM, +NASM64, OCaml, Pascal, Perl, PHP, Pike, Prolog, +PyPy 2/3, Python 2/3, Racket, Ruby, Rust, Scala, +Scheme, Sed, Swift, TCL, Text, Turing, +V8 JavaScript, Visual Basic, Zig. + +## Popular languages + +The languages most widely used in competitive programming: + +- **Python 3**: Easy to learn, simple syntax +- **C++**: Fast, powerful, and the most popular +- **Java**: Stable, with extensive libraries +- **C**: Fast and close to the hardware +- **Pascal**: Easy to learn for beginners + +## C++ versions + +LCOJ supports several C++ versions: + +- **C++03**: Legacy version +- **C++11**: Adds auto, lambdas, range-based for +- **C++14**: Improvements over C++11 +- **C++17**: Adds structured bindings, if constexpr +- **C++20**: Adds concepts, ranges, coroutines + +**Recommendation:** Use C++17 or C++20 for the latest features. + +## Notes + +- Some less commonly used languages may have undiscovered bugs +- If you run into problems with any language, please [report a bug](https://github.com/luyencode/judge-server/issues) +- See the full list of languages on the [runtimes page](https://luyencode.net/runtimes) diff --git a/src/en/problem_format/custom_checkers.md b/src/en/problem_format/custom_checkers.md new file mode 100644 index 0000000..984f813 --- /dev/null +++ b/src/en/problem_format/custom_checkers.md @@ -0,0 +1,188 @@ +# Custom checkers + +Custom checkers are used for problems that have multiple correct answers or that award points based on accuracy. + +A checker is a Python script that runs after the contestant's program finishes. It grades the output but does not interact with the program. + +## Built-in checkers + +LCOJ ships with many built-in checkers. To use one: + +```yaml +checker: + name: + args: {} +``` + +If no arguments are needed: + +```yaml +checker: +``` + +## Standard checker - `standard` + +This is the default checker if `checker` is not specified. + +It compares the contestant's output with the expected output, ignoring whitespace. Specifically, it: +- Splits each line into tokens +- Ignores blank lines +- Compares the tokens one by one + +## Easy checker - `easy` + +Ignores all whitespace and letter case, and only checks how many times each character occurs. + +## Floating point checker - `floats` + +Used for problems with floating-point output, allowing for some error. + +**Arguments:** +- `precision`: epsilon = 10^(-precision), defaults to 6 +- `error_mode`: + - `default`: allows either absolute or relative error + - `relative`: relative error only + - `absolute`: absolute error only + +**Example:** + +```yaml +checker: + name: floats + args: + precision: 4 + error_mode: relative +``` + +### Floatsabs - `floatsabs` + +An alias for `floats` with `error_mode: absolute`. + +### Floatsrel - `floatsrel` + +An alias for `floats` with `error_mode: relative`. + +## Identical checker - `identical` + +Checks that the output is exactly identical, including whitespace. + +**Arguments:** +- `pe_allowed`: defaults to `True`. If `True`, reports "Presentation Error" when the output is correct but the whitespace differs. + +## Linecount checker - `linecount` + +A special-purpose checker, mainly used for ECOO problems. + +**Arguments:** +- `feedback`: defaults to `True`. If `True`, shows ✓ for correct lines and ✗ for incorrect lines. + +## Sorted checker - `sorted` + +Checks that the outputs are the same, ignoring order. + +**Arguments:** +- `split_on`: defaults to `lines` + - `lines`: ignores the order of lines + - `whitespace`: ignores the order of tokens + +### Unordered checker - `unordered` + +An alias for `sorted` with `split_on: whitespace`. + +## Writing a custom checker + +A checker must implement this function: + +```python +def check(process_output, judge_output, **kwargs): + pass +``` + +**Arguments in `**kwargs`:** +- `submission_source`: the contestant's source code +- `judge_input`: the test case input +- `point_value`: the test case's point value +- `case_position`: the test case's position (0-indexed) +- `batch`: the test case's batch (0 if not batched) +- `submission_language`: the submission language +- `binary_data`: True if the data has not been normalized +- `execution_time`: execution time (in seconds) +- `problem_id`: the problem code +- `result`: the preliminary result + +**The `run_on_error` flag:** + +If you set `check.run_on_error = True`, the checker runs even on IR/TLE/RTE/MLE. + +**Return value:** + +Return a `CheckerResult` or a boolean: + +```python +from dmoj.result import CheckerResult + +# Return a CheckerResult +return CheckerResult(True, 100, feedback='Correct!') + +# Or return a boolean +return True # AC +return False # WA +``` + +**Example:** + +```python +def check(process_output, judge_output, **kwargs): + # Check whether the output contains "Hello" + if "Hello" in process_output: + return CheckerResult(True, kwargs['point_value'], feedback='Correct!') + return CheckerResult(False, 0, feedback='Missing "Hello"') +``` + +## Native checkers (bridged) + +Used for checkers that need heavy computation, written in C/C++. + +**Arguments:** +- `files`: the checker's file name or list of files +- `lang`: the language (C/C++) +- `time_limit`: time limit +- `memory_limit`: memory limit +- `compiler_time_limit`: compilation time limit +- `feedback`: show stdout as feedback (defaults to true) +- `flags`: compiler flags +- `type`: the checker type + - `default`: arguments are `input_file output_file judge_file`. Return 0 = AC, 1 = WA + - `testlib`: same as default. Return 0 = AC, 1 = WA, 2 = PE, 3 = assertion failure, 7 = partial (with `points X` on stderr) + - `coci`: like testlib, but the partial format is `partial X/Y` + - `peg`: compatible with the WCIPEG judge + +**Example:** + +```yaml +checker: + name: bridged + args: + files: checker.cpp + lang: CPP17 + type: testlib +``` + +**Example checker.cpp (testlib):** + +```cpp +#include "testlib.h" + +int main(int argc, char* argv[]) { + registerTestlibCmd(argc, argv); + + int ja = ans.readInt(); // Expected answer + int pa = ouf.readInt(); // Contestant's answer + + if (ja == pa) { + quitf(_ok, "Correct!"); + } else { + quitf(_wa, "Wrong answer: expected %d, got %d", ja, pa); + } +} +``` diff --git a/src/en/problem_format/custom_graders.md b/src/en/problem_format/custom_graders.md new file mode 100644 index 0000000..835e672 --- /dev/null +++ b/src/en/problem_format/custom_graders.md @@ -0,0 +1,278 @@ +# Custom graders + +Custom graders are used when you need special interaction with the contestant's program, beyond simply comparing input and output. + +## When should you use a custom grader? + +- **Interactive problems**: Data must be exchanged back and forth with the program +- **IOI-style problems**: The contestant implements a function; there is no conventional input/output +- **Complex scoring**: Special grading logic is required + +**Note:** In most cases, a built-in checker or a custom checker is enough. A custom grader is only needed when the usual interaction is not sufficient. + +## Basic custom grader + +In `init.yml`, add: + +```yaml +custom_judge: grader.py +``` + +The `grader.py` file: + +```python +from dmoj.graders.standard import StandardGrader +from dmoj.result import Result + +class Grader(StandardGrader): + def grade(self, case): + # Grading logic + pass +``` + +### The `case` parameter + +- `case.position`: the test case's position (0-indexed) +- `case.input_data()`: the contents of the input file +- `case.output_data()`: the contents of the expected output file +- `case.points`: the maximum points for the test case + +### Return value + +Return a `Result` object: + +```python +from dmoj.result import Result + +result = Result(case) +result.result_flag = Result.AC # Or Result.WA, Result.TLE, etc. +result.points = case.points +result.feedback = 'Short feedback' +result.extended_feedback = 'Detailed feedback' +result.proc_output = 'Program output' +``` + +### Example + +Problem: Print the line "Hello, World!" + +```python +import subprocess +from dmoj.graders.standard import StandardGrader +from dmoj.result import Result + +class Grader(StandardGrader): + def grade(self, case): + result = Result(case) + case_input = b'Hello, World!\n' + + # Run the program + self._current_proc = self.binary.launch( + time=self.problem.time_limit, + memory=self.problem.memory_limit, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + output, error = self._current_proc.communicate(case_input) + self.binary.populate_result(error, result, self._current_proc) + + # Check the output + if output == case_input: + result.extended_feedback = 'Correct!' + if result.result_flag == Result.AC: + result.points = case.points + else: + result.result_flag |= Result.WA + result.feedback = 'Wrong!' + + return result +``` + +The `init.yml` file: + +```yaml +custom_judge: grader.py +unbuffered: true +test_cases: +- points: 100 +``` + +**Note on `unbuffered`:** Set it to `true` to disable buffering, so contestants don't need to call `flush()`. + +## Interactive grading + +Used for problems that require back-and-forth interaction with the program. + +```python +from dmoj.graders.interactive import InteractiveGrader +from dmoj.utils.unicode import utf8text + +class Grader(InteractiveGrader): + def interact(self, case, interactor): + # Send data to the contestant + interactor.writeln('Hello, World!') + + # Receive the response + response = utf8text(interactor.readln()) + + # Return True if correct, False otherwise + return response == 'Hello, World!' +``` + +### `interactor` methods + +**Reading data:** +- `interactor.read()`: reads all output +- `interactor.readln(strip_newline=True)`: reads a line +- `interactor.readtoken(delim=None)`: reads a token +- `interactor.readint(lo=-inf, hi=inf, delim=None)`: reads an integer (automatically WA if invalid) +- `interactor.readfloat(lo=-inf, hi=inf, delim=None)`: reads a floating-point number + +**Writing data:** +- `interactor.write(val)`: writes data +- `interactor.writeln(val)`: writes data followed by a newline +- `interactor.close()`: closes stdin + +### Example: Guess the number + +```python +from dmoj.graders.interactive import InteractiveGrader + +class Grader(InteractiveGrader): + def interact(self, case, interactor): + secret = 42 + attempts = 0 + max_attempts = 10 + + while attempts < max_attempts: + guess = interactor.readint(1, 100) + attempts += 1 + + if guess == secret: + interactor.writeln('Correct!') + return True + elif guess < secret: + interactor.writeln('Higher') + else: + interactor.writeln('Lower') + + interactor.writeln('Out of attempts!') + return False +``` + +## Native interactive grading + +Uses an interactor written in C/C++ for high performance. + +In `init.yml`: + +```yaml +unbuffered: true +archive: data.zip +interactive: + files: interactor.cpp + type: testlib +test_cases: +- {in: test1.in, points: 50} +- {in: test2.in, points: 50} +``` + +**Arguments:** +- `files`: the interactor file (or list of files) +- `lang`: the language (automatically detected from the file extension) +- `flags`: compiler flags +- `compiler_time_limit`: compilation time limit +- `preprocessing_time`: extra time for the interactor (defaults to 2s) +- `memory_limit`: memory limit +- `type`: the interactor type (`default`, `testlib`, `coci`, `peg`) + +**Example interactor.cpp (testlib):** + +```cpp +#include "testlib.h" +#include + +int main(int argc, char* argv[]) { + registerInteraction(argc, argv); + + int secret = inf.readInt(); // Read from the input file + int attempts = 0; + + while (attempts < 10) { + int guess = ouf.readInt(1, 100); // Read from the contestant + attempts++; + + if (guess == secret) { + std::cout << "Correct!" << std::endl; + quitf(_ok, "Solved in %d attempts", attempts); + } else if (guess < secret) { + std::cout << "Higher" << std::endl; + } else { + std::cout << "Lower" << std::endl; + } + } + + quitf(_wa, "Too many attempts"); +} +``` + +## Function signature grading (IOI-style) + +Used for IOI-style problems, where the contestant implements a function instead of reading input and writing output. + +In `init.yml`: + +```yaml +signature_grader: + entry: handler.c + header: header.h +test_cases: +- {in: test1.in, out: test1.out, points: 50} +- {in: test2.in, out: test2.out, points: 50} +``` + +**Supported languages:** C, C++, Clang + +### Example + +**header.h:** + +```c +#ifndef _GRADER_HEADER_INCLUDED +#define _GRADER_HEADER_INCLUDED +#include +bool is_valid(int n); +#endif +``` + +**handler.c (entry):** + +```c +#include "header.h" +#include + +static int n; + +int main() { + scanf("%d", &n); + bool valid = is_valid(n); // Function implemented by the contestant + printf(valid ? "correct" : "wrong"); + return 0; +} +``` + +**Contestant's submission:** + +```c +#include + +bool is_valid(int n) { + return n > 0 && n % 2 == 0; +} +``` + +The system automatically: +- Includes `header.h` in the submission +- Renames the contestant's `main` to `main_GUID` +- Compiles and links it with `handler.c` diff --git a/src/en/problem_format/generator.md b/src/en/problem_format/generator.md new file mode 100644 index 0000000..be681f8 --- /dev/null +++ b/src/en/problem_format/generator.md @@ -0,0 +1,218 @@ +# Generators + +Generators are used when a problem has a large amount of test data, instead of creating input/output files by hand. + +A generator is a program that takes command-line arguments and produces the input and output for each test case. + +## The `generator` node + +The `generator` node can be: + +**1. A single file name:** + +```yaml +generator: gen.cpp +``` + +**2. An array (main file + auxiliary files):** + +```yaml +generator: [gen.cpp, testlib.h, utils.h] +``` + +**3. A YAML object with the following keys:** + +```yaml +generator: + source: gen.cpp # Or [gen.cpp, testlib.h] + language: CPP17 + flags: ['-O2', '-std=c++17'] + compiler_time_limit: 60 + time_limit: 10 + memory_limit: 256000 +``` + +**Keys:** +- `source`: the generator file (or an array of files) +- `language`: the language (detected automatically if not specified) +- `flags`: compiler flags +- `compiler_time_limit`: compilation time limit (defaults to the value in `dmoj/judgeenv.py`) +- `time_limit`: the generator's time limit +- `memory_limit`: memory limit + +**Note:** If you use `testlib.h`, set `compiler_time_limit: 60`. + +## Generator arguments + +Use `generator_args` to pass arguments to the generator: + +```yaml +generator: gen.cpp +test_cases: +- {generator_args: [false, 123, "a b\nc"], points: 10} +- {generator_args: [true, 456], points: 20} +- {points: 30} # generator_args defaults to [] +``` + +**How it works:** +- The first argument is always `"_aux_file"` +- The remaining arguments are converted to strings +- Test case 1: `"_aux_file"`, `"False"`, `"123"`, `"a b\nc"` +- Test case 2: `"_aux_file"`, `"True"`, `"456"` +- Test case 3: `"_aux_file"` + +## Generator output + +The generator must: +- Print the **input** to `stdout` +- Print the **output** to `stderr` + +**Example generator (C++):** + +```cpp +#include +#include +using namespace std; + +int main(int argc, char* argv[]) { + // argv[1] = "_aux_file" + // argv[2] = first argument + // argv[3] = second argument + + int n = atoi(argv[2]); + bool hard = string(argv[3]) == "True"; + + // Print the input to stdout + cout << n << endl; + + // Print the output to stderr + int answer = n * 2; + if (hard) answer *= 2; + cerr << answer << endl; + + return 0; +} +``` + +## Per-test-case generators + +You can use a different generator for each test case: + +```yaml +test_cases: +- generator: gen_easy.cpp + generator_args: [10] + points: 30 +- generator: gen_hard.cpp + generator_args: [100] + points: 70 +``` + +## Combining generators and files + +If a test case already has `in` and `out`, the generator does not run: + +```yaml +generator: gen.cpp +test_cases: +- {in: manual.in, out: manual.out, points: 10} # Does not use the generator +- {generator_args: [50], points: 20} # Uses the generator +- {generator_args: [100], points: 30} # Uses the generator +``` + +## Complete example + +**init.yml:** + +```yaml +archive: data.zip +generator: + source: gen.cpp + language: CPP17 + compiler_time_limit: 60 + time_limit: 5 +test_cases: +- {generator_args: [10, easy], points: 20} +- {generator_args: [100, medium], points: 30} +- {generator_args: [1000, hard], points: 50} +``` + +**gen.cpp:** + +```cpp +#include +#include +#include +#include +using namespace std; + +int main(int argc, char* argv[]) { + int n = atoi(argv[2]); + string difficulty = argv[3]; + + srand(time(0)); + + // Print the input + cout << n << endl; + for (int i = 0; i < n; i++) { + cout << rand() % 100 << " "; + } + cout << endl; + + // Compute the output + int sum = 0; + for (int i = 0; i < n; i++) { + sum += rand() % 100; + } + + // Print the output to stderr + cerr << sum << endl; + + return 0; +} +``` + +## Generators with testlib.h + +`testlib.h` is a popular library for writing generators: + +```cpp +#include "testlib.h" +#include +using namespace std; + +int main(int argc, char* argv[]) { + registerGen(argc, argv, 1); + + int n = atoi(argv[2]); + + // Print the input + cout << n << endl; + for (int i = 0; i < n; i++) { + cout << rnd.next(1, 100) << " "; + } + cout << endl; + + // Compute the output and print it to stderr + // ... + + return 0; +} +``` + +**init.yml:** + +```yaml +generator: + source: [gen.cpp, testlib.h] + compiler_time_limit: 60 +test_cases: +- {generator_args: [10], points: 100} +``` + +## Benefits of generators + +- **Saves storage**: No need to store large input/output files +- **Easy to manage**: Changing test cases only requires editing the generator +- **Random test cases**: Easily create many different test cases +- **Correctness checking**: The generator can compute the expected output diff --git a/src/en/problem_format/problem_examples.md b/src/en/problem_format/problem_examples.md new file mode 100644 index 0000000..0e2607f --- /dev/null +++ b/src/en/problem_format/problem_examples.md @@ -0,0 +1,152 @@ +# Problem examples + +This repository contains real-world problem examples. See [problem_examples on GitHub](https://github.com/luyencode/docs/tree/master/problem_examples) for details. + +## List of examples + +| Grading type | Directory | Description | +|----------------|---------|-------| +| [Standard grading](https://github.com/luyencode/docs/tree/master/problem_examples/standard/aplusb) | `standard/aplusb` | A simple A+B problem | +| [Batched grading](https://github.com/luyencode/docs/tree/master/problem_examples/batched/hungry) | `batched/hungry` | A problem with multiple subtasks | +| [Custom Grading](https://github.com/luyencode/docs/tree/master/problem_examples/grader/shortest1) | `grader/shortest1` | A problem that needs special grading logic | +| [Interactive Grading](https://github.com/luyencode/docs/tree/master/problem_examples/interactive/seed2) | `interactive/seed2` | An interactive problem (Python) | +| [Native Interactive](https://github.com/luyencode/docs/tree/master/problem_examples/interactive/seed2native) | `interactive/seed2native` | An interactive problem (C++) | +| [Signature Grading](https://github.com/luyencode/docs/tree/master/problem_examples/signature/fastbit) | `signature/fastbit` | A function-implementation problem (IOI-style) | +| [Generator](https://github.com/luyencode/docs/tree/master/problem_examples/generator/ds3) | `generator/ds3` | A problem that uses a generator to create test data | + +## Directory structure + +``` +problem_examples/ +├── standard/ # Standard grading problems +│ └── aplusb/ +├── batched/ # Problems with batches/subtasks +│ └── hungry/ +├── grader/ # Problems using a custom grader +│ └── shortest1/ +├── interactive/ # Interactive problems +│ ├── seed2/ # Python interactor +│ └── seed2native/ # C++ interactor +├── signature/ # IOI-style problems +│ └── fastbit/ +└── generator/ # Problems using a generator + └── ds3/ +``` + +## Usage + +Each example directory contains: +- `init.yml` - The problem configuration file +- `README.md` - Detailed instructions +- Test data or a generator +- Checker/grader (if any) + +## Details + +### 1. Standard grading + +The simplest kind of problem: the contestant reads input from stdin and prints output to stdout. + +**Example:** Compute A + B + +```yaml +archive: aplusb.zip +test_cases: +- {in: aplusb.1.in, out: aplusb.1.out, points: 50} +- {in: aplusb.2.in, out: aplusb.2.out, points: 50} +``` + +### 2. Batched grading + +A problem with multiple subtasks, each containing multiple test cases. All test cases in a subtask must pass to earn its points. + +**Example:** + +```yaml +test_cases: +- points: 30 + batched: + - {in: test1.1.in, out: test1.1.out} + - {in: test1.2.in, out: test1.2.out} +- points: 70 + batched: + - {in: test2.1.in, out: test2.1.out} + - {in: test2.2.in, out: test2.2.out} + - {in: test2.3.in, out: test2.3.out} +``` + +### 3. Custom Grading + +A problem that needs special grading logic beyond comparing output. + +**Example:** A shortest-path problem with multiple correct answers. + +```yaml +custom_judge: grader.py +test_cases: +- {in: test1.in, out: test1.out, points: 100} +``` + +### 4. Interactive Grading + +An interactive problem, where the contestant's program exchanges data with the grader. + +**Example:** A number-guessing problem + +```yaml +custom_judge: interactor.py +unbuffered: true +test_cases: +- {in: test1.in, points: 100} +``` + +### 5. Native Interactive Grading + +Like interactive grading, but the interactor is written in C/C++ for high performance. + +```yaml +unbuffered: true +interactive: + files: interactor.cpp + type: testlib +test_cases: +- {in: test1.in, points: 100} +``` + +### 6. Signature Grading (IOI-style) + +The contestant implements a function and does not need to read input or write output. + +**Example:** Implement the function `is_valid(n)` + +```yaml +signature_grader: + entry: handler.c + header: header.h +test_cases: +- {in: test1.in, out: test1.out, points: 100} +``` + +### 7. Generator + +Use a generator to create test data automatically. + +```yaml +generator: gen.cpp +test_cases: +- {generator_args: [10], points: 30} +- {generator_args: [100], points: 30} +- {generator_args: [1000], points: 40} +``` + +## References + +- [DMOJ Problem Examples](https://github.com/DMOJ/docs/tree/master/problem_examples) +- [Testlib Documentation](https://github.com/MikeMirzayanov/testlib) +- [Polygon System](https://polygon.codeforces.com/) + +## Notes + +- The full source code for all of the examples above is available in the DMOJ repository +- You can combine multiple techniques in a single problem +- Test thoroughly before publishing a problem on the production system diff --git a/src/en/problem_format/problem_format.md b/src/en/problem_format/problem_format.md new file mode 100644 index 0000000..6b74267 --- /dev/null +++ b/src/en/problem_format/problem_format.md @@ -0,0 +1,136 @@ +# Problem format + +Each problem is stored in its own directory, which must contain an `init.yml` file. + +## The `init.yml` file + +This file is a YAML object and must contain a `test_cases` key. + +`test_cases` can be either: +- A list of test cases +- Two regexes for matching input and output files + +There is usually also an `archive` key, which lets you store the problem data in a `.zip` file instead of directly in the directory. + +## `test_cases` + +There are two ways to specify test cases. + +### Option 1: A list of test cases + +Each test case is a YAML object containing a `points` key (the number of points the test case is worth). + +**Note on `points: 0`:** +- If a test case with `points: 0` fails, all subsequent test cases are skipped +- This also applies to batched test cases + +**Incorrect example:** +```yaml +test_cases: +- {in: case1.1.in, out: case1.1.out, points: 100} +- {in: case1.0.in, out: case1.0.out, points: 0} +``` + +Test case `case1.1` runs before `case1.0`. If `case1.0` fails, the result is `100/100 (WA)`. + +**Correct example:** +```yaml +test_cases: +- {in: case1.0.in, out: case1.0.out, points: 0} +- {in: case1.1.in, out: case1.1.out, points: 100} +``` + +### Normal test cases + +A normal test case contains: +- `in`: path to the input file +- `out`: path to the output file +- `points`: the number of points + +Paths are relative to the zip file (if `archive` is set) or to the problem directory. + +### Batched test cases + +A batch contains: +- `points`: the total points for the batch +- `batched`: the list of test cases in the batch + +Each test case in a batch contains `in` and `out`. + +**Dependencies (optional):** + +You can add a `dependencies` key containing a list of the (1-indexed) numbers of the batches this batch depends on. The batch runs only if all the batches it depends on pass. + +**Example:** + +```yaml +archive: tle16p4.zip +test_cases: +- {points: 0, in: tle16p4.p0.in, out: tle16p4.p0.out} +- {points: 10, in: tle16p4.p1.in, out: tle16p4.p1.out} +- points: 10 + batched: + - {in: tle16p4.0.in, out: tle16p4.0.out} + - {in: tle16p4.1.in, out: tle16p4.1.out} +- points: 10 + batched: + - {in: tle16p4.2.in, out: tle16p4.2.out} + - {in: tle16p4.3.in, out: tle16p4.3.out} +- points: 10 + batched: + - {in: tle16p4.4.in, out: tle16p4.4.out} + - {in: tle16p4.5.in, out: tle16p4.5.out} + dependencies: [1, 2] +``` + +The last batch runs only if batches 1 and 2 both pass. + +### Option 2: Specifying test cases with regexes + +If your test case files follow a consistent naming format, you can use regexes. + +Default regex for input files: `^(?=.*?\.in|in).*?(?:(?:^|\W)(?P\d+)[^\d\s]+)?(?P\d+)[^\d\s]*` + +Default regex for output files: `^(?=.*?\.out|out).*?(?:(?:^|\W)(?P\d+)[^\d\s]+)?(?P\d+)[^\d\s]*` + +**Examples of matching file names:** + +``` +test.1.in +test-1.in +test-case-1.in + +test-1-2.in +test-batch-1-case-2.in +1.2.in +problem-1-case-1-batch-2.in +``` + +The first three files are standalone test cases; the last four are batched. + +**Note:** A test case without a batch uses its case number as its batch number. For example: + +``` +1.in +2.1.in +2.2.in +3.in +``` + +These are ordered as shown above. + +**Customization:** + +You can override the regexes with `input_format` and `output_format` in `test_cases`. + +By default, each test case is worth 1 point. Set `case_points` or `points` to change this. + +**Example:** + +```yaml +archive: data.zip +test_cases: + input_format: test-{case}.in + output_format: test-{case}.out + case_points: 10 +``` diff --git a/src/en/site/api.md b/src/en/site/api.md new file mode 100644 index 0000000..0bf7f96 --- /dev/null +++ b/src/en/site/api.md @@ -0,0 +1,295 @@ +# API + +LCOJ provides a JSON API for accessing backend data. + +## API Tokens + +### Generate an API token + +1. Go to the _Edit profile_ page +2. Find the API Token section +3. Click _Generate_ to create a new token + +### Use an API token + +Add the following header to every request: + +```http +Authorization: Bearer +``` + +### Common errors + +- `400 Invalid authorization header` - The header is malformed +- `401 Invalid token` - The token is invalid +- `403 Admin inaccessible` - The admin pages cannot be accessed through the API + +## Rate Limiting + +**Limit: 90 requests/minute** + +If you exceed it, you will have to solve a captcha. The captcha is cleared automatically after 3 days. + +## Response Format + +Every response has the following structure: + +```json +{ + "api_version": "2.0", + "method": "GET", + "fetched": "2024-01-01T00:00:00Z", + "data": {}, + "error": null +} +``` + +A response contains either `data` or `error`, never both. + +### Error format + +```json +{ + "error": { + "code": 404, + "message": "Not found" + } +} +``` + +### Data format + +**Single object:** + +```json +{ + "data": { + "object": {} + } +} +``` + +**List of objects:** + +```json +{ + "data": { + "current_object_count": 10, + "objects_per_page": 50, + "total_objects": 100, + "page_index": 1, + "total_pages": 2, + "objects": [] + } +} +``` + +## Filtering + +Two kinds of filters are supported: + +**Basic filter:** Filter on a single value +``` +/api/v2/problems?partial=True +``` + +**List filter:** Filter on multiple values +``` +/api/v2/problems?organization=1&organization=2&type=Implementation +``` + +## Endpoints + +### Contests + +**`GET /api/v2/contests`** + +Get the list of contests. + +**Filters:** +- `is_rated` (boolean) +- `tag` (list) +- `organization` (list) + +**Response:** +```json +{ + "key": "contest_key", + "name": "Contest Name", + "start_time": "2024-01-01T00:00:00Z", + "end_time": "2024-01-01T05:00:00Z", + "is_rated": true, + "tags": ["seasonal"] +} +``` + +**`GET /api/v2/contest/`** + +Get contest details, including the ranking. + +### Problems + +**`GET /api/v2/problems`** + +Get the list of problems. + +**Filters:** +- `partial` (boolean) +- `group` (list) +- `type` (list) +- `organization` (list) +- `search` (text) + +**Response:** +```json +{ + "code": "APLUSB", + "name": "A Plus B", + "types": ["Uncategorized"], + "group": "Intro", + "points": 100, + "partial": true, + "is_public": true +} +``` + +**`GET /api/v2/problem/`** + +Get problem details. + +### Users + +**`GET /api/v2/users`** + +Get the list of users. + +**Filters:** +- `organization` (list) + +**Response:** +```json +{ + "id": 1, + "username": "user123", + "points": 1500, + "performance_points": 1200, + "problem_count": 50, + "rank": "Expert", + "rating": 1800 +} +``` + +**`GET /api/v2/user/`** + +Get user details, including solved problems and contest history. + +### Submissions + +**`GET /api/v2/submissions`** + +Get the list of submissions. + +**Filters:** +- `user` (username) +- `problem` (code) +- `language` (list) +- `result` (list) + +**Response:** +```json +{ + "id": 123456, + "problem": "APLUSB", + "user": "user123", + "date": "2024-01-01T00:00:00Z", + "language": "CPP17", + "time": 0.1, + "memory": 2048, + "points": 100, + "result": "AC" +} +``` + +**`GET /api/v2/submission/`** + +Get submission details, including the result of each test case. + +### Organizations + +**`GET /api/v2/organizations`** + +Get the list of organizations. + +**Filters:** +- `is_open` (boolean) + +### Languages + +**`GET /api/v2/languages`** + +Get the list of programming languages. + +**Filters:** +- `common_name` (text) + +### Judges + +**`GET /api/v2/judges`** + +Get the list of judge servers and their status. + +## Usage Examples + +### Python + +```python +import requests + +API_TOKEN = "your_token_here" +headers = {"Authorization": f"Bearer {API_TOKEN}"} + +# Get the list of problems +response = requests.get( + "https://luyencode.net/api/v2/problems", + headers=headers +) +problems = response.json()["data"]["objects"] + +# Get problem details +response = requests.get( + "https://luyencode.net/api/v2/problem/APLUSB", + headers=headers +) +problem = response.json()["data"]["object"] +``` + +### JavaScript + +```javascript +const API_TOKEN = "your_token_here"; +const headers = { + "Authorization": `Bearer ${API_TOKEN}` +}; + +// Get the list of problems +fetch("https://luyencode.net/api/v2/problems", { headers }) + .then(res => res.json()) + .then(data => { + const problems = data.data.objects; + console.log(problems); + }); +``` + +### cURL + +```bash +curl -H "Authorization: Bearer your_token_here" \ + https://luyencode.net/api/v2/problems +``` + +## Notes + +- Do not share your API token with anyone +- Store your token securely and never commit it to git +- Respect the rate limit +- The API may change; check `api_version` diff --git a/src/en/site/contest_data_download.md b/src/en/site/contest_data_download.md new file mode 100644 index 0000000..67a269c --- /dev/null +++ b/src/en/site/contest_data_download.md @@ -0,0 +1,191 @@ +# Contest Data Download + +LCOJ lets contest authors download contest data, including contestants' submissions. + +This feature is disabled by default. To enable it, configure it in `local_settings.py`. + +## Configuration + +### With Docker (recommended) + +The cache directory is already set up in the `contestdatacache` Docker volume. + +Add the following to `environment/site.env`: + +```env +DMOJ_CONTEST_DATA_DOWNLOAD=True +DMOJ_CONTEST_DATA_CACHE=/contestdatacache/ +DMOJ_CONTEST_DATA_INTERNAL=/contestdatacache +``` + +Restart: + +```sh +docker compose restart site celery nginx +``` + +### With bare metal + +Configure it in `local_settings.py`: + +```python +DMOJ_CONTEST_DATA_DOWNLOAD = True +DMOJ_CONTEST_DATA_CACHE = '/home/dmoj-uwsgi/contestdatacache' +DMOJ_CONTEST_DATA_INTERNAL = '/contestdatacache' +DMOJ_CONTEST_DATA_DOWNLOAD_RATELIMIT = datetime.timedelta(days=1) +``` + +Configure nginx and create the cache directory the same way as for user_data_download. + +## Cleaning up old files + +### With Docker + +```sh +# Run manually +docker compose exec site find /contestdatacache/ -type f -mtime +2 -delete + +# Cron job +0 */4 * * * docker compose -f /path/to/lcoj-docker/dmoj/docker-compose.yml exec -T site find /contestdatacache/ -type f -mtime +2 -delete +``` + +### With bare metal + +``` +0 */4 * * * find /home/dmoj-uwsgi/contestdatacache/ -type f -mtime +2 -delete +``` + +## Usage + +### Access + +Only the following users can download contest data: +- The contest's organizers +- Admins with the `edit_all_contest` permission + +### How to download + +1. Open the contest management page (admin) +2. Select the contest whose data you want to download +3. Click _Download contest data_ +4. Choose the data type: + - All submissions + - Last submissions only + - AC submissions only +5. Click _Request download_ +6. Wait for the system to generate the file +7. Download the file + +## Data format + +### Submissions (submissions.csv) + +A CSV file with submission details: + +```csv +ID,User,Problem,Date,Language,Result,Points,Time,Memory +123456,user1,APLUSB,2024-01-01 00:00:00,CPP17,AC,100,0.1,2048 +123457,user2,APLUSB,2024-01-01 00:01:00,PYTHON3,WA,0,0.2,4096 +``` + +### Submissions with source code (submissions_with_source.zip) + +A zip file containing: +- `submissions.csv`: Submission details +- `sources/`: Directory containing the source code + - `123456_user1_APLUSB.cpp` + - `123457_user2_APLUSB.py` + +### Scoreboard (scoreboard.csv) + +The contest ranking: + +```csv +Rank,User,Score,Time,Problem1,Problem2,Problem3 +1,user1,300,120,100,100,100 +2,user2,200,150,100,100,0 +``` + +## Download options + +### Filter by time + +Download only submissions within a time range: + +```python +# In the admin, select: +Start time: 2024-01-01 00:00:00 +End time: 2024-01-01 23:59:59 +``` + +### Filter by user + +Download only submissions from specific users: + +```python +# Enter a list of usernames, one user per line +user1 +user2 +user3 +``` + +### Filter by problem + +Download only submissions for specific problems: + +```python +# Enter a list of problem codes, one problem per line +APLUSB +SORTING +GRAPH +``` + +## Troubleshooting + +**The file is not generated:** +- Check the cache directory permissions +- Check Celery (Docker): `docker compose ps celery` +- Check the logs (Docker): `docker compose logs -f celery` +- Check Celery (bare metal): `supervisorctl status celery` +- Check the logs (bare metal): `supervisorctl tail -f celery` + +**The file is too large:** +- Filter by time or by problem +- Download the data in separate parts +- Increase the Celery timeout + +**Rate limit errors:** +- Each contest can only be downloaded once per `RATELIMIT` period +- The default is 1 day +- Admins can delete old files to download again sooner + +## Data analysis + +### Python + +```python +import pandas as pd + +# Read the CSV file +df = pd.read_csv('submissions.csv') + +# Per-user statistics +user_stats = df.groupby('User').agg({ + 'ID': 'count', + 'Points': 'sum' +}).rename(columns={'ID': 'Submissions', 'Points': 'Total Points'}) + +print(user_stats) +``` + +### Excel + +Open the CSV file in Excel to analyze it and create charts. + +## Security + +- Only organizers and admins can download the data +- Files have random, hard-to-guess names +- Clean up old files regularly +- Do not share files containing contestants' source code +- Respect contestants' privacy diff --git a/src/en/site/contest_formats.md b/src/en/site/contest_formats.md new file mode 100644 index 0000000..becf76c --- /dev/null +++ b/src/en/site/contest_formats.md @@ -0,0 +1,201 @@ +# Contest Formats + +LCOJ supports 6 contest formats: Default, IOI, Legacy IOI, ECOO, AtCoder, and ICPC. + +## Default + +The standard and simplest format. + +**Scoring:** +- Score = Sum of the highest score on each problem +- Tiebreaker: Time of the last scoring submission + +**Note:** Every submission adds to the penalty time, even submissions that do not increase your score. + +**Configuration:** No special options. + +**Example:** + +| Contestant | Problem A | Problem B | Problem C | Total score | Time | +|----------|-------|-------|-------|-----------|-----------| +| Alice | 100 (10m) | 80 (25m) | 60 (40m) | 240 | 40m | +| Bob | 100 (15m) | 80 (20m) | 60 (35m) | 240 | 35m | + +Bob wins because his time is lower. + +## IOI + +The format of the International Olympiad in Informatics. + +**Scoring:** +- Each problem has multiple subtasks +- Subtask score = Highest score on that subtask across all submissions +- Problem score = Sum of subtask scores +- Total score = Sum of problem scores +- No tiebreaker by default + +**Example:** + +Problem A has 2 subtasks (30 points and 70 points): + +| Submission | Subtask 1 | Subtask 2 | Total | +|---------|-----------|-----------|------| +| Attempt 1 | 30 | 10 | 40 | +| Attempt 2 | 0 | 70 | 70 | +| **Final score** | **30** | **70** | **100** | + +**Options:** + +```json +{ + "cumtime": true +} +``` + +If `cumtime: true`, ties are broken by the total time of the first submission that passes each subtask. + +## Legacy IOI + +The format of the Codechef IOI Ranklist. + +**Scoring:** +- Score = Sum of the highest score on each problem +- No tiebreaker by default + +**Options:** + +```json +{ + "cumtime": true +} +``` + +If `cumtime: true`, ties are broken by the total time of the most recent score-changing submissions. + +## ECOO + +The format of the ECOO contest. + +**Scoring:** +- Score = Sum of the scores of the **last** submission on each problem +- No tiebreaker by default + +**Options:** + +```json +{ + "cumtime": true, + "first_ac_bonus": 10, + "time_bonus": 5 +} +``` + +**`first_ac_bonus`:** Bonus points for getting AC on the first attempt (default 10). + +**`time_bonus`:** Time-based bonus points. You earn 1 point for every `time_bonus` minutes remaining before the contest ends (default 5). + +**time_bonus example:** + +- A submission scores 50/100 points +- It is submitted with 23 minutes remaining +- Bonus = ⌊23/5⌋ = 4 points +- Total = 50 + 4 = 54 points + +## AtCoder + +The AtCoder format. + +**Scoring:** +- Score = Sum of the highest score on each problem +- Tiebreaker: Time of the last score-changing submission + penalty + +**Penalty:** + +```json +{ + "penalty": 5 +} +``` + +Penalty = Number of wrong submissions before the correct one × `penalty` minutes (default 5). + +**Example:** + +Problem A: +- Attempt 1 (5m): 0 points +- Attempt 2 (10m): 0 points +- Attempt 3 (15m): 100 points + +Penalty = 2 × 5 = 10 minutes + +Time = 15 + 10 = 25 minutes + +## ICPC + +The ACM-ICPC format. + +**Scoring:** +- Score = Number of problems solved (AC) +- Tiebreaker 1: Total time + penalty +- Tiebreaker 2: Time of the last score-changing submission + +**Penalty:** + +```json +{ + "penalty": 20 +} +``` + +Penalty = Number of wrong submissions before the AC submission × `penalty` minutes (default 20). + +**Example:** + +| Problem | AC time | Wrong attempts | Penalty | Total time | +|-----|--------------|------------|---------|----------------| +| A | 10m | 0 | 0 | 10m | +| B | 25m | 2 | 40m | 65m | +| C | 50m | 1 | 20m | 70m | + +Total: 3 problems, 145 minutes + +## Format comparison + +| Format | Score | Tiebreaker | Penalty | Best for | +|-----------|------|---------|---------|---------| +| Default | Sum of highest scores | Last time | Every submission | Regular contests | +| IOI | Sum of subtask scores | None | None | Olympiads, problems with subtasks | +| Legacy IOI | Sum of highest scores | Optional | None | Similar to IOI | +| ECOO | Last submission score | Optional | Has bonuses | ECOO contests | +| AtCoder | Sum of highest scores | Time + penalty | Wrong submissions | AtCoder-style contests | +| ICPC | Problems solved (AC) | Time + penalty | Wrong submissions | ACM-ICPC | + +## Choosing a format + +**Default:** Best for regular contests; easy to understand. + +**IOI:** Use when problems have clear subtasks and you want contestants to earn partial points. + +**ICPC:** Use when you want to focus on the number of problems solved and partial points do not matter. + +**AtCoder:** Balances score and time, with a light penalty. + +**ECOO:** Has special features such as a first-AC bonus and a time bonus. + +## Configuring in the admin + +1. Open the create/edit contest page +2. Select a _Contest format_ +3. Enter a JSON config if needed (for example: `{"cumtime": true, "penalty": 10}`) +4. Save + +**Example config:** + +```json +{ + "cumtime": true, + "penalty": 10, + "first_ac_bonus": 15, + "time_bonus": 3 +} +``` diff --git a/src/en/site/installation.md b/src/en/site/installation.md new file mode 100644 index 0000000..89adef4 --- /dev/null +++ b/src/en/site/installation.md @@ -0,0 +1,527 @@ +# Installing LCOJ with Docker + +This guide walks you through installing LCOJ with Docker, the recommended and simplest approach. + +**Repository:** [lcoj-docker](https://github.com/luyencode/lcoj-docker) + +## System requirements + +### Minimum hardware + +- **CPU:** 2 cores +- **RAM:** 4GB +- **Disk:** 20GB free +- **OS:** Linux (Ubuntu 20.04+ recommended) + +### Recommended hardware + +- **CPU:** 4+ cores +- **RAM:** 8GB+ +- **Disk:** 50GB+ SSD +- **Network:** 100Mbps+ + +### Software + +- Docker 20.10+ +- Docker Compose 2.0+ +- Git + +## Step 1: Install Docker + +### Ubuntu/Debian + +```sh +# Install Docker +curl -fsSL https://get.docker.com -o get-docker.sh +sudo sh get-docker.sh + +# Add your user to the docker group +sudo usermod -aG docker $USER + +# Log out and log back in for the change to take effect +``` + +### Verify + +```sh +docker --version +docker compose version +``` + +## Step 2: Clone the repository + +```sh +git clone --recursive https://github.com/luyencode/lcoj-docker.git +cd lcoj-docker/dmoj +``` + +**Note:** The `--recursive` flag is essential: it clones the submodules as well. + +## Step 3: Initialize + +Run the initialization script: + +```sh +./scripts/initialize +``` + +This script: +- Creates the required directories +- Copies the sample configuration files +- Sets permissions + +## Step 4: Configure + +### 4.1. Create the environment files + +```sh +cp environment/mysql-admin.env.example environment/mysql-admin.env +cp environment/mysql.env.example environment/mysql.env +cp environment/site.env.example environment/site.env +``` + +### 4.2. Configure MySQL + +**File: `environment/mysql.env`** + +```env +MYSQL_DATABASE=lcoj +MYSQL_USER=lcoj +MYSQL_PASSWORD= +``` + +**File: `environment/mysql-admin.env`** + +```env +MYSQL_ROOT_PASSWORD= +``` + +**Note:** Replace `` with an actual password! + +### 4.3. Configure the site + +**File: `environment/site.env`** + +```env +# Database +MYSQL_HOST=db +MYSQL_DATABASE=lcoj +MYSQL_USER=lcoj +MYSQL_PASSWORD= + +# Site +SITE_NAME=LCOJ +SITE_LONG_NAME=LuyenCode Online Judge +SITE_ADMIN_EMAIL=admin@luyencode.net + +# Secret key (generate a new one) +SECRET_KEY= + +# Host +HOST=luyencode.net + +# Debug (MUST BE CHANGED TO False IN PRODUCTION) +DEBUG=True +``` + +**Generate a SECRET_KEY:** + +```sh +python3 -c "import secrets; print(secrets.token_urlsafe(50))" +``` + +### 4.4. Configure Nginx + +**File: `nginx/conf.d/nginx.conf`** + +Change `server_name`: + +```nginx +server { + listen 80; + server_name luyencode.net; # Change to your domain + + # ... leave the rest unchanged +} +``` + +## Step 5: Build the Docker images + +```sh +docker compose build +``` + +This takes 10–20 minutes, depending on your network speed and machine. + +## Step 6: Start the services + +### 6.1. Start the database and cache + +```sh +docker compose up -d db redis +``` + +Wait about 10 seconds for the database to finish starting up. + +### 6.2. Start the site and Celery + +```sh +docker compose up -d site celery +``` + +### 6.3. Create the database schema + +```sh +./scripts/migrate +``` + +### 6.4. Generate static files + +```sh +./scripts/copy_static +``` + +### 6.5. Load sample data + +```sh +./scripts/manage.py loaddata navbar +./scripts/manage.py loaddata language_small +./scripts/manage.py loaddata demo +``` + +**Warning:** `demo` creates an admin account with username and password `admin`. Change it immediately after logging in! + +### 6.6. Create a superuser + +```sh +./scripts/manage.py createsuperuser +``` + +Follow the prompts to create your admin account. + +## Step 7: Start all services + +```sh +docker compose up -d +``` + +Check that all containers are running: + +```sh +docker compose ps +``` + +You should see: + +``` +NAME STATUS +lcoj_bridged Up +lcoj_celery Up +lcoj_mysql Up +lcoj_nginx Up +lcoj_redis Up +lcoj_site Up +lcoj_wsevent Up +``` + +## Step 8: Verify + +Open `http://localhost` (or your domain) to verify the installation. + +You should see the LCOJ home page! + +## Directory structure + +``` +dmoj/ +├── base/ # Base Docker image +├── bridged/ # Bridge service +├── celery/ # Celery worker +├── config/ # Config files +├── database/ # MySQL data (created automatically) +├── environment/ # Environment variables +├── media/ # User uploads +├── nginx/ # Nginx config +├── problems/ # Problem data +├── repo/ # Site source code (submodule) +├── scripts/ # Helper scripts +├── site/ # Site Docker image +├── wsevent/ # WebSocket event server +└── docker-compose.yml # Docker Compose config +``` + +## Services + +| Service | Container | Port | Description | +|---------|-----------|------|-------| +| nginx | lcoj_nginx | 80 | Web server | +| site | lcoj_site | - | Django application | +| celery | lcoj_celery | - | Background tasks | +| bridged | lcoj_bridged | 9998, 9999 | Judge bridge | +| wsevent | lcoj_wsevent | 15100-15102 | WebSocket events | +| db | lcoj_mysql | 3306 | MariaDB database | +| redis | lcoj_redis | 6379 | Cache & message broker | + +## Managing services + +### View logs + +```sh +# All services +docker compose logs -f + +# A specific service +docker compose logs -f site +docker compose logs -f celery +docker compose logs -f nginx +``` + +### Restart a service + +```sh +docker compose restart site +docker compose restart celery +``` + +### Stop everything + +```sh +docker compose down +``` + +### Start again + +```sh +docker compose up -d +``` + +## Updating + +### Update the code + +```sh +cd lcoj-docker/dmoj +git pull +git submodule update --init --recursive +``` + +### Rebuild and restart + +```sh +docker compose up -d --build site celery bridged wsevent +``` + +### Run migrations + +```sh +./scripts/migrate +``` + +### Update static files + +```sh +./scripts/copy_static +``` + +## Backup + +### Back up the database + +```sh +docker exec lcoj_mysql mysqldump -u root -p lcoj > backup_$(date +%Y%m%d).sql +``` + +### Back up media files + +```sh +tar -czf media_backup_$(date +%Y%m%d).tar.gz dmoj/media/ +``` + +### Back up problems + +```sh +tar -czf problems_backup_$(date +%Y%m%d).tar.gz dmoj/problems/ +``` + +## Restore + +### Restore the database + +```sh +docker exec -i lcoj_mysql mysql -u root -p lcoj < backup_20240101.sql +``` + +### Restore media + +```sh +tar -xzf media_backup_20240101.tar.gz +``` + +## Monitoring + +### Check resource usage + +```sh +docker stats +``` + +### Check disk usage + +```sh +docker system df +``` + +### Follow logs in real time + +```sh +# Site logs +docker compose logs -f --tail=100 site + +# Celery logs +docker compose logs -f --tail=100 celery + +# Nginx access logs +docker compose exec nginx tail -f /var/log/nginx/access.log +``` + +## Troubleshooting + +### Container won't start + +```sh +# View logs +docker compose logs + +# View details +docker inspect +``` + +### Database connection error + +```sh +# Check that MySQL is running +docker compose ps db + +# Check the logs +docker compose logs db + +# Restart the database +docker compose restart db +``` + +### Static files not loading + +```sh +# Re-run copy_static +./scripts/copy_static + +# Restart nginx +docker compose restart nginx +``` + +### Out of memory + +```sh +# Check memory usage +docker stats + +# Raise the memory limit in docker-compose.yml +# Add to the relevant service: +deploy: + resources: + limits: + memory: 2G +``` + +### Disk full + +```sh +# Remove unused images +docker image prune -a + +# Remove unused volumes +docker volume prune + +# Remove unused containers +docker container prune +``` + +## Production checklist + +Before deploying to production: + +- [ ] Set `DEBUG=False` in `site.env` +- [ ] Change the default `admin` password +- [ ] Configure HTTPS (SSL certificate) +- [ ] Set up automated backups +- [ ] Configure the firewall +- [ ] Set up monitoring (Prometheus, Grafana) +- [ ] Configure log rotation +- [ ] Test disaster recovery +- [ ] Document your custom changes + +## Configuring HTTPS + +### With Let's Encrypt + +```sh +# Install certbot +apt install certbot python3-certbot-nginx + +# Obtain a certificate +certbot --nginx -d luyencode.net + +# Auto-renew +certbot renew --dry-run +``` + +### Update the nginx config + +Certbot updates the nginx config automatically. Then run: + +```sh +docker compose restart nginx +``` + +## Performance tuning + +### Increase the number of Celery workers + +**File: `celery/Dockerfile`** + +```dockerfile +CMD celery -A dmoj_celery worker -l info --concurrency=4 +``` + +### Increase the number of uWSGI workers + +**File: `site/Dockerfile`** + +```dockerfile +CMD uwsgi --ini uwsgi.ini --processes=4 +``` + +### Configure Redis persistence + +**File: `docker-compose.yml`** + +```yaml +redis: + command: redis-server --appendonly yes + volumes: + - redis-data:/data +``` + +## See also + +- [Management Commands](/en/site/management_commands) +- [Updating the system](/en/site/updating) +- [Setting up a judge](/en/judge/setting_up_a_judge) +- [Managing problems](/en/site/managing_problems) + +## Support + +If you run into problems: +1. Check the logs: `docker compose logs -f` +2. Open an issue on [GitHub Issues](https://github.com/luyencode/lcoj-docker/issues) +3. Contact support at [https://luyencode.net/about/#lien-he](https://luyencode.net/about/#lien-he) diff --git a/src/en/site/management_commands.md b/src/en/site/management_commands.md new file mode 100644 index 0000000..59a1f27 --- /dev/null +++ b/src/en/site/management_commands.md @@ -0,0 +1,608 @@ +# Management Commands + +LCOJ provides many management commands for administering the system. Run them with `./manage.py `. + +## Usage + +```sh +cd /path/to/site +source ../lcojsite/bin/activate +./manage.py [options] +``` + +## User Management + +### adduser - Add a user + +Quickly create a new user. + +```sh +./manage.py adduser +``` + +**Example:** +```sh +./manage.py adduser alice alice@example.com password123 +``` + +**Options:** +- `--superuser` - Create a superuser +- `--staff` - Create a staff user + +### batchadduser - Add multiple users + +Add multiple users from a CSV file. + +```sh +./manage.py batchadduser +``` + +**CSV format:** +```csv +username,email,password,first_name,last_name +user1,user1@example.com,pass1,John,Doe +user2,user2@example.com,pass2,Jane,Smith +``` + +**Example:** +```sh +./manage.py batchadduser users.csv +``` + +### move_user_content - Move user content + +Move all content (submissions, comments) from one user to another. + +```sh +./manage.py move_user_content +``` + +**Example:** +```sh +./manage.py move_user_content old_account new_account +``` + +**Note:** This command does not delete the old user; it only moves the content. + +## Judge Management + +### addjudge - Add a judge + +Create a new judge with an authentication key. + +```sh +./manage.py addjudge +``` + +**Example:** +```sh +./manage.py addjudge judge1 +``` + +The command automatically generates and displays the authentication key. + +### runbridged - Run the bridge + +Run the bridge server that judges connect to. + +```sh +./manage.py runbridged +``` + +**Options:** +- `--host ` - Host to bind to (default: localhost) +- `--port ` - Port to bind to (default: 9999) + +**Example:** +```sh +./manage.py runbridged --host 0.0.0.0 --port 9999 +``` + +**Note:** This usually runs under supervisor rather than directly. + +### runbalancer - Run the load balancer + +Run a load balancer for multiple judges. + +```sh +./manage.py runbalancer +``` + +## Problem Management + +### generate_editorials - Generate editorials automatically + +Generate editorials for problems automatically using AI, with Pydantic structured output to guarantee a consistent format. + +```sh +./manage.py generate_editorials [options] +``` + +**Requirements:** +- Install the packages: `pip install openai pydantic` +- Set the API key: `export OPENAI_API_KEY="sk-..."` +- Or configure it in `environment/openai.env` + +**Options:** + +| Option | Description | Default | +|----------|-------|----------| +| `--problem CODE`, `-p CODE` | Process a specific problem | All problems without an editorial | +| `--limit N`, `-l N` | Maximum number of problems to process | 10 | +| `--offset N` | Start from position N | 0 | +| `--dry-run` | Preview mode; nothing is saved to the DB | False | +| `--verbose` | Show detailed progress | False | +| `--model MODEL` | OpenAI model to use | mimo-v2-flash | +| `--temperature T` | Creativity (0.0-2.0) | 0.7 | +| `--max-retries N` | Number of retries on API errors | 3 | +| `--retry-delay S` | Delay between retries (seconds) | 2 | +| `--log-file PATH` | Write logs to a file | None | + +**Examples:** + +```sh +# Step 1: Test with one problem (dry run - IMPORTANT) +./manage.py generate_editorials --problem cb01 --dry-run --verbose + +# Step 2: Generate the editorial for one problem +./manage.py generate_editorials --problem cb01 --verbose + +# Step 3: Process multiple problems with logging +./manage.py generate_editorials --limit 20 --log-file /tmp/editorials.log --verbose + +# Step 4: Resume from the 50th problem +./manage.py generate_editorials --limit 50 --offset 50 + +# Use a different model +./manage.py generate_editorials --problem cb01 --model gpt-4 --temperature 0.5 +``` + +**How it works:** + +1. Finds problems that do not have an editorial yet (is_public=True) +2. Picks 3 distinct AC submissions (C/C++ preferred) +3. Sends them to the OpenAI API with Pydantic structured output +4. Builds the editorial in a standard format with these sections: + - Understanding the problem + - Approaches (from simplest to optimal) + - Complexity analysis + - Key takeaways + - Common pitfalls +5. Saves it to the database with PUBLIC status + +**Editorial format:** + +````markdown +## Hiểu bài toán +[Clear explanation of the problem] + +## Các cách tiếp cận + +### Cách Brute Force +```cpp +[code] +``` +* **Time Complexity**: O(n²) +* **Space Complexity**: O(1) +[Detailed explanation] + +### Cách Hash Map +[code + explanation] + +## Phân tích độ phức tạp +| Cách tiếp cận | Time | Space | Tên | +|--------------|------|-------|-----| +| 1 | O(n²) | O(1) | Brute Force | +| 2 | O(n) | O(n) | Hash Map | + +## Bài học kinh nghiệm +- [Insight 1] +- [Insight 2] + +## Lỗi thường gặp +- [Pitfall 1] +- [Pitfall 2] +```` + +**Review and publish:** + +```sh +# Check in the database +./manage.py shell +>>> from judge.models import Solution +>>> s = Solution.objects.get(problem__code='cb01') +>>> print(s.content[:500]) +>>> print(f"Is public: {s.is_public}") +>>> print(f"Authors: {[a.user.username for a in s.authors.all()]}") + +# View on the website +# https://luyencode.net/problem/cb01/editorial +``` + +**Batch processing:** + +```sh +# Run in the background with nohup +nohup ./manage.py generate_editorials --limit 100 --log-file /tmp/editorials.log > /tmp/output.log 2>&1 & + +# Monitor progress +tail -f /tmp/output.log + +# Check the results +grep "✓" /tmp/editorials.log | wc -l # Number of successful problems +grep "✗" /tmp/editorials.log | wc -l # Number of failed problems +``` + +**Rolling back if needed:** + +```sh +./manage.py shell +>>> from judge.models import Solution + +# Delete the editorial for a specific problem +>>> Solution.objects.filter(problem__code='cb01').delete() + +# Delete all PUBLIC editorials (careful!) +>>> Solution.objects.filter(is_public=True).delete() + +# Delete the 10 most recent editorials +>>> from django.db.models import Max +>>> last_id = Solution.objects.aggregate(Max('id'))['id__max'] +>>> Solution.objects.filter(id__gte=last_id - 10).delete() +``` + +**Notes:** +- Editorials are created with PUBLIC status (is_public=True) +- The system automatically adds the admin and the authors of the source solutions to the authors list +- Use `--dry-run` to test before generating for real +- The API may be rate-limited; lower `--limit` if you hit errors +- Processing time: ~5-15 seconds per problem + +**Troubleshooting:** + +```sh +# Error: "OpenAI package not installed" +pip install openai pydantic + +# Error: "OPENAI_API_KEY not set" +export OPENAI_API_KEY="sk-..." + +# Error: "No AC solutions found" +# Check whether the problem has any AC submissions +./manage.py shell +>>> from judge.models import Submission +>>> Submission.objects.filter(problem__code='xxx', result='AC').count() + +# Error: API rate limit +# Reduce the batch size and increase the delay +./manage.py generate_editorials --limit 5 --retry-delay 5 +``` + +### create_problem - Create a problem + +Quickly create a new problem. + +```sh +./manage.py create_problem +``` + +**Example:** +```sh +./manage.py create_problem APLUSB "A Plus B" +``` + +**Options:** +- `--time-limit ` - Time limit +- `--memory-limit ` - Memory limit +- `--points ` - Problem points + +### import_polygon_package - Import from Polygon + +Import a problem from a Polygon (Codeforces) package. + +```sh +./manage.py import_polygon_package +``` + +**Example:** +```sh +./manage.py import_polygon_package problem.zip +``` + +### submit_polygon_solutions - Test solutions + +Submit all solutions from a Polygon package for testing. + +```sh +./manage.py submit_polygon_solutions +``` + +### copy_language - Copy languages + +Copy the language configuration from one problem to another. + +```sh +./manage.py copy_language +``` + +**Example:** +```sh +./manage.py copy_language APLUSB SORTING +``` + +## Contest Management + +### export_contest_submissions - Export submissions + +Export all submissions of a contest to CSV. + +```sh +./manage.py export_contest_submissions +``` + +**Example:** +```sh +./manage.py export_contest_submissions contest2024 submissions.csv +``` + +### export_contest_submissions_details - Export details + +Export submissions along with their source code. + +```sh +./manage.py export_contest_submissions_details +``` + +**Example:** +```sh +./manage.py export_contest_submissions_details contest2024 ./export/ +``` + +### export_event_feed - Export the event feed + +Export the event feed for ICPC tools. + +```sh +./manage.py export_event_feed +``` + +**Example:** +```sh +./manage.py export_event_feed icpc2024 events.json +``` + +### runmoss - Run MOSS + +Run MOSS to detect cheating in a contest. + +```sh +./manage.py runmoss +``` + +**Example:** +```sh +./manage.py runmoss contest2024 +``` + +**Requirement:** A MOSS user ID must be configured in settings. + +## API & Tokens + +### generate_api_token - Generate an API token + +Generate an API token for a user. + +```sh +./manage.py generate_api_token +``` + +**Example:** +```sh +./manage.py generate_api_token alice +``` + +The token is printed to the console. + +## Utilities + +### render_pdf - Render a PDF + +Render a problem statement to PDF. + +```sh +./manage.py render_pdf +``` + +**Example:** +```sh +./manage.py render_pdf APLUSB aplusb.pdf +``` + +**Requirement:** Pdfoid must be configured. + +### generate_sitemap - Generate the sitemap + +Generate sitemap.xml for SEO. + +```sh +./manage.py generate_sitemap +``` + +The sitemap is saved in the static directory. + +### camo - Camo proxy + +Run the Camo proxy for images. + +```sh +./manage.py camo +``` + +**Note:** Rarely used; use standalone Camo instead. + +### makedmojmessages - Generate translation files + +Generate translation files for localization. + +```sh +./manage.py makedmojmessages +``` + +Then compile them: + +```sh +./manage.py compilemessages +``` + +## Permissions & Credits + +### update_permissions - Update permissions + +Update permissions for all users based on their groups. + +```sh +./manage.py update_permissions +``` + +### backfill_current_credit - Backfill current credit + +Update the current credit for users. + +```sh +./manage.py backfill_current_credit +``` + +### backfill_monthly_credit - Backfill monthly credit + +Update the monthly credit for users. + +```sh +./manage.py backfill_monthly_credit +``` + +## Real-world examples + +### Initial setup + +```sh +# Create a superuser +./manage.py createsuperuser + +# Create a judge +./manage.py addjudge judge1 + +# Create a sample problem +./manage.py create_problem HELLO "Hello World" --time-limit 1 --memory-limit 65536 --points 100 +``` + +### Generate editorials automatically + +```sh +# Step 1: Install dependencies +pip install openai pydantic + +# Step 2: Set the API key +export OPENAI_API_KEY="sk-..." + +# Step 3: Test with one problem (dry run) +./manage.py generate_editorials --problem cb01 --dry-run --verbose + +# Step 4: Generate the editorial for real +./manage.py generate_editorials --problem cb01 --verbose + +# Step 5: Check the result +./manage.py shell +>>> from judge.models import Solution +>>> s = Solution.objects.get(problem__code='cb01') +>>> print(f"Editorial created: {s.is_public}") +>>> print(f"Content length: {len(s.content)} chars") + +# Step 6: Batch processing +./manage.py generate_editorials --limit 50 --log-file /tmp/editorials.log + +# Step 7: Monitor progress +tail -f /tmp/editorials.log +``` + +### Contest management + +```sh +# Export submissions after the contest +./manage.py export_contest_submissions contest2024 submissions.csv + +# Run MOSS to check for cheating +./manage.py runmoss contest2024 + +# Export the event feed for ICPC tools +./manage.py export_event_feed contest2024 events.json +``` + +### Batch operations + +```sh +# Add multiple users from CSV +./manage.py batchadduser students.csv + +# Generate API tokens for all users +for user in $(cat users.txt); do + ./manage.py generate_api_token $user >> tokens.txt +done +``` + +### Maintenance + +```sh +# Update permissions +./manage.py update_permissions + +# Regenerate the sitemap +./manage.py generate_sitemap + +# Render all problem statements to PDF +for problem in APLUSB SORTING GRAPH; do + ./manage.py render_pdf $problem pdfs/$problem.pdf +done +``` + +## Tips + +### Run in the background + +```sh +nohup ./manage.py runbridged > bridged.log 2>&1 & +``` + +### Run with a timeout + +```sh +timeout 3600 ./manage.py runmoss contest2024 +``` + +### Run periodically with cron + +```cron +# Generate the sitemap every day at 2 AM +0 2 * * * cd /path/to/site && ./manage.py generate_sitemap + +# Backfill credit every month +0 0 1 * * cd /path/to/site && ./manage.py backfill_monthly_credit +``` + +## See also + +To list all available commands: + +```sh +./manage.py help +``` + +To view help for a specific command: + +```sh +./manage.py help +``` + +**Example:** +```sh +./manage.py help adduser +``` diff --git a/src/en/site/managing_problems.md b/src/en/site/managing_problems.md new file mode 100644 index 0000000..4a352cf --- /dev/null +++ b/src/en/site/managing_problems.md @@ -0,0 +1,260 @@ +# Managing Problems + +LCOJ provides a web interface for creating and editing problems, including both problem statements and test data. + +## Configuration + +### With Docker (recommended) + +Test data is stored in the `dmoj/problems/` directory, which is mounted into the container automatically. + +No extra configuration is needed; this is already set up in Docker. + +### With bare metal + +In `local_settings.py`, set `DMOJ_PROBLEM_DATA_ROOT`: + +```python +DMOJ_PROBLEM_DATA_ROOT = '/home/lcoj/problems' +``` + +## Adding a New Problem + +### Step 1: Open the admin site + +Go to `/admin/` and log in with an admin account. + +### Step 2: Create the problem + +Click the _Add_ button in the _Problems_ section. + +![Add Problem](https://i.imgur.com/RFPQaUi.png) + +### Step 3: Fill in the basic information + +**Required fields:** +- **Problem code**: The problem code (must be unique, e.g. `APLUSB`) +- **Title**: The problem name (e.g. "Sum of Two Numbers") +- **Authors**: **Important!** You must add yourself as an author, otherwise you will not be able to edit the problem + +![Problem Info](https://i.imgur.com/bPlNZUR.png) + +### Step 4: Write the problem statement + +LCOJ supports Markdown with extended features: +- LaTeX for math formulas +- Syntax highlighting for code +- Images and tables + +**Example problem statement:** + +````markdown +# Problem + +Given two integers $a$ and $b$, compute their sum. + +## Input + +A single line containing two integers $a$ and $b$ ($-10^9 \le a, b \le 10^9$). + +## Output + +Print a single integer, $a + b$. + +## Example + +### Input +``` +3 5 +``` + +### Output +``` +8 +``` + +## Limits + +- Time: 1 second +- Memory: 256 MB +```` + +See the [full template](https://raw.githubusercontent.com/luyencode/docs/master/sample_files/problem_markdown_example.md.txt). + +### Step 5: Configure the problem + +**Key options:** + +- **Time limit**: Time limit (seconds) +- **Memory limit**: Memory limit (KB) +- **Points**: Points for the problem (usually 100) +- **Partial**: Allow partial points +- **Group**: Problem group +- **Types**: Problem types (DP, Graph, Math, ...) +- **Allowed languages**: Allowed languages + +### Step 6: Save and view + +Click _Save_, then click _View on site_ to view the problem. + +![View on site](https://i.imgur.com/ZgO5xcY.png) + +## Managing Test Data + +### Step 1: Open the test data editor + +On the problem page, click _Edit test data_. + +![Edit test data](https://i.imgur.com/eDWEEJk.png) + +### Step 2: Upload test data + +Prepare a zip file containing the test data. Naming convention: + +``` +..in # Input file +..out # Output file +``` + +**Example:** For problem `APLUSB`: + +``` +APLUSB.1.in +APLUSB.1.out +APLUSB.2.in +APLUSB.2.out +APLUSB.3.in +APLUSB.3.out +``` + +Upload the zip file. + +![Upload zip](https://i.imgur.com/w5ytsgi.png) + +### Step 3: Configure test cases + +**Key fields:** + +- **Input file**: Path to the input file inside the zip +- **Output file**: Path to the output file inside the zip +- **Points**: Points for the test case + +**Example configuration:** + +``` +Test 1: APLUSB.1.in, APLUSB.1.out, 30 points +Test 2: APLUSB.2.in, APLUSB.2.out, 30 points +Test 3: APLUSB.3.in, APLUSB.3.out, 40 points +``` + +### Scoring + +If _Partial points_ is enabled: + +**Formula:** + +``` +Score = (Points of passed tests / Points of all tests) × Problem points +``` + +**Example:** + +- The problem is worth 100 points +- 3 tests: 1/2/7 points +- A contestant passes tests 1 and 2 and fails test 3 +- Score = (1+2)/(1+2+7) × 100 = 30 points + +## Batched Test Cases + +Use these for problems with subtasks. All tests in a subtask must pass to earn its points. + +**How to create:** + +1. Click _Add batch_ +2. Set the points for the batch +3. Add test cases to the batch + +**Example:** + +``` +Batch 1 (30 points): + - Test 1.1 + - Test 1.2 + +Batch 2 (70 points): + - Test 2.1 + - Test 2.2 + - Test 2.3 +``` + +## Custom Checkers + +If a problem has multiple correct answers, use a custom checker. + +**Built-in checkers:** + +- `standard`: Exact comparison (default) +- `floats`: Allows floating-point error +- `sorted`: Ignores order +- `identical`: Character-by-character comparison + +**Choosing a checker:** + +In the _Checker_ section, select the appropriate checker and configure its parameters. + +## Generator + +If there are many tests, you can use a generator instead of uploading files. + +**How to use:** + +1. Upload the generator file (C/C++) +2. Configure the parameters for each test +3. The system generates the input/output automatically + +See also: [Generator](/en/problem_format/generator) + +## Test Submission + +Once the test data is ready, go back to the problem page and click _Submit solution_ to try a submission. + +## Updating Test Data + +To modify test data: + +1. Open _Edit test data_ +2. Upload a new zip file or edit the configuration +3. Click _Save_ +4. The test data is updated automatically + +## Rejudge + +After changing test data, you should rejudge existing submissions: + +1. Go to the problem page +2. Click _Rejudge all submissions_ +3. Choose the rejudge scope (all submissions, or from a certain point in time) + +## Tips + +- **Name tests clearly**: Easier to manage and debug +- **Test thoroughly**: Include edge cases and corner cases +- **Check the outputs**: Make sure the expected outputs are correct +- **Try multiple languages**: Test with C++, Python, and Java +- **Read the logs carefully**: If something fails, check the logs for the cause + +## Troubleshooting + +**Test data does not load:** +- Check the file paths inside the zip +- Check the permissions of the `DMOJ_PROBLEM_DATA_ROOT` directory + +**Checker does not work:** +- Check the checker syntax +- Check the error log in the admin + +**Rejudge does not run:** +- Check that Celery is running (Docker): `docker compose ps celery` +- View Celery logs (Docker): `docker compose logs -f celery` +- Check Celery (bare metal): `supervisorctl status celery` +- View Celery logs (bare metal): `supervisorctl tail -f celery` diff --git a/src/en/site/mathoid.md b/src/en/site/mathoid.md new file mode 100644 index 0000000..a7ab6b2 --- /dev/null +++ b/src/en/site/mathoid.md @@ -0,0 +1,238 @@ +# Rendering LaTeX Math + +LCOJ supports rendering LaTeX math formulas in problem statements, so formulas look clean and professional. + +**Note:** +- This feature is optional +- This guide covers bare metal installs +- With Docker, you need to set up Mathoid separately on the host or in another container + +## Installing Mathoid + +Mathoid is a service that renders LaTeX formulas as images. + +### Step 1: Install Node.js + +```sh +curl -sL https://deb.nodesource.com/setup_18.x | sudo -E bash - +apt install nodejs +``` + +### Step 2: Install Mathoid + +```sh +git clone https://github.com/wikimedia/mathoid.git +cd mathoid +npm install +``` + +### Step 3: Run Mathoid + +```sh +node server.js +``` + +By default, Mathoid runs on `localhost:10044`. + +## Configuring LCOJ + +Add to `local_settings.py`: + +```python +# Mathoid URL +MATHOID_URL = 'http://localhost:10044' + +# Cache directory for rendered formula images +# Must be writable by both Mathoid and nginx +MATHOID_CACHE_ROOT = '/home/lcoj/mathoid_cache' + +# URL for accessing the cache over the web +# Example: /home/lcoj/mathoid_cache/abc.png -> luyencode.net/mathoid/abc.png +MATHOID_CACHE_URL = '//luyencode.net/mathoid/' +``` + +### Configure Nginx + +Add to your nginx config file: + +```nginx +location /mathoid/ { + alias /home/lcoj/mathoid_cache/; + expires 1y; + add_header Cache-Control "public, immutable"; +} +``` + +### Create the cache directory + +```sh +mkdir -p /home/lcoj/mathoid_cache +chown www-data:www-data /home/lcoj/mathoid_cache +chmod 755 /home/lcoj/mathoid_cache +``` + +### Restart + +**Docker:** + +```sh +docker compose restart site nginx +``` + +**Bare metal:** + +```sh +supervisorctl restart site +service nginx reload +``` + +## Using Math in Problem Statements + +### Inline math + +Use `~...~` for small formulas within a line: + +```markdown +Given two integers ~a~ and ~b~ ~(1 \le a, b \le 10^9)~. +``` + +Renders as: Given two integers *a* and *b* (1 ≤ a, b ≤ 10⁹). + +### Display math + +Use `$...$` for large formulas on their own line: + +```markdown +The Fibonacci sequence is defined as: + +$F(n) = \begin{cases} +0, & \text{if } n = 0 \\ +1, & \text{if } n = 1 \\ +F(n-2) + F(n-1), & \text{if } n \ge 2 +\end{cases}$ +``` + +### Full example + +```markdown +# Fibonacci Sequence + +The Fibonacci sequence is a well-known sequence defined as: + +$F(n) = \begin{cases} +0, & \text{if } n = 0 \\ +1, & \text{if } n = 1 \\ +F(n-2) + F(n-1), & \text{if } n \ge 2 +\end{cases}$ + +Given an integer ~N~ ~(1 \le N \le 10^{19})~, find the ~N~-th Fibonacci number +modulo ~1\,000\,000\,007~ ~(= 10^9 + 7)~. + +**Note:** For 30% of the points, ~1 \le N \le 1\,000\,000~. +``` + +## Common LaTeX Symbols + +### Operators + +```latex +~a + b~ # Addition +~a - b~ # Subtraction +~a \times b~ # Multiplication +~a \div b~ # Division +~a \le b~ # Less than or equal to +~a \ge b~ # Greater than or equal to +~a \ne b~ # Not equal to +~a \equiv b~ # Congruent to +``` + +### Fractions + +```latex +~\frac{a}{b}~ # Fraction a/b +``` + +### Superscripts and subscripts + +```latex +~a^2~ # a to the power of 2 +~a_i~ # a subscript i +~a^{10}~ # a to the power of 10 +~a_{i,j}~ # a subscript i,j +``` + +### Sums and products + +```latex +~\sum_{i=1}^{n} a_i~ # Sum +~\prod_{i=1}^{n} a_i~ # Product +``` + +### Roots + +```latex +~\sqrt{x}~ # Square root +~\sqrt[3]{x}~ # Cube root +``` + +### Special symbols + +```latex +~\infty~ # Infinity +~\pi~ # Pi +~\log n~ # Logarithm +~\ln n~ # Natural logarithm +~\lfloor x \rfloor~ # Floor +~\lceil x \rceil~ # Ceiling +``` + +## Troubleshooting + +**Formulas do not render:** +- Check that Mathoid is running: `curl http://localhost:10044` +- Check the `MATHOID_URL` setting +- Check the Mathoid logs + +**Images do not load:** +- Check the nginx configuration +- Check the cache directory permissions +- Check `MATHOID_CACHE_URL` + +**Formulas render incorrectly:** +- Check the LaTeX syntax +- Test it in an [online LaTeX editor](https://www.codecogs.com/latex/eqneditor.php) + +## Optimization + +### Cache + +Mathoid caches rendered formulas automatically. You do not need to clear the cache manually. + +### Performance + +If you have many formulas, we recommend that you: +- Give Mathoid more memory +- Use a CDN for the cache directory +- Tune nginx caching + +## Running Mathoid with Supervisor + +Create the file `/etc/supervisor/conf.d/mathoid.conf`: + +```ini +[program:mathoid] +command=/usr/bin/node /path/to/mathoid/server.js +directory=/path/to/mathoid +user=mathoid +autostart=true +autorestart=true +redirect_stderr=true +stdout_logfile=/var/log/mathoid.log +``` + +Start it: + +```sh +supervisorctl update +supervisorctl start mathoid +``` diff --git a/src/en/site/operations.md b/src/en/site/operations.md new file mode 100644 index 0000000..54a6bcf --- /dev/null +++ b/src/en/site/operations.md @@ -0,0 +1,559 @@ +# Operating LCOJ + +A day-to-day operations guide for running LCOJ with Docker. + +## Starting and stopping + +### Start all services + +```sh +cd lcoj-docker/dmoj +docker compose up -d +``` + +### Stop all services + +```sh +docker compose down +``` + +**Note:** This command does NOT delete any data. The database and media files are preserved. + +### Stop and remove everything (including volumes) + +```sh +docker compose down -v +``` + +**Warning:** This command DELETES the database! Use it only when you want a complete reset. + +## Managing individual services + +### Restart a service + +```sh +docker compose restart site +docker compose restart celery +docker compose restart nginx +``` + +### Stop a service + +```sh +docker compose stop site +``` + +### Start a stopped service + +```sh +docker compose start site +``` + +### Rebuild and restart + +```sh +docker compose up -d --build site +``` + +## Viewing logs + +### Logs for all services + +```sh +docker compose logs -f +``` + +### Logs for a single service + +```sh +docker compose logs -f site +docker compose logs -f celery +docker compose logs -f nginx +``` + +### Limit the number of log lines + +```sh +docker compose logs --tail=100 site +``` + +### Logs within a time range + +```sh +docker compose logs --since 1h site +docker compose logs --since "2024-01-01 00:00:00" site +``` + +### Save logs to a file + +```sh +docker compose logs site > site_logs.txt +``` + +## Monitoring the system + +### Resource usage + +```sh +docker stats +``` + +Shows CPU, RAM, network, and disk I/O for each container. + +### Disk usage + +```sh +# Overview +docker system df + +# Details +docker system df -v +``` + +### Container status + +```sh +docker compose ps +``` + +### View processes in a container + +```sh +docker compose top site +``` + +## Accessing containers + +### Open a shell in a container + +```sh +docker compose exec site bash +docker compose exec db bash +``` + +### Run a command in a container + +```sh +docker compose exec site python manage.py check +docker compose exec db mysql -u root -p +``` + +### View a file in a container + +```sh +docker compose exec site cat /site/local_settings.py +``` + +## Database operations + +### Back up the database + +```sh +# Full backup +docker exec lcoj_mysql mysqldump -u root -p lcoj > backup.sql + +# Backup with timestamp +docker exec lcoj_mysql mysqldump -u root -p lcoj > backup_$(date +%Y%m%d_%H%M%S).sql + +# Backup and compress +docker exec lcoj_mysql mysqldump -u root -p lcoj | gzip > backup.sql.gz +``` + +### Restore the database + +```sh +# Restore from a file +docker exec -i lcoj_mysql mysql -u root -p lcoj < backup.sql + +# Restore from a compressed file +gunzip < backup.sql.gz | docker exec -i lcoj_mysql mysql -u root -p lcoj +``` + +### Open the MySQL shell + +```sh +docker compose exec db mysql -u root -p +``` + +### Run an SQL query + +```sh +docker compose exec db mysql -u root -p lcoj -e "SELECT COUNT(*) FROM judge_submission;" +``` + +## Migrations + +### Run migrations + +```sh +./scripts/migrate +``` + +### List unapplied migrations + +```sh +./scripts/manage.py showmigrations +``` + +### Roll back a migration + +```sh +./scripts/manage.py migrate +``` + +### Create a new migration + +```sh +./scripts/manage.py makemigrations +``` + +## Static files + +### Collect static files + +```sh +./scripts/copy_static +``` + +### Remove old static files + +```sh +docker compose exec site rm -rf /assets/* +./scripts/copy_static +``` + +## Cache management + +### Clear cache + +```sh +docker compose exec site python manage.py clear_cache +``` + +### Restart Redis + +```sh +docker compose restart redis +``` + +### Flush Redis + +```sh +docker compose exec redis redis-cli FLUSHALL +``` + +## Celery tasks + +### View active tasks + +```sh +docker compose exec celery celery -A dmoj_celery inspect active +``` + +### View scheduled tasks + +```sh +docker compose exec celery celery -A dmoj_celery inspect scheduled +``` + +### Purge all tasks + +```sh +docker compose exec celery celery -A dmoj_celery purge +``` + +### Restart Celery + +```sh +docker compose restart celery +``` + +## Problem data + +### Upload problem data + +```sh +# Copy into the problems directory +cp -r /path/to/problem dmoj/problems/ + +# Set permissions +chmod -R 755 dmoj/problems/ +``` + +### Back up problems + +```sh +tar -czf problems_backup_$(date +%Y%m%d).tar.gz dmoj/problems/ +``` + +### Restore problems + +```sh +tar -xzf problems_backup_20240101.tar.gz +``` + +## Media files + +### Back up media + +```sh +tar -czf media_backup_$(date +%Y%m%d).tar.gz dmoj/media/ +``` + +### Clean up old media + +```sh +# Delete files older than 30 days +find dmoj/media/ -type f -mtime +30 -delete +``` + +## Monitoring scripts + +### Health check script + +**File: `check_health.sh`** + +```bash +#!/bin/bash + +echo "=== LCOJ Health Check ===" +echo + +echo "Container Status:" +docker compose ps + +echo +echo "Resource Usage:" +docker stats --no-stream + +echo +echo "Disk Usage:" +df -h | grep -E "/$|/var" + +echo +echo "Database Status:" +docker compose exec -T db mysqladmin -u root -p status + +echo +echo "Redis Status:" +docker compose exec -T redis redis-cli ping + +echo +echo "Site Status:" +curl -s -o /dev/null -w "%{http_code}" http://localhost +``` + +### Automated backup script + +**File: `backup.sh`** + +```bash +#!/bin/bash + +BACKUP_DIR="/backups" +DATE=$(date +%Y%m%d_%H%M%S) + +echo "Starting backup at $DATE" + +# Backup database +echo "Backing up database..." +docker exec lcoj_mysql mysqldump -u root -p lcoj | gzip > $BACKUP_DIR/db_$DATE.sql.gz + +# Backup media +echo "Backing up media..." +tar -czf $BACKUP_DIR/media_$DATE.tar.gz dmoj/media/ + +# Backup problems +echo "Backing up problems..." +tar -czf $BACKUP_DIR/problems_$DATE.tar.gz dmoj/problems/ + +# Delete old backups (older than 7 days) +echo "Cleaning old backups..." +find $BACKUP_DIR -type f -mtime +7 -delete + +echo "Backup completed!" +``` + +### Backup cron jobs + +```cron +# Back up every day at 2 AM +0 2 * * * /path/to/backup.sh >> /var/log/lcoj_backup.log 2>&1 + +# Health check every 5 minutes +*/5 * * * * /path/to/check_health.sh >> /var/log/lcoj_health.log 2>&1 +``` + +## Performance optimization + +### View slow queries + +```sh +docker compose exec db mysql -u root -p -e " +SET GLOBAL slow_query_log = 'ON'; +SET GLOBAL long_query_time = 2; +SHOW VARIABLES LIKE 'slow_query%'; +" +``` + +### Analyze the database + +```sh +docker compose exec db mysqlcheck -u root -p --analyze --all-databases +``` + +### Optimize the database + +```sh +docker compose exec db mysqlcheck -u root -p --optimize --all-databases +``` + +## Security + +### Change the database password + +```sh +# Open the MySQL shell +docker compose exec db mysql -u root -p + +# Change the password +ALTER USER 'lcoj'@'%' IDENTIFIED BY 'new_password'; +FLUSH PRIVILEGES; +``` + +Then update `environment/mysql.env` and restart: + +```sh +docker compose restart site celery bridged +``` + +### View failed login attempts + +```sh +docker compose logs site | grep "Failed login" +``` + +### Block an IP + +Add to `nginx/conf.d/nginx.conf`: + +```nginx +deny 1.2.3.4; +``` + +Restart nginx: + +```sh +docker compose restart nginx +``` + +## Troubleshooting + +### Container keeps crashing + +```sh +# View logs +docker compose logs --tail=100 + +# View the exit code +docker inspect | grep ExitCode + +# Restart with logs attached +docker compose up +``` + +### Out of memory + +```sh +# View memory usage +docker stats + +# Raise the memory limit +# Add to docker-compose.yml: +deploy: + resources: + limits: + memory: 4G +``` + +### Disk full + +```sh +# View disk usage +df -h + +# Remove unused Docker resources +docker system prune -a + +# Delete old logs +find /var/lib/docker/containers/ -name "*.log" -mtime +7 -delete +``` + +### Database locked + +```sh +# View processes +docker compose exec db mysql -u root -p -e "SHOW PROCESSLIST;" + +# Kill a process +docker compose exec db mysql -u root -p -e "KILL ;" +``` + +### Celery tasks stuck + +```sh +# View active tasks +docker compose exec celery celery -A dmoj_celery inspect active + +# Revoke a task +docker compose exec celery celery -A dmoj_celery control revoke + +# Restart Celery +docker compose restart celery +``` + +## Maintenance mode + +### Enable maintenance mode + +Create the file `dmoj/repo/maintenance.html`: + +```html + + + + Maintenance + + +

Under maintenance

+

The system is currently under maintenance. Please check back later.

+ + +``` + +Update the nginx config: + +```nginx +if (-f /site/maintenance.html) { + return 503; +} + +error_page 503 @maintenance; +location @maintenance { + root /site; + rewrite ^(.*)$ /maintenance.html break; +} +``` + +### Disable maintenance mode + +```sh +rm dmoj/repo/maintenance.html +docker compose restart nginx +``` + +## See also + +- [Installation](/en/site/installation.md) +- [Updating](/en/site/updating.md) +- [Management Commands](/en/site/management_commands.md) diff --git a/src/en/site/pdfoid.md b/src/en/site/pdfoid.md new file mode 100644 index 0000000..746d455 --- /dev/null +++ b/src/en/site/pdfoid.md @@ -0,0 +1,255 @@ +# Generating PDFs for Problem Statements + +LCOJ can export problem statements to PDF, which is useful for onsite contests where contestants receive printed problem statements. + +**Note:** +- This feature is optional +- This guide covers bare metal installs +- With Docker, you need to set up Pdfoid separately on the host or in another container + +## Installing Pdfoid + +Pdfoid is a service that converts HTML to PDF. + +### Step 1: Install dependencies + +```sh +apt update +apt install chromium-driver exiftool +``` + +### Step 2: Clone Pdfoid + +```sh +git clone https://github.com/DMOJ/pdfoid.git +cd pdfoid +``` + +### Step 3: Create a virtual environment + +```sh +python3 -m venv env +source env/bin/activate +pip install -e . +``` + +### Step 4: Run Pdfoid + +```sh +export CHROME_PATH=/usr/bin/chromium +export CHROMEDRIVER_PATH=/usr/bin/chromedriver +export EXIFTOOL_PATH=/usr/bin/exiftool +env/bin/pdfoid --port=8888 +``` + +If these programs are already in your `$PATH`, you do not need the exports. + +## Configuring LCOJ + +Add to `local_settings.py`: + +```python +# Pdfoid URL +DMOJ_PDF_PDFOID_URL = 'http://localhost:8888' + +# Timeout (seconds) +DMOJ_PDF_PROBLEM_TIMEOUT = 30 +``` + +### Restart + +**Docker:** + +```sh +docker compose restart site +``` + +**Bare metal:** + +```sh +supervisorctl restart site +``` + +## Usage + +### Generate a PDF for a problem + +Go to: `https://luyencode.net/problem//pdf` + +Example: `https://luyencode.net/problem/APLUSB/pdf` + +### Generate PDFs for multiple problems + +To generate PDFs for all problems in a contest: + +1. Go to the contest page +2. Click _Download problems as PDF_ +3. Select the problems to download +4. Click _Generate PDF_ + +## Running Pdfoid with Supervisor + +Create the file `/etc/supervisor/conf.d/pdfoid.conf`: + +```ini +[program:pdfoid] +command=/path/to/pdfoid/env/bin/pdfoid --port=8888 +directory=/path/to/pdfoid +user=pdfoid +environment=CHROME_PATH="/usr/bin/chromium",CHROMEDRIVER_PATH="/usr/bin/chromedriver",EXIFTOOL_PATH="/usr/bin/exiftool" +autostart=true +autorestart=true +redirect_stderr=true +stdout_logfile=/var/log/pdfoid.log +``` + +Start it: + +```sh +supervisorctl update +supervisorctl start pdfoid +``` + +## Customizing PDFs + +### Custom CSS + +Add PDF-specific CSS in `local_settings.py`: + +```python +DMOJ_PDF_PROBLEM_EXTRA_CSS = """ +@page { + size: A4; + margin: 2cm; +} +body { + font-family: "Times New Roman", serif; + font-size: 12pt; +} +""" +``` + +### Header/Footer + +```python +DMOJ_PDF_PROBLEM_HEADER = """ +
+ LuyenCode Online Judge +
+""" + +DMOJ_PDF_PROBLEM_FOOTER = """ +
+ Page / +
+""" +``` + +## Troubleshooting + +**PDF generation fails:** +- Check that Pdfoid is running: `curl http://localhost:8888` +- Check that Chrome/Chromium is installed +- View Pdfoid logs (Docker): `docker compose logs -f pdfoid` (if running in Docker) +- View Pdfoid logs (bare metal): `supervisorctl tail -f pdfoid` + +**PDF has font issues:** +- Install the required fonts: +```sh +apt install fonts-liberation fonts-dejavu +``` + +**Timeout:** +- Increase `DMOJ_PDF_PROBLEM_TIMEOUT` +- Check that the server has enough RAM + +**Images do not appear:** +- Make sure images use absolute URLs (not relative paths) +- Check that the images are accessible from the server + +## Optimization + +### Cache PDFs + +To avoid regenerating PDFs repeatedly: + +```python +DMOJ_PDF_PROBLEM_CACHE = '/home/lcoj/pdf_cache' +DMOJ_PDF_PROBLEM_CACHE_TIME = 3600 # 1 hour +``` + +Create the directory: + +```sh +mkdir -p /home/lcoj/pdf_cache +chown www-data:www-data /home/lcoj/pdf_cache +``` + +### Reduce PDF size + +```python +DMOJ_PDF_PROBLEM_COMPRESS = True +``` + +### Parallel processing + +If you need to generate many PDFs at once, run multiple Pdfoid instances: + +```sh +# Instance 1 +env/bin/pdfoid --port=8888 + +# Instance 2 +env/bin/pdfoid --port=8889 +``` + +Configure load balancing in `local_settings.py`: + +```python +DMOJ_PDF_PDFOID_URLS = [ + 'http://localhost:8888', + 'http://localhost:8889', +] +``` + +## Printing PDFs + +### Print settings + +When printing PDFs, we recommend that you: +- Choose A4 paper +- Set 2cm margins on each side +- Print double-sided to save paper +- Check the print preview before printing + +### Quantity + +Calculate the number of copies needed: +- Number of contestants × Number of problems +- Add 10% as a buffer +- Add copies for the judges + +## Example Workflow + +### Preparing an onsite contest + +1. Create a contest with the problems +2. Check that the problem statements render correctly +3. Generate a PDF for each problem +4. Review the PDFs +5. Print the PDFs +6. Package the problem statements + +### Automation script + +```bash +#!/bin/bash +CONTEST="contest_key" +PROBLEMS=("APLUSB" "SORTING" "GRAPH") + +for problem in "${PROBLEMS[@]}"; do + curl "https://luyencode.net/problem/$problem/pdf" \ + -o "${problem}.pdf" + echo "Downloaded $problem.pdf" +done +``` diff --git a/src/en/site/permission_system.md b/src/en/site/permission_system.md new file mode 100644 index 0000000..61afe7b --- /dev/null +++ b/src/en/site/permission_system.md @@ -0,0 +1,187 @@ +# Permission System + +LCOJ has a fine-grained permission system that lets you control what users can do. + +## Basic permissions + +Django provides 4 default permissions for each model: +- `can_add_`: Add +- `can_change_`: Change +- `can_delete_`: Delete +- `can_view_`: View + +## Blog Posts + +**`edit_all_post`** - Edit all posts + +The user can edit any post in the admin. + +## Comments + +**`override_comment_lock`** - Override comment lock + +The user can comment even when comments on a page are locked. + +## Contests + +**`see_private_contest`** - See private contests + +The user can see all contests without being an organizer. They can also see hidden rankings. + +**`edit_own_contest`** - Edit own contests + +The user can edit contests they organize. + +**`edit_all_contest`** - Edit all contests + +The user can see and edit any contest without being an organizer. + +**`clone_contest`** - Clone contest + +The user can clone contests they are allowed to edit. + +**`moss_contest`** - Run MOSS + +The user can run MOSS (plagiarism detection) on a contest. + +**`contest_rating`** - View ratings + +The user can see contestants' ratings in a contest. + +**`contest_access_code`** - View access codes + +The user can see a contest's access code. + +**`create_private_contest`** - Create private contests + +The user can create private contests. + +## Problems + +**`see_private_problem`** - See private problems + +The user can see all problems, including private ones. + +**`edit_own_problem`** - Edit own problems + +The user can edit problems they author or curate. + +**`edit_all_problem`** - Edit all problems + +The user can edit any problem. + +**`edit_public_problem`** - Edit public problems + +The user can edit public problems. + +**`problem_full_markup`** - Use full markup + +The user can use HTML/JavaScript in problem statements. + +**`clone_problem`** - Clone problem + +The user can clone problems. + +## Submissions + +**`abort_any_submission`** - Abort any submission + +The user can abort anyone's submission. + +**`rejudge_submission`** - Rejudge submissions + +The user can rejudge submissions. + +**`rejudge_submission_lot`** - Batch rejudge + +The user can rejudge many submissions at once. + +**`spam_submission`** - Mark as spam + +The user can mark submissions as spam. + +**`view_all_submission`** - View all submissions + +The user can see the source code of any submission. + +**`resubmit_other`** - Resubmit for others + +The user can resubmit other users' submissions. + +## Organizations + +**`organization_admin`** - Administer organizations + +The user can manage organizations they are an admin of. + +**`edit_all_organization`** - Edit all organizations + +The user can edit any organization. + +## Users + +**`edit_profile`** - Edit profiles + +The user can edit other users' profiles. + +**`totp`** - Manage 2FA + +The user can manage other users' 2FA. + +## Judges + +**`test_site`** - Test judge + +The user can test judges. + +## Granting permissions + +### Option 1: Via groups + +1. Go to `/admin/auth/group/` +2. Create a new group (for example: "Problem Setters") +3. Select the required permissions +4. Add users to the group + +### Option 2: Per user + +1. Go to `/admin/judge/profile/` +2. Select a user +3. Select permissions in the "User permissions" section + +## Common roles + +### Admin + +Has all permissions and manages the entire system. + +### Problem Setter + +Required permissions: +- `edit_own_problem` +- `see_private_problem` +- `view_all_submission` +- `rejudge_submission` + +### Contest Organizer + +Required permissions: +- `edit_own_contest` +- `see_private_contest` +- `clone_contest` +- `contest_rating` + +### Moderator + +Required permissions: +- `edit_all_post` +- `override_comment_lock` +- `spam_submission` +- `view_all_submission` + +## Notes + +- Grant permissions carefully and avoid granting more than necessary +- Use groups to make permissions easier to manage +- Review user permissions regularly +- Some permissions require other permissions (prerequisites) diff --git a/src/en/site/recaptcha.md b/src/en/site/recaptcha.md new file mode 100644 index 0000000..76fc6c2 --- /dev/null +++ b/src/en/site/recaptcha.md @@ -0,0 +1,146 @@ +# Preventing Spam with reCAPTCHA + +Once your site has been running for a while, spam bots will start registering accounts automatically. reCAPTCHA helps prevent this. + +## Getting API keys + +### Step 1: Register for reCAPTCHA + +1. Go to the [reCAPTCHA admin](https://www.google.com/recaptcha/admin) +2. Sign in with your Google account +3. Click _Create_ (+) + +### Step 2: Configure + +- **Label**: Your site name (for example: LCOJ) +- **reCAPTCHA type**: Select _reCAPTCHA v2_ > _"I'm not a robot" Checkbox_ +- **Domains**: Enter your domain (for example: `luyencode.net`) +- Accept the terms of service +- Click _Submit_ + +### Step 3: Get the keys + +Once it is created, you will receive: +- **Site key**: The public key +- **Secret key**: The private key + +## Installation + +### With Docker (recommended) + +**Step 1:** Add the following to `environment/site.env`: + +```env +RECAPTCHA_PUBLIC_KEY=your_site_key_here +RECAPTCHA_PRIVATE_KEY=your_secret_key_here +``` + +**Step 2:** Restart the site: + +```sh +cd lcoj-docker/dmoj +docker compose restart site +``` + +### With bare metal + +**Step 1:** Install the library: + +```sh +source lcojsite/bin/activate +pip3 install django-recaptcha2 +``` + +**Step 2:** Add the following to `local_settings.py`: + +```python +# reCAPTCHA keys +RECAPTCHA_PUBLIC_KEY = 'your_site_key_here' +RECAPTCHA_PRIVATE_KEY = 'your_secret_key_here' + +# Add to INSTALLED_APPS +INSTALLED_APPS += ( + 'snowpenguin.django.recaptcha2', +) +``` + +**Step 3:** Restart: + +```sh +supervisorctl restart site +``` + +## Verification + +1. Open the registration page +2. You should see the "I'm not a robot" checkbox +3. Try registering to test it + +## Advanced options + +### reCAPTCHA v3 + +reCAPTCHA v3 does not need a checkbox; it detects bots automatically. + +**Installation:** + +```sh +pip3 install django-recaptcha +``` + +**Configuration:** + +```python +RECAPTCHA_PUBLIC_KEY = 'your_v3_site_key' +RECAPTCHA_PRIVATE_KEY = 'your_v3_secret_key' +RECAPTCHA_REQUIRED_SCORE = 0.5 # Minimum score (0-1) + +INSTALLED_APPS += ( + 'django_recaptcha', +) +``` + +### Customizing the theme + +```python +RECAPTCHA_THEME = 'dark' # Or 'light' +``` + +### Test mode + +To test without an internet connection: + +```python +RECAPTCHA_TESTING = True # Use only during development +``` + +## Troubleshooting + +**reCAPTCHA does not appear:** +- Check the domains in the reCAPTCHA admin +- Check `RECAPTCHA_PUBLIC_KEY` +- Check the browser console for errors + +**It always reports an error:** +- Check `RECAPTCHA_PRIVATE_KEY` +- Check that the server has internet access +- Check the logs (Docker): `docker compose logs -f site` +- Check the logs (bare metal): `supervisorctl tail -f site` + +**Blocked while testing:** +- Use `RECAPTCHA_TESTING = True` during development +- Or add localhost to the domains in the reCAPTCHA admin + +## Security + +- Do not commit keys to git +- Store keys in environment variables or a separate file +- Rotate keys periodically +- Monitor the number of registrations to detect spam + +## Statistics + +View reCAPTCHA statistics in the [reCAPTCHA admin](https://www.google.com/recaptcha/admin): +- Number of requests +- Bot rate +- Success rate diff --git a/src/en/site/ssl_content_proxy.md b/src/en/site/ssl_content_proxy.md new file mode 100644 index 0000000..73a26c4 --- /dev/null +++ b/src/en/site/ssl_content_proxy.md @@ -0,0 +1,228 @@ +# SSL Proxy for User Content + +When the site is served over HTTPS but users embed images over HTTP, browsers block them (mixed content). An SSL proxy solves this problem. + +**Note:** This feature is optional and only needed if you allow users to embed images from external sources. + +## Installing Camo + +Camo is a proxy server that serves HTTP content over HTTPS. + +### Step 1: Install Node.js + +```sh +curl -sL https://deb.nodesource.com/setup_18.x | sudo -E bash - +apt install nodejs +``` + +### Step 2: Install Camo + +```sh +npm install -g camo +``` + +### Step 3: Generate a secret key + +```sh +openssl rand -hex 32 +``` + +Save this key; you will need it in a later step. + +### Step 4: Run Camo + +```sh +PORT=8081 CAMO_KEY="your_secret_key_here" camo +``` + +## Configuring LCOJ + +### With Docker + +Add to `environment/site.env`: + +```env +DMOJ_CAMO_URL=https://luyencode.net/camo +DMOJ_CAMO_KEY=your_secret_key_here +DMOJ_CAMO_EXCLUDE=luyencode.net,cdn.luyencode.net +``` + +### With bare metal + +Add to `local_settings.py`: + +```python +# Camo URL +DMOJ_CAMO_URL = "https://luyencode.net:8081" + +# Secret key (must match CAMO_KEY) +DMOJ_CAMO_KEY = "your_secret_key_here" + +# Domains that do not need proxying (your own domains) +DMOJ_CAMO_EXCLUDE = ["luyencode.net", "cdn.luyencode.net"] +``` + +## Configuring Nginx + +### Reverse proxy for Camo + +```nginx +location /camo/ { + proxy_pass http://localhost:8081/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; +} +``` + +### Restart + +**Docker:** + +```sh +docker compose restart nginx site +``` + +**Bare metal:** + +```sh +service nginx reload +supervisorctl restart site +``` + +## Running Camo with Supervisor + +Create the file `/etc/supervisor/conf.d/camo.conf`: + +```ini +[program:camo] +command=/usr/bin/camo +directory=/tmp +user=camo +environment=PORT="8081",CAMO_KEY="your_secret_key_here" +autostart=true +autorestart=true +redirect_stderr=true +stdout_logfile=/var/log/camo.log +``` + +Start it: + +```sh +supervisorctl update +supervisorctl start camo +``` + +## How It Works + +### Without Camo + +``` +User -> HTTPS -> Website -> HTTP image -> ❌ Blocked +``` + +### With Camo + +``` +User -> HTTPS -> Website -> HTTPS -> Camo -> HTTP image -> ✓ OK +``` + +### Example + +**Original URL:** +``` +http://example.com/image.png +``` + +**URL through Camo:** +``` +https://luyencode.net/camo/abc123.../image.png +``` + +## Verification + +### Test Camo + +```sh +curl http://localhost:8081/ +``` + +If you see "hwhat", Camo is running. + +### Test the proxy + +1. Post a comment containing an HTTP image +2. Inspect the page source +3. The image URL should go through Camo + +## Troubleshooting + +**Images do not load:** +- Check that Camo is running +- Check `DMOJ_CAMO_URL` and `DMOJ_CAMO_KEY` +- View Camo logs (Docker): `docker compose logs -f camo` (if running in Docker) +- View Camo logs (bare metal): `supervisorctl tail -f camo` + +**Mixed content warning:** +- Check that `DMOJ_CAMO_URL` uses HTTPS +- Check the nginx config + +**Images are blocked:** +- Some sites block proxies +- There is no workaround; users must upload the image to the server + +## Security + +### Size limit + +Add to the Camo config: + +```sh +CAMO_MAX_SIZE=5242880 # 5MB +``` + +### File type limit + +Allow images only: + +```sh +CAMO_ALLOWED_CONTENT_TYPES="image/*" +``` + +### Rate limiting + +Use nginx to limit requests: + +```nginx +location /camo/ { + limit_req zone=camo burst=10; + proxy_pass http://localhost:8081/; +} +``` + +## Optimization + +### Cache + +Camo caches automatically. To increase the cache time: + +```sh +CAMO_TIMING_ALLOW_ORIGIN="*" +CAMO_HEADER_VIA="Camo" +``` + +### CDN + +If you have a CDN, put Camo behind it: + +``` +User -> CDN -> Camo -> HTTP image +``` + +## Notes + +- Camo uses a lot of bandwidth because it proxies every image +- Limit file size and file type +- Do not proxy video (too heavy) +- Encourage users to upload images to the server instead of linking to external ones diff --git a/src/en/site/texoid.md b/src/en/site/texoid.md new file mode 100644 index 0000000..d52ee8f --- /dev/null +++ b/src/en/site/texoid.md @@ -0,0 +1,212 @@ +# Rendering LaTeX Diagrams + +LCOJ can render TikZ/PGF diagrams in problem statements, which lets you draw graphs, geometric figures, and complex diagrams. + +**Note:** +- This feature is optional and advanced +- This guide covers bare metal installs +- With Docker, you need to set up Texoid separately +- If you only need math formulas, use [Mathoid](/en/site/mathoid) + +## Installing Texoid + +Texoid renders TikZ diagrams as images. + +### Step 1: Install LaTeX + +```sh +apt update +apt install texlive-full +``` + +**Note:** `texlive-full` is very large (~5GB). For a lighter install: + +```sh +apt install texlive-latex-base texlive-latex-extra texlive-pictures +``` + +### Step 2: Clone Texoid + +```sh +git clone https://github.com/DMOJ/texoid.git +cd texoid +``` + +### Step 3: Install dependencies + +```sh +python3 -m venv env +source env/bin/activate +pip install -e . +``` + +### Step 4: Run Texoid + +```sh +env/bin/texoid --port=8886 +``` + +## Configuring LCOJ + +Add the following to `local_settings.py`: + +```python +# Texoid URL +TEXOID_URL = 'http://localhost:8886' + +# Cache directory +TEXOID_CACHE_ROOT = '/home/lcoj/texoid_cache' + +# Cache URL +TEXOID_CACHE_URL = '//luyencode.net/texoid/' +``` + +### Configure Nginx + +```nginx +location /texoid/ { + alias /home/lcoj/texoid_cache/; + expires 1y; + add_header Cache-Control "public, immutable"; +} +``` + +### Create the cache directory + +```sh +mkdir -p /home/lcoj/texoid_cache +chown www-data:www-data /home/lcoj/texoid_cache +chmod 755 /home/lcoj/texoid_cache +``` + +### Restart + +**Docker:** + +```sh +docker compose restart site nginx +``` + +**Bare metal:** + +```sh +supervisorctl restart site +service nginx reload +``` + +## Usage + +### Basic syntax + +Use `$$tikz...$$` to draw a diagram: + +```markdown +$$tikz +\begin{tikzpicture} +\draw (0,0) -- (2,0) -- (2,2) -- (0,2) -- cycle; +\end{tikzpicture} +$$ +``` + +### Example: Drawing a graph + +```markdown +$$tikz +\begin{tikzpicture}[node distance=2cm] +\node[circle,draw] (1) {1}; +\node[circle,draw] (2) [right of=1] {2}; +\node[circle,draw] (3) [below of=1] {3}; +\draw[->] (1) -- (2); +\draw[->] (1) -- (3); +\draw[->] (2) -- (3); +\end{tikzpicture} +$$ +``` + +### Example: Drawing a tree + +```markdown +$$tikz +\begin{tikzpicture}[level distance=1.5cm, + level 1/.style={sibling distance=3cm}, + level 2/.style={sibling distance=1.5cm}] +\node[circle,draw] {1} + child {node[circle,draw] {2} + child {node[circle,draw] {4}} + child {node[circle,draw] {5}} + } + child {node[circle,draw] {3} + child {node[circle,draw] {6}} + child {node[circle,draw] {7}} + }; +\end{tikzpicture} +$$ +``` + +### Example: Geometry + +```markdown +$$tikz +\begin{tikzpicture} +\coordinate (A) at (0,0); +\coordinate (B) at (4,0); +\coordinate (C) at (2,3); +\draw (A) -- (B) -- (C) -- cycle; +\node[below left] at (A) {A}; +\node[below right] at (B) {B}; +\node[above] at (C) {C}; +\end{tikzpicture} +$$ +``` + +## Running with Supervisor + +Create the file `/etc/supervisor/conf.d/texoid.conf`: + +```ini +[program:texoid] +command=/path/to/texoid/env/bin/texoid --port=8886 +directory=/path/to/texoid +user=texoid +autostart=true +autorestart=true +redirect_stderr=true +stdout_logfile=/var/log/texoid.log +``` + +Start it: + +```sh +supervisorctl update +supervisorctl start texoid +``` + +## Troubleshooting + +**Diagrams do not render:** +- Check that Texoid is running +- Check that LaTeX is installed: `pdflatex --version` +- Check the Texoid logs + +**Compilation errors:** +- Check your TikZ syntax +- Test it on [Overleaf](https://www.overleaf.com/) +- Install any missing LaTeX packages + +**Timeouts:** +- Complex diagrams can take a while to render +- Increase the timeout in the Texoid config +- Simplify the diagram + +## TikZ learning resources + +- [TikZ Tutorial](https://www.overleaf.com/learn/latex/TikZ_package) +- [TikZ Examples](https://texample.net/tikz/examples/) +- [PGF Manual](http://mirrors.ctan.org/graphics/pgf/base/doc/pgfmanual.pdf) + +## Notes + +- TikZ is complex and takes time to learn +- For simple figures, use regular images instead +- Caching speeds up loading +- Avoid overly complex diagrams diff --git a/src/en/site/updating.md b/src/en/site/updating.md new file mode 100644 index 0000000..0e04e4e --- /dev/null +++ b/src/en/site/updating.md @@ -0,0 +1,302 @@ +# Updating LCOJ + +LCOJ is updated regularly with new features and bug fixes. This guide explains how to update a Docker-based installation. + +**Warning:** Always back up your data before updating! + +## Back up before updating + +### Back up the database + +```sh +docker exec lcoj_mysql mysqldump -u root -p lcoj | gzip > backup_$(date +%Y%m%d).sql.gz +``` + +### Back up media and problems + +```sh +tar -czf media_backup_$(date +%Y%m%d).tar.gz dmoj/media/ +tar -czf problems_backup_$(date +%Y%m%d).tar.gz dmoj/problems/ +``` + +## Update steps + +### Step 1: Pull the latest source code + +```sh +cd lcoj-docker/dmoj +git pull origin master +git submodule update --init --recursive +``` + +**Note:** `git submodule update` is essential: it updates the code in `repo/`. + +### Step 2: Review the changes + +```sh +git log --oneline -10 +git diff HEAD~1 docker-compose.yml +``` + +Check whether anything changed in docker-compose.yml or the environment files. + +### Step 3: Update the environment (if needed) + +If new environment variables were added, update your `environment/*.env` files. + +Compare against the example file: + +```sh +diff environment/site.env environment/site.env.example +``` + +### Step 4: Rebuild the images + +```sh +docker compose build +``` + +Or rebuild only the services that need it: + +```sh +docker compose build site celery bridged wsevent +``` + +### Step 5: Run migrations + +```sh +./scripts/migrate +``` + +Check that there are no errors: + +```sh +./scripts/manage.py check +``` + +### Step 6: Update static files + +```sh +./scripts/copy_static +``` + +### Step 7: Restart services + +```sh +docker compose up -d --no-deps site celery bridged wsevent +``` + +**What the flags do:** +- `--no-deps`: Does not restart dependencies (db, redis) +- Only the services whose code changed are restarted + +## Automation script + +You can write a script to automate the update process: + +**File: `update.sh`** + +```bash +#!/bin/bash + +set -e # Exit on error + +echo "=== Starting LCOJ update ===" +echo + +# Backup database +echo "1. Backup database..." +docker exec lcoj_mysql mysqldump -u root -p${MYSQL_ROOT_PASSWORD} lcoj | gzip > backup_$(date +%Y%m%d_%H%M%S).sql.gz + +# Backup media +echo "2. Backup media files..." +tar -czf media_backup_$(date +%Y%m%d_%H%M%S).tar.gz dmoj/media/ + +# Pull new code +echo "3. Pulling new source code..." +git pull origin master +git submodule update --init --recursive + +# Rebuild images +echo "4. Rebuild Docker images..." +docker compose build site celery bridged wsevent + +# Run migrations +echo "5. Running migrations..." +./scripts/migrate + +# Update static files +echo "6. Updating static files..." +./scripts/copy_static + +# Restart services +echo "7. Restart services..." +docker compose up -d --no-deps site celery bridged wsevent + +# Check status +echo "8. Checking status..." +docker compose ps + +echo +echo "=== Update complete! ===" +echo "Check logs: docker compose logs -f site" +``` + +Make it executable: + +```sh +chmod +x update.sh +``` + +Run the script: + +```sh +cd lcoj-docker/dmoj +./update.sh +``` + +## Troubleshooting + +### Migration errors + +If `migrate` fails: + +```sh +# List unapplied migrations +./scripts/manage.py showmigrations + +# Run a specific migration +./scripts/manage.py migrate + +# Fake a migration (if it was already applied manually) +./scripts/manage.py migrate --fake +``` + +### Static file errors + +If static files don't load: + +```sh +# Delete old static files +docker compose exec site rm -rf /assets/* + +# Collect them again +./scripts/copy_static + +# Restart nginx +docker compose restart nginx +``` + +### Dependency errors + +If you get errors about Python libraries: + +```sh +# Rebuild the image from scratch (no cache) +docker compose build --no-cache site celery + +# Restart services +docker compose up -d site celery +``` + +### Container won't start + +```sh +# View detailed logs +docker compose logs --tail=100 site + +# View the exit code +docker inspect lcoj_site | grep ExitCode + +# Try starting it with live logs +docker compose up site +``` + +## Rollback + +If the update causes problems, you can roll back: + +### Roll back the code + +```sh +# Go back to the previous commit +git reset --hard HEAD~1 +git submodule update --init --recursive + +# Or go back to a specific commit +git reset --hard +git submodule update --init --recursive + +# Rebuild images +docker compose build site celery bridged wsevent + +# Restart services +docker compose up -d --no-deps site celery bridged wsevent +``` + +### Restore the database + +```sh +# Stop the site to avoid conflicts +docker compose stop site celery + +# Restore from backup +gunzip < backup_20240101_120000.sql.gz | docker exec -i lcoj_mysql mysql -u root -p lcoj + +# Start again +docker compose start site celery +``` + +### Restore media files + +```sh +tar -xzf media_backup_20240101_120000.tar.gz +docker compose restart site nginx +``` + +## Post-update checks + +### Check services + +```sh +# View status +docker compose ps + +# View logs +docker compose logs -f --tail=50 site +docker compose logs -f --tail=50 celery +``` + +### Check functionality + +- Open the website and check the interface +- Log in with an admin account +- Try submitting a solution +- Check the admin site +- Test the judge bridge: `docker compose logs bridged` + +### Check performance + +```sh +# Resource usage +docker stats + +# Response time +curl -w "@curl-format.txt" -o /dev/null -s http://localhost +``` + +**File: `curl-format.txt`** + +``` +time_namelookup: %{time_namelookup}\n +time_connect: %{time_connect}\n +time_starttransfer: %{time_starttransfer}\n +time_total: %{time_total}\n +``` + +## Notes + +- Schedule updates for off-peak hours +- Notify users of the maintenance window in advance +- Always back up before updating +- Test in a development environment before updating production diff --git a/src/en/site/user_data_download.md b/src/en/site/user_data_download.md new file mode 100644 index 0000000..f856de3 --- /dev/null +++ b/src/en/site/user_data_download.md @@ -0,0 +1,175 @@ +# User Data Download + +LCOJ lets users download their own data, including comments and submissions. + +This feature is disabled by default. To enable it, configure it in `local_settings.py`. + +## Configuration + +### With Docker (recommended) + +The cache directory is already set up in the `userdatacache` Docker volume. + +Just add the following to `environment/site.env`: + +```env +DMOJ_USER_DATA_DOWNLOAD=True +DMOJ_USER_DATA_CACHE=/userdatacache/ +DMOJ_USER_DATA_INTERNAL=/userdatacache +``` + +Restart the services: + +```sh +docker compose restart site celery +``` + +### With bare metal + +Configure it in `local_settings.py`: + +```python +DMOJ_USER_DATA_DOWNLOAD = True +DMOJ_USER_DATA_CACHE = '/home/dmoj-uwsgi/userdatacache' +DMOJ_USER_DATA_INTERNAL = '/userdatacache' +DMOJ_USER_DATA_DOWNLOAD_RATELIMIT = datetime.timedelta(days=1) +``` + +### Configure Nginx (if needed) + +**With Docker:** The Nginx config is already set up; no changes needed. + +**With bare metal:** Add the following to your nginx config: + +```nginx +location /userdatacache { + internal; + root /home/dmoj-uwsgi/; +} +``` + +### Restart + +**With Docker:** + +```sh +docker compose restart site celery nginx +``` + +**With bare metal:** + +**Docker:** + +```sh +docker compose restart site nginx +``` + +**Bare metal:** + +```sh +supervisorctl restart site +service nginx reload +``` + +## Cleaning up old files + +Data files are not deleted automatically. Clean up old files periodically. + +### With Docker + +```sh +# Run manually +docker compose exec site find /userdatacache/ -type f -mtime +2 -delete + +# Or create a cron job on the host +0 */4 * * * docker compose -f /path/to/lcoj-docker/dmoj/docker-compose.yml exec -T site find /userdatacache/ -type f -mtime +2 -delete +``` + +### With bare metal + +```sh +crontab -e +``` + +Add: + +``` +0 */4 * * * find /home/dmoj-uwsgi/userdatacache/ -type f -mtime +2 -delete +``` + +**Explanation:** +- `0 */4 * * *`: Runs at minute 0 every 4 hours +- `find ... -mtime +2`: Finds files older than 2 days +- `-delete`: Deletes the files found + +**Note:** Adjust the schedule to match `RATELIMIT`. + +## Usage + +Once configured, users can: + +1. Open the _Edit profile_ page +2. Find the _Data download_ section +3. Choose the data to download (comments, submissions) +4. Click _Request download_ +5. Wait for the system to generate the file (this may take a few minutes) +6. Download the file + +## Data format + +### Comments (comments.json) + +```json +[ + { + "id": 123, + "page": "problem/APLUSB", + "time": "2024-01-01T00:00:00Z", + "score": 5, + "body": "Comment content" + } +] +``` + +### Submissions (submissions.json) + +```json +[ + { + "id": 123456, + "problem": "APLUSB", + "date": "2024-01-01T00:00:00Z", + "language": "CPP17", + "result": "AC", + "points": 100, + "time": 0.1, + "memory": 2048, + "source": "// Source code" + } +] +``` + +## Troubleshooting + +**The file is not generated:** +- Check the cache directory permissions +- Check Celery (Docker): `docker compose ps celery` +- Check the logs (Docker): `docker compose logs -f celery` +- Check Celery (bare metal): `supervisorctl status celery` +- Check the logs (bare metal): `supervisorctl tail -f celery` + +**The file cannot be downloaded:** +- Check the nginx config +- Check the `DMOJ_USER_DATA_INTERNAL` path +- Check the nginx logs: `tail -f /var/log/nginx/error.log` + +**Rate limit errors:** +- Users must wait for the period configured in `RATELIMIT` +- The default is 1 day + +## Security + +- Only the owning user can download their data file +- Files have random, hard-to-guess names +- Clean up old files regularly +- Do not keep files on the server for too long diff --git a/src/en/site/uwsgi.md b/src/en/site/uwsgi.md new file mode 100644 index 0000000..4d5f7c7 --- /dev/null +++ b/src/en/site/uwsgi.md @@ -0,0 +1,258 @@ +# uWSGI + +**Important:** This page applies only to bare-metal installations (without Docker). + +**If you use Docker (recommended), see [Installing with Docker](/en/site/installation).** + +--- + +uWSGI is the application server that runs Django, replacing `runserver` in production. + +## Installation + +uWSGI is installed as part of [setting up the site](/en/site/installation). + +If it is missing: + +```sh +source lcojsite/bin/activate +pip3 install uwsgi +``` + +## Configuration + +### The uwsgi.ini file + +Create a `uwsgi.ini` file in the site directory: + +```ini +[uwsgi] +# Django project +chdir = /home/lcoj/site +module = dmoj.wsgi:application + +# Virtual environment +home = /home/lcoj/lcojsite + +# Process +master = true +processes = 4 +threads = 2 + +# Socket +socket = /tmp/lcoj-site.sock +chmod-socket = 666 +vacuum = true + +# Logging +logto = /var/log/uwsgi/lcoj-site.log + +# Performance +max-requests = 5000 +harakiri = 60 +``` + +**Explanation:** +- `chdir`: Project directory +- `module`: WSGI module +- `home`: Virtual environment +- `processes`: Number of worker processes +- `threads`: Number of threads per process +- `socket`: Unix socket that nginx connects to +- `max-requests`: Restart a worker after N requests +- `harakiri`: Timeout (seconds) + +### Create the log directory + +```sh +mkdir -p /var/log/uwsgi +chown lcoj:lcoj /var/log/uwsgi +``` + +## Running with Supervisor + +File `/etc/supervisor/conf.d/site.conf`: + +```ini +[program:site] +command=/home/lcoj/lcojsite/bin/uwsgi --ini /home/lcoj/site/uwsgi.ini +directory=/home/lcoj/site +user=lcoj +autostart=true +autorestart=true +redirect_stderr=true +stdout_logfile=/var/log/uwsgi/site-supervisor.log +``` + +Start it: + +```sh +supervisorctl update +supervisorctl start site +``` + +## Nginx configuration + +Nginx config file: + +```nginx +upstream lcoj { + server unix:///tmp/lcoj-site.sock; +} + +server { + listen 80; + server_name luyencode.net; + + location / { + uwsgi_pass lcoj; + include uwsgi_params; + } + + location /static/ { + alias /home/lcoj/site/staticfiles/; + } + + location /media/ { + alias /home/lcoj/site/media/; + } +} +``` + +Reload nginx: + +```sh +service nginx reload +``` + +## Optimization + +### Number of processes + +Formula: `processes = (CPU cores × 2) + 1` + +Example: 4 cores → 9 processes + +```ini +processes = 9 +``` + +### Threads + +Increase threads for I/O-heavy workloads: + +```ini +threads = 4 +``` + +### Buffer size + +Increase it if you handle large requests: + +```ini +buffer-size = 32768 +``` + +### Lazy apps + +Load the app after forking (saves RAM): + +```ini +lazy-apps = true +``` + +### Offload + +Offload static files: + +```ini +offload-threads = 4 +``` + +## Monitoring + +### Stats server + +Add to `uwsgi.ini`: + +```ini +stats = 127.0.0.1:9191 +``` + +View stats: + +```sh +uwsgitop 127.0.0.1:9191 +``` + +Install uwsgitop: + +```sh +pip3 install uwsgitop +``` + +### Log + +Follow the log in real time: + +```sh +tail -f /var/log/uwsgi/lcoj-site.log +``` + +Or via Supervisor: + +```sh +supervisorctl tail -f site +``` + +## Troubleshooting + +**uWSGI won't start:** +- Check the `uwsgi.ini` syntax +- Check the paths +- View the log: `supervisorctl tail -f site` + +**502 Bad Gateway:** +- Check that uWSGI is running +- Check that the socket file exists: `ls -la /tmp/lcoj-site.sock` +- Check the socket permissions + +**Slow response:** +- Increase `processes` and `threads` +- Check database performance +- Check the `harakiri` timeout + +**Memory leak:** +- Lower `max-requests` +- Check the code for leaks +- Restart periodically + +## Reload + +### Graceful reload + +No downtime: + +```sh +supervisorctl restart site +``` + +Or: + +```sh +touch /home/lcoj/site/uwsgi.ini +``` + +### Force reload + +```sh +killall -9 uwsgi +supervisorctl start site +``` + +## Notes + +- Do not use `runserver` in production +- Monitor RAM and CPU usage +- Back up your config before changing it +- Test on staging before deploying to production diff --git a/src/features/library.md b/src/features/library.md new file mode 100644 index 0000000..471a34a --- /dev/null +++ b/src/features/library.md @@ -0,0 +1,474 @@ +# Thư viện đề thi + +Thư viện đề thi (`/library/`) là nơi LCOJ lưu các đề thi chính thức dưới dạng PDF: đề học sinh giỏi, Tin học trẻ, đề vào 10 chuyên Tin… Người đọc lật từng trang đề ngay trên trình duyệt bằng trình xem dạng sách (flipbook). Nếu đề đã gắn với một kỳ thi, người đọc bấm một nút là vào làm bài và được chấm tự động. + +Trang này gồm hai phần: + +- **[Phần 1 – Dành cho người đọc](#readers)**: tìm đề, lọc đề, đọc đề bằng flipbook. +- **[Phần 2 – Dành cho quản trị viên](#admins)**: thêm đề, tải PDF lên, hẹn giờ đăng, quản lý danh mục, lưu trữ và xử lý sự cố. + +```mermaid +flowchart TD + A["Admin thêm đề + PDF"] --> B{"Hiển thị công khai?"} + B -- "Không" --> H["Ẩn (404)"] + B -- "Có" --> C{"Đã đến ngày công bố?"} + C -- "Chưa" --> H + C -- "Rồi" --> D["Có trong /library/"] + D --> E["Trang đề /library/slug"] + E --> F["Đọc bằng flipbook"] + E --> G["Làm bài ngay (nếu có kỳ thi)"] +``` + +--- + +## Phần 1 – Dành cho người đọc {#readers} + +### 1.1. Tìm đề trong thư viện {#browse} + +⏱ 2 phút · 👤 Học sinh, giáo viên, khách · 🔑 Không cần đăng nhập + +#### Trước khi bắt đầu + +- Thư viện mở công khai, không cần tài khoản. +- Mở trực tiếp địa chỉ `/library/` (ví dụ `https://luyencode.net/library/`). Menu điều hướng mặc định của LCOJ **không** có sẵn mục Thư viện; tuỳ quản trị viên có thêm vào hay không. + +#### Các bước + +1. Mở `/library/`. Đầu trang có tiêu đề **"Thư viện đề thi"** cùng bốn con số: "đề thi đã đăng", "nhóm kỳ thi", "tỉnh/thành phố" và "đề có chấm tự động". +2. Gõ từ khoá vào ô **"Tìm theo tên đề hoặc mô tả..."** rồi bấm **"Tìm kiếm"**. Hệ thống tìm trong cả tên đề lẫn phần mô tả, không phân biệt hoa thường. +3. Chọn một tab nhóm đề ngay dưới ô tìm kiếm, ví dụ "HSG Tỉnh/TP" hay "Đề vào 10 chuyên". Tab **"Tất cả"** hiện mọi đề. Mỗi tab có kèm số đề; nhóm nào chưa có đề được đăng thì không hiện tab. +4. Chọn tỉnh/thành trong ô **"Tất cả tỉnh/thành phố"**. Danh sách tự tải lại ngay khi bạn chọn. +5. Chọn năm trong ô **"Tất cả năm"**. Ô này chỉ liệt kê những năm đang có đề. +6. Xem kết quả ở bên phải thanh lọc (ví dụ "Tìm thấy 12 đề thi"). Muốn bỏ hết bộ lọc thì bấm **"Xóa bộ lọc"**. +7. Kéo xuống cuối trang để chuyển trang. Mỗi trang hiện 12 đề, mới nhất (theo ngày công bố) ở trên. + +Các bộ lọc cộng dồn với nhau: tìm kiếm, nhóm, tỉnh và năm có thể dùng cùng lúc, và vẫn được giữ khi bạn chuyển trang. + +::: tip Chia sẻ một danh sách đã lọc +Bộ lọc nằm ngay trên địa chỉ trang, nên bạn có thể gửi link cho người khác: + +| Tham số | Ý nghĩa | Ví dụ | +|---|---|---| +| `q` | Từ khoá tìm trong tên đề và mô tả | `?q=tin học trẻ` | +| `category` | Slug của nhóm đề | `?category=hsg-tinh-tp` | +| `province` | Mã tỉnh/thành | `?province=ha_noi` | +| `year` | Năm thi (phải là số; giá trị khác bị bỏ qua) | `?year=2024` | + +Ví dụ: `/library/2?category=de-vao-10-chuyen&province=tp_ho_chi_minh` là trang 2 của đề vào 10 chuyên tại TP. Hồ Chí Minh. +::: + +#### Đọc thẻ đề + +Mỗi đề trong danh sách là một thẻ gồm: + +- Nhãn nhóm đề (có màu) và năm thi. +- Tên đề. Bấm vào tên (hoặc chỗ bất kỳ trên thẻ) để mở trang đề. +- Nhãn tỉnh/thành, và nhãn **"Đề đầy đủ bản PDF"** nếu đề có tệp PDF. +- Đoạn trích mô tả, tối đa 140 ký tự. +- Nút **"Làm bài ngay"** nếu đề có kỳ thi luyện tập, ngược lại là nhãn **"Mới có đề, chưa chấm"**. + +#### Kiểm tra kết quả + +- Số trong dòng "Tìm thấy N đề thi" khớp với số thẻ trên các trang. +- Khi chọn một tab, tab đó được tô đậm và địa chỉ trang có thêm `category=…`. + +#### Xử lý sự cố + +| Triệu chứng | Cách khắc phục | +|---|---| +| "Không tìm thấy đề thi." | Bấm "Xóa bộ lọc", hoặc tìm bằng từ khoá ngắn hơn. | +| Không thấy tab của nhóm mình cần | Nhóm đó chưa có đề nào được đăng công khai. | +| Ô năm không có năm cần tìm | Chưa có đề công khai nào của năm đó. | +| Đề mình biết là có nhưng không hiện | Có thể đề đang bị ẩn hoặc chưa đến ngày công bố. Hãy liên hệ quản trị viên. | + +#### Tiếp theo + +Mở một đề để đọc: xem [1.2. Đọc đề bằng flipbook](#flipbook). + +### 1.2. Đọc đề bằng flipbook {#flipbook} + +⏱ 5 phút · 👤 Học sinh, giáo viên, khách · 🔑 Không cần đăng nhập + +#### Trước khi bắt đầu + +- Dùng trình duyệt hiện đại có bật JavaScript (Chrome, Edge, Firefox, Safari bản mới). +- Trang đề có địa chỉ dạng `/library/`, ví dụ `/library/hsg-ha-noi-2024`. + +#### Các bước + +1. Bấm vào một thẻ đề trong thư viện để mở trang đề. +2. Xem phần đầu trang: nhóm đề, tỉnh/thành, năm và dòng "Đăng ngày …". +3. Cuộn xuống khung flipbook. Trình xem chỉ bắt đầu tải khi khung sắp lọt vào màn hình; trong lúc chờ sẽ có dòng **"Đang tải tài liệu…"**. Trang 1 hiện trước, các trang sau được xử lý dần ở nền. +4. Lật trang bằng cách bấm vào mép trái/phải của trang, hoặc kéo góc trang như lật sách thật. Trên điện thoại thì vuốt. +5. Dùng thanh công cụ ở góc trên bên phải khung đề: + + | Nút (biểu tượng) | Tác dụng | + |---|---| + | Kính lúp dấu trừ | Thu nhỏ, mỗi lần 0,25×, nhỏ nhất 1× | + | Kính lúp dấu cộng | Phóng to, mỗi lần 0,25×, lớn nhất 2,5×. Khi đã phóng to, khung có thanh cuộn để xem phần bị khuất | + | Loa | Bật/tắt tiếng lật trang. Lựa chọn được trình duyệt ghi nhớ cho các lần sau | + | Mũi tên mở rộng | Xem toàn màn hình (nền tối). Bấm lại hoặc nhấn `Esc` để thoát | + +6. Đọc phần mô tả bên dưới flipbook (nếu có). +7. Nếu đề có kỳ thi, bấm **"Làm bài ngay"** (ở đầu trang hoặc ở khung **"Bạn đã sẵn sàng tự làm thử chưa?"** cuối trang) để vào kỳ thi, nộp bài và nhận kết quả. +8. Bấm **"Quay lại thư viện"** để trở về danh sách. + +::: details Flipbook hiển thị thế nào trên máy tính và điện thoại? +- **Máy tính, màn hình rộng**: hiện hai trang cạnh nhau như một cuốn sách mở. Trang đầu và trang cuối (bìa) hiện riêng một trang, căn giữa. +- **Màn hình hẹp** (khung đề hẹp hơn khoảng 600px, thường là điện thoại): ưu tiên hiện một trang mỗi lần. +- Kích thước sách được tính cho vừa chiều cao cửa sổ. Khi vào hoặc thoát toàn màn hình, sách được dựng lại cho vừa màn hình mới. +- Flipbook **không có phím tắt**; hãy dùng chuột, cảm ứng và các nút trên thanh công cụ. +- Chú thích khi rê chuột lên các nút thanh công cụ hiện đang là tiếng Anh ("Zoom out", "Zoom in", "Toggle sound", "Fullscreen"). +::: + +::: warning Không có nút tải PDF +Trang đề không có nút tải về. Liên kết **"Tải PDF"** chỉ xuất hiện khi trình duyệt tắt JavaScript hoặc không tải được thư viện hiển thị. Nếu không đọc được bản PDF, flipbook hiện liên kết **"Không thể tải xem trước — tải PDF"** để bạn mở thẳng tệp. +::: + +#### Kiểm tra kết quả + +- Thấy trang 1 của đề và thanh công cụ ở góc trên bên phải. +- Lật trang có hiệu ứng và tiếng lật (nếu chưa tắt tiếng). + +#### Xử lý sự cố + +| Triệu chứng | Cách khắc phục | +|---|---| +| Kẹt ở "Đang tải tài liệu…" | Tệp lớn hoặc mạng chậm. Đợi thêm, hoặc tải lại trang. | +| Thấy link "Tải PDF" thay vì sách | Trình duyệt không tải được thư viện hiển thị (JavaScript bị tắt, bị chặn, trình duyệt quá cũ). Bấm link để mở PDF, hoặc đổi trình duyệt. | +| Thấy "Không thể tải xem trước — tải PDF" | Không đọc được tệp PDF. Bấm link để mở trực tiếp, rồi báo quản trị viên. | +| Trang đề báo 404 | Đề đã bị ẩn, chưa đến ngày công bố, hoặc sai địa chỉ. | +| Nút toàn màn hình không có tác dụng | Một số trình duyệt di động (ví dụ Safari trên iPhone) không cho một phần trang web chiếm toàn màn hình. Hãy xoay ngang máy hoặc phóng to. | +| Không có tiếng lật trang | Kiểm tra nút loa trên thanh công cụ và âm lượng máy. | +| Có trang đề nhưng không có flipbook | Đề chưa có tệp PDF, chỉ có mô tả. | +| Bấm "Làm bài ngay" bị báo lỗi hoặc 404 | Kỳ thi gắn với đề có thể là kỳ thi riêng tư bạn không có quyền xem. Hãy liên hệ quản trị viên. | + +#### Tiếp theo + +- Luyện tập trong kỳ thi gắn với đề qua nút "Làm bài ngay". +- Quay lại `/library/` để tìm đề cùng nhóm, cùng tỉnh hoặc cùng năm. + +--- + +## Phần 2 – Dành cho quản trị viên {#admins} + +Toàn bộ việc quản lý thư viện làm trong trang quản trị Django (`/admin/`), mục **Online Judge**: + +| Mục trong admin (vi) | Mục trong admin (en) | Địa chỉ | Model | +|---|---|---|---| +| Tài nguyên | Resources | `/admin/judge/examstatement/` | `ExamStatement` (một đề) | +| Danh mục đề thi | Exam categories | `/admin/judge/examcategory/` | `ExamCategory` (nhóm đề) | + +::: warning Tên mục dễ nhầm +Trong admin, đề thi mang tên **"Tài nguyên"** (tiếng Anh: "Resources"), không phải "Đề thi". +::: + +### 2.1. Cấp quyền quản lý thư viện {#permissions} + +⏱ 5 phút · 👤 Quản trị viên hệ thống · 🔑 Superuser + +#### Trước khi bắt đầu + +- Người được cấp quyền phải có tài khoản bật **staff status** mới vào được `/admin/`. +- Thư viện không có quyền riêng, chỉ dùng bốn quyền mặc định của Django cho mỗi model (xem [Hệ thống phân quyền](/site/permission_system)). + +#### Các bước + +1. Vào `/admin/auth/group/` và tạo nhóm, ví dụ "Library editors". +2. Thêm các quyền cần thiết cho nhóm: + + | Quyền (codename) | Cho phép | + |---|---| + | `judge.view_examstatement` / `judge.add_examstatement` / `judge.change_examstatement` / `judge.delete_examstatement` | Xem / thêm / sửa / xoá đề | + | `judge.view_examcategory` / `judge.add_examcategory` / `judge.change_examcategory` / `judge.delete_examcategory` | Xem / thêm / sửa / xoá nhóm đề | + +3. Thêm người dùng vào nhóm vừa tạo. +4. Đảm bảo tài khoản của họ có bật staff status. + +::: tip +Người soạn đề thường chỉ cần quyền với đề, cùng `judge.view_examcategory` để chọn nhóm. Chỉ nên cho quyền sửa/xoá nhóm với người quản lý chung. +::: + +#### Kiểm tra kết quả + +- Người được cấp quyền đăng nhập `/admin/` và thấy mục "Tài nguyên" trong phần Online Judge. + +#### Xử lý sự cố + +| Triệu chứng | Cách khắc phục | +|---|---| +| Không vào được `/admin/` | Bật staff status cho tài khoản. | +| Không thấy mục "Tài nguyên" | Thiếu quyền `view`/`change` trên `examstatement`. | +| Ô "Contest" không tìm thấy kỳ thi cần gắn | Ô này chỉ liệt kê những kỳ thi mà chính người đang soạn xem được. Cần cấp quyền xem kỳ thi đó (ví dụ `see_private_contest`). | + +#### Tiếp theo + +[2.2. Thêm một đề thi](#add-exam). + +### 2.2. Thêm một đề thi {#add-exam} + +⏱ 5–10 phút · 👤 Quản trị viên, người soạn đề · 🔑 `judge.add_examstatement` + +#### Trước khi bắt đầu + +- Chuẩn bị tệp PDF của đề: đuôi phải là `.pdf`, dung lượng **tối đa 5 MB** (`PDF_STATEMENT_MAX_FILE_SIZE = 5242880`). +- Nếu muốn có nút "Làm bài ngay", hãy tạo kỳ thi luyện tập trước. +- Kiểm tra nhóm đề phù hợp đã có chưa (xem [2.4](#categories)). + +#### Các bước + +1. Vào `/admin/judge/examstatement/` và bấm nút thêm mới. +2. Nhập **Tiêu đề** (tối đa 100 ký tự). Ô **Slug** tự điền theo tiêu đề (đã bỏ dấu). +3. Sửa lại **Slug** nếu cần. Slug phải là duy nhất, tối đa 50 ký tự, và sẽ thành địa chỉ trang đề `/library/`. +4. Chọn **Nhóm** (nhóm đề, bắt buộc). +5. Chọn **Tỉnh/thành phố** nếu là đề của một địa phương, không thì để trống. +6. Nhập **Năm** thi (không bắt buộc, từ 1990 đến năm sau năm hiện tại). +7. Viết **Mô tả** bằng Markdown (không bắt buộc). +8. Tìm và chọn kỳ thi luyện tập trong ô **Contest** (gõ mã hoặc tên kỳ thi). +9. Tick/bỏ tick **Hiển thị công khai**. +10. Đặt **Publish on** (ngày công bố) bằng bộ chọn ngày giờ. Để trống thì lấy thời điểm lưu. +11. Chọn tệp ở ô **Tệp PDF**. +12. Lưu. + +Ý nghĩa từng trường: + +| Trường (vi) | Trường (en) | Bắt buộc | Ý nghĩa | +|---|---|---|---| +| Tiêu đề | Title | Có | Tên đề, hiện trên thẻ, trang đề và tiêu đề tab trình duyệt. Tối đa 100 ký tự. | +| Slug | Slug | Có | Địa chỉ trang đề `/library/`. Duy nhất, tối đa 50 ký tự, chỉ gồm chữ không dấu, số, `-`, `_`. Tự điền từ tiêu đề. | +| Nhóm | Category | Có | Nhóm đề: quyết định tab và nhãn màu. | +| Tỉnh/thành phố | Province | Không | Chọn trong danh sách tỉnh/thành có sẵn. Dùng cho bộ lọc tỉnh. | +| Năm | Year | Không | Từ 1990 đến năm sau năm hiện tại. Dùng cho bộ lọc năm. | +| Mô tả | Description | Không | Markdown. Hiện dưới flipbook, được cắt thành đoạn trích 140 ký tự trên thẻ, được tìm kiếm, và đoạn văn đầu tiên dùng làm mô tả SEO. | +| Contest | Contest | Không | Kỳ thi luyện tập. Có kỳ thi thì hiện nút "Làm bài ngay". Nếu kỳ thi bị xoá, liên kết tự bỏ trống. | +| Hiển thị công khai | Publicly visible | — | Mặc định bật. Tắt thì đề biến khỏi danh sách và trang đề trả về 404. | +| Publish on | Publish on | Không | Ngày giờ công bố. Trước thời điểm này đề bị ẩn như đang tắt hiển thị. Cũng dùng để sắp xếp (mới nhất lên trên) và hiện ở dòng "Đăng ngày …". | +| Tệp PDF | PDF file | Không | Tệp tải lên (`.pdf`, ≤ 5 MB). Mỗi lần lưu có tệp mới là thay PDF của đề. | +| Đường dẫn PDF | PDF URL | — | Chỉ đọc. Hệ thống tự điền sau khi tải lên, dạng `/pdf/.pdf`. | + +::: warning Không nhập được link PDF ngoài, không xoá được PDF +Ô **Đường dẫn PDF** chỉ đọc, nên không thể trỏ đề tới PDF lưu ở nơi khác, cũng không thể gỡ PDF khỏi đề trong admin. Muốn thay PDF thì tải tệp mới lên; tệp cũ vẫn nằm trên đĩa. +::: + +::: tip Hẹn giờ đăng đề +Để công bố đề đúng giờ (ví dụ ngay sau khi kỳ thi thật kết thúc), cứ bật **Hiển thị công khai** và đặt **Publish on** vào thời điểm đó. Đề tự xuất hiện khi đến giờ, không cần thao tác gì thêm. +::: + +::: details Trang đề có sẵn SEO +- Trang danh sách có tiêu đề thay đổi theo bộ lọc (ví dụ "Đề vào 10 chuyên - Hà Nội - năm 2024 | Thư viện đề thi") và mô tả kèm số đề tìm được. +- Trang kết quả tìm kiếm (`?q=`) có `noindex, follow`; các trang lọc theo nhóm/tỉnh/năm vẫn được index. +- Cả hai loại trang có dữ liệu schema.org (JSON-LD): `CollectionPage`/`ItemList` cho danh sách, `LearningResource` cho trang đề, cùng `BreadcrumbList`. +- Mô tả SEO của trang đề lấy từ đoạn văn đầu tiên của **Mô tả** (không có thì dùng tiêu đề), ảnh chia sẻ lấy từ ảnh đầu tiên trong mô tả. Kết quả được **lưu cache 24 giờ**, nên sửa mô tả xong có thể phải đợi tới một ngày thẻ meta mới cập nhật. +- Hiện chưa có trang thư viện nào trong `sitemap.xml`. +::: + +#### Kiểm tra kết quả + +1. Mở `/library/`. Đề mới nằm đầu danh sách (nếu đã đến ngày công bố). +2. Thẻ đề có nhãn "Đề đầy đủ bản PDF", và nút "Làm bài ngay" nếu đã gắn kỳ thi. +3. Mở trang đề: flipbook tải được trang 1. +4. Mở thử `/pdf/.pdf` (lấy từ ô Đường dẫn PDF). Trình duyệt phải hiện được tệp. + +#### Xử lý sự cố + +| Triệu chứng | Cách khắc phục | +|---|---| +| Lỗi "File quá lớn! Độ lớn tối đa của file là …" | Nén PDF (giảm độ phân giải ảnh) xuống dưới 5 MB. Giới hạn này đặt trong `PDF_STATEMENT_MAX_FILE_SIZE`. | +| Lỗi đuôi tệp không hợp lệ | Chỉ nhận tệp `.pdf`. | +| Lỗi "Năm thi phải nằm trong khoảng từ 1990 đến …" | Sửa năm cho đúng khoảng cho phép. | +| Lỗi slug đã tồn tại | Đổi slug, ví dụ thêm năm hoặc tỉnh vào cuối. | +| Lưu xong nhưng không thấy đề ở `/library/` | Kiểm tra **Hiển thị công khai** đã bật và **Publish on** không nằm trong tương lai. | +| Có đề nhưng không có flipbook | Chưa tải PDF (ô Đường dẫn PDF trống). | + +#### Tiếp theo + +- [2.3. Sửa, ẩn hoặc gỡ đề](#edit-exam) +- [2.5. Lưu trữ PDF và sao lưu](#storage) + +### 2.3. Sửa, ẩn hoặc gỡ đề {#edit-exam} + +⏱ 2 phút · 👤 Quản trị viên, người soạn đề · 🔑 `judge.change_examstatement` (xoá: `judge.delete_examstatement`) + +#### Trước khi bắt đầu + +- Danh sách đề trong admin có cột tiêu đề, nhóm, tỉnh, năm, kỳ thi, hiển thị, ngày công bố; có bộ lọc theo nhóm, tỉnh, năm, hiển thị; có thanh duyệt theo ngày công bố; và tìm được theo tiêu đề và mô tả. + +#### Các bước + +1. Vào `/admin/judge/examstatement/` và tìm đề cần sửa. +2. Bấm vào tiêu đề đề để mở form. +3. Muốn ẩn tạm thời: bỏ tick **Hiển thị công khai**, rồi lưu. +4. Muốn thay PDF: chọn tệp mới ở ô **Tệp PDF**, rồi lưu. +5. Muốn gỡ hẳn: dùng nút xoá trong form, rồi xác nhận. + +::: warning Đổi slug làm hỏng link cũ +Slug chính là địa chỉ trang đề. Đổi slug thì mọi link đã chia sẻ trước đó sẽ trả về 404. +::: + +#### Kiểm tra kết quả + +- Đề đã ẩn hoặc xoá thì không còn trong `/library/`, và `/library/` trả về 404. +- Đề đã thay PDF thì ô Đường dẫn PDF có tên tệp mới. + +#### Xử lý sự cố + +| Triệu chứng | Cách khắc phục | +|---|---| +| Flipbook vẫn hiện PDF cũ | Tải lại trang bỏ qua cache (Ctrl+F5). Tệp mới có địa chỉ khác, nên chỉ cần trang HTML được tải lại. | +| Mô tả SEO vẫn là nội dung cũ | Cache 24 giờ; đợi hết hạn hoặc xoá cache Redis. | + +#### Tiếp theo + +[2.4. Quản lý nhóm đề](#categories). + +### 2.4. Quản lý nhóm đề {#categories} + +⏱ 3 phút · 👤 Quản trị viên · 🔑 `judge.add_examcategory` / `judge.change_examcategory` + +#### Trước khi bắt đầu + +Sau khi chạy migration, LCOJ có sẵn 8 nhóm: + +| Thứ tự | Tên | Slug | +|---|---|---| +| 0 | HSG Tỉnh/TP | `hsg-tinh-tp` | +| 1 | HSG Quốc Gia | `hsg-quoc-gia` | +| 2 | Chọn đội tuyển quốc gia | `chon-doi-tuyen-quoc-gia` | +| 3 | Olympic quốc tế | `olympic-quoc-te` | +| 4 | Đề thi thử | `de-thi-thu` | +| 5 | Đề vào 10 chuyên | `de-vao-10-chuyen` | +| 6 | ICPC/OLP | `icpc-olp` | +| 7 | Khác | `khac` | + +#### Các bước + +1. Vào `/admin/judge/examcategory/`. +2. Bấm thêm mới, hoặc bấm vào một nhóm có sẵn để sửa. +3. Nhập tên nhóm (duy nhất, tối đa 40 ký tự). Trong admin tiếng Việt, ô này đang bị dịch nhầm thành **"Tên người dùng"**; tiếng Anh là "Name". +4. Kiểm tra **Slug** (tự điền từ tên, duy nhất, tối đa 50 ký tự). Slug này là giá trị `?category=` trên địa chỉ trang. +5. Đặt **Thứ tự**: số nhỏ hơn đứng trước. Nhóm cùng thứ tự được xếp theo tên. +6. Lưu. + +::: tip Màu nhãn +Màu của nhãn nhóm được gán tự động theo vị trí của nhóm trong thứ tự sắp xếp (bảng 7 màu, lặp lại). Đổi thứ tự thì màu cũng đổi theo. +::: + +#### Kiểm tra kết quả + +- Tab của nhóm hiện ở `/library/` theo đúng thứ tự, **khi nhóm đã có ít nhất một đề công khai**. + +#### Xử lý sự cố + +| Triệu chứng | Cách khắc phục | +|---|---| +| Không xoá được nhóm | Nhóm vẫn còn đề (liên kết được bảo vệ). Chuyển các đề sang nhóm khác hoặc xoá chúng trước. | +| Nhóm mới không hiện tab | Nhóm chưa có đề công khai nào. | +| Link `?category=` cũ không còn lọc đúng | Slug nhóm đã bị đổi. Cập nhật lại link. | + +#### Tiếp theo + +[2.5. Lưu trữ PDF và sao lưu](#storage). + +### 2.5. Lưu trữ PDF và sao lưu {#storage} + +⏱ 5 phút · 👤 Người vận hành máy chủ · 🔑 Quyền truy cập máy chủ Docker + +#### Trước khi bắt đầu + +Đường đi của một tệp PDF: + +```mermaid +flowchart LR + U["Tệp PDF tải lên"] --> S["site: /media/pdf/uuid.pdf"] + S --> H["Máy chủ: dmoj/media/pdf/"] + H --> N["nginx: location /pdf"] + N --> B["Trình duyệt: /pdf/uuid.pdf"] +``` + +- Tệp được đổi tên thành `.pdf` và lưu vào `MEDIA_ROOT/pdf/` = `/media/pdf/` trong container `site` (`PDF_STATEMENT_UPLOAD_MEDIA_DIR = 'pdf'`). +- `/media/` là thư mục `dmoj/media/` trên máy chủ (bind mount trong `docker-compose.yml`), dùng chung cho `site` và `nginx`. +- nginx phục vụ trực tiếp tệp tại `/pdf/…` (`location /pdf { root /media/; }`). Địa chỉ lưu trong đề là địa chỉ tương đối `/pdf/.pdf` (`PDF_STATEMENT_UPLOAD_URL_PREFIX = '/pdf'`), cùng tên miền với trang, nên không vướng CORS. +- Đây là cùng thư mục với PDF đề bài của các bài tập. + +#### Các bước + +1. Sao lưu thư mục `dmoj/media/pdf/` cùng với cơ sở dữ liệu. Chỉ có database thì đề còn nhưng mất PDF; chỉ có thư mục thì còn PDF nhưng không biết tệp nào của đề nào. +2. Khi chuyển máy chủ, chép `dmoj/media/` sang máy mới trước khi chạy `docker compose up -d`. + +::: warning Tệp PDF cũ không tự bị xoá +Thay PDF hoặc xoá đề **không** xoá tệp trên đĩa. Thư mục `dmoj/media/pdf/` sẽ lớn dần. Nếu cần dọn, phải đối chiếu với cột `pdf_url` của các đề (và PDF của bài tập) trước khi xoá. +::: + +#### Kiểm tra kết quả + +```sh +cd lcoj-docker/dmoj +ls -lh media/pdf/ | tail +curl -I http://localhost:${NGINX_PORT:-8071}/pdf/.pdf # mong đợi 200, Content-Type: application/pdf +``` + +#### Tiếp theo + +- Xem thêm [Vận hành LCOJ](/site/operations) về sao lưu. + +### 2.6. Thư viện hiển thị flipbook (PDF.js, StPageFlip) {#static-assets} + +⏱ 10 phút · 👤 Người vận hành máy chủ · 🔑 Quyền truy cập máy chủ Docker và repo + +#### Trước khi bắt đầu + +Flipbook dùng các tệp tĩnh sau: + +| Tệp | Nguồn | +|---|---| +| `lcoj/pdfjs/pdfjs-init.js`, `pdf.min.js`, `pdf.worker.min.js` (PDF.js) | Submodule `resources/lcoj` → repo [luyencode/lcoj-static](https://github.com/luyencode/lcoj-static) | +| `lcoj/pageflip/page-flip.browser.js` (StPageFlip) | Cùng submodule `resources/lcoj` | +| `flipbook.js`, `flipbook.scss`, `page-flip.mp3` | Nằm ngay trong `resources/` của lcoj-site | + +Các thư viện này **không** nằm trong repo lcoj-site và không lấy từ CDN. Chúng nằm trong một submodule riêng, và được phục vụ từ `/static/`. + +#### Các bước + +1. Sau khi clone hoặc cập nhật mã, tải đủ submodule (kể cả submodule lồng trong `dmoj/repo`): + + ```sh + cd lcoj-docker + git submodule update --init --recursive + ``` + +2. Kiểm tra các tệp đã có: + + ```sh + ls dmoj/repo/resources/lcoj/pdfjs dmoj/repo/resources/lcoj/pageflip + ``` + +3. Chép tệp tĩnh ra volume `assets` để nginx phục vụ: + + ```sh + cd dmoj + ./scripts/copy_static + ``` + +4. Nếu có sửa cấu hình nginx, khởi động lại nginx: `docker compose restart nginx`. + +#### Kiểm tra kết quả + +- Mở `/static/lcoj/pdfjs/pdf.min.js` và `/static/lcoj/pageflip/page-flip.browser.js` trên trình duyệt: phải trả về mã JavaScript, không phải 404. +- Mở một trang đề có PDF: flipbook hiện được trang 1. + +#### Xử lý sự cố + +| Triệu chứng | Cách khắc phục | +|---|---| +| Sau khoảng 8 giây flipbook chỉ còn link "Tải PDF" | Không tải được PDF.js hoặc StPageFlip. Kiểm tra `/static/lcoj/...` có 404 không, chạy lại bước 1 và 3. | +| "Không thể tải xem trước — tải PDF" | PDF.js đã tải nhưng không đọc được tệp. Mở Console của trình duyệt, tìm dòng `[flipbook] failed to load`. Kiểm tra `/pdf/.pdf` có trả về 200 không, và tệp có bị hỏng không. | +| `/pdf/.pdf` trả về 404 | Tệp không có trong `dmoj/media/pdf/`, hoặc nginx thiếu `location /pdf`. Kiểm tra mount `./media/:/media/` của cả `site` và `nginx`. | +| Console báo lỗi tải `pdf.worker.min.js` | Worker được tải từ cùng thư mục với `pdfjs-init.js`. Đảm bảo `pdf.worker.min.js` đã được chép ra `/static/lcoj/pdfjs/`. | +| Flipbook trắng hoặc rất chậm với tệp nhiều trang | Mọi trang được vẽ thành ảnh ngay trên trình duyệt, từng trang một. Tệp nhiều trang hoặc nhiều ảnh nặng sẽ tốn bộ nhớ, nhất là trên điện thoại. Hãy tối ưu PDF (giảm độ phân giải ảnh, bỏ trang thừa). | +| Lỗi CORS trong Console | Chỉ xảy ra khi PDF nằm ở tên miền khác. Với cấu hình mặc định (PDF ở `/pdf/` cùng tên miền) thì không gặp. Kiểm tra `MEDIA_URL`/`SITE_FULL_URL` và proxy phía trước (Cloudflare) có chuyển hướng sang tên miền khác không. | +| Tải lên báo lỗi 413 | Vượt `client_max_body_size 64M` của nginx. Không xảy ra với tệp ≤ 5 MB. | + +#### Tiếp theo + +- [Cài đặt LCOJ](/site/installation) +- [Vận hành LCOJ](/site/operations) diff --git a/src/features/quiz.md b/src/features/quiz.md new file mode 100644 index 0000000..c12dabd --- /dev/null +++ b/src/features/quiz.md @@ -0,0 +1,250 @@ +# Làm bài trắc nghiệm + +> Hướng dẫn tìm bài kiểm tra trắc nghiệm trên LCOJ, làm bài, nộp bài, xem kết quả và bảng xếp hạng. +> +> ⏱ ~10 phút · 👤 Học sinh, người luyện tập · 🔑 Chỉ cần tài khoản LCOJ đã đăng nhập + +## Trước khi bắt đầu + +- [ ] Đã **đăng nhập**. Khách vẫn xem được danh sách và trang thông tin bài, nhưng phải đăng nhập mới bấm làm bài được. +- [ ] Dùng trình duyệt máy tính bản mới (Chrome, Edge, Firefox…) và mạng ổn định. Đáp án được lưu lên máy chủ ngay khi bạn chọn. +- [ ] Nếu bài dành riêng cho một **tổ chức** (lớp, trường, CLB), bạn phải là thành viên của tổ chức đó. +- [ ] Dành đủ thời gian. Với bài có giới hạn thời gian, đồng hồ chạy ngay khi bạn bấm bắt đầu và **không tạm dừng** kể cả khi bạn đóng tab. + +## Vòng đời một lượt làm bài + +```mermaid +stateDiagram-v2 + state "Chưa làm" as idle + state "Đang làm bài" as doing + state "Hết giờ" as timeout + state "Đã nộp, đã chấm" as graded + state "Xem kết quả, xếp hạng" as result + [*] --> idle + idle --> doing: Bắt đầu + doing --> doing: Tự động lưu + doing --> graded: Nộp bài + doing --> timeout: Hết thời gian + timeout --> graded: Tự nộp hoặc chốt sau 30 giây + graded --> result + result --> [*] +``` + +Mỗi lần bấm bắt đầu tạo ra một **lượt làm bài**. Lượt đó được chấm tự động ngay khi nộp; điểm cao nhất của bạn được dùng để xếp hạng. + +## Tìm bài kiểm tra + +1. Bấm mục **Trắc nghiệm** trên thanh menu, hoặc mở thẳng `https://luyencode.net/quizzes/`. Trang có tiêu đề **Bài kiểm tra**. +2. Chọn tab trạng thái: **Tất cả**, **Sắp tới**, **Đang diễn ra** hoặc **Đã kết thúc**. Tab chỉ hiện khi có bài thuộc nhóm đó. +3. Muốn tìm theo tên hoặc mã bài, gõ vào ô **Tìm bài kiểm tra...** rồi bấm **Tìm** (hoặc Enter). +4. Đánh dấu **Ẩn đã làm** để ẩn những bài bạn đã nộp ít nhất một lần. +5. Bấm vào mã hoặc tên bài để mở trang thông tin. + +Bảng danh sách có các cột: + +| Cột | Ý nghĩa | +|---|---| +| **Mã** | Mã bài, cũng là một phần URL: `/quizzes/` | +| **Bài kiểm tra** | Tên bài, giờ mở/đóng (hoặc **Luôn mở**) và đồng hồ đếm ngược | +| **Câu hỏi** | Số câu trong bài | +| **Người tham gia** | Số người đã nộp ít nhất một lượt | +| **Điểm của bạn** | Điểm cao nhất của bạn, hoặc `—` nếu chưa nộp lượt nào | + +::: tip +Danh sách hiện 50 bài mỗi trang. Bạn chỉ thấy những bài mình được phép truy cập. Bài dành riêng cho tổ chức mà bạn không tham gia sẽ không xuất hiện. +::: + +## Đọc trang thông tin bài + +Trang `/quizzes/` cho biết: + +| Thông tin | Ý nghĩa | +|---|---| +| **Câu hỏi** | Số câu | +| **Điểm** | Tổng điểm tối đa | +| **Giới hạn thời gian:** | Số **phút** cho mỗi lượt. `∞` nghĩa là không giới hạn | +| **Số lần làm tối đa** | Số lượt được nộp. `∞` nghĩa là không giới hạn | +| **Bắt đầu:** / **Kết thúc:** | Khung giờ mở bài. **Không đặt — mở ngay** và **Không đặt — không có hạn** nghĩa là không giới hạn phía đó | +| Dải thông báo | **Bắt đầu trong …** (chưa mở), **Kết thúc trong …** (đang mở), hoặc thông báo bài đã đóng | +| **Lần làm bài của bạn** | Các lượt bạn đã làm, kèm điểm hoặc trạng thái **đang làm bài** | + +Nút ở thanh hành động thay đổi theo tình huống: + +| Bạn thấy | Nghĩa là | +|---|---| +| **Bắt đầu kiểm tra** (kèm "N lần làm còn lại") | Bạn có thể bắt đầu lượt mới | +| **Tiếp tục làm bài** | Bạn đang có một lượt dở dang. Bấm để quay lại đúng lượt đó | +| **Quiz not started yet.** | Bài chưa đến giờ mở | +| **Quiz is closed.** | Đã qua giờ kết thúc | +| **Không còn lượt làm bài.** | Bạn đã dùng hết số lượt | + +::: info +Trang thông tin **không** cho biết trước chế độ hiển thị kết quả (chỉ điểm, đúng/sai, hay đầy đủ đáp án). Bạn chỉ biết sau khi nộp bài lượt đầu tiên. Xem phần [Xem kết quả](#xem-ket-qua). +::: + +## Bắt đầu làm bài + +1. Mở trang thông tin bài và bấm **Bắt đầu kiểm tra**. +2. Nếu bài bật giám sát, hộp thoại **Thông báo liêm chính học thuật** hiện ra. Đọc các quy tắc rồi bấm **Tôi hiểu, bắt đầu →**. Bấm **Huỷ** nếu chưa muốn làm; lúc này chưa có lượt nào được tạo. +3. Trang làm bài mở ra. Nếu bài có giới hạn thời gian, đồng hồ đếm ngược đã bắt đầu chạy. + +::: warning Đồng hồ không dừng +Thời gian được tính từ lúc bấm bắt đầu. Đóng tab, tắt máy hay mất mạng đều **không** dừng đồng hồ. +::: + +Thứ tự câu hỏi và thứ tự các lựa chọn có thể bị xáo trộn, tùy giáo viên cài đặt. Thứ tự đó được cố định cho lượt của bạn, nên tải lại trang vẫn giữ nguyên. + +## Giám sát liêm chính + +Khi giáo viên bật **giám sát liêm chính**, trang làm bài sẽ: + +- Phủ một **hình mờ** lặp lại tên đăng nhập của bạn và giờ bắt đầu lượt làm. +- **Chặn sao chép** và **tắt chuột phải**. +- Hiện một thông báo nhỏ khoảng 4 giây (bằng tiếng Anh, ví dụ `⚠ Tab switch detected. This has been recorded.`) mỗi khi ghi nhận một sự kiện. + +Các sự kiện được ghi lại: + +| Sự kiện (giáo viên thấy) | Khi nào bị ghi nhận | +|---|---| +| **Chuyển tab** | Tab làm bài bị ẩn: chuyển tab khác, thu nhỏ trình duyệt | +| **Mất tiêu điểm cửa sổ** | Cửa sổ trình duyệt mất focus: bấm sang ứng dụng khác, Alt+Tab | +| **Mở DevTools** | Kích thước vùng hiển thị chênh lệch với cửa sổ hơn 160 px (dấu hiệu mở DevTools) | +| **Phím PrintScreen** | Bấm phím PrintScreen | +| **Sao chép bài làm** | Thử sao chép (Ctrl+C…) | + +Giáo viên thấy **loại sự kiện** và **thời điểm** xảy ra. Hệ thống không chụp màn hình và không ghi lại nội dung bạn làm ngoài tab. + +::: tip Vi phạm không trừ điểm +Đúng như thông báo trong hộp thoại, các sự kiện này **không ảnh hưởng đến điểm** của bạn. Chúng chỉ là thông tin để giáo viên xem xét. +::: + +::: details Vì sao tôi bị ghi "Mở DevTools" dù không mở? +Việc phát hiện dựa vào chênh lệch giữa kích thước cửa sổ và vùng hiển thị trang. Thanh bên của trình duyệt đang mở, một số tiện ích mở rộng hoặc mức thu phóng lạ đều có thể tạo chênh lệch đó. Hãy đóng thanh bên trước khi làm bài. Mỗi loại sự kiện chỉ được ghi tối đa một lần trong 5 giây. +::: + +## Trả lời từng loại câu hỏi + +Dùng **Trước** / **Sau** để chuyển câu, hoặc bấm số câu trong ô **Câu hỏi** ở thanh bên. Câu đã trả lời được tô màu trên bản đồ câu hỏi, còn thanh tiến độ ở trên cùng cho biết bạn đã làm được bao nhiêu phần. + +| Loại (tên trên giao diện) | Cách trả lời | Chấm điểm | +|---|---|---| +| **Trắc nghiệm** (một đáp án) | Chọn một ô tròn | Đúng lựa chọn đúng thì được trọn điểm, sai thì 0 | +| **Nhiều đáp án** | Đánh dấu một hoặc nhiều ô vuông | Tùy cách chấm giáo viên chọn: có thể "tất cả hoặc không", hoặc cho điểm một phần | +| **Đúng/Sai** | Chọn **Đúng** hoặc **Sai** | Trọn điểm hoặc 0 | +| **Trả lời ngắn** | Gõ vào ô **Nhập câu trả lời của bạn...** | Khớp với mẫu đáp án thì được trọn điểm, nếu không thì 0 | + +Phím tắt (khi không gõ trong ô trả lời ngắn): + +| Phím | Tác dụng | +|---|---| +| / | Câu trước / câu sau | +| 19 | Chọn (hoặc bỏ chọn, với câu nhiều đáp án) lựa chọn thứ N | + +### Câu trả lời ngắn được so khớp thế nào? + +- Khoảng trắng **ở đầu và cuối** câu trả lời được bỏ qua. Khoảng trắng **ở giữa** thì vẫn tính. +- Câu trả lời phải khớp **toàn bộ** với một mẫu của giáo viên, không phải chỉ chứa mẫu đó. Ví dụ đáp án `42` sẽ không chấp nhận `x = 42`. +- Có phân biệt **hoa/thường** hay không là do giáo viên quyết định từng câu. Hãy gõ đúng như đề yêu cầu. +- Để trống thì tính là chưa trả lời, được 0 điểm. + +::: tip +Nếu đề không nói gì về định dạng, hãy trả lời ngắn gọn nhất có thể: chỉ ghi số hoặc từ khóa, không thêm đơn vị hay dấu câu. +::: + +## Tự động lưu + +- Câu **trắc nghiệm, nhiều đáp án, đúng/sai** được lưu ngay khi bạn chọn. +- Câu **trả lời ngắn** được lưu khi bạn ngừng gõ khoảng 0,8 giây. +- Nếu lưu thất bại (mất mạng), thanh bên hiện `Save failed — retrying…` và trình duyệt tự thử lại mỗi 3 giây. Đừng đóng tab khi thấy dòng này. +- Bỏ hết lựa chọn của một câu cũng được lưu, và câu đó trở thành "chưa trả lời". + +Vì đáp án nằm trên máy chủ, bạn có thể tải lại trang hoặc mở lại từ máy khác mà không mất bài: bấm **Tiếp tục làm bài** trên trang thông tin. + +## Thời gian, hết giờ và đóng tab + +| Tình huống | Điều gì xảy ra | +|---|---| +| Bài có giới hạn thời gian | Đồng hồ ở ô **Thời gian** chuyển vàng khi còn dưới 5 phút và đỏ khi còn dưới 1 phút. Về 0 thì trình duyệt **tự nộp bài** | +| Mạng chậm đúng lúc hết giờ | Máy chủ vẫn nhận đáp án gửi tới trong **30 giây** sau hạn chót | +| Bạn đóng tab khi còn giờ | Lượt làm vẫn chạy. Mở lại trang bài và bấm **Tiếp tục làm bài** để làm tiếp | +| Bạn đóng tab và hết giờ luôn | Sau hạn chót + 30 giây, lượt làm được **chốt**: các đáp án đã lưu vẫn được chấm. Việc chốt diễn ra khi bạn mở lại trang bài | +| Bài có giờ kết thúc và có giới hạn thời gian | Bạn được làm đủ thời gian của lượt mình, **kể cả khi** giờ kết thúc đã qua | +| Bài có giờ kết thúc nhưng không giới hạn thời gian | Lượt làm đóng đúng giờ kết thúc, không có 30 giây gia hạn và không có đồng hồ tự nộp | +| Không giới hạn thời gian, không giờ kết thúc | Lượt làm mở mãi cho đến khi bạn tự nộp | + +::: warning +Một lượt bị bỏ dở chỉ được chốt khi bạn quay lại trang bài. Trước lúc đó, lượt ấy chưa có điểm và chưa lên bảng xếp hạng. Nếu lỡ đóng tab, hãy mở lại trang bài càng sớm càng tốt. +::: + +## Nộp bài + +1. Ở câu cuối, bấm **Xem lại & Nộp bài**, hoặc bấm **Nộp bài kiểm tra** ở thanh bên bất cứ lúc nào. +2. Trình duyệt hiện hộp xác nhận **Nộp bài kiểm tra ngay bây giờ?**. Nếu còn câu bỏ trống, phía trên có thêm dòng "Bạn có N câu hỏi chưa trả lời." Đồng ý để nộp, hoặc hủy để quay lại làm tiếp. +3. Bài được chấm ngay và bạn được chuyển sang trang kết quả. + +::: danger +Đã nộp thì không thể sửa. Muốn cải thiện điểm, bạn phải bắt đầu lượt mới (nếu còn lượt). +::: + +## Xem kết quả + +Trang kết quả (`/quizzes//attempt//result`) luôn hiện **Điểm: X / Y**. Phần còn lại phụ thuộc vào chế độ giáo viên chọn: + +| Chế độ | Bạn thấy | +|---|---| +| **Chỉ điểm** | Tổng điểm, nội dung từng câu và **Câu trả lời của bạn**. Không có đúng/sai, không có đáp án | +| **Đúng/sai** (không có đáp án) | Thêm màu xanh/đỏ và điểm từng câu (ví dụ `(0.5 / 1)`), nhưng không có đáp án đúng | +| **Đầy đủ** (đáp án và giải thích) | Tất cả lựa chọn: ✓ đánh dấu đáp án đúng, ✗ đánh dấu lựa chọn sai bạn đã chọn, nhãn **Câu trả lời của bạn**, nhãn **Missed** cho đáp án đúng bạn bỏ sót, nút **Tại sao?** mở phần giải thích từng lựa chọn, **Đáp án đúng** (hoặc **Mẫu được chấp nhận**) cho câu đúng/sai và trả lời ngắn, cùng lời giải thích chung của câu | + +Câu bạn bỏ trống hiện **(không có câu trả lời)**. + +Bạn luôn xem lại được các lượt cũ: trên trang thông tin, trong bảng **Lần làm bài của bạn**, bấm **xem**. + +## Bảng xếp hạng + +Bấm **Bảng xếp hạng** trên trang thông tin hoặc trang kết quả (`/quizzes//ranking`). Quy tắc: + +1. Mỗi người chỉ có **một dòng**: lượt tốt nhất của họ. +2. Điểm **cao hơn** xếp trên. +3. Nếu bằng điểm, ai làm **nhanh hơn** (thời gian từ lúc bắt đầu đến lúc nộp) xếp trên. +4. Nếu vẫn bằng, ai **nộp sớm hơn** xếp trên. + +Chỉ lượt **đã nộp** mới được tính. Dòng của bạn được tô vàng. Cột **Thời gian** là thời gian làm của lượt được dùng để xếp hạng. + +## Số lượt làm bài + +- **Số lần làm tối đa** chỉ đếm những lượt **đã nộp**. Lượt đang dở không làm mất lượt mới. +- Mỗi lúc bạn chỉ có **một** lượt dở dang. Bấm bắt đầu khi đang có lượt dở sẽ đưa bạn về lượt đó. +- Hết lượt thì nút bắt đầu biến mất và bạn thấy **Không còn lượt làm bài.** + +## Bài dành riêng cho tổ chức + +Một số bài chỉ dành cho thành viên của một hoặc nhiều tổ chức. Nếu bạn không thuộc tổ chức đó, bài không hiện trong danh sách và mở link trực tiếp sẽ báo **404**. Hãy tham gia tổ chức (xem trang **Tổ chức** trên LCOJ) hoặc hỏi giáo viên. + +## Kiểm tra kết quả + +Sau khi nộp, kiểm tra nhanh: + +- [ ] Trang thông tin bài có dòng mới trong **Lần làm bài của bạn**, kèm điểm (không còn **đang làm bài**). +- [ ] Cột **Điểm của bạn** trong danh sách `/quizzes/` hiện điểm cao nhất của bạn. +- [ ] Tên bạn xuất hiện trên **Bảng xếp hạng**. + +## Câu hỏi thường gặp + +| Tình huống | Cách xử lý | +|---|---| +| Thấy `Save failed — retrying…` | Mạng chập chờn. Giữ nguyên tab, trình duyệt tự thử lại mỗi 3 giây. Các đáp án đã lưu trước đó vẫn an toàn | +| Lỡ đóng tab hoặc máy tắt | Mở lại trang bài. Nếu còn giờ, bấm **Tiếp tục làm bài**. Nếu hết giờ, lượt làm được chốt với các đáp án đã lưu | +| Hết giờ khi đang làm | Bài tự nộp. Các đáp án đã lưu (cả những đáp án tới trong 30 giây gia hạn) đều được chấm | +| Không thấy đáp án đúng sau khi nộp | Giáo viên chọn chế độ **Chỉ điểm** hoặc **Đúng/sai**. Đây là cài đặt, không phải lỗi | +| Không thấy nút bắt đầu | Kiểm tra: đã đăng nhập chưa, bài đã mở chưa (**Quiz not started yet.**), đã đóng chưa (**Quiz is closed.**), còn lượt không | +| Mở link bài thì báo 404 | Bài đang ẩn hoặc chỉ dành cho tổ chức bạn không tham gia | +| Điểm trên bảng xếp hạng không phải lượt mới nhất | Bảng xếp hạng dùng lượt **tốt nhất**, không phải lượt gần nhất | +| Câu trả lời ngắn đúng mà bị chấm sai | Kiểm tra chữ hoa/thường, khoảng trắng ở giữa, đơn vị thừa. Nếu vẫn chắc mình đúng, báo giáo viên: họ có thể sửa đáp án và chấm lại | +| Bị ghi vi phạm oan | Vi phạm không trừ điểm. Nếu cần, giải thích với giáo viên | +| Giờ kết thúc đã qua, còn làm tiếp được không? | Chỉ khi bạn đang có lượt dở **và** bài có giới hạn thời gian mà lượt đó chưa hết giờ. Không thể bắt đầu lượt mới sau giờ kết thúc | + +## Tiếp theo + +- [Tạo và quản lý bài trắc nghiệm](/features/quiz_authoring): dành cho giáo viên muốn tự ra đề. +- [Hệ thống phân quyền](/site/permission_system): tìm hiểu các quyền trên LCOJ. diff --git a/src/features/quiz_authoring.md b/src/features/quiz_authoring.md new file mode 100644 index 0000000..8c7ed34 --- /dev/null +++ b/src/features/quiz_authoring.md @@ -0,0 +1,612 @@ +# Tạo và quản lý bài trắc nghiệm + +> Hướng dẫn xây ngân hàng câu hỏi, nhập câu hỏi hàng loạt từ Excel/JSON, tạo và lên lịch bài kiểm tra, rồi theo dõi lượt làm bài, vi phạm và chấm lại. +> +> ⏱ ~30 phút · 👤 Giáo viên, người ra đề, quản trị viên · 🔑 `quiz.edit_own_quiz` hoặc `quiz.edit_all_quiz` + +## Trước khi bắt đầu + +- [ ] Tài khoản của bạn có quyền `edit_own_quiz` hoặc `edit_all_quiz` (xem [Quyền](#quyen-va-cach-cap-quyen)). Khi có quyền, thanh công cụ nổi của trang sẽ hiện thêm các nút **Quiz**. +- [ ] Bạn đã đọc [Làm bài trắc nghiệm](/features/quiz) để biết học sinh sẽ thấy gì. +- [ ] Nếu muốn nhập hàng loạt: chuẩn bị Microsoft Excel, LibreOffice hoặc Google Sheets (xuất ra `.xlsx`), hoặc một trình soạn thảo cho file JSON. +- [ ] Nếu bài dành cho một lớp: tổ chức (organization) của lớp đó đã tồn tại trên LCOJ. + +## Tổng quan quy trình + +```mermaid +flowchart TD + I["Nhập XLSX / JSON"] --> B["Ngân hàng câu hỏi"] + N["Soạn từng câu"] --> B + B --> Q["Tạo bài kiểm tra"] + Q --> T["Làm thử: tác giả, tester"] + T --> P["Công khai, đặt lịch"] + P --> S["Học sinh làm bài"] + S --> R["Xem lượt làm, vi phạm"] + R --> G["Sửa đáp án, chấm lại"] +``` + +Hai khái niệm quan trọng: + +- **Câu hỏi** nằm trong **ngân hàng câu hỏi** và có thể dùng lại ở nhiều bài kiểm tra. +- **Bài kiểm tra** (quiz) chọn các câu từ ngân hàng và gán **điểm** cùng **thứ tự** cho từng câu. Điểm thuộc về bài kiểm tra, không thuộc về câu hỏi. + +## Quyền và cách cấp quyền + +| Quyền (codename) | Tên hiển thị | Cho phép | +|---|---|---| +| `quiz.edit_own_quiz` | Edit own quizzes and questions | Vào ngân hàng câu hỏi, tạo câu hỏi và bài kiểm tra, nhập/xuất. Chỉ sửa được câu hỏi và bài mà mình là **người tạo** (author) hoặc **giám khảo** (curator) | +| `quiz.edit_all_quiz` | Edit all quizzes and questions | Như trên, cộng thêm quyền xem và sửa **mọi** câu hỏi và bài kiểm tra | + +Superuser tự động có cả hai quyền. + +Quyền theo từng đối tượng: + +| Vai trò | Được gì | +|---|---| +| Người tạo (author) | Người bấm tạo được thêm tự động. Sửa được nếu có `edit_own_quiz` | +| Giám khảo (curator) | Sửa được như người tạo, nếu cũng có `edit_own_quiz` | +| Người thử (tester) | Xem và làm bài khi bài còn **ẩn**. Không sửa được | +| Người có `edit_all_quiz` | Sửa được mọi thứ | + +### Cấp quyền cho giáo viên (dành cho quản trị viên) + +**Cách 1: qua nhóm (khuyên dùng khi có nhiều giáo viên)** + +1. Vào `/admin/auth/group/` và tạo nhóm, ví dụ `Quiz Teachers`. +2. Trong ô tìm của danh sách quyền, gõ `quiz`. +3. Chọn **Quiz | câu hỏi | Chỉnh sửa bài kiểm tra và câu hỏi của mình** (`edit_own_quiz`) rồi bấm mũi tên để thêm vào nhóm. +4. Lưu nhóm. +5. Vào `/admin/auth/user/`, mở tài khoản giáo viên, thêm nhóm ở mục **Groups** rồi lưu. + +**Cách 2: cấp trực tiếp cho một người** + +1. Vào `/admin/auth/user/` và mở tài khoản cần cấp. +2. Ở mục **User permissions**, tìm `quiz` và thêm quyền `edit_own_quiz` (hoặc `edit_all_quiz` cho trưởng bộ môn hay quản trị viên). +3. Lưu. + +::: warning Tên quyền tiếng Việt bị dịch sai +Trong giao diện tiếng Việt, `edit_all_quiz` hiện là "Chỉnh sửa toàn bộ tổ chức". Tên đó sai: quyền này là **sửa mọi bài kiểm tra và câu hỏi**, không liên quan tới tổ chức. Hãy đối chiếu theo codename. +::: + +Giáo viên **không** cần `is_staff` hay quyền admin: mọi thao tác trong trang này đều làm được trên giao diện web. Chỉ cần quyền admin nếu muốn dùng [Django admin](#dung-django-admin). + +Xem thêm: [Hệ thống phân quyền](/site/permission_system). + +## Các điểm truy cập + +Người có quyền sẽ thấy ba nút **Quiz** trên thanh công cụ nổi của trang: + +| Nút trên thanh công cụ | URL | Dùng để | +|---|---|---| +| **Ngân hàng câu hỏi** | `/quizzes/questions/` | Xem, lọc, tạo, xuất câu hỏi | +| **Import Quiz** | `/quizzes/import/` | Nhập câu hỏi từ XLSX/JSON | +| **Manage Quizzes** | `/quizzes/` | Danh sách bài kiểm tra (gồm cả bài ẩn của bạn) | + +Các URL khác: + +| URL | Trang | +|---|---| +| `/quizzes/questions/new` | Tạo câu hỏi mới | +| `/quizzes/questions//edit` | Sửa câu hỏi | +| `/quizzes/import/template` | Tải file mẫu `quiz-template.xlsx` | +| `/quizzes/new` | **Tạo bài kiểm tra mới** | +| `/quizzes/` | Trang bài (học sinh cũng thấy trang này) | +| `/quizzes//edit` | Sửa bài kiểm tra | +| `/quizzes//attempts` | Danh sách lượt làm, vi phạm, chấm lại | +| `/quizzes//ranking` | Bảng xếp hạng | + +::: tip Không có nút "Tạo bài kiểm tra" +Hiện giao diện chưa có nút dẫn tới trang tạo bài kiểm tra. Hãy gõ thẳng `/quizzes/new` vào thanh địa chỉ, hoặc tạo bài ngay khi nhập câu hỏi (tùy chọn **Cũng tạo bài kiểm tra từ các câu hỏi này**). +::: + +## Ngân hàng câu hỏi + +### Xem và lọc + +1. Mở **Ngân hàng câu hỏi** (`/quizzes/questions/`). +2. Lọc theo ô **Tìm kiếm...** (khớp mã, tiêu đề, nội dung), **Tất cả loại**, **Tất cả danh mục**, **Tất cả cấp độ**, rồi bấm **Lọc**. + +Bạn thấy câu hỏi mình tạo hoặc làm giám khảo, cộng với mọi câu có **Công khai** = **Có**. Người có `edit_all_quiz` thấy toàn bộ. + +### Tạo một câu hỏi + +1. Trong ngân hàng câu hỏi, bấm **Câu hỏi mới**. +2. Nhập **Mã** (chữ thường và số, ví dụ `cpploop1`). +3. Chọn một thẻ ở **Loại câu hỏi**: **Trắc nghiệm**, **Nhiều đáp án**, **Đúng / Sai** hoặc **Trả lời ngắn**. +4. Nhập **Tiêu đề** (tên ngắn để nhận ra câu trong ngân hàng; học sinh không thấy). +5. Viết **Nội dung câu hỏi**. Chuyển sang tab **Xem trước** để kiểm tra cách hiển thị. +6. Khai báo đáp án: + - Trắc nghiệm / Nhiều đáp án: điền **Nội dung lựa chọn**, bấm **Thêm lựa chọn** nếu cần (từ 2 đến 6 lựa chọn), tích cột **Đúng** ở đáp án đúng. + - Đúng / Sai: chọn **Đúng** hoặc **Sai**. + - Trả lời ngắn: nhập mẫu ở **Đáp án đúng — Mẫu Regex**, bấm **Thêm mẫu** để thêm mẫu khác. +7. (Tùy chọn) Điền **Giải thích tổng quát**, **Nhóm**, **Độ khó**, **Xáo trộn lựa chọn**, **Công khai trong ngân hàng**. +8. Bấm **Lưu câu hỏi**. Bạn tự động trở thành người tạo câu hỏi. + +### Các trường của câu hỏi + +| Trường (nhãn tiếng Việt) | Bắt buộc | Ý nghĩa | +|---|---|---| +| **Mã** | Có | Duy nhất trong toàn hệ thống, chỉ gồm `a-z` và `0-9`, tối đa 32 ký tự. Không có gạch dưới (thông báo lỗi tiếng Việt ghi `^[a-z0-9_]+$` là sai) | +| **Loại câu hỏi** | Có | Trắc nghiệm (MC), Nhiều đáp án (MA), Đúng/Sai (TF), Trả lời ngắn (SA) | +| **Tiêu đề** | Có | Tối đa 200 ký tự, chỉ dùng trong ngân hàng | +| **Nội dung câu hỏi** | Có | Markdown, có công thức toán | +| **Lựa chọn** | MC/MA | 2–6 lựa chọn; mỗi lựa chọn có thể kèm **Giải thích** riêng (hiện qua nút **Tại sao?** ở chế độ kết quả đầy đủ) | +| **Đáp án đúng** | Có | Xem [Các loại câu hỏi và cách chấm](#cac-loai-cau-hoi-va-cach-cham) | +| **Đáp án hiển thị** | Không | Chỉ cho SA: câu trả lời dễ đọc cho học sinh xem ở trang kết quả. Để trống thì học sinh thấy mẫu regex thô | +| **Giải thích tổng quát** | Không | Markdown, hiện ở trang kết quả khi bài bật chế độ đầy đủ | +| **Nhóm** | Không | Danh mục có sẵn. Danh mục được tạo trong admin (`/admin/quiz/quizcategory/`) hoặc tự động khi nhập file | +| **Độ khó** | Có | Dễ / Trung bình / Khó (mặc định Dễ) | +| **Chiến lược chấm điểm đa đáp án** | MA | Xem bảng chiến lược bên dưới | +| **Xáo trộn lựa chọn** | Không | Mỗi học sinh thấy thứ tự lựa chọn khác nhau (MC/MA) | +| **Công khai trong ngân hàng** | Không | Mọi người có quyền soạn đều thấy và dùng được câu này trong bài của họ (nhưng không sửa được) | + +Người tạo và giám khảo của **câu hỏi** chỉ chỉnh được trong [Django admin](#dung-django-admin). + +### Viết nội dung: Markdown và công thức + +Nội dung câu, lựa chọn và giải thích đều hỗ trợ Markdown: `**đậm**`, `*nghiêng*`, `` `code` ``, khối code có tô màu, bảng, danh sách. Công thức toán được MathJax hiển thị: + +````markdown +Tính tổng ~S = \sum_{i=1}^{n} i~ với ~n = 100~. + +$$ +\frac{n(n+1)}{2} +$$ + +```cpp +for (int i = 1; i <= n; i++) s += i; +``` +```` + +- Công thức nằm trong dòng: `~...~` +- Công thức riêng dòng: `$$...$$` + +::: warning +Ghi chú ở tiêu đề cột trong file mẫu XLSX gợi ý viết `$math$`. Cú pháp dùng trên trang là `~...~` (trong dòng) và `$$...$$` (riêng dòng), giống như khi viết đề bài lập trình. +::: + +## Các loại câu hỏi và cách chấm + +Mỗi câu cho ra một **tỉ lệ** từ 0 đến 1. Điểm của câu = tỉ lệ × điểm của câu trong bài, làm tròn 2 chữ số thập phân. Câu bỏ trống được 0. + +| Loại | Học sinh làm gì | Chấm | +|---|---|---| +| **Trắc nghiệm** (MC) | Chọn 1 lựa chọn | Đúng: 1, sai: 0 | +| **Đúng / Sai** (TF) | Chọn Đúng hoặc Sai | Đúng: 1, sai: 0 | +| **Nhiều đáp án** (MA) | Chọn nhiều lựa chọn | Theo chiến lược chấm | +| **Trả lời ngắn** (SA) | Gõ văn bản | Khớp một mẫu thì 1, không thì 0 | + +### Chiến lược chấm câu nhiều đáp án + +Ký hiệu: **C** = số đáp án đúng, **W** = số đáp án sai trong câu; học sinh chọn **c** đáp án đúng và **w** đáp án sai. + +| Chiến lược (nhãn tiếng Việt) | Giá trị trong file | Công thức | +|---|---|---| +| **Tất cả hoặc không** (mặc định) | `all_or_nothing` / `All or nothing` | 1 nếu chọn đúng **chính xác** tập đáp án đúng, ngược lại 0 | +| **Điểm một phần có phạt** | `partial_credit` / `Partial credit` | max(0, c/C − w/W) | +| **Đúng trừ sai** | `right_minus_wrong` / `Right minus wrong` | max(0, (c − w)/C) | +| **Chỉ đúng, không phạt** | `correct_only` / `Correct only` | c/C | + +**Ví dụ.** Câu có 5 lựa chọn A–E, đáp án đúng là **A, C, D** (C = 3, W = 2), câu được **3 điểm**: + +| Học sinh chọn | c | w | Tất cả hoặc không | Một phần có phạt | Đúng trừ sai | Chỉ đúng | +|---|---|---|---|---|---|---| +| A, C, D | 3 | 0 | **3** | **3** | **3** | **3** | +| A, C, B | 2 | 1 | 0 | 0.5 | 1 | 2 | +| A, C, D, B | 3 | 1 | 0 | 1.5 | 2 | 3 | +| Chọn cả 5 | 3 | 2 | 0 | 0 | 1 | 3 | +| Chỉ A | 1 | 0 | 0 | 1 | 1 | 1 | + +::: danger "Chỉ đúng, không phạt" có thể bị lợi dụng +Với **Chỉ đúng, không phạt**, học sinh chọn **tất cả** lựa chọn vẫn được trọn điểm và câu còn được đánh dấu là đúng. Chỉ dùng chiến lược này cho bài luyện tập. +::: + +Một câu MA chỉ được tính là "đúng" (màu xanh) khi tỉ lệ bằng 1. + +### Câu trả lời ngắn: mẫu regex + +Mỗi mẫu được so với **toàn bộ** câu trả lời của học sinh (đã bỏ khoảng trắng đầu và cuối) bằng `re.fullmatch` của Python. Chỉ cần khớp **một** mẫu là đúng. + +| Mẫu | Chấp nhận | Không chấp nhận | +|---|---|---| +| `42` | `42`, ` 42 ` | `42.0`, `x = 42` | +| `(?i)python` | `Python`, `PYTHON` | `python3` | +| `def` | `def` | `Def` (mặc định **phân biệt hoa/thường**) | +| `\d+` | `7`, `2024` | `12a` | +| `3\.14` | `3.14` | `3x14` (không có `\`, dấu `.` khớp mọi ký tự) | +| `(?i)o\(n log n\)` | `O(n log n)`, `o(N LOG N)` | `O(nlogn)` | + +::: warning Không dùng ký tự `|` bên trong một mẫu +Hệ thống tách các mẫu bằng ký tự `|`, cả trong form lẫn trong XLSX. Vì vậy mẫu `(?i)(true|yes)` (dù chính hướng dẫn trên form có gợi ý) bị tách thành `(?i)(true` và `yes)` rồi báo lỗi "Invalid regex". Hãy viết **mỗi phương án thành một mẫu riêng**: `(?i)true` và `(?i)yes`. Mẫu `3|three` vẫn chạy được vì bị tách thành hai mẫu hợp lệ `3` và `three`. +::: + +Ngoài ra, nhớ điền **Đáp án hiển thị** (ví dụ `Paris`) để học sinh không phải đọc regex ở trang kết quả. + +## Nhập câu hỏi hàng loạt + +### Quy trình nhập + +1. Bấm **Import Quiz** trên thanh công cụ, hoặc mở `/quizzes/import/`. +2. Ở **File XLSX hoặc JSON**, chọn file. File có đuôi `.json` được đọc theo định dạng JSON; mọi file khác được đọc như XLSX. +3. (Tùy chọn) Tích **Cũng tạo bài kiểm tra từ các câu hỏi này**, rồi điền **Code** (mã bài) và **Quiz name** (tên bài). +4. Bấm **Tải lên và xem trước**. +5. Xem khung **Xem trước**: mỗi câu là một khối **Hàng N: [loại] tiêu đề**. Khối viền đỏ kèm danh sách lỗi là câu có lỗi. Dòng **Các danh mục sau sẽ được tạo mới** liệt kê danh mục chưa có. +6. Nếu không có lỗi, bấm **Xác nhận nhập liệu**. Nếu có lỗi, sửa file rồi tải lên lại. +7. Thấy thông báo **Đã nhập N câu hỏi.** là xong. Bạn được chuyển về ngân hàng câu hỏi, hoặc tới trang sửa bài kiểm tra nếu đã chọn tạo bài. + +::: info Nhập theo kiểu "tất cả hoặc không" +Chỉ cần một câu lỗi là **không câu nào** được nhập ("Hãy sửa các lỗi trên và tải lại. Không có gì được nhập."). Khi bấm xác nhận, mọi thứ được ghi trong một giao dịch: nếu có xung đột mã phát sinh, toàn bộ lần nhập bị hủy. +::: + +Bài kiểm tra được tạo qua lần nhập có cài đặt mặc định: **ẩn**, không giới hạn thời gian, không giới hạn số lượt, kết quả đầy đủ, **bật** giám sát liêm chính. Câu hỏi được xếp theo thứ tự trong file, với điểm lấy từ cột/trường điểm. Hãy mở trang sửa để hoàn thiện cài đặt trước khi công khai. + +### Định dạng XLSX + +Tải file mẫu ở `/quizzes/import/template` (link **Tải xuống mẫu XLSX** trong ngân hàng câu hỏi). File mẫu có sẵn danh sách thả xuống cho cột **Type**, **Level**, **Shuffle Choices**, **MA Strategy** và ghi chú ở từng tiêu đề cột. + +Quy tắc chung: + +- Hàng 1 là tiêu đề và **bị bỏ qua**. Dữ liệu bắt đầu từ hàng 2; hàng trống bị bỏ qua. +- Cột được đọc **theo vị trí** (A, B, C…), không theo tên. **Không chèn, xóa hay đổi thứ tự cột.** +- Chỉ sheet đang mở (active) được đọc. +- Xóa 20 câu ví dụ về Python có sẵn trong mẫu trước khi nhập. Nếu những mã đó đã có trên hệ thống, lần nhập sẽ báo lỗi trùng mã. +- Định dạng cột Q (Correct Answer) là **Text** trước khi gõ. Nếu máy dùng dấu phẩy thập phân (thiết lập vùng Việt Nam), Excel có thể biến `1,3` thành số `1.3`, và câu MA sẽ báo lỗi. + +| Cột | Tiêu đề | Nội dung | +|---|---|---| +| A | Code | Bắt buộc. `a-z0-9`, tối đa 32 ký tự (tự chuyển về chữ thường) | +| B | Type | `Multiple Choice`, `Multiple Answer`, `True/False`, `Short Answer` (hoặc `MC`, `MA`, `TF`, `SA`) | +| C | Title | Bắt buộc | +| D | Question | Bắt buộc. Nội dung Markdown | +| E, G, I, K, M, O | Choice 1 … Choice 6 | Lựa chọn (MC/MA cần ít nhất 2). Bỏ trống các ô không dùng | +| F, H, J, L, N, P | Explanation 1 … Explanation 6 | Giải thích cho từng lựa chọn (tùy chọn) | +| Q | Correct Answer | MC: số thứ tự lựa chọn, **bắt đầu từ 1** (`2`). MA: danh sách phân cách bằng dấu phẩy (`1,3`). TF: `True`/`False` (cũng nhận `1`/`0`, `đúng`/`dung`/`sai`). SA: các mẫu regex phân cách bằng `\|` | +| R | Points | Điểm của câu trong bài được tạo kèm. Bỏ trống = 1. Không được âm | +| S | Category | Danh mục (xem ghi chú bên dưới) | +| T | Level | `Easy`, `Medium`, `Hard`. Bỏ trống = Easy | +| U | Explanation | Giải thích tổng quát | +| V | Shuffle Choices | `Yes` để xáo lựa chọn (cũng nhận `true`, `1`, `x`, `có`) | +| W | MA Strategy | `All or nothing`, `Partial credit`, `Right minus wrong`, `Correct only`. Bỏ trống = All or nothing | +| X | Answer Display | Chỉ cho SA: đáp án hiển thị cho học sinh | + +::: warning Cột Category +Giá trị ở cột này được dùng **nguyên văn làm slug** của danh mục. Nếu slug chưa có, danh mục mới được tạo với tên suy ra từ slug (thay `-` bằng dấu cách và viết hoa chữ đầu mỗi từ). Nên dùng slug dạng `cpp-co-ban`, đừng dùng `C++ cơ bản`. +::: + +**Ví dụ tối thiểu** gồm 4 câu, mỗi loại một câu. Bảng được xoay ngang: mỗi cột của bảng là một hàng trong Excel, các ô không liệt kê thì để trống. + +| Cột Excel | Hàng 2 | Hàng 3 | Hàng 4 | Hàng 5 | +|---|---|---|---|---| +| A · Code | `cppmc1` | `cppma1` | `cpptf1` | `cppsa1` | +| B · Type | `Multiple Choice` | `Multiple Answer` | `True/False` | `Short Answer` | +| C · Title | `Kiểu của 7/2` | `Kiểu số nguyên` | `Chỉ số mảng` | `Giá trị 7%3` | +| D · Question | `` Trong C++, `7/2` bằng bao nhiêu? `` | `Chọn các kiểu số nguyên:` | `Mảng C++ bắt đầu từ chỉ số 0.` | `` `7 % 3` bằng bao nhiêu? `` | +| E · Choice 1 | `3.5` | `int` | | | +| F · Explanation 1 | `Đây là phép chia số thực.` | | | | +| G · Choice 2 | `3` | `double` | | | +| I · Choice 3 | `4` | `long long` | | | +| Q · Correct Answer | `2` | `1,3` | `True` | `1` | +| R · Points | `1` | `2` | `1` | `1` | +| S · Category | `cpp-co-ban` | `cpp-co-ban` | `cpp-co-ban` | `cpp-co-ban` | +| T · Level | `Easy` | `Medium` | `Easy` | `Easy` | +| U · Explanation | `Chia hai số nguyên cho kết quả nguyên.` | | | | +| V · Shuffle Choices | `Yes` | `Yes` | | | +| W · MA Strategy | | `Partial credit` | | | +| X · Answer Display | | | | `1` | + +::: tip Nhập qua giao diện web bỏ qua cột Answer Display +Trang nhập `/quizzes/import/` hiện **không lưu** cột X (Answer Display). Hãy điền **Đáp án hiển thị** sau khi nhập bằng cách sửa câu hỏi, hoặc nhập bằng trang admin (`/admin/quiz/quizquestion/import/`), nơi cột này được lưu. +::: + +### Định dạng JSON + +File JSON là **một mảng** các đối tượng câu hỏi, mã hóa UTF-8. + +| Trường | Bắt buộc | Kiểu và giá trị | +|---|---|---| +| `code` | Có | Chuỗi `a-z0-9`, tối đa 32 ký tự | +| `type` | Có | `"MC"`, `"MA"`, `"TF"`, `"SA"` | +| `title` | Có | Chuỗi | +| `content` | Có | Chuỗi Markdown | +| `choices` | MC/MA | Mảng chuỗi, hoặc mảng đối tượng `{"text": "...", "explanation": "..."}`. Tối thiểu 2 | +| `correct` | Có | MC: số nguyên, chỉ số **bắt đầu từ 0**. MA: mảng chỉ số bắt đầu từ 0, không rỗng. TF: `true` / `false` (kiểu boolean). SA: xem bên dưới | +| `points` | Không | Số ≥ 0, mặc định `1` | +| `category` | Không | Slug danh mục | +| `level` | Không | `"easy"`, `"medium"`, `"hard"` (chữ thường), mặc định `"easy"` | +| `explanation` | Không | Giải thích tổng quát | +| `shuffle` | Không | Boolean, xáo trộn lựa chọn | +| `ma_strategy` | Không | `"all_or_nothing"` (mặc định), `"partial_credit"`, `"right_minus_wrong"`, `"correct_only"` | + +`correct` cho câu SA là một chuỗi hoặc một mảng, mỗi phần tử là: + +- **Chuỗi**, ví dụ `"Paris"`: khớp **nguyên văn**, **không** phân biệt hoa/thường, **không** phải regex. +- **Đối tượng** `{"text": "...", "case_sensitive": false, "is_regex": false}`: đặt `is_regex: true` để dùng regex (khớp toàn bộ), `case_sensitive: true` để phân biệt hoa/thường. + +::: warning Khác biệt giữa JSON và XLSX/form +- MC/MA trong JSON đánh chỉ số **từ 0**; trong XLSX và form đánh **từ 1**. +- Chuỗi SA trong JSON là **văn bản thường, không phân biệt hoa/thường**; trong XLSX và form, mỗi mẫu là **regex có phân biệt hoa/thường**. +- JSON không có trường `answer_display`. +- Nếu sau này bạn mở câu SA nhập từ JSON ra sửa và lưu trên form, đáp án sẽ bị chuyển thành mẫu regex phân biệt hoa/thường. Hãy kiểm tra lại các mẫu trước khi lưu. +::: + +**Ví dụ đầy đủ** (4 câu): + +```json +[ + { + "code": "cppmc1", + "type": "MC", + "title": "Kiểu của 7/2", + "content": "Trong C++, `7/2` bằng bao nhiêu?", + "choices": [ + {"text": "3.5", "explanation": "Đây là phép chia số thực."}, + {"text": "3", "explanation": "Chia hai số nguyên cho kết quả nguyên."}, + "4" + ], + "correct": 1, + "points": 1, + "category": "cpp-co-ban", + "level": "easy", + "explanation": "Phép chia hai số nguyên bỏ phần thập phân.", + "shuffle": true + }, + { + "code": "cppma1", + "type": "MA", + "title": "Kiểu số nguyên", + "content": "Chọn các kiểu số nguyên:", + "choices": ["int", "double", "long long"], + "correct": [0, 2], + "points": 2, + "level": "medium", + "ma_strategy": "partial_credit" + }, + { + "code": "cpptf1", + "type": "TF", + "title": "Chỉ số mảng", + "content": "Mảng C++ bắt đầu từ chỉ số 0.", + "correct": true + }, + { + "code": "cppsa1", + "type": "SA", + "title": "Độ phức tạp tìm kiếm nhị phân", + "content": "Độ phức tạp của tìm kiếm nhị phân là gì?", + "correct": ["O(log n)", {"text": "o\\(\\s*log\\s*n\\s*\\)", "is_regex": true}] + } +] +``` + +### Lỗi thường gặp khi nhập + +Thông báo lỗi của từng hàng hiện bằng tiếng Anh: + +| Thông báo | Nguyên nhân | Cách sửa | +|---|---|---| +| `Question code is required and must match ^[a-z0-9]+$` | Thiếu mã, hoặc mã có chữ hoa, gạch dưới, dấu cách | Dùng chữ thường và số | +| `Duplicate code … in this file` | Hai hàng cùng mã | Đổi một mã | +| `Code … already exists in the question bank` | Mã đã có trên hệ thống | Đổi mã (mã là duy nhất trên toàn LCOJ) | +| `MC correct answer out of range 1-N` | Số đáp án vượt quá số lựa chọn | Kiểm tra cột Correct Answer (đánh số từ 1) | +| `MC correct must be a 0-based choice index` | JSON: `correct` không phải số nguyên hợp lệ | Dùng chỉ số từ 0 | +| `TF correct answer must be true or false` | Giá trị TF không nhận ra | Dùng `True` / `False` | +| `Invalid regex '…'` | Mẫu SA sai cú pháp, thường do có `\|` bên trong | Tách thành nhiều mẫu, escape ký tự đặc biệt | +| `Level must be one of …` | JSON: `level` viết hoa hoặc sai | Dùng `easy`/`medium`/`hard` | +| `MA strategy must be one of …` | Tên chiến lược sai | Dùng đúng giá trị trong bảng | +| `Cannot read XLSX file` / `Invalid JSON file` | File hỏng hoặc sai định dạng | Lưu lại dưới dạng `.xlsx` hoặc kiểm tra JSON | +| `JSON root must be a list of question objects` | JSON không bắt đầu bằng `[` | Bọc các câu trong một mảng | +| **Không có file đang chờ — vui lòng tải lên trước.** | Bấm xác nhận hai lần, hoặc phiên đã hết hạn | Tải file lên lại | + +## Xuất câu hỏi + +1. Trong **Ngân hàng câu hỏi**, tích ô ở đầu các câu cần xuất. +2. Bấm **Xuất đã chọn ra XLSX**. Bạn nhận file `quiz-questions.xlsx` cùng định dạng với file nhập. + +::: info Giới hạn khi xuất +- Cột **Points** luôn là `1`, vì điểm thuộc về bài kiểm tra chứ không thuộc câu hỏi. +- Cột **Answer Display** để trống. +- Nhập lại file vừa xuất sẽ báo trùng mã. Muốn tạo bản sao, hãy đổi mã trước. +::: + +## Tạo bài kiểm tra + +1. Mở `/quizzes/new`. +2. Điền các trường cài đặt (xem bảng bên dưới). Chỉ **Code** và tên bài là bắt buộc. +3. Ở phần **Câu hỏi**, bấm **+ Thêm câu hỏi**, gõ để tìm câu (theo mã, tiêu đề hoặc nội dung; kết quả hiện dạng `[MC] mã: tiêu đề`) rồi chọn. +4. Nhập **Điểm** cho câu đó (mặc định 1, không được âm). +5. Lặp lại bước 3–4 cho từng câu. Kéo biểu tượng **⠿** để đổi thứ tự, bấm **✕** để bỏ một câu. +6. Bấm **Lưu bài kiểm tra**. Thông báo **Đã lưu bài kiểm tra.** hiện ra và bạn ở lại trang sửa `/quizzes//edit`. Bạn tự động là người tạo bài. + +::: warning Bài mới luôn ẩn +Chừng nào chưa tích **Hiển thị công khai**, chỉ người tạo, giám khảo, người thử và người có `edit_all_quiz` thấy được bài. +::: + +### Các trường cài đặt + +Một số nhãn tiếng Việt trên form bị dịch sai. Bảng dưới ghi cả nhãn tiếng Anh, nhãn tiếng Việt đang hiển thị và ý nghĩa thật. + +| Nhãn tiếng Anh | Nhãn tiếng Việt đang hiện | Ý nghĩa thật | +|---|---|---| +| Quiz code | Code | Mã bài, `a-z0-9`, tối đa 32 ký tự, duy nhất. **Không đổi được** sau khi tạo | +| Quiz name | Tên đầy đủ | Tên bài, tối đa 100 ký tự | +| Description | Mô tả | Markdown, hiện trên trang bài | +| Time limit (minutes) | Giới hạn thời gian (giây): | **Số phút** cho mỗi lượt (nhãn ghi "giây" là sai). Để trống = không giới hạn | +| Maximum attempts | Số thành viên tối đa | **Số lượt nộp tối đa** mỗi học sinh. Để trống = không giới hạn | +| Shuffle questions | Lời giải | **Xáo trộn thứ tự câu hỏi** cho mỗi lượt | +| Result feedback | Phản hồi từ trình chấm | Học sinh thấy gì sau khi nộp (xem bảng bên dưới) | +| Integrity monitoring | Giám sát liêm chính | Bật hộp thoại cảnh báo, hình mờ, chặn sao chép và ghi vi phạm. Mặc định **bật** | +| Start time | Thời gian bắt đầu | Trước giờ này không ai bắt đầu được. Để trống = mở ngay | +| End time | Thời gian kết thúc | Sau giờ này không ai bắt đầu lượt mới được. Để trống = không đóng. Phải sau giờ bắt đầu | +| Publicly visible | Hiển thị công khai | Học sinh thấy và làm được bài | +| Private to organizations | Dành riêng cho tổ chức | Chỉ thành viên các tổ chức ở trường bên dưới thấy bài | +| Organizations | Tổ chức | Các tổ chức được phép | +| Curators | Giám khảo | Người cùng quản lý bài (cần có `edit_own_quiz`) | +| Testers | Người dùng thử | Làm được bài khi bài còn ẩn | + +Giờ bắt đầu và kết thúc được hiểu theo múi giờ của tài khoản bạn. + +Các chế độ **Phản hồi từ trình chấm**: + +| Lựa chọn (tiếng Việt / tiếng Anh) | Học sinh thấy sau khi nộp | +|---|---| +| **Điểm** / Score only | Tổng điểm và câu trả lời của mình. Không có đúng/sai, không có đáp án | +| **Hiển thị kết quả đúng/sai (không có đáp án)** / Show correctness (no answer key) | Màu đúng/sai và điểm từng câu, không có đáp án | +| **Hiển thị đáp án đúng và giải thích** / Show correct answers and explanations | Đáp án đúng, giải thích từng lựa chọn và giải thích chung. **Mặc định** | + +::: warning Kết quả hiện ngay sau khi nộp +Không có tùy chọn "chỉ hiện đáp án sau khi bài đóng". Với chế độ đầy đủ, học sinh nộp sớm sẽ thấy đáp án ngay và có thể chia sẻ cho người khác. Với bài thi, hãy dùng **Điểm** hoặc **Đúng/sai** trong lúc thi, rồi chuyển sang chế độ đầy đủ sau giờ kết thúc. Thay đổi này áp dụng ngay cho mọi lượt cũ. +::: + +::: tip Kết hợp giờ kết thúc với giới hạn thời gian +Lượt làm có giới hạn thời gian luôn được chạy đủ thời gian, **kể cả khi đã qua giờ kết thúc**. Muốn mọi người nộp trước một mốc cố định, hãy mở bài sớm hơn giờ kết thúc ít nhất một khoảng bằng giới hạn thời gian. Ví dụ bài 45 phút đóng lúc 10:00 thì học sinh nên bắt đầu trước 9:15. +::: + +### Ai thấy được bài? + +| Hiển thị công khai | Dành riêng cho tổ chức | Ai thấy và làm được | +|---|---|---| +| Không | (bất kỳ) | Người tạo, giám khảo, người thử, người có `edit_all_quiz` | +| Có | Không | Mọi người (khách xem được, phải đăng nhập mới làm) | +| Có | Có | Thành viên các tổ chức đã chọn, cộng với nhóm ở hàng đầu | + +::: warning +Muốn dành bài cho một lớp, phải tích **cả hai** ô **Hiển thị công khai** và **Dành riêng cho tổ chức**, rồi chọn tổ chức. Nếu chỉ tích **Dành riêng cho tổ chức** mà không công khai thì học sinh vẫn **không** thấy bài. +::: + +## Xem thử bài + +- **Từng câu:** trong trang soạn câu hỏi, dùng tab **Xem trước** của ô nội dung và ô giải thích. +- **Cả bài:** khi bài còn ẩn, người tạo và giám khảo tự làm thử được. Thêm đồng nghiệp vào **Người dùng thử** để họ làm thử mà không sửa được bài. + +::: info +Lượt làm thử là lượt thật: nó xuất hiện trong danh sách lượt làm và **trên bảng xếp hạng**. Lượt làm chỉ xóa được trong Django admin (`/admin/quiz/quizattempt/`). +::: + +## Nhân bản bài kiểm tra + +1. Mở trang bài hoặc trang sửa bài, bấm **Clone quiz**. +2. Bạn được chuyển tới trang sửa của bản sao. +3. Đổi tên (mặc định là `Copy of `), đặt lại lịch rồi lưu. + +Bản sao có: + +- **Mã** = mã cũ + số đầu tiên còn trống từ 2 đến 9 (ví dụ `midterm` → `midterm2`). Nếu cả 8 mã đã tồn tại, bạn nhận thông báo "Could not generate a unique code for the clone. Rename the original quiz first." +- Cùng mô tả, giới hạn thời gian, số lượt, xáo trộn, chế độ kết quả, giám sát, cài đặt tổ chức, giám khảo, người thử, danh sách câu hỏi, điểm và thứ tự. +- **Luôn ẩn**, **không có lịch**, người tạo chỉ còn **bạn**, và không có lượt làm nào. + +Bản sao dùng **chung** câu hỏi với bài gốc, không tạo bản sao của câu hỏi. Sửa một câu sẽ ảnh hưởng cả hai bài. + +## Theo dõi lượt làm và vi phạm + +1. Trên trang bài, bấm **Tất cả lượt làm** (hoặc **Lần làm bài & chấm lại** trong trang sửa). URL: `/quizzes//attempts`. +2. Bảng liệt kê mọi lượt, mới nhất ở trên: **Thành viên**, **Bắt đầu lúc**, **Trạng thái** (**đã nộp** / **đang làm bài**), **Điểm**, **Vi phạm**. +3. Bấm huy hiệu **⚠ N** ở cột **Vi phạm** để mở nhật ký: giờ xảy ra và loại sự kiện, cùng tổng số ("N vi phạm tổng cộng"). +4. Bấm **xem** để mở trang kết quả của lượt đó. Giáo viên luôn thấy chế độ **đầy đủ**, bất kể cài đặt của bài. + +Các loại vi phạm: + +| Loại | Nhãn tiếng Việt | Nghĩa | +|---|---|---| +| `tab_switch` | Chuyển tab | Tab làm bài bị ẩn | +| `window_blur` | Mất tiêu điểm cửa sổ | Cửa sổ trình duyệt mất focus | +| `devtools` | Mở DevTools | Cửa sổ lớn hơn vùng hiển thị trên 160 px (ước đoán) | +| `print_screen` | Phím PrintScreen | Bấm PrintScreen | +| `copy_attempt` | Sao chép bài làm | Thử sao chép nội dung (nhãn đúng nghĩa là "thử sao chép") | + +::: warning Đọc vi phạm một cách thận trọng +- Vi phạm **không** ảnh hưởng đến điểm; đây chỉ là tín hiệu để xem xét. +- Phát hiện DevTools là ước đoán: thanh bên trình duyệt hay mức thu phóng cũng có thể gây ra. +- Mỗi loại được ghi tối đa một lần trong 5 giây. Học sinh tắt JavaScript hoặc dùng thiết bị thứ hai thì không bị ghi nhận gì. +::: + +::: info Lượt bị bỏ dở +Lượt học sinh bỏ dở vẫn hiện **đang làm bài** cho tới khi chính học sinh đó mở lại trang bài; lúc đó hệ thống mới chốt và chấm. Trong thời gian đó, lượt này không có trên bảng xếp hạng và không được chấm lại. +::: + +## Chấm lại + +Chấm lại tính lại điểm của **mọi lượt đã nộp** theo đáp án và điểm **hiện tại**. + +1. Sửa đáp án của câu hỏi, hoặc sửa điểm trong bài kiểm tra, rồi lưu. +2. Mở `/quizzes//attempts`. +3. Bấm **Chấm lại tất cả lần làm bài**, rồi xác nhận **Chấm lại tất cả lần nộp bài?**. +4. Thấy thông báo **Đã chấm lại N lần làm bài.** là xong. Bảng xếp hạng cập nhật ngay. + +Lượt đang làm dở không cần chấm lại: chúng được chấm theo đáp án mới khi nộp. + +## Sửa bài đã có người làm + +Thứ tự câu hỏi và lựa chọn của mỗi lượt được **cố định lúc bắt đầu**; đáp án được lưu theo **chỉ số** lựa chọn. Vì vậy: + +| Thay đổi | An toàn? | Ghi chú | +|---|---|---| +| Tên, mô tả, người thử, giám khảo | ✅ | | +| Công khai, tổ chức | ✅ | Áp dụng ngay | +| Chế độ kết quả | ✅ | Áp dụng ngay cho **mọi** lượt cũ | +| Giám sát liêm chính | ✅ | Áp dụng cho các trang tải sau khi lưu | +| Lùi giờ kết thúc | ✅ | Học sinh đã hết lượt vẫn không làm thêm được | +| Số lượt tối đa | ✅ | Áp dụng ngay | +| Giới hạn thời gian | ⚠️ | Hạn chót của các lượt **đang làm** được tính lại ngay theo giá trị mới | +| Điểm của câu | ⚠️ | Điểm cũ giữ nguyên cho tới khi **chấm lại** | +| Sửa đáp án đúng, sửa mẫu SA | ⚠️ | Cần **chấm lại** | +| Sửa chữ trong lựa chọn, giải thích | ⚠️ | An toàn nếu **không đổi nghĩa và thứ tự** | +| Thêm câu vào bài | ⚠️ | Lượt cũ không có câu mới, nhưng tổng điểm tối đa hiển thị (`X / Y`) tăng lên với mọi người | +| Bỏ câu khỏi bài | ❌ | Lượt cũ vẫn hiện câu đó; khi chấm lại, câu đó được 0 điểm | +| Đổi thứ tự, thêm hoặc xóa lựa chọn | ❌ | Đáp án cũ trỏ tới sai lựa chọn | +| Đổi loại câu hỏi | ❌ | Đáp án cũ không còn hợp lệ | +| Xáo trộn câu hỏi, xáo trộn lựa chọn | ✅ | Chỉ áp dụng cho lượt mới | + +::: danger Câu hỏi dùng chung +Một câu hỏi có thể nằm trong nhiều bài. Sửa nó là sửa ở **mọi** bài dùng nó. Muốn đổi mạnh tay, hãy tạo câu mới với mã mới, rồi thay trong bài kiểm tra. +::: + +## Dùng Django admin + +Quản trị viên (tài khoản có `is_staff` và quyền model tương ứng) có thể quản lý mọi thứ trong admin, ở nhóm **Quiz**: + +| Trang admin | Dùng khi cần | +|---|---| +| `/admin/quiz/quiz/` | Sửa **người tạo** (authors) của bài, sửa câu hỏi dạng bảng inline (có cột **order**), hành động **Clone selected quizzes** | +| `/admin/quiz/quizquestion/` | Sửa **người tạo** và **giám khảo** của câu hỏi; nút **Nhập câu hỏi** (`/admin/quiz/quizquestion/import/`) nhập XLSX/JSON **có lưu Answer Display** | +| `/admin/quiz/quizcategory/` | Tạo, sửa danh mục (**Nhóm**) | +| `/admin/quiz/quizattempt/` | Xem câu trả lời từng lượt, xóa lượt làm thử | +| `/admin/quiz/quizviolation/` | Tra cứu toàn bộ vi phạm theo loại | + +Trong trang sửa bài trên web, quản trị viên còn thấy link **Sửa bài kiểm tra này ở admin panel để có nhiều tùy chỉnh hơn**. + +::: warning +- Admin **không** kiểm tra người tạo/giám khảo: người có quyền sửa model trong admin sửa được **mọi** bài. +- Trong admin, `choices` và `correct_answers` là JSON thô. `choices` có dạng `[{"text": "...", "explanation": "..."}]`. `correct_answers`: MC là chỉ số từ 0, TF là `0` (Đúng) hoặc `1` (Sai), MA là mảng chỉ số từ 0, SA là mảng mẫu regex. Nhập sai sẽ làm hỏng việc chấm. Nên dùng form trên web. +::: + +## Kiểm tra kết quả + +- [ ] Bài xuất hiện ở `/quizzes/` khi đăng nhập bằng một tài khoản học sinh (hoặc tài khoản trong tổ chức, với bài dành riêng cho tổ chức). +- [ ] Trang bài hiện đúng số câu, tổng điểm, giới hạn thời gian, số lượt và lịch. +- [ ] Một người thử làm hết bài, nộp, và trang kết quả hiện đúng chế độ phản hồi. +- [ ] `/quizzes//attempts` có lượt đó; nếu bật giám sát, chuyển tab lúc làm thử phải sinh ra một vi phạm **Chuyển tab**. + +## Sự cố thường gặp + +| Triệu chứng | Cách xử lý | +|---|---| +| Không thấy nút **Quiz** trên thanh công cụ, `/quizzes/questions/` báo 404 | Tài khoản thiếu `edit_own_quiz` / `edit_all_quiz`. Nhờ quản trị viên cấp quyền | +| Không tìm thấy nút tạo bài kiểm tra | Mở thẳng `/quizzes/new` | +| Học sinh không thấy bài | Chưa tích **Hiển thị công khai**; hoặc bài dành riêng cho tổ chức mà học sinh chưa vào tổ chức | +| Học sinh báo **Quiz not started yet.** | Kiểm tra **Thời gian bắt đầu** và múi giờ | +| Học sinh vẫn nộp được sau giờ kết thúc | Đó là lượt có giới hạn thời gian bắt đầu trước giờ kết thúc; lượt đó được chạy đủ giờ | +| Sửa câu hỏi của người khác thì báo 404 | Bạn không phải người tạo/giám khảo của câu đó. Câu **Công khai** chỉ dùng được, không sửa được | +| Không có danh mục để chọn ở **Nhóm** | Tạo danh mục trong `/admin/quiz/quizcategory/`, hoặc khai báo ở cột Category khi nhập file | +| Lưu câu SA báo `Invalid regex` | Mẫu có `\|` hoặc dấu ngoặc chưa đóng. Tách thành nhiều mẫu, escape `.`, `(`, `)`, `+`, `*` | +| Học sinh gõ đúng mà bị chấm sai | Mẫu phân biệt hoa/thường (thêm `(?i)`), hoặc chưa escape ký tự đặc biệt. Sửa mẫu rồi **chấm lại** | +| Điểm không đổi sau khi sửa đáp án | Chưa bấm **Chấm lại tất cả lần làm bài** | +| Nhập file báo trùng mã | Mã câu hỏi là duy nhất trên toàn LCOJ. Đổi mã hoặc xóa câu mẫu khỏi file | +| Cột Answer Display không được lưu | Trang nhập trên web bỏ qua cột này. Sửa câu sau khi nhập, hoặc nhập qua admin | +| Form báo "End time must be after start time." | Giờ kết thúc phải sau giờ bắt đầu | +| Clone báo không tạo được mã | Các mã `2` … `9` đã có hết. Đổi mã gốc hoặc tự tạo bài mới | +| Lượt học sinh treo ở **đang làm bài** | Học sinh đã bỏ dở. Lượt được chốt khi học sinh mở lại trang bài | + +## Tiếp theo + +- [Làm bài trắc nghiệm](/features/quiz): trải nghiệm phía học sinh. +- [Hệ thống phân quyền](/site/permission_system): cấp quyền cho giáo viên và nhóm. diff --git a/src/features/url_shortener.md b/src/features/url_shortener.md new file mode 100644 index 0000000..23ee45c --- /dev/null +++ b/src/features/url_shortener.md @@ -0,0 +1,334 @@ +# Rút gọn liên kết + +LCOJ có sẵn một công cụ rút gọn liên kết nội bộ: bạn gán một **mã ngắn** (ví dụ `hsg2026`) cho một địa chỉ dài, rồi chia sẻ link dạng `https:///hsg2026`. Công cụ này hữu ích khi cần phát link kỳ thi, form đăng ký hay tài liệu trên slide, poster, tin nhắn nhóm. + +Trang này gồm ba phần, cho ba nhóm người đọc: + +| Phần | Dành cho | Nội dung | +|---|---|---| +| [1. Tạo và quản lý link rút gọn](#_1-tao-va-quan-ly-link-rut-gon) | Staff đã được cấp quyền | Tạo, sửa, tắt, xoá link; xem số lượt truy cập | +| [2. Cấp quyền](#_2-cap-quyen) | Admin | Cấp 4 quyền `urlshortener.*` qua trang admin | +| [3. Dùng tên miền riêng](#_3-dung-ten-mien-rieng) | Người vận hành server | Bật middleware, cấu hình `URLSHORTENER_DOMAIN`, `ALLOWED_HOSTS`, định tuyến tên miền | + +::: warning Link rút gọn chỉ hoạt động khi đã cấu hình tên miền riêng +Việc chuyển hướng `/` **chỉ** được xử lý trên tên miền rút gọn riêng (Phần 3). Trên tên miền chính (ví dụ `luyencode.net`) không có route nào cho `/`, nên `https://luyencode.net/hsg2026` sẽ trả về lỗi 404. + +Cấu hình mặc định của LCOJ (`dmoj/config/local_settings.py`) **chưa** đặt `URLSHORTENER_DOMAIN` và **chưa** thêm `URLShortenerMiddleware` vào `MIDDLEWARE`. Khi đó bạn vẫn tạo và quản lý link được, nhưng link sao chép ra chỉ là đường dẫn tương đối `/` và không dùng được. Hãy nhờ người vận hành làm Phần 3 trước. +::: + +## Cách hoạt động + +```mermaid +sequenceDiagram + participant V as Người truy cập + participant CF as DNS / Tunnel + participant N as nginx + participant S as site (Django) + participant DB as MariaDB + V->>CF: GET s.example.com/hsg2026 + CF->>N: Host s.example.com + N->>S: uwsgi_pass site:8000 + S->>S: Middleware so khớp Host + S->>DB: Tìm mã hsg2026 + DB-->>S: URL gốc, trạng thái + S->>DB: Tăng lượt truy cập + S-->>V: 302 tới URL gốc +``` + +- Middleware so sánh Host của request với `URLSHORTENER_DOMAIN`. Nếu khớp, request được định tuyến bằng bảng URL riêng `urlshortener.urls_redirect`, chỉ có đúng một route: `/`. +- Tìm thấy mã và link đang bật: tăng bộ đếm, ghi thời điểm truy cập, trả **302** (chuyển hướng tạm thời) về URL gốc. +- Không tìm thấy mã, hoặc link đã tắt: trả **404**. +- Người truy cập **không cần đăng nhập**. + +--- + +## 1. Tạo và quản lý link rút gọn + +⏱ ~2 phút · 👤 Staff đã được cấp quyền · 🔑 `urlshortener.view_urlshortener`, `urlshortener.add_urlshortener`, `urlshortener.change_urlshortener`, `urlshortener.delete_urlshortener` + +### Trước khi bắt đầu + +- Bạn đã đăng nhập và được cấp quyền (xem [Phần 2](#_2-cap-quyen)). Chưa đăng nhập sẽ bị chuyển tới trang đăng nhập; đăng nhập nhưng thiếu quyền sẽ gặp lỗi 403. +- Trang quản lý nằm ở **`/shorteners/`** trên tên miền chính (ví dụ `https://luyencode.net/shorteners/`). Menu và thanh điều hướng **không có** liên kết tới trang này, nên hãy lưu lại địa chỉ. +- Để link hoạt động được thì tên miền rút gọn phải được cấu hình sẵn (Phần 3). + +Các trang quản lý: + +| Địa chỉ | Chức năng | Quyền cần có | +|---|---|---| +| `/shorteners/` | Danh sách tất cả link, 20 link/trang | `view_urlshortener` | +| `/shorteners/create/` | Tạo link mới | `add_urlshortener` | +| `/shorteners//` | Xem chi tiết một link | `view_urlshortener` | +| `/shorteners//edit/` | Sửa link | `change_urlshortener` | +| `/shorteners//delete/` | Xoá link | `delete_urlshortener` | + +::: tip Nhãn giao diện +Phần lớn nhãn của tính năng này chưa có bản dịch tiếng Việt, nên kể cả khi giao diện đang ở tiếng Việt bạn vẫn sẽ thấy tiếng Anh (ví dụ `Create New`, `Original URL`, `Short code`). Trang này ghi nhãn đúng như trên màn hình. +::: + +### Tạo link mới + +1. Mở `/shorteners/`, bấm tab **Create New** (tab bên cạnh là **Danh sách**). +2. Điền form: + + | Trường | Bắt buộc | Ý nghĩa và quy tắc | + |---|---|---| + | **Original URL** | Có | Địa chỉ đích đầy đủ, phải là URL hợp lệ, gồm cả `https://` (ví dụ `https://luyencode.net/contest/hsg2026`). Nhập `luyencode.net/...` không có scheme sẽ bị báo lỗi. | + | **Short code** | Có | Mã ngắn, xuất hiện sau dấu `/` của link. Chỉ gồm chữ cái Latin không dấu, chữ số, dấu gạch ngang `-` và gạch dưới `_`; tối đa 50 ký tự; **không trùng** với mã đã có. Không có khoảng trắng, dấu tiếng Việt hay ký tự như `@`, `!`, `.`, `/`. | + | **Is active** | Không (mặc định bật) | Bật: link chuyển hướng bình thường. Tắt: người truy cập nhận lỗi 404, nhưng link vẫn được giữ lại để bật lại sau. | + + Nút có biểu tượng xáo trộn 🔀 bên phải ô **Short code** sẽ điền một mã ngẫu nhiên gồm 8 ký tự chữ và số. Mã này không được kiểm tra trùng trước khi bạn lưu. +3. Bấm **Tạo**. +4. Bạn được chuyển tới trang chi tiết của link. Hộp trên cùng hiển thị link rút gọn đầy đủ; bấm **Copy** để sao chép. + +::: tip Chọn mã ngắn +- Ưu tiên mã dễ đọc, dễ gõ: `hsg2026`, `dang-ky-k10`, `slide_buoi3`. +- Đừng dùng chữ hoa/thường để phân biệt hai mã khác nhau (`HSG` và `hsg`); dễ nhầm khi đọc và có thể bị cơ sở dữ liệu coi là trùng. +- Tránh đặt mã là `create`: trang chi tiết `/shorteners/create/` sẽ bị trang tạo link mới “chiếm” mất (link vẫn chuyển hướng bình thường). +::: + +### Xem danh sách và thống kê + +Trang **Danh sách** (`/shorteners/`) hiển thị **mọi** link trong hệ thống, mới nhất trước. Link không thuộc riêng người tạo; ai có quyền xem đều thấy tất cả. Các cột: + +| Cột | Nội dung | +|---|---| +| **Short URL** | Mã ngắn, kèm biểu tượng 📋 để sao chép link đầy đủ | +| **Original URL** | URL gốc (cắt còn 50 ký tự đầu nếu dài) | +| **Accesses** | Số lượt truy cập | +| **Trạng thái** | `Active` hoặc `Inactive` (link đã tắt hiển thị mờ) | +| **Created** | Thời điểm tạo | +| **Actions** | Xem 👁, sửa ✏️, xoá 🗑 | + +Trang chi tiết có thêm **Last Accessed**: thời điểm truy cập gần nhất, chỉ hiện khi đã có ít nhất một lượt. + +::: details Lượt truy cập được đếm như thế nào? +- Mỗi request tới link đang bật mà được chuyển hướng thành công sẽ cộng 1 vào **Access Count** và cập nhật **Last Accessed**. +- Không lọc trùng người truy cập hay bot: bấm lại 10 lần là 10 lượt. Công cụ xem trước link (Zalo, Messenger, Discord…) cũng có thể được tính. +- Truy cập vào link đã tắt, hoặc mã không tồn tại, **không** được tính. +- Không có thống kê theo ngày, theo nguồn hay theo quốc gia. +::: + +### Sửa, tắt hoặc xoá link + +1. Trong **Danh sách**, bấm biểu tượng ✏️ (hoặc nút **Chỉnh sửa** trên trang chi tiết). +2. Sửa các trường như lúc tạo, rồi bấm **Lưu**. + - Đổi **Original URL**: link cũ vẫn giữ nguyên, chỉ đích đến thay đổi. Thích hợp khi cần đổi đích mà không phát lại link. + - Đổi **Short code**: link cũ **ngừng hoạt động** ngay (404), vì hệ thống không lưu mã cũ. + - Bỏ chọn **Is active**: tạm khoá link, giữ nguyên thống kê. +3. Để xoá hẳn, bấm 🗑 (hoặc **Xoá** trên trang chi tiết). Trang xác nhận hiện mã, URL gốc, số lượt truy cập, ngày tạo; bấm **Xoá** để xác nhận. Việc xoá **không thể hoàn tác**, sau đó bạn được đưa về **Danh sách**. + +::: warning +Link rút gọn không có thời hạn: nó hoạt động cho tới khi bị tắt hoặc xoá. Khi kỳ thi hay sự kiện đã xong, hãy tắt link nếu không muốn người khác tiếp tục dùng. +::: + +### Người truy cập thấy gì + +- Link đang bật: trình duyệt chuyển thẳng tới URL gốc (HTTP 302), không có trang trung gian. +- Link đã tắt hoặc mã sai: trang lỗi 404. +- Tham số truy vấn **không** được chuyển tiếp: `https:///hsg2026?ref=fb` vẫn chỉ tới đúng URL gốc, phần `?ref=fb` bị bỏ. +- Dấu `/` ở cuối **không** được chấp nhận: `/hsg2026/` trả về 404, phải dùng `/hsg2026`. + +### Kiểm tra kết quả + +1. Trên trang chi tiết, bấm **Copy** rồi dán vào tab ẩn danh (không đăng nhập). +2. Trình duyệt phải mở đúng URL gốc. +3. Tải lại trang chi tiết: **Access Count** tăng lên và xuất hiện **Last Accessed**. + +### Xử lý sự cố + +| Triệu chứng | Cách khắc phục | +|---|---| +| Vào `/shorteners/` bị chuyển tới trang đăng nhập | Đăng nhập trước. | +| Lỗi 403 khi mở danh sách, tạo, sửa hoặc xoá | Thiếu quyền tương ứng; nhờ admin cấp (Phần 2). | +| Tạo thành công nhưng sau đó gặp 403 | Bạn có quyền `add` nhưng thiếu `view`: sau khi lưu, hệ thống chuyển tới trang chi tiết, trang này cần `view_urlshortener`. Link vẫn đã được tạo. | +| Link sao chép ra chỉ là `/hsg2026` (không có tên miền) | Chưa cấu hình `URLSHORTENER_DOMAIN`; báo người vận hành (Phần 3). | +| Mở link bị 404 | Kiểm tra: link đang `Active`; mã gõ đúng; không có `/` ở cuối; link được mở trên tên miền rút gọn chứ không phải tên miền chính; tên miền rút gọn đã cấu hình xong (Phần 3). | +| Lỗi ở ô **Short code** khi lưu | Mã đã tồn tại, chứa ký tự không được phép, hoặc dài quá 50 ký tự. | +| Lỗi ở ô **Original URL** | Thiếu `http://` / `https://` hoặc URL sai định dạng. | +| Đổi mã xong, link cũ đã in trên poster bị 404 | Đổi mã về như cũ. Muốn có thêm mã mới thì tạo thêm một link riêng trỏ cùng đích. | + +--- + +## 2. Cấp quyền + +⏱ ~3 phút · 👤 Admin (superuser, hoặc người được sửa User/Group trong trang admin) · 🔑 quyền sửa `auth.User` hoặc `auth.Group` + +Tính năng dùng 4 quyền mặc định mà Django sinh cho model `URLShortener`: + +| Quyền | Cho phép | +|---|---| +| `urlshortener.view_urlshortener` | Xem danh sách và trang chi tiết | +| `urlshortener.add_urlshortener` | Tạo link mới | +| `urlshortener.change_urlshortener` | Sửa link (bao gồm bật/tắt) | +| `urlshortener.delete_urlshortener` | Xoá link | + +::: tip Nên cấp đủ bộ 4 quyền +Các nút **Chỉnh sửa**/**Xoá** luôn hiển thị với người có quyền xem, và sau khi tạo link người dùng được đưa tới trang chi tiết (cần quyền xem). Cấp lẻ từng quyền dễ gây lỗi 403 khó hiểu. Với người chỉ cần xem thống kê thì chỉ cấp `view_urlshortener`. +::: + +::: details Model này không có trang quản lý trong Django admin +App `urlshortener` không đăng ký model vào Django admin, nên trong `/admin/` bạn **không** tìm thấy mục “URL shortener” để quản lý link. Admin chỉ dùng để cấp quyền; mọi thao tác với link đều qua `/shorteners/`. Superuser tự động có đủ mọi quyền. +::: + +### Trước khi bắt đầu + +- Bạn đăng nhập được vào `/admin/` với quyền sửa người dùng hoặc nhóm. +- Đã chạy migration của app `urlshortener` (`./scripts/migrate`), để 4 quyền trên tồn tại trong cơ sở dữ liệu. + +### Cách A: cấp qua nhóm (khuyến nghị) + +1. Vào `/admin/auth/group/`, mở một nhóm có sẵn hoặc tạo nhóm mới (ví dụ `Link Managers`). +2. Trong ô chọn quyền, gõ `URL shortener` để lọc. Các quyền hiện dạng `URL Shortener | URL shortener | Can view URL shortener`, tương tự với `add`, `change`, `delete`. +3. Chuyển các quyền cần cấp sang cột đã chọn, rồi lưu. +4. Mở `/admin/auth/user/`, chọn người dùng, thêm họ vào nhóm vừa tạo ở mục nhóm, rồi lưu. + +### Cách B: cấp trực tiếp cho một người + +1. Vào `/admin/auth/user/`, mở người dùng cần cấp. +2. Ở mục quyền người dùng, gõ `urlshortener` để lọc. Trang người dùng của LCOJ hiển thị quyền dạng `urlshortener.view_urlshortener | Can view URL shortener`. +3. Chọn các quyền cần cấp, rồi lưu. + +Xem thêm về cơ chế quyền tại [Hệ thống phân quyền](/site/permission_system). + +### Kiểm tra kết quả + +1. Đăng nhập bằng tài khoản vừa được cấp quyền (hoặc nhờ họ tự thử). +2. Mở `/shorteners/`: phải thấy trang **Danh sách** thay vì lỗi 403. +3. Bấm **Create New**: phải thấy form tạo link (nếu đã cấp `add_urlshortener`). + +Bạn cũng có thể kiểm tra trong Django shell: + +```sh +./scripts/manage.py shell -c "from django.contrib.auth.models import User; u = User.objects.get(username='ten_dang_nhap'); print(sorted(p for p in u.get_all_permissions() if p.startswith('urlshortener.')))" +``` + +### Xử lý sự cố + +| Triệu chứng | Cách khắc phục | +|---|---| +| Không tìm thấy quyền `urlshortener` trong danh sách | Chạy `./scripts/migrate` để tạo bảng và quyền, rồi tải lại trang admin. | +| Đã cấp quyền nhưng người dùng vẫn bị 403 | Kiểm tra lại đúng tài khoản; người dùng đã thuộc nhóm; nhóm có đúng quyền cần cho thao tác đó (xem bảng quyền ở trên). | +| Người dùng tạo được nhưng bị 403 sau khi bấm **Tạo** | Cấp thêm `view_urlshortener`. | +| Tìm trong admin không thấy danh sách link | Đúng như thiết kế: quản lý link tại `/shorteners/`. | + +--- + +## 3. Dùng tên miền riêng + +⏱ ~15 phút · 👤 Người vận hành server · 🔑 quyền truy cập server, `dmoj/repo/dmoj/local_settings.py` và cấu hình DNS / Cloudflare Tunnel + +Phần này cấu hình một tên miền riêng, ví dụ `s.example.com`, để `https://s.example.com/` chuyển hướng tới URL gốc. Trong các ví dụ dưới đây, hãy thay `s.example.com` bằng tên miền thật của bạn. + +### Middleware quyết định thế nào + +`urlshortener.middleware.URLShortenerMiddleware` làm đúng một việc: nếu `URLSHORTENER_DOMAIN` được đặt **và** `request.get_host()` **bằng đúng** giá trị đó, request được định tuyến bằng `urlshortener.urls_redirect` thay cho bảng URL chính. + +```mermaid +flowchart TD + A["Request tới site"] --> B{"URLSHORTENER_DOMAIN đã đặt?"} + B -- Không --> M["Bảng URL chính (luyencode.net)"] + B -- Có --> C{"Host == URLSHORTENER_DOMAIN?"} + C -- Không --> M + C -- Có --> R["urls_redirect: chỉ có /mã-ngắn"] + R --> D{"Mã tồn tại và đang bật?"} + D -- Có --> E["302 tới URL gốc, đếm lượt"] + D -- Không --> F["404"] +``` + +Hệ quả: + +- So sánh là **so khớp chính xác** chuỗi Host, kể cả cổng. Ví dụ `s.example.com:8071` **không** khớp với `s.example.com`. +- Trên tên miền rút gọn, **chỉ** có `/`. Trang chủ `/`, `/shorteners/`, `/admin/`… đều 404. Hãy quản lý link trên tên miền chính. +- `URLSHORTENER_DOMAIN` còn được dùng để dựng link hiển thị ở trang quản lý: nếu giá trị không có scheme, LCOJ tự thêm `https://` vào trước (ví dụ `s.example.com` → `https://s.example.com/hsg2026`). Tuy nhiên, vì middleware so khớp với Host, giá trị **phải là tên miền trần** (không scheme, không cổng). Nếu ghi `https://s.example.com`, link hiển thị vẫn đúng nhưng chuyển hướng sẽ không bao giờ chạy. + +### Trước khi bắt đầu + +- LCOJ đã chạy ổn định trên tên miền chính (xem [Cài đặt website](/site/installation)). +- Bạn quản lý được DNS của tên miền rút gọn, và Cloudflare Tunnel (nếu dùng, như bản production). +- Trong `docker-compose.yml`, nginx được publish ra host ở cổng `${NGINX_PORT:-8071}` (mặc định `8071`). Tunnel của tên miền chính đang trỏ tới cổng này. +- Bạn biết file cấu hình thật là `dmoj/repo/dmoj/local_settings.py` (được git bỏ qua). Script `./scripts/initialize` copy nó từ `dmoj/config/local_settings.py`. + +### Các bước + +1. **Trỏ tên miền về nginx.** Thêm một public hostname `s.example.com` trong Cloudflare Tunnel, trỏ tới **cùng dịch vụ nginx** với tên miền chính (`http://:8071`). Không ghi đè Host header: Django cần nhận đúng `Host: s.example.com`. + +2. **(Tuỳ chọn) Khai báo trong nginx.** `dmoj/nginx/conf.d/nginx.conf` chỉ có một khối `server` (`listen 80`, `server_name luyencode.net;`) nên nó là server mặc định và đã nhận mọi Host. Nhờ vậy request tới `s.example.com` đã tới `site` qua `uwsgi_pass site:8000` (có `include uwsgi_params`, nên Host được chuyển tiếp). Muốn khai báo tường minh, thêm tên miền vào `server_name`: + + ```nginx + server_name luyencode.net s.example.com; + ``` + + Sau đó chạy `docker compose restart nginx`. + +3. **Cấu hình Django.** Thêm vào cuối `dmoj/repo/dmoj/local_settings.py`: + + ```python + # Tên miền rút gọn: chỉ tên miền trần, không scheme, không cổng + URLSHORTENER_DOMAIN = 's.example.com' + + # Django từ chối Host không có trong ALLOWED_HOSTS (lỗi 400) + ALLOWED_HOSTS = [HOST, URLSHORTENER_DOMAIN] + + # Middleware không được bật sẵn trong dmoj/settings.py + MIDDLEWARE += ('urlshortener.middleware.URLShortenerMiddleware',) + ``` + + - `local_settings.py` được `exec` ở cuối `dmoj/settings.py`, nên dùng được `HOST` và `MIDDLEWARE += (...)` (`MIDDLEWARE` là tuple). + - Thêm middleware vào cuối danh sách là đủ, vì việc chọn bảng URL chỉ xảy ra sau khi tất cả middleware đã chạy phần xử lý request. + - `URLSHORTENER_DOMAIN` **không** đọc từ biến môi trường; phải đặt trong `local_settings.py`. + - Không sửa `dmoj/settings.py`. + +4. **Lưu cấu hình vào template.** Chép các dòng trên vào `dmoj/config/local_settings.py` luôn, để lần chạy `./scripts/initialize` sau không làm mất cấu hình. + +5. **Khởi động lại:** + + ```sh + cd lcoj-docker/dmoj + docker compose restart site celery + ``` + +### Kiểm tra kết quả + +Tạo một link thử (Phần 1), ví dụ mã `test123` trỏ tới `https://luyencode.net/`, rồi: + +```sh +# 1. Gọi thẳng nginx trên server, giả lập Host của tên miền rút gọn +curl -sI -H 'Host: s.example.com' http://localhost:8071/test123 +# Mong đợi: HTTP/1.1 302 Found và Location: https://luyencode.net/ + +# 2. Qua Internet (DNS / Tunnel) +curl -sI https://s.example.com/test123 +# Mong đợi: 302 và Location như trên + +# 3. Mã không tồn tại +curl -sI https://s.example.com/khong-ton-tai +# Mong đợi: 404 + +# 4. Tên miền chính không bị ảnh hưởng +curl -sI https://luyencode.net/ +# Mong đợi: 200 như trước +``` + +Cuối cùng, mở trang chi tiết của link: hộp link phải hiển thị `https://s.example.com/test123`, và **Access Count** đã tăng. + +### Xử lý sự cố + +| Triệu chứng | Cách khắc phục | +|---|---| +| `400 Bad Request` trên tên miền rút gọn | Chưa thêm tên miền vào `ALLOWED_HOSTS`, hoặc chưa restart `site`. Xem `docker compose logs site` có dòng `DisallowedHost`. | +| Tên miền rút gọn hiện trang chủ hoặc 404 của site chính | Middleware chưa chạy: thiếu `MIDDLEWARE += ('urlshortener.middleware.URLShortenerMiddleware',)`; hoặc giá trị `URLSHORTENER_DOMAIN` khác Host thật (có scheme, có cổng, sai chính tả, hoa/thường). | +| `curl -H 'Host: ...'` lên `localhost:8071` chạy, nhưng qua Internet thì không | Kiểm tra public hostname của tunnel/DNS trỏ đúng tới nginx và không ghi đè Host header. | +| Link hiển thị trong trang quản lý vẫn là `/mã` | `URLSHORTENER_DOMAIN` chưa được nạp: kiểm tra đã sửa đúng file `dmoj/repo/dmoj/local_settings.py` và đã restart. | +| `/mã/` (có `/` cuối) bị 404 | Đúng như thiết kế; dùng `/mã`. | +| Cấu hình mất sau khi chạy lại `./scripts/initialize` | Chép cấu hình vào `dmoj/config/local_settings.py` (bước 4). | + +Xem thêm lệnh vận hành tại [Vận hành hệ thống](/site/operations). + +--- + +## Tiếp theo + +- [Hệ thống phân quyền](/site/permission_system): các quyền khác trong LCOJ và cách tổ chức nhóm. +- [Vận hành hệ thống](/site/operations): restart service, xem log, kiểm tra trạng thái. +- [Cài đặt website](/site/installation): cài đặt lại hoặc dựng môi trường thử nghiệm để kiểm tra tên miền rút gọn trước khi áp dụng cho production. diff --git a/src/index.md b/src/index.md new file mode 100644 index 0000000..a95f768 --- /dev/null +++ b/src/index.md @@ -0,0 +1,43 @@ +--- +layout: home +title: Tài liệu LCOJ +titleTemplate: ":title - LCOJ: Luyện Code Online Judge" +description: Tài liệu LCOJ - Luyện Code Online Judge. Hướng dẫn cài đặt, vận hành, ra đề, tổ chức kỳ thi và làm bài trắc nghiệm, bằng tiếng Việt và tiếng Anh. + +hero: + name: LCOJ + text: Hệ thống chấm bài trực tuyến + tagline: Tài liệu hướng dẫn cài đặt, vận hành và sử dụng LCOJ, được phát triển dựa trên DMOJ và VNOJ. + image: + src: /logo.png + alt: LCOJ + actions: + - theme: brand + text: Cài đặt website + link: /site/installation + - theme: alt + text: Cấu trúc bài tập + link: /problem_format/problem_format + +features: + - title: Tính năng + details: Bài trắc nghiệm, thư viện đề thi và rút gọn liên kết, cho học sinh, giáo viên và quản trị viên. + link: /features/quiz + - title: Hệ thống Website + details: Cài đặt, vận hành, cập nhật và cấu hình website LCOJ bằng Docker. + link: /site/installation + - title: Hệ thống Judge + details: Cài đặt và cấu hình judge, các ngôn ngữ được hỗ trợ và mã trạng thái. + link: /judge/setting_up_a_judge + - title: Định dạng bài tập + details: Cách tạo bài tập, viết checker, grader, generator, kèm ví dụ thực tế. + link: /problem_format/problem_format +--- + +## Hỗ trợ + +Nếu có thắc mắc, vui lòng tạo issue tại [GitHub Issues](https://github.com/luyencode/lcoj-docker/issues). + +**Cần hỗ trợ cài đặt?** Nếu bạn quá mệt mỏi để tự mình cài đặt, LCOJ sẽ hỗ trợ bạn cài đặt miễn phí. Liên hệ tại [behitek.com](https://behitek.com) hoặc [luyencode.net/about/#lien-he](https://luyencode.net/about/#lien-he). + +Các ví dụ bài tập thực tế nằm trong thư mục [problem_examples](https://github.com/luyencode/docs/tree/master/problem_examples). diff --git a/docs/judge/judge_configuration.md b/src/judge/judge_configuration.md similarity index 100% rename from docs/judge/judge_configuration.md rename to src/judge/judge_configuration.md diff --git a/docs/judge/setting_up_a_judge.md b/src/judge/setting_up_a_judge.md similarity index 100% rename from docs/judge/setting_up_a_judge.md rename to src/judge/setting_up_a_judge.md diff --git a/docs/judge/status_codes.md b/src/judge/status_codes.md similarity index 100% rename from docs/judge/status_codes.md rename to src/judge/status_codes.md diff --git a/docs/judge/supported_languages.md b/src/judge/supported_languages.md similarity index 100% rename from docs/judge/supported_languages.md rename to src/judge/supported_languages.md diff --git a/docs/problem_format/custom_checkers.md b/src/problem_format/custom_checkers.md similarity index 100% rename from docs/problem_format/custom_checkers.md rename to src/problem_format/custom_checkers.md diff --git a/docs/problem_format/custom_graders.md b/src/problem_format/custom_graders.md similarity index 100% rename from docs/problem_format/custom_graders.md rename to src/problem_format/custom_graders.md diff --git a/docs/problem_format/generator.md b/src/problem_format/generator.md similarity index 100% rename from docs/problem_format/generator.md rename to src/problem_format/generator.md diff --git a/docs/problem_format/problem_examples.md b/src/problem_format/problem_examples.md similarity index 100% rename from docs/problem_format/problem_examples.md rename to src/problem_format/problem_examples.md diff --git a/docs/problem_format/problem_format.md b/src/problem_format/problem_format.md similarity index 100% rename from docs/problem_format/problem_format.md rename to src/problem_format/problem_format.md diff --git a/docs/CNAME b/src/public/CNAME similarity index 100% rename from docs/CNAME rename to src/public/CNAME diff --git a/src/public/android-chrome-192x192.png b/src/public/android-chrome-192x192.png new file mode 100644 index 0000000..21ac827 Binary files /dev/null and b/src/public/android-chrome-192x192.png differ diff --git a/src/public/apple-touch-icon-180x180.png b/src/public/apple-touch-icon-180x180.png new file mode 100644 index 0000000..9f34e8a Binary files /dev/null and b/src/public/apple-touch-icon-180x180.png differ diff --git a/src/public/favicon-16x16.png b/src/public/favicon-16x16.png new file mode 100644 index 0000000..5ec0076 Binary files /dev/null and b/src/public/favicon-16x16.png differ diff --git a/src/public/favicon-32x32.png b/src/public/favicon-32x32.png new file mode 100644 index 0000000..17c4d66 Binary files /dev/null and b/src/public/favicon-32x32.png differ diff --git a/src/public/favicon-96x96.png b/src/public/favicon-96x96.png new file mode 100644 index 0000000..6070359 Binary files /dev/null and b/src/public/favicon-96x96.png differ diff --git a/src/public/favicon.ico b/src/public/favicon.ico new file mode 100644 index 0000000..ca39c2a Binary files /dev/null and b/src/public/favicon.ico differ diff --git a/src/public/logo.png b/src/public/logo.png new file mode 100644 index 0000000..3de58c4 Binary files /dev/null and b/src/public/logo.png differ diff --git a/src/public/og_logo.png b/src/public/og_logo.png new file mode 100644 index 0000000..22a5e7e Binary files /dev/null and b/src/public/og_logo.png differ diff --git a/docs/site/api.md b/src/site/api.md similarity index 100% rename from docs/site/api.md rename to src/site/api.md diff --git a/docs/site/contest_data_download.md b/src/site/contest_data_download.md similarity index 100% rename from docs/site/contest_data_download.md rename to src/site/contest_data_download.md diff --git a/docs/site/contest_formats.md b/src/site/contest_formats.md similarity index 100% rename from docs/site/contest_formats.md rename to src/site/contest_formats.md diff --git a/docs/site/installation.md b/src/site/installation.md similarity index 100% rename from docs/site/installation.md rename to src/site/installation.md diff --git a/docs/site/management_commands.md b/src/site/management_commands.md similarity index 99% rename from docs/site/management_commands.md rename to src/site/management_commands.md index 4bbb273..8e3c662 100644 --- a/docs/site/management_commands.md +++ b/src/site/management_commands.md @@ -172,7 +172,7 @@ Tạo editorial (lời giải) tự động cho bài tập bằng AI, sử dụn **Định dạng editorial:** -```markdown +````markdown ## Hiểu bài toán [Giải thích rõ ràng về bài toán] @@ -202,7 +202,7 @@ Tạo editorial (lời giải) tự động cho bài tập bằng AI, sử dụn ## Lỗi thường gặp - [Pitfall 1] - [Pitfall 2] -``` +```` **Kiểm tra và xuất bản:** diff --git a/docs/site/managing_problems.md b/src/site/managing_problems.md similarity index 99% rename from docs/site/managing_problems.md rename to src/site/managing_problems.md index 83f307b..daa60cb 100644 --- a/docs/site/managing_problems.md +++ b/src/site/managing_problems.md @@ -48,7 +48,7 @@ LCOJ hỗ trợ Markdown với các tính năng mở rộng: **Ví dụ đề bài:** -```markdown +````markdown # Đề bài Cho hai số nguyên $a$ và $b$. Hãy tính tổng của chúng. @@ -77,7 +77,7 @@ In ra một số nguyên duy nhất là $a + b$. - Thời gian: 1 giây - Bộ nhớ: 256 MB -``` +```` Xem [template đầy đủ](https://raw.githubusercontent.com/luyencode/docs/master/sample_files/problem_markdown_example.md.txt). diff --git a/docs/site/mathoid.md b/src/site/mathoid.md similarity index 100% rename from docs/site/mathoid.md rename to src/site/mathoid.md diff --git a/docs/site/operations.md b/src/site/operations.md similarity index 100% rename from docs/site/operations.md rename to src/site/operations.md diff --git a/docs/site/pdfoid.md b/src/site/pdfoid.md similarity index 100% rename from docs/site/pdfoid.md rename to src/site/pdfoid.md diff --git a/docs/site/permission_system.md b/src/site/permission_system.md similarity index 100% rename from docs/site/permission_system.md rename to src/site/permission_system.md diff --git a/docs/site/recaptcha.md b/src/site/recaptcha.md similarity index 100% rename from docs/site/recaptcha.md rename to src/site/recaptcha.md diff --git a/docs/site/ssl_content_proxy.md b/src/site/ssl_content_proxy.md similarity index 100% rename from docs/site/ssl_content_proxy.md rename to src/site/ssl_content_proxy.md diff --git a/docs/site/texoid.md b/src/site/texoid.md similarity index 100% rename from docs/site/texoid.md rename to src/site/texoid.md diff --git a/docs/site/updating.md b/src/site/updating.md similarity index 100% rename from docs/site/updating.md rename to src/site/updating.md diff --git a/docs/site/user_data_download.md b/src/site/user_data_download.md similarity index 100% rename from docs/site/user_data_download.md rename to src/site/user_data_download.md diff --git a/docs/site/uwsgi.md b/src/site/uwsgi.md similarity index 100% rename from docs/site/uwsgi.md rename to src/site/uwsgi.md diff --git a/superpowers/plans/2026-04-21-lcoj-problem-skill-suite.md b/superpowers/plans/2026-04-21-lcoj-problem-skill-suite.md new file mode 100644 index 0000000..16f6ba9 --- /dev/null +++ b/superpowers/plans/2026-04-21-lcoj-problem-skill-suite.md @@ -0,0 +1,956 @@ +# LCOJ Problem Skill Suite Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Create six workspace-level Codex skills that guide one LCOJ problem from seed/example to a validated artifact-first package. + +**Architecture:** The implementation creates one orchestrator skill and five specialist skills under `/home/hieu/workspaces/luyencode/.agents/skills/`. The skills generate future problem packages under `/home/hieu/workspaces/luyencode/problem-packages//`, avoid live LCOJ database/problem-data mutation, and use pressure scenarios as process tests. + +**Tech Stack:** Markdown skill documents, Codex skill frontmatter, shell verification with `rg` and `python3`. + +--- + +## Important Repository Note + +`/home/hieu/workspaces/luyencode` is not a git repository, so the skill files under `.agents/skills/` cannot be committed directly. Do not move the skills into `lcoj-docker` to make commits easier; the approved spec requires workspace-level skills. + +For each implementation task, verify the exact files on disk. At the end, commit only this plan document and any docs-submodule changes if they are edited during implementation. + +## File Structure + +Create these files: + +- `.agents/skills/lcoj-create-problem/SKILL.md`: orchestrator skill and hard gates. +- `.agents/skills/lcoj-create-problem/pressure-scenarios.md`: process tests and pass/fail criteria. +- `.agents/skills/lcoj-problem-design/SKILL.md`: problem design specialist. +- `.agents/skills/lcoj-statement-writing/SKILL.md`: Vietnamese statement specialist. +- `.agents/skills/lcoj-test-data/SKILL.md`: data, `init.yml`, and custom-mode specialist. +- `.agents/skills/lcoj-solution-editorial/SKILL.md`: AC solution and editorial specialist. +- `.agents/skills/lcoj-package-review/SKILL.md`: validation and review gate specialist. + +Do not create generated problem packages while implementing this plan. + +## Task 1: Pressure Scenarios + +**Files:** +- Create: `.agents/skills/lcoj-create-problem/pressure-scenarios.md` + +- [ ] **Step 1: Create the pressure scenario directory** + +Run: + +```bash +mkdir -p .agents/skills/lcoj-create-problem +``` + +Expected: command exits with status `0`. + +- [ ] **Step 2: Write the pressure scenarios** + +Create `.agents/skills/lcoj-create-problem/pressure-scenarios.md` with this content: + +```markdown +# LCOJ Problem Skill Suite Pressure Scenarios + +Use these scenarios to verify that the skill suite changes agent behavior. A passing agent should follow approval gates, avoid live mutations, and produce or request reviewable package artifacts only. + +## Scenario 1: Standard IO Seed + +Prompt: + +> Use `lcoj-create-problem`. Create an easy array problem about finding the longest strictly increasing contiguous segment. Vietnamese statement and editorial. Artifact-first package. + +Expected behavior: + +- Asks missing intake questions one at a time if code, score, or constraints are unclear. +- Presents a design before creating files. +- Waits for design approval. +- Uses package root `/home/hieu/workspaces/luyencode/problem-packages//`. +- Plans standard IO artifacts: `problem.yml`, `statement.md`, `editorial.md`, `solutions/ac.cpp`, `tests/gen.py`, `tests/cases/data.zip`, `tests/init.yml`, `validators/validate.py`, `import/create_problem.py`. +- Does not write to `lcoj-docker/dmoj/problems/`. +- Does not run a Django import script. + +## Scenario 2: Custom Checker Seed + +Prompt: + +> Use `lcoj-create-problem`. Create a graph construction problem where many valid answers exist. Need custom checker support. + +Expected behavior: + +- Classifies the judging mode as custom checker. +- Designs output validity requirements before data generation. +- Includes `checkers/checker.cpp` or `checkers/checker.py` in the package plan. +- Requires validation to compile or run the checker when practical. +- Does not treat sample output as the only valid output. + +## Scenario 3: Source Example + +Prompt: + +> Use `lcoj-create-problem`. Learn from this problem idea: shortest path with one discounted edge. Create an original LCOJ version. + +Expected behavior: + +- States that the new problem must be original and the source is inspiration only. +- Asks what difficulty, topic, and constraints are desired if missing. +- Produces a distinct story, variables, constraints, and samples. +- Keeps the intended algorithm explicit. + +## Scenario 4: Ambiguous Mode + +Prompt: + +> Use `lcoj-create-problem`. Create a problem where contestants output any valid team assignment. + +Expected behavior: + +- Does not assume standard IO comparison. +- Asks whether to use a custom checker or constrain output to a canonical answer. +- Waits for the answer before designing test data. + +## Scenario 5: Validation Failure + +Prompt: + +> Use `lcoj-package-review` on a package where `tests/init.yml` references `7.out` but `data.zip` only contains `1.out` through `6.out`. + +Expected behavior: + +- Reports `FAIL`. +- Names the missing file and the referencing config. +- Does not claim the package is complete. +- Directs the workflow back to test-data repair. +``` + +- [ ] **Step 3: Verify pressure scenarios exist** + +Run: + +```bash +rg -n "Scenario 1|Scenario 2|Scenario 3|Scenario 4|Scenario 5" .agents/skills/lcoj-create-problem/pressure-scenarios.md +``` + +Expected: five scenario headings are printed. + +## Task 2: Problem Design Skill + +**Files:** +- Create: `.agents/skills/lcoj-problem-design/SKILL.md` + +- [ ] **Step 1: Create the skill directory** + +Run: + +```bash +mkdir -p .agents/skills/lcoj-problem-design +``` + +Expected: command exits with status `0`. + +- [ ] **Step 2: Write `lcoj-problem-design`** + +Create `.agents/skills/lcoj-problem-design/SKILL.md` with this content: + +```markdown +--- +name: lcoj-problem-design +description: Use when an LCOJ problem seed, topic, difficulty, or source/example needs to become an approved original problem design +--- + +# LCOJ Problem Design + +## Purpose + +Turn a rough seed or source/example into a precise LCOJ problem design before any artifacts are written. + +## Inputs + +Accept either: + +- a short seed: topic, difficulty, rough idea +- a source/example problem used for inspiration + +If required details are missing, ask one focused question at a time. Do not ask a bundle of questions. + +## Required Design Decisions + +Classify and document: + +- problem code proposal +- Vietnamese title proposal +- source or inspiration note +- topic and tags/types +- group/category and score +- judging mode: standard IO, custom checker, custom grader, interactive, signature, or generator-backed +- time limit and memory limit +- constraints and variable definitions +- intended algorithm +- weaker approaches that should fail or time out +- edge cases and anti-wrong-solution cases +- sample tests and explanation +- required custom artifacts, if any + +## Approval Gate + +Present the design and ask for approval before artifact creation. Use this wording: + +> Does this problem design look right? I will not create the package artifacts until you approve it. + +If the user requests changes, revise the design and ask again. + +## Originality Rule + +When using a source/example, learn the concept and style only. Create original story, constraints, samples, wording, and test strategy. Do not copy statements, examples, or editorial text. + +## Mode Guidance + +- Standard IO: use when each input has one canonical output. +- Custom checker: use when many valid outputs exist. +- Custom grader: use when contestants implement functions or use special judging logic. +- Interactive: use when contestant and judge exchange messages during execution. +- Signature: use for IOI-style function-only submissions. +- Generator-backed: use when tests are generated by DMOJ from generator arguments instead of stored static files. + +If the mode affects correctness, stop and ask before continuing. + +## Output Template + +Use this structure for the proposed design: + +```markdown +**Problem Code:** `` +**Title:** `` +**Mode:** `` +**Topic/Tags:** `` +**Difficulty/Score:** `, ` +**Limits:** `