diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e145a3578..c1f63bb6f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -20,7 +20,7 @@ jobs: - name: Set up Node uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 - name: Get yarn cache directory path id: yarn-cache-dir-path run: echo "::set-output name=dir::$(yarn config get cacheFolder)" diff --git a/framework/eslint-config/package.json b/framework/eslint-config/package.json index 89e568941..d032dcc56 100644 --- a/framework/eslint-config/package.json +++ b/framework/eslint-config/package.json @@ -4,12 +4,12 @@ "license": "MIT", "main": "base.mjs", "dependencies": { - "@eslint-react/eslint-plugin": "^1.51.2", + "@eslint-react/eslint-plugin": "^1.51.3", "@eslint/compat": "^1.2.9", "@eslint/eslintrc": "^3.3.1", "@stylistic/eslint-plugin": "^4.4.1", - "@typescript-eslint/eslint-plugin": "^8.33.1", - "@typescript-eslint/parser": "^8.33.1", + "@typescript-eslint/eslint-plugin": "^8.34.0", + "@typescript-eslint/parser": "^8.34.0", "eslint-config-airbnb": "^19.0.4", "eslint-plugin-de-morgan": "^1.3.0", "eslint-plugin-github": "^6.0.0", diff --git a/package.json b/package.json index 1244386db..072dcb53d 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "mongodb-memory-server": "10.1.4", "nyc": "^17.1.0", "ora": "^8.2.0", - "oxlint": "^0.16.12", + "oxlint": "^1.0.0", "package-json": "^10.0.1", "semver": "^7.7.2", "simple-git": "^3.28.0", diff --git a/packages/components/frontend/ConfigEditor.tsx b/packages/components/frontend/ConfigEditor.tsx index f39c0b732..5df323611 100644 --- a/packages/components/frontend/ConfigEditor.tsx +++ b/packages/components/frontend/ConfigEditor.tsx @@ -117,7 +117,7 @@ function MonacoContainer({ } export default function ConfigEditor({ - schema, config, monaco, Markdown, onSave, registerAction, sidebar, + schema, config, monaco, Markdown, onSave, registerAction, sidebar, dynamic, }) { const getValue = () => { try { @@ -178,7 +178,7 @@ export default function ConfigEditor({
-
+
{sidebar && {sidebar}} diff --git a/packages/components/package.json b/packages/components/package.json index eb1e35469..fa8922738 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -3,20 +3,20 @@ "version": "1.0.0-beta.3", "main": "./frontend/index.ts", "dependencies": { - "allotment": "^1.20.3", + "allotment": "^1.20.4", "diff": "^8.0.2", "js-yaml": "^4.1.0", "lodash": "^4.17.21", "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", "react-toastify": "^11.0.5", - "schemastery-react": "^0.1.3" + "schemastery-react": "^0.1.4" }, "devDependencies": { "@types/diff": "^8.0.0", "@types/js-yaml": "^4.0.9", "@types/lodash": "^4.17.17", - "sass": "^1.89.1" + "sass": "^1.89.2" }, "peerDependencies": { "react": "*", diff --git a/packages/hydrooj/src/handler/domain.ts b/packages/hydrooj/src/handler/domain.ts index aae0826d9..811592e59 100644 --- a/packages/hydrooj/src/handler/domain.ts +++ b/packages/hydrooj/src/handler/domain.ts @@ -434,7 +434,7 @@ export const DomainApi = { }), async (ctx, args) => { if (!ctx.user.hasPerm(PERM.PERM_EDIT_DOMAIN)) throw new PermissionError(PERM.PERM_EDIT_DOMAIN); - if (args.uids) { + if (args.uids?.length) { const res = await user.updateGroup(ctx.domain._id, args.name, args.uids); return res.upsertedCount > 0; } diff --git a/packages/hydrooj/src/init.ts b/packages/hydrooj/src/init.ts index 592b178b1..931b52c26 100644 --- a/packages/hydrooj/src/init.ts +++ b/packages/hydrooj/src/init.ts @@ -1,5 +1,5 @@ const versionNum = +process.version.replace(/v/gim, '').split('.')[0]; -if (versionNum < 18) throw new Error('NodeJS >=18 required'); +if (versionNum < 22) throw new Error('NodeJS >=22 required'); console.log('Process', process.pid, 'running as', process.env.NODE_APP_INSTANCE === '0' ? 'master' : 'worker'); if (!global.Hydro) { diff --git a/packages/hydrooj/src/model/document.ts b/packages/hydrooj/src/model/document.ts index d076b0a26..647984ade 100644 --- a/packages/hydrooj/src/model/document.ts +++ b/packages/hydrooj/src/model/document.ts @@ -104,9 +104,9 @@ export async function set( ): Promise { await bus.parallel('document/set', domainId, docType, docId, $set, $unset); const update: UpdateFilter = {}; - if ($set) update.$set = $set; - if ($unset) update.$unset = $unset; - if ($push) update.$push = $push; + if ($set && Object.keys($set).length) update.$set = $set; + if ($unset && Object.keys($unset).length) update.$unset = $unset; + if ($push && Object.keys($push).length) update.$push = $push; return await coll.findOneAndUpdate( { domainId, docType, docId }, update, @@ -317,14 +317,14 @@ export async function setMultiStatus( ); } -export async function setStatusIfNotCondition( +export async function setStatusIfCondition( domainId: string, docType: T, docId: DocStatusType[T]['docId'], uid: number, filter: Filter, args: Partial = {}, returnDocument: 'before' | 'after' = 'after', ): Promise { try { return await collStatus.findOneAndUpdate( - { domainId, docType, docId, uid, $nor: [filter] }, + { domainId, docType, docId, uid, ...filter }, { $set: args }, { upsert: true, returnDocument }, ); @@ -338,8 +338,8 @@ export async function setIfNotStatus, returnDocument: 'before' | 'after' = 'after', ): Promise { - return await setStatusIfNotCondition( - domainId, docType, docId, uid, { [key]: ifNot } as any, + return await setStatusIfCondition( + domainId, docType, docId, uid, { [key]: { $ne: ifNot } } as any, { [key]: value, ...args }, returnDocument, ); } @@ -474,7 +474,7 @@ global.Hydro.model.document = { revSetStatus, set, setIfNotStatus, - setStatusIfNotCondition, + setStatusIfCondition, setStatus, setMultiStatus, setSub, diff --git a/packages/hydrooj/src/model/problem.ts b/packages/hydrooj/src/model/problem.ts index ce91df227..e90c1aabd 100644 --- a/packages/hydrooj/src/model/problem.ts +++ b/packages/hydrooj/src/model/problem.ts @@ -231,14 +231,15 @@ export class ProblemModel { static async edit(domainId: string, _id: number, $set: Partial): Promise { const delpid = $set.pid === ''; const ddoc = await DomainModel.get(domainId); + const $unset = delpid ? { pid: '' } : {}; if (delpid) { delete $set.pid; $set.sort = sortable(`P${_id}`, ddoc.namespaces); } else if ($set.pid) { $set.sort = sortable($set.pid, ddoc.namespaces); } - await bus.parallel('problem/before-edit', $set); - const result = await document.set(domainId, document.TYPE_PROBLEM, _id, $set, delpid ? { pid: '' } : undefined); + await bus.parallel('problem/before-edit', $set, $unset); + const result = await document.set(domainId, document.TYPE_PROBLEM, _id, $set, $unset); await bus.emit('problem/edit', result); return result; } @@ -419,10 +420,10 @@ export class ProblemModel { domainId: string, pid: number, uid: number, rid: ObjectId, status: number, score: number, ) { - const filter: Filter = { rid: { $ne: rid }, status: STATUS.STATUS_ACCEPTED }; - const res = await document.setStatusIfNotCondition( + const condition = status === STATUS.STATUS_ACCEPTED ? {} : { status: { $ne: STATUS.STATUS_ACCEPTED } }; + const res = await document.setStatusIfCondition( domainId, document.TYPE_PROBLEM, pid, uid, - filter, { rid, status, score }, + condition, { rid, status, score }, ); return !!res; } diff --git a/packages/hydrooj/src/model/record.ts b/packages/hydrooj/src/model/record.ts index a1114a8fb..da19759e8 100644 --- a/packages/hydrooj/src/model/record.ts +++ b/packages/hydrooj/src/model/record.ts @@ -15,6 +15,7 @@ import { ArgMethod, buildProjection, Time } from '../utils'; import { STATUS } from './builtin'; import DomainModel from './domain'; import problem from './problem'; +import * as SystemModel from './system'; import task from './task'; export default class RecordModel { @@ -291,6 +292,14 @@ export async function apply(ctx: Context) { ctx.on('domain/delete', (domainId) => RecordModel.coll.deleteMany({ domainId })); ctx.on('record/judge', async (rdoc, updated) => { if (rdoc.status === STATUS.STATUS_ACCEPTED && updated) { + if (SystemModel.get('record.statMode') === 'unique') { + await RecordModel.collStat.deleteMany({ + _id: { $ne: rdoc._id }, + uid: rdoc.uid, + pid: rdoc.pid, + domainId: rdoc.domainId, + }); + } await RecordModel.collStat.updateOne({ _id: rdoc._id, }, { diff --git a/packages/hydrooj/src/model/setting.ts b/packages/hydrooj/src/model/setting.ts index a4d2de59b..1c869829d 100644 --- a/packages/hydrooj/src/model/setting.ts +++ b/packages/hydrooj/src/model/setting.ts @@ -334,6 +334,7 @@ SystemSetting( Setting('setting_basic', 'discussion.nodes', builtin.DEFAULT_NODES, 'yaml', 'discussion.nodes', 'Discussion Nodes'), Setting('setting_basic', 'problem.categories', builtin.CATEGORIES, 'yaml', 'problem.categories', 'Problem Categories'), Setting('setting_basic', 'training.enrolled-users', true, 'boolean', 'training.enrolled-users', 'Show enrolled users for training'), + Setting('setting_basic', 'record.statMode', 'unique', 'text', 'record.statMode', 'Record stat mode'), Setting('setting_basic', 'pagination.problem', 100, 'number', 'pagination.problem', 'Problems per page'), Setting('setting_basic', 'pagination.contest', 20, 'number', 'pagination.contest', 'Contests per page'), Setting('setting_basic', 'pagination.discussion', 50, 'number', 'pagination.discussion', 'Discussions per page'), diff --git a/packages/hydrooj/src/service/bus.ts b/packages/hydrooj/src/service/bus.ts index 8d8444a59..a9b7ef284 100644 --- a/packages/hydrooj/src/service/bus.ts +++ b/packages/hydrooj/src/service/bus.ts @@ -66,7 +66,7 @@ export interface EventMap { 'problem/before-add': (domainId: string, content: string, owner: number, docId: number, doc: Partial) => VoidReturn 'problem/add': (doc: Partial, docId: number) => VoidReturn - 'problem/before-edit': (doc: Partial) => VoidReturn + 'problem/before-edit': (doc: Partial, $unset: OnlyFieldsOfType) => VoidReturn 'problem/edit': (doc: ProblemDoc) => VoidReturn 'problem/before-del': (domainId: string, docId: number) => VoidReturn 'problem/del': (domainId: string, docId: number) => VoidReturn diff --git a/packages/ui-default/index.ts b/packages/ui-default/index.ts index 3eff2918c..163e151bb 100644 --- a/packages/ui-default/index.ts +++ b/packages/ui-default/index.ts @@ -183,6 +183,7 @@ export function apply(ctx: Context) { about: Schema.string().role('markdown').default(defaultAbout), }), })); + ctx.Route('config_schema', '/manage/config/schema.json', SystemConfigSchemaHandler, PRIV.PRIV_EDIT_SYSTEM); }); if (process.env.HYDRO_CLI) return; ctx.Route('wiki_help', '/wiki/help', WikiHelpHandler); @@ -190,7 +191,6 @@ export function apply(ctx: Context) { ctx.Route('set_theme', '/set_theme/:theme', SetThemeHandler); ctx.Route('set_legacy', '/legacy', LegacyModeHandler); ctx.Route('markdown', '/markdown', MarkdownHandler); - ctx.Route('config_schema', '/manage/config/schema.json', SystemConfigSchemaHandler, PRIV.PRIV_EDIT_SYSTEM); ctx.Route('media', '/media', RichMediaHandler); ctx.on('handler/after/DiscussionRaw', async (that) => { if (that.args.render && that.response.type === 'text/markdown') { diff --git a/packages/ui-default/locales/zh.yaml b/packages/ui-default/locales/zh.yaml index 2ac42fe09..4b18ee1f6 100644 --- a/packages/ui-default/locales/zh.yaml +++ b/packages/ui-default/locales/zh.yaml @@ -6,6 +6,7 @@ __langname: 简体中文 '{0} is a moderator of this domain.': '{}是这个域的管理者之一。' '{0} is a superuser.': '{0}是超级管理员。' '{0} limit exceeded (limit: {2} operations in {1} seconds).': '{0} 超过频率限制。限制:{1} 秒内最多 {2} 次操作。' +'{0} problem(s) selected': '{0} 道题目已选中' '{0} problems': '{0} 道题' '{0} sections': '{0} 小节' '{0} solutions': '{0} 条题解' diff --git a/packages/ui-default/package.json b/packages/ui-default/package.json index f42e3dbaa..b733cf7c5 100644 --- a/packages/ui-default/package.json +++ b/packages/ui-default/package.json @@ -21,7 +21,7 @@ "@fontsource/source-code-pro": "^5.2.6", "@fontsource/ubuntu-mono": "^5.2.6", "@hydrooj/utils": "workspace:^", - "@sentry/browser": "^9.27.0", + "@sentry/browser": "^9.28.0", "@sentry/webpack-plugin": "^3.5.0", "@simplewebauthn/browser": "13.1.0", "@svgr/webpack": "^8.1.0", @@ -41,7 +41,7 @@ "@types/webpack-env": "^1.18.8", "@vscode/codicons": "^0.0.36", "ajv": "^8.17.1", - "allotment": "^1.20.3", + "allotment": "^1.20.4", "autoprefixer": "^10.4.21", "browser-update": "^3.3.55", "chalk": "^5.4.1", @@ -98,7 +98,7 @@ "redux-promise-middleware": "^6.2.0", "redux-thunk": "^3.1.0", "rupture": "^0.7.1", - "sass": "^1.89.1", + "sass": "^1.89.2", "sass-loader": "^16.0.5", "schemastery": "^3.16.1", "shorty.js": "^1.0.1", diff --git a/packages/ui-default/pages/problem_main.page.ts b/packages/ui-default/pages/problem_main.page.tsx similarity index 75% rename from packages/ui-default/pages/problem_main.page.ts rename to packages/ui-default/pages/problem_main.page.tsx index bd326751c..a8b12837f 100644 --- a/packages/ui-default/pages/problem_main.page.ts +++ b/packages/ui-default/pages/problem_main.page.tsx @@ -1,6 +1,9 @@ import parser, { SearchParserResult } from '@hydrooj/utils/lib/search'; import $ from 'jquery'; import _ from 'lodash'; +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import ProblemSelectAutoComplete from 'vj/components/autocomplete/components/ProblemSelectAutoComplete'; import DomainSelectAutoComplete from 'vj/components/autocomplete/DomainSelectAutoComplete'; import { ActionDialog, ConfirmDialog, Dialog } from 'vj/components/dialog'; import Dropdown from 'vj/components/dropdown/Dropdown'; @@ -18,6 +21,9 @@ const pinned: Record = { category: [], difficulty: [] }; const selections = { category: {}, difficulty: {} }; const selectedTags: Record = { category: [], difficulty: [] }; +let selectedPids: string[] = []; +let clearSelectionHandler: (() => void) | null = null; + function setDomSelected($dom, selected, icon?) { if (selected) { $dom.addClass('selected'); @@ -163,15 +169,11 @@ function parseCategorySelection() { } function ensureAndGetSelectedPids() { - const pids = _.map( - $('tbody [data-checkbox-group="problem"]:checked'), - (ch) => $(ch).closest('tr').attr('data-pid'), - ); - if (pids.length === 0) { + if (selectedPids.length === 0) { Notification.error(i18n('Please select at least one problem to perform this operation.')); return null; } - return pids; + return selectedPids; } async function handleOperation(operation) { @@ -204,6 +206,7 @@ async function handleOperation(operation) { await request.post('', { operation, pids, ...payload }); Notification.success(i18n(`Selected problems have been ${operation === 'copy' ? 'copie' : operation}d.`)); await delay(2000); + clearSelectionHandler?.(); loadQuery(); } catch (error) { Notification.error(error.message); @@ -282,6 +285,94 @@ function processElement(ele) { createHint('Hint::icon::difficulty', $(ele).find('th.col--difficulty')); } +function ProblemSelectionDisplay(props) { // eslint-disable-line + const [pids, setPids] = React.useState([]); + const [dialogOpen, setDialogOpen] = React.useState(false); + + React.useEffect(() => { + props.onClear?.(() => { + setPids([]); + }); + }, [props.onClear]); + + React.useEffect(() => { + $(document).on('click', '[data-checkbox-group="problem"]:checked', (ev) => { + const $checkbox = $(ev.currentTarget); + const pid = $checkbox.closest('tr').attr('data-pid'); + setPids((o) => Array.from(new Set([...o, pid]))); + }); + $(document).on('click', '[data-checkbox-group="problem"]:not(:checked)', (ev) => { + const $checkbox = $(ev.currentTarget); + const pid = $checkbox.closest('tr').attr('data-pid'); + setPids((o) => o.filter((i) => i !== pid)); + }); + $(document).on('click', '[data-checkbox-toggle="problem"]:checked', () => { + const all = $('[data-checkbox-group="problem"]').map((_index, i) => $(i).closest('tr').attr('data-pid')).get(); + setPids((o) => Array.from(new Set([...o, ...all]))); + }); + $(document).on('click', '[data-checkbox-toggle="problem"]:not(:checked)', () => { + const all = $('[data-checkbox-group="problem"]').map((_index, i) => $(i).closest('tr').attr('data-pid')).get(); + setPids((o) => o.filter((i) => !all.includes(i))); + }); + }, []); + + const updateCheckboxSelection = React.useCallback(() => { + for (const i of $('[data-checkbox-group="problem"]:checked')) { + if (!pids.includes(i.closest('tr').dataset.pid)) { + $(i).prop('checked', false); + } + } + for (const i of pids) { + $(`[data-pid="${i}"]`).find('[data-checkbox-group="problem"]')?.prop('checked', true); + } + }, [pids]); + + React.useEffect(() => { + updateCheckboxSelection(); + props.onChange(pids); + $(document).on('vjContentNew', updateCheckboxSelection); + return () => { + $(document).off('vjContentNew', updateCheckboxSelection); + }; + }, [pids]); + + return (<> + setDialogOpen(true)}> + + {' '}{i18n('{0} problem(s) selected', pids.length)} + +
setDialogOpen(false)}> +
ev.stopPropagation()}> +
+
+
+

Select Problems

+
+
+
+
+ setPids(v.split(',').filter((i) => i.trim()))} + selectedKeys={pids} + /> + +
+
+
+
+
+
+ {' '} + +
+
+
+
+
+ ); +} + const page = new NamedPage(['problem_main'], () => { const $body = $('body'); $body.addClass('display-mode'); @@ -344,6 +435,12 @@ const page = new NamedPage(['problem_main'], () => { $(document).on('vjContentNew', (e) => processElement(e.target)); processElement(document); + ReactDOM.createRoot(document.getElementById('problem_selection')) + .render( { selectedPids = pids; }} + onClear={(handler) => { clearSelectionHandler = handler; }} + />); + addSpeculationRules({ prerender: [{ 'where': { diff --git a/packages/ui-default/pages/setting.page.tsx b/packages/ui-default/pages/setting.page.tsx index aa308f4a3..a9c9d4a1b 100644 --- a/packages/ui-default/pages/setting.page.tsx +++ b/packages/ui-default/pages/setting.page.tsx @@ -45,6 +45,7 @@ const page = new NamedPage('manage_config', async () => { }); }} sidebar={} + dynamic={UiContext.dynamic || {}} />