diff --git a/package.json b/package.json index d00626301..8c569b6de 100644 --- a/package.json +++ b/package.json @@ -18,51 +18,53 @@ "benchmark": "cross-env BENCHMARK=true mocha", "lint": "eslint packages --ext ts --fix", "lint:ci": "eslint packages --ext ts", - "lint:ui": "yarn workspace @hydrooj/ui-default eslint --ext .js,.ts,.jsx,.tsx . --fix", - "lint:ui:ci": "yarn workspace @hydrooj/ui-default eslint --ext .js,.ts,.jsx,.tsx .", + "lint:ui": "yarn workspace @hydrooj/ui-default lint --ext .js,.ts,.jsx,.tsx . --fix", + "lint:ui:ci": "yarn workspace @hydrooj/ui-default lint --ext .js,.ts,.jsx,.tsx .", "typedoc": "cross-env NODE_OPTIONS=--max_old_space_size=8192 typedoc", "debug:all": "node --trace-warnings --async-stack-traces --trace-deprecation build/start --debug --template", "debug": "node --trace-warnings --async-stack-traces --trace-deprecation node_modules/hydrooj/bin/hydrooj --debug --template", "start:all": "node build/start", "start": "node_modules/.bin/hydrooj", - "postinstall": "node build/hack && node build/prepare.js && rm -rf node_modules/esbuild-loader/node_modules/esbuild" + "postinstall": "node build/hack && node build/prepare.js" }, "version": "1.0.0", "license": "AGPL-3.0-only", "devDependencies": { "@types/autocannon": "^7.6.1", "@types/cross-spawn": "^6.0.2", - "@types/mocha": "^9.1.0", - "@types/node": "^17.0.23", + "@types/mocha": "^9.1.1", + "@types/node": "^17.0.32", "@types/semver": "^7.3.9", "@types/supertest": "^2.0.12", - "@typescript-eslint/eslint-plugin": "5.14.0", - "@typescript-eslint/parser": "5.14.0", - "autocannon": "^7.8.1", + "@typescript-eslint/eslint-plugin": "^5.23.0", + "@typescript-eslint/parser": "^5.23.0", + "autocannon": "^7.9.0", "cac": "^6.7.12", "cross-env": "^7.0.3", "cross-spawn": "^7.0.3", "esbuild": "0.14.3", - "eslint": "^8.12.0", + "eslint": "^8.15.0", "eslint-config-airbnb": "^19.0.4", - "eslint-config-airbnb-typescript": "16.1.1", - "eslint-import-resolver-typescript": "2.5.0", + "eslint-config-airbnb-typescript": "^17.0.0", + "eslint-import-resolver-typescript": "^2.7.1", "eslint-import-resolver-webpack": "^0.13.2", - "eslint-plugin-import": "2.25.4", + "eslint-plugin-import": "2.26.0", "eslint-plugin-jsx-a11y": "^6.5.1", "eslint-plugin-react": "^7.29.4", "eslint-plugin-simple-import-sort": "7.0.0", - "fs-extra": "^10.0.1", + "fs-extra": "^10.1.0", "globby": "11.1.0", "latest-version": "^6.0.0", - "mocha": "^9.2.2", - "mongo-mock": "^4.1.0", + "mocha": "^10.0.0", "mongodb": "^3.7.3", - "mongodb-memory-server": "^8.4.2", + "mongodb-memory-server": "^8.5.2", "ora": "5.4.1", - "semver": "^7.3.5", - "supertest": "^6.2.2", - "typedoc": "^0.22.13", - "typescript": "4.6.2" + "semver": "^7.3.7", + "supertest": "^6.2.3", + "typedoc": "^0.22.15", + "typescript": "^4.6.4" + }, + "resolutions": { + "esbuild": "0.14.3" } } diff --git a/packages/center/package.json b/packages/center/package.json index f259298d9..c4a01711f 100644 --- a/packages/center/package.json +++ b/packages/center/package.json @@ -8,7 +8,7 @@ "preferUnplugged": true, "dependencies": { "js-yaml": "^4.1.0", - "superagent": "^7.1.2" + "superagent": "^7.1.3" }, "devDependencies": { "@types/js-yaml": "^4.0.5" diff --git a/packages/elastic/package.json b/packages/elastic/package.json index 3ecf7f1e1..6c6507332 100644 --- a/packages/elastic/package.json +++ b/packages/elastic/package.json @@ -1,11 +1,11 @@ { "name": "@hydrooj/elastic-search", - "version": "1.0.2", + "version": "1.0.5", "main": "package.json", "repository": "https://github.com/hydro-dev/Hydro", "author": "undefined ", "license": "AGPL-3.0-or-later", "dependencies": { - "@elastic/elasticsearch": "^8.1.0" + "@elastic/elasticsearch": "^8.2.0-patch.1" } } diff --git a/packages/elastic/script.ts b/packages/elastic/script.ts index 0e269e476..0c4412913 100644 --- a/packages/elastic/script.ts +++ b/packages/elastic/script.ts @@ -32,8 +32,9 @@ bus.on('problem/del', async (domainId, docId) => { }); global.Hydro.lib.problemSearch = async (domainId, q, opts) => { + const allowedSize = system.get('elasic-search.indexSize') || 10000; const size = opts?.limit || system.get('pagination.problem'); - const from = opts?.skip || 0; + const from = Math.min(allowedSize - size, opts?.skip || 0); const union = await DomainModel.getUnion(domainId); const domainIds = [domainId, ...(union?.union || [])]; const res = await client.search({ diff --git a/packages/elastic/setting.yaml b/packages/elastic/setting.yaml index aea785885..f40c9cc15 100644 --- a/packages/elastic/setting.yaml +++ b/packages/elastic/setting.yaml @@ -1,2 +1,4 @@ url: value: http://127.0.0.1:9200 +indexSize: + value: 10000 \ No newline at end of file diff --git a/packages/fps-importer/handler.ts b/packages/fps-importer/handler.ts index 3278192de..f6569802e 100644 --- a/packages/fps-importer/handler.ts +++ b/packages/fps-importer/handler.ts @@ -104,15 +104,15 @@ class FpsProblemImportHandler extends Handler { if (!this.request.files.file) throw new ValidationError('file'); const tasks = []; try { - const file = await fs.stat(this.request.files.file.path); + const file = await fs.stat(this.request.files.file.filepath); if (file.size > 64 * 1024 * 1024) throw new FileTooLargeError('64m'); - const content = fs.readFileSync(this.request.files.file.path, 'utf-8'); + const content = fs.readFileSync(this.request.files.file.filepath, 'utf-8'); const result = await xml2js.parseStringPromise(content); tasks.push(result); } catch (e) { if (e instanceof FileTooLargeError) throw e; console.log(e); - const zip = new AdmZip(this.request.files.file.path); + const zip = new AdmZip(this.request.files.file.filepath); for (const entry of zip.getEntries()) { try { const buf = entry.getData(); diff --git a/packages/fps-importer/package.json b/packages/fps-importer/package.json index c6cd48c0c..96cf8473d 100644 --- a/packages/fps-importer/package.json +++ b/packages/fps-importer/package.json @@ -1,6 +1,6 @@ { "name": "@hydrooj/fps-importer", - "version": "1.3.3", + "version": "1.3.4", "description": "Import FPS problems", "main": "package.json", "repository": "https://github.com/hydro-dev/Hydro.git", @@ -10,12 +10,12 @@ "dependencies": { "adm-zip": "0.5.5", "decode-html": "^2.0.0", - "fs-extra": "^10.0.1", + "fs-extra": "^10.1.0", "mongodb": "^3.7.3", "xml2js": "^0.4.23" }, "devDependencies": { - "@types/xml2js": "^0.4.9" + "@types/xml2js": "^0.4.11" }, "peerDependencies": { "hydrooj": "*" diff --git a/packages/hydrojudge/package.json b/packages/hydrojudge/package.json index 6e7a8550f..ce698f16e 100644 --- a/packages/hydrojudge/package.json +++ b/packages/hydrojudge/package.json @@ -1,29 +1,29 @@ { "name": "@hydrooj/hydrojudge", "bin": "bin/hydrojudge.js", - "version": "2.18.1", + "version": "2.20.4", "main": "package.json", "author": "undefined ", "repository": "https://github.com/hydro-dev/Hydro.git", "dependencies": { "@hydrooj/utils": "workspace:*", - "axios": "^0.26.1", + "axios": "^0.27.2", "cac": "^6.7.12", "esbuild": "0.14.3", - "fs-extra": "^10.0.1", + "fs-extra": "^10.1.0", "js-yaml": "^4.1.0", "lodash": "^4.17.21", "mongodb": "^3.7.3", "p-queue": "^7.2.0", "shell-quote": "^1.7.3", - "ws": "^8.5.0" + "ws": "^8.6.0" }, "preferUnplugged": true, "license": "AGPL-3.0-or-later", "devDependencies": { "@types/fs-extra": "^9.0.13", "@types/js-yaml": "^4.0.5", - "@types/lodash": "^4.14.181", + "@types/lodash": "^4.14.182", "@types/shell-quote": "^1.7.1", "@types/ws": "^8.5.3" }, diff --git a/packages/hydrojudge/setting.yaml b/packages/hydrojudge/setting.yaml index d9533b83a..a3af6198d 100644 --- a/packages/hydrojudge/setting.yaml +++ b/packages/hydrojudge/setting.yaml @@ -37,11 +37,6 @@ memoryMax: type: text name: memoryMax default: 512m -retry_delay_sec: - type: number - name: retry_delay_sec - desc: Retry delay sec - default: 15 cache_dir: type: text name: cache_dir @@ -52,6 +47,10 @@ parallelism: name: parallelism desc: Runner parallelism default: 2 +singleTaskParallelism: + type: number + name: singleTaskParallelism + default: 2 rate: type: float name: rate diff --git a/packages/hydrojudge/src/cases.ts b/packages/hydrojudge/src/cases.ts index 9c71ed897..baf38cd5d 100644 --- a/packages/hydrojudge/src/cases.ts +++ b/packages/hydrojudge/src/cases.ts @@ -1,233 +1,104 @@ +/* eslint-disable no-await-in-loop */ import path from 'path'; import fs from 'fs-extra'; import yaml from 'js-yaml'; import { max, sum } from 'lodash'; import readYamlCases, { convertIniConfig } from '@hydrooj/utils/lib/cases'; +import { normalizeSubtasks, readSubtasksFromFiles } from '@hydrooj/utils/lib/common'; import { changeErrorType } from '@hydrooj/utils/lib/utils'; import { getConfig } from './config'; import { FormatError, SystemError } from './error'; -import { ensureFile, parseMemoryMB, parseTimeMS } from './utils'; - -interface Re0 { - reg: RegExp, - output: ((a: RegExpExecArray) => string)[], - id: (a: RegExpExecArray) => number, -} - -interface Re1 extends Re0 { - subtask: (a: RegExpExecArray) => number, -} - -const RE0: Re0[] = [ - { - reg: /^([^\d]*)(\d+).(in|txt)$/, - output: [ - (a) => `${a[1] + a[2]}.out`, - (a) => `${a[1] + a[2]}.ans`, - (a) => `${a[1] + a[2]}.out`.replace(/input/g, 'output'), - (a) => (a[1].includes('input') ? `${a[1] + a[2]}.txt`.replace(/input/g, 'output') : null), - ], - id: (a) => +a[2], - }, - { - reg: /^([^\d]*)\.in(\d+)$/, - output: [ - (a) => `${a[1]}.ou${a[2]}`, - (a) => `${a[1]}.ou${a[2]}`.replace(/input/g, 'output'), - ], - id: (a) => +a[2], - }, -]; -const RE1: Re1[] = [ - { - reg: /^([^\d]*)([0-9]+)([-_])([0-9]+).in$/, - output: [(a) => `${a[1] + a[2]}${a[3]}${a[4]}.out`], - subtask: (a) => +a[2], - id: (a) => +a[4], - }, -]; - -async function read0(folder: string, files: string[], checkFile, cfg) { - const cases = []; - for (const file of files) { - for (const REG of RE0) { - if (REG.reg.test(file)) { - const data = REG.reg.exec(file); - const c = { input: file, output: '', id: REG.id(data) }; - for (const func of REG.output) { - if (cfg.noOutputFile) c.output = '/dev/null'; - else c.output = func(data); - if (c.output && (c.output === '/dev/null' || fs.existsSync(path.resolve(folder, c.output)))) { - cases.push(c); - break; - } - } - } - } - } - cases.sort((a, b) => (a.id - b.id)); - const extra = cases.length - (100 % cases.length); - const config = { - count: 0, - subtasks: [{ - time: parseTimeMS(cfg.time || '1s'), - memory: parseMemoryMB(cfg.memory || '256m'), - type: 'sum', - cases: [], - score: Math.floor(100 / cases.length), - }], - }; - for (let i = 0; i < extra; i++) { - config.count++; - config.subtasks[0].cases.push({ - id: config.count, - input: checkFile(cases[i].input), - output: checkFile(cases[i].output), - }); - } - if (extra < cases.length) { - config.subtasks.push({ - time: parseTimeMS(cfg.time || '1s'), - memory: parseMemoryMB(cfg.memory || '256m'), - type: 'sum', - cases: [], - score: Math.floor(100 / cases.length) + 1, - }); - for (let i = extra; i < cases.length; i++) { - config.count++; - config.subtasks[1].cases.push({ - id: config.count, - input: checkFile(cases[i].input), - output: checkFile(cases[i].output), - }); - } - } - return config; -} - -async function read1(folder: string, files: string[], checkFile, cfg, rst) { - const subtask = {}; - for (const s of rst.subtasks) if (s.id) subtask[s.id] = s; - const subtasks = []; - for (const file of files) { - for (const REG of RE1) { - if (REG.reg.test(file)) { - const data = REG.reg.exec(file); - const sid = REG.subtask(data); - const c = { input: file, output: '', id: REG.id(data) }; - for (const func of REG.output) { - if (cfg.noOutputFile) c.output = '/dev/null'; - else c.output = func(data); - if (c.output === '/dev/null' || fs.existsSync(path.resolve(folder, c.output))) { - if (!subtask[sid]) { - subtask[sid] = { - time: parseTimeMS(cfg.time || '1s'), - memory: parseMemoryMB(cfg.memory || '256m'), - type: 'min', - cases: [c], - }; - } else if (!subtask[sid].cases) subtask[sid].cases = [c]; - else subtask[sid].cases.push(c); - break; - } - } - } - } - } - for (const i in subtask) { - subtask[i].cases.sort((a, b) => (a.id - b.id)); - subtasks.push(subtask[i]); - } - const base = Math.floor(100 / subtasks.length); - const extra = subtasks.length - (100 % subtasks.length); - const config = { count: 0, subtasks }; - const keys = Object.keys(subtask); - for (let i = 0; i < keys.length; i++) { - if (i >= extra) subtask[keys[i]].score = base + 1; - else subtask[keys[i]].score = base; - for (const j of subtask[keys[i]].cases) { - config.count++; - j.input = checkFile(j.input); - j.output = checkFile(j.output); - j.id = config.count; - } - } - return config; -} - -async function readAutoCases(folder: string, { next }, cfg, rst) { - const config = { - checker_type: 'default', - count: 0, - subtasks: [], - judge_extra_files: [], - user_extra_files: [], - }; - const checkFile = ensureFile(folder); - try { - const files = await fs.readdir(folder); - if (await fs.pathExists(path.resolve(folder, 'input'))) { - const inputs = await fs.readdir(path.resolve(folder, 'input')); - files.push(...inputs.map((i) => `input/${i}`)); - } - if (await fs.pathExists(path.resolve(folder, 'output'))) { - const outputs = await fs.readdir(path.resolve(folder, 'output')); - files.push(...outputs.map((i) => `output/${i}`)); - } - let result = await read0(folder, files, checkFile, cfg); - if (!result.count) result = await read1(folder, files, checkFile, cfg, rst); - Object.assign(config, result); - if (cfg.isSelfSubmission) next({ message: { message: 'Found {0} testcases.', params: [config.count] } }); - } catch (e) { - throw new SystemError('Cannot parse testdata.', [e.message, ...e.params]); - } - return config; -} +import { ensureFile, parseMemoryMB } from './utils'; function isValidConfig(config) { if (config.count > (getConfig('testcases_max') || 100)) { throw new FormatError('Too many testcases. Cancelled.'); } - const time = sum(config.subtasks.map((subtask) => subtask.time * subtask.cases.length)); + const time = sum(config.subtasks.map((subtask) => sum(subtask.cases.map((c) => c.time)))); if (time > (getConfig('total_time_limit') || 60) * 1000) { throw new FormatError('Total time limit longer than {0}s. Cancelled.', [+getConfig('total_time_limit') || 60]); } - const memMax = max(config.subtasks.map((subtask) => subtask.memory)); + const memMax = max(config.subtasks.map((subtask) => max(subtask.cases.map((c) => c.memory)))); if (memMax > parseMemoryMB(getConfig('memoryMax'))) throw new FormatError('Memory limit larger than memory_max'); if (!['default', 'strict'].includes(config.checker_type || 'default') && !config.checker) { throw new FormatError('You did not specify a checker.'); } } +async function collectFiles(folder: string) { + const files = await fs.readdir(folder); + await Promise.all(['input', 'output'].map(async (t) => { + if (await fs.pathExists(path.resolve(folder, t))) { + const f = await fs.readdir(path.resolve(folder, t)); + files.push(...f.map((i) => `${t}/${i}`)); + } + })); + return files; +} + +export async function processTestdata(folder: string) { + let files = await fs.readdir(folder); + if (files.length <= 2) { + if (files.length === 2) files.splice(files.indexOf('version'), 1); + if (fs.statSync(path.resolve(folder, files[0])).isDirectory()) { + folder = path.resolve(folder, files[0]); + files = await fs.readdir(folder); + } + } + const ini = files.filter((i) => i.toLowerCase() === 'config.ini')[0]; + if (!ini) return; + const t = fs.readFileSync(path.resolve(folder, ini), 'utf8'); + await fs.writeFile(path.resolve(folder, 'config.ini'), t.toLowerCase()); + for (const i of files) { + if (i.toLowerCase() === 'input') await fs.rename(`${folder}/${i}`, `${folder}/input`); + if (i.toLowerCase() === 'output') await fs.rename(`${folder}/${i}`, `${folder}/output`); + } + await Promise.all(['input', 'output'].flatMap(async (f) => { + const dir = path.resolve(folder, f); + const sf = await fs.readdir(dir); + return sf + .filter((i) => i !== i.toLowerCase()) + .map((i) => fs.rename(`${dir}/${i}`, `${dir}/${i.toLowerCase()}`)); + })); +} + export default async function readCases(folder: string, cfg: Record = {}, args) { const iniConfig = path.resolve(folder, 'config.ini'); const yamlConfig = path.resolve(folder, 'config.yaml'); const ymlConfig = path.resolve(folder, 'config.yml'); - let config; - if (fs.existsSync(yamlConfig)) { - config = { ...cfg, ...yaml.load(fs.readFileSync(yamlConfig).toString()) as object }; - } else if (fs.existsSync(ymlConfig)) { - config = { ...cfg, ...yaml.load(fs.readFileSync(ymlConfig).toString()) as object }; - } else if (fs.existsSync(iniConfig)) { - try { - config = { ...cfg, ...convertIniConfig(fs.readFileSync(iniConfig).toString()) }; - } catch (e) { - throw changeErrorType(e, FormatError); - } - } else config = cfg; - let result; + const config: Record = { + checker_type: 'default', + count: 0, + subtasks: [], + judge_extra_files: [], + user_extra_files: [], + ...cfg, + }; try { - result = await readYamlCases(config, ensureFile(folder)); + if (fs.existsSync(yamlConfig)) { + Object.assign(config, yaml.load(fs.readFileSync(yamlConfig).toString())); + } else if (fs.existsSync(ymlConfig)) { + Object.assign(config, yaml.load(fs.readFileSync(ymlConfig).toString())); + } else if (fs.existsSync(iniConfig)) { + Object.assign(config, convertIniConfig(fs.readFileSync(iniConfig).toString())); + } } catch (e) { throw changeErrorType(e, FormatError); } - let cases = result.outputs?.length || 0; - cases += Math.sum((result.subtasks || []).map((subtask) => subtask.cases.length)); - if (!cases) { - const c = await readAutoCases(folder, args, config, result); - result.subtasks = c.subtasks; - result.count = c.count; + const checkFile = ensureFile(folder); + const result = await readYamlCases(config, checkFile) + .catch((e) => { throw changeErrorType(e, FormatError); }); + result.count = result.outputs?.length || Math.sum((result.subtasks || []).map((s) => s.cases.length)); + if (!result.count) { + try { + result.subtasks = readSubtasksFromFiles(await collectFiles(folder), cfg); + result.count = Math.sum(result.subtasks.map((i) => i.cases.length)); + if (cfg.isSelfSubmission) args.next?.({ message: { message: 'Found {0} testcases.', params: [result.count] } }); + } catch (e) { + throw new SystemError('Cannot parse testdata.', [e.message, ...(e.params || [])]); + } } + result.subtasks = normalizeSubtasks(result.subtasks || [], checkFile, config.time, config.memory); if (result.key && args.key !== result.key) throw new FormatError('Incorrect secret key'); if (!result.key) isValidConfig(result); return result; diff --git a/packages/hydrojudge/src/check.ts b/packages/hydrojudge/src/check.ts index 8155c878c..1223c6dab 100644 --- a/packages/hydrojudge/src/check.ts +++ b/packages/hydrojudge/src/check.ts @@ -16,6 +16,9 @@ export async function check(config: CheckConfig): Promise<[number, number, strin } export async function compileChecker(getLang: Function, checkerType: string, checker: string, copyIn: any): Promise { + if (['default', 'strict'].includes(checkerType)) { + return { execute: '', copyIn: {}, clean: () => Promise.resolve(null) }; + } if (!checkers[checkerType]) throw new SystemError('Unknown checker type {0}.', [checkerType]); if (checkerType === 'testlib') copyIn['testlib.h'] = { src: testlibSrc }; const s = checker.replace('@', '.').split('.'); diff --git a/packages/hydrojudge/src/compile.ts b/packages/hydrojudge/src/compile.ts index eb767152b..76c170c5c 100644 --- a/packages/hydrojudge/src/compile.ts +++ b/packages/hydrojudge/src/compile.ts @@ -25,7 +25,7 @@ export = async function compile( ); if (status !== STATUS.STATUS_ACCEPTED) throw new CompileError({ status, stdout, stderr }); if (!fileIds[target]) throw new CompileError({ stderr: 'Executable file not found.' }); - if (next) next({ compiler_text: compilerText(stdout, stderr) }); + if (next) next({ compilerText: compilerText(stdout, stderr) }); return { execute, copyIn: { ...copyIn, [target]: { fileId: fileIds[target] } }, diff --git a/packages/hydrojudge/src/config.ts b/packages/hydrojudge/src/config.ts index 51cdbab56..564728377 100644 --- a/packages/hydrojudge/src/config.ts +++ b/packages/hydrojudge/src/config.ts @@ -1,4 +1,3 @@ -/* eslint-disable prefer-const */ import os from 'os'; import path from 'path'; import cac from 'cac'; @@ -9,19 +8,19 @@ const argv = cac().parse(); // eslint-disable-next-line @typescript-eslint/naming-convention let CONFIG_FILE = path.resolve(os.homedir(), '.config', 'hydro', 'judge.yaml'); -let config = { +const config = { cache_dir: path.resolve(os.homedir(), '.cache', 'hydro', 'judge'), tmp_dir: path.resolve(os.tmpdir(), 'hydro', 'judge'), tmpfs_size: '256m', stdio_size: '32m', memoryMax: '512m', strict_memory: false, - retry_delay_sec: 15, sandbox_host: 'http://localhost:5050', testcases_max: 100, total_time_limit: 60, processLimit: 32, parallelism: 2, + singleTaskParallelism: 2, rerun: 0, rate: 1, config: null, @@ -29,11 +28,12 @@ let config = { env: `\ PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin HOME=/w`, + hosts: {}, secret: String.random(32), }; -export function getConfig(key: string) { - return global.Hydro ? global.Hydro.model.system.get(`hydrojudge.${key}`) : config[key]; +export function getConfig(key: keyof typeof config) { + return global.Hydro ? global.Hydro.model.system.get(`hydrojudge.${key}`) ?? config[key] : config[key]; } if (fs.existsSync(path.resolve(process.cwd(), '.env'))) { @@ -61,5 +61,5 @@ if (!global.Hydro) { config.sandbox_host = path.resolve(process.env.EXECUTION_HOST || argv.options.sandbox); } const configFile = fs.readFileSync(CONFIG_FILE).toString(); - config = { ...config, ...yaml.load(configFile) as any }; + Object.assign(config, yaml.load(configFile) as any); } diff --git a/packages/hydrojudge/src/daemon.ts b/packages/hydrojudge/src/daemon.ts index 858f12fec..02cfe91ad 100644 --- a/packages/hydrojudge/src/daemon.ts +++ b/packages/hydrojudge/src/daemon.ts @@ -18,7 +18,7 @@ import './utils'; import { getConfig } from './config'; import * as Session from './hosts/index'; import log from './log'; -import { Queue, sleep } from './utils'; +import { Queue } from './utils'; declare global { namespace NodeJS { @@ -28,8 +28,8 @@ declare global { } } } -if (!global.onDestroy) global.onDestroy = []; -if (!global.hosts) global.hosts = []; +global.onDestroy ||= []; +global.hosts ||= []; let exit = false; const terminate = async () => { @@ -60,28 +60,16 @@ async function worker(queue: Queue) { async function daemon() { const _hosts = getConfig('hosts'); - const delay = getConfig('retry_delay_sec'); const hosts = {}; const queue = new Queue(); + worker(queue).catch((e) => log.error(e)); for (const i in _hosts) { _hosts[i].host = _hosts[i].host || i; - hosts[i] = new Session[_hosts[i].type || 'vj4'](_hosts[i]); + hosts[i] = new Session[_hosts[i].type || 'hydro'](_hosts[i]); await hosts[i].init(); } + for (const i in hosts) hosts[i].consume(queue); global.hosts = hosts; - worker(queue); - for (const i in hosts) { - while ('Orz twd2') { - try { - await hosts[i].consume(queue); - break; - } catch (e) { - log.error(e, e.stack); - log.info(`在 ${delay} 秒后重试`); - await sleep(delay * 1000); - } - } - } } if (require.main === module) daemon(); diff --git a/packages/hydrojudge/src/error.ts b/packages/hydrojudge/src/error.ts index 29419e5ab..ff79b9a0b 100644 --- a/packages/hydrojudge/src/error.ts +++ b/packages/hydrojudge/src/error.ts @@ -5,7 +5,7 @@ export class CompileError extends Error { stdout: string; stderr: string; status: string; - type: string; + type = 'CompileError'; constructor(obj: string | CompileErrorInfo) { super('Compile Error'); @@ -17,48 +17,29 @@ export class CompileError extends Error { this.stderr = obj.stderr || ''; this.status = obj.status ? STATUS_TEXTS[obj.status] || '' : ''; } - this.type = 'CompileError'; } } export class FormatError extends Error { - type: string; - params: any[]; + type = 'FormatError'; - constructor(message: string, params = []) { + constructor(message: string, public params = []) { super(message); - this.type = 'FormatError'; - this.params = params; } } export class RuntimeError extends Error { - type: string; - detail: any; + type = 'RuntimeError'; - constructor(detail: string, message: string) { + constructor(public detail: string, message: string) { super(message); - this.type = 'RuntimeError'; - this.detail = detail; } } export class SystemError extends Error { - type: string; - params: any[]; + type = 'SystemError'; - constructor(message: string, params = []) { + constructor(message: string, public params = []) { super(message); - this.type = 'SystemError'; - this.params = params; - } -} - -export class TooFrequentError extends Error { - type: string; - - constructor(message: string) { - super(message); - this.type = 'TooFrequentError'; } } diff --git a/packages/hydrojudge/src/hosts/hydro.ts b/packages/hydrojudge/src/hosts/hydro.ts index 950975099..6824b5af0 100644 --- a/packages/hydrojudge/src/hosts/hydro.ts +++ b/packages/hydrojudge/src/hosts/hydro.ts @@ -9,12 +9,13 @@ import PQueue from 'p-queue'; import WebSocket from 'ws'; import { LangConfig } from '@hydrooj/utils/lib/lang'; import { STATUS } from '@hydrooj/utils/lib/status'; -import readCases from '../cases'; +import type { JudgeResultBody } from 'hydrooj'; +import readCases, { processTestdata } from '../cases'; import { getConfig } from '../config'; import { CompileError, FormatError, SystemError } from '../error'; import judge from '../judge'; import log from '../log'; -import { CopyInFile } from '../sandbox/interface'; +import type { CopyInFile } from '../sandbox/interface'; import * as sysinfo from '../sysinfo'; import * as tmpfs from '../tmpfs'; import { @@ -82,21 +83,21 @@ class JudgeTask { await this.doSubmission(startPromise); } catch (e) { if (e instanceof CompileError) { - this.next({ compiler_text: compilerText(e.stdout, e.stderr) }); + this.next({ compilerText: compilerText(e.stdout, e.stderr) }); this.end({ - status: STATUS.STATUS_COMPILE_ERROR, score: 0, time_ms: 0, memory_kb: 0, + status: STATUS.STATUS_COMPILE_ERROR, score: 0, time: 0, memory: 0, }); } else if (e instanceof FormatError) { - this.next({ judge_text: 'Testdata configuration incorrect.' }); - this.next({ judge_text: { message: e.message, params: e.params } }); + this.next({ message: 'Testdata configuration incorrect.' }); + this.next({ message: { message: e.message, params: e.params } }); this.end({ - status: STATUS.STATUS_FORMAT_ERROR, score: 0, time_ms: 0, memory_kb: 0, + status: STATUS.STATUS_FORMAT_ERROR, score: 0, time: 0, memory: 0, }); } else { log.error(e); this.next({ message: { message: e.message, params: e.params || [], ...argv.options.debug ? { stack: e.stack } : {} } }); this.end({ - status: STATUS.STATUS_SYSTEM_ERROR, score: 0, time_ms: 0, memory_kb: 0, + status: STATUS.STATUS_SYSTEM_ERROR, score: 0, time: 0, memory: 0, }); } } finally { @@ -132,38 +133,21 @@ class JudgeTask { await judge[type].judge(this, startPromise); } - next(data, id?: number) { + next(data: Partial, id?: number) { log.debug('Next: %d %o', id, data); data.key = 'next'; data.rid = new ObjectID(this.rid); - if (data.time_ms) data.time = data.time_ms; - if (data.memory_kb) data.memory = data.memory_kb; - if (data.compiler_text) data.compilerText = data.compiler_text; - if (data.judge_text) data.message = data.judge_text; - delete data.time_ms; - delete data.memory_kb; - delete data.compiler_text; - delete data.judge_text; if (data.case) { - data.case = { - id, - status: data.case.status, - time: data.case.time_ms || data.case.time, - memory: data.case.memory_kb || data.case.memory, - message: data.case.message || data.case.judgeText || data.case.judge_text || '', - }; + data.case.id ||= id; + data.case.message ||= ''; } this.ws.send(JSON.stringify(data)); } - end(data) { + end(data: Partial) { log.info('End: %o', data); data.key = 'end'; - data.rid = new ObjectID(this.request.rid); - data.time = data.time_ms ?? data.time; - delete data.time_ms; - data.memory = data.memory_kb ?? data.memory; - delete data.memory_kb; + data.rid = this.request.rid; this.ws.send(JSON.stringify(data)); } } @@ -221,7 +205,7 @@ export default class Hydro { } if (filenames.length) { log.info(`Getting problem data: ${this.config.host}/${source}`); - if (next) next({ judge_text: 'Syncing testdata, please wait...' }); + if (next) next({ message: 'Syncing testdata, please wait...' }); await this.ensureLogin(); const res = await this.axios.post(`/d/${domainId}/judge/files`, { pid: +pid, @@ -248,7 +232,7 @@ export default class Hydro { queue.start(); await Promise.all(tasks); fs.writeFileSync(path.join(filePath, 'etags'), JSON.stringify(version)); - await this.processData(filePath).catch(noop); + await processTestdata(filePath); } fs.writeFileSync(path.join(filePath, 'lastUsage'), new Date().getTime().toString()); return filePath; @@ -348,34 +332,6 @@ export default class Hydro { } } - async processData(folder: string) { // eslint-disable-line class-methods-use-this - let files = await fs.readdir(folder); - let ini = false; - if (files.length <= 2) { - if (files.length === 2) files.splice(files.indexOf('version'), 1); - const s = fs.statSync(path.resolve(folder, files[0])); - if (s.isDirectory()) folder = path.resolve(folder, files[0]); - } - files = await fs.readdir(folder); - for (const i of files) { - if (i.toLowerCase() === 'config.ini') { - ini = true; - await fs.rename(`${folder}/${i}`, `${folder}/config.ini`); - break; - } - } - if (ini) { - for (const i of files) { - if (i.toLowerCase() === 'input') await fs.rename(`${folder}/${i}`, `${folder}/input`); - else if (i.toLowerCase() === 'output') await fs.rename(`${folder}/${i}`, `${folder}/output`); - } - files = await fs.readdir(`${folder}/input`); - for (const i of files) await fs.rename(`${folder}/input/${i}`, `${folder}/input/${i.toLowerCase()}`); - files = await fs.readdir(`${folder}/output`); - for (const i of files) await fs.rename(`${folder}/output/${i}`, `${folder}/output/${i.toLowerCase()}`); - } - } - async retry(queue: Queue) { this.consume(queue).catch(() => { setTimeout(() => this.retry(queue), 30000); diff --git a/packages/hydrojudge/src/judge/default.ts b/packages/hydrojudge/src/judge/default.ts index 14a589783..e9a9aab0a 100644 --- a/packages/hydrojudge/src/judge/default.ts +++ b/packages/hydrojudge/src/judge/default.ts @@ -8,10 +8,8 @@ import { Logger } from '../log'; import { del, run } from '../sandbox'; import { CmdFile } from '../sandbox/interface'; import signals from '../signals'; -import { parseFilename } from '../utils'; -import { - Case, Context, ContextSubTask, SubTask, -} from './interface'; +import { NormalizedCase, NormalizedSubtask, parseFilename } from '../utils'; +import { Context, ContextSubTask } from './interface'; const Score = { sum: (a: number, b: number) => (a + b), @@ -21,18 +19,18 @@ const Score = { const logger = new Logger('judge/default'); -function judgeCase(c: Case, sid: string) { +function judgeCase(c: NormalizedCase, sid: string) { return async (ctx: Context, ctxSubtask: ContextSubTask, runner?: Function) => { if (ctx.errored || (ctx.failed[sid] && ctxSubtask.subtask.type === 'min') || (ctxSubtask.subtask.type === 'max' && ctxSubtask.score === ctxSubtask.subtask.score) - || ((ctxSubtask.subtask.if || []).filter((i: string) => ctx.failed[i]).length) + || ((ctxSubtask.subtask.if || []).filter((i) => ctx.failed[i]).length) ) { ctx.next({ case: { status: STATUS.STATUS_CANCELED, score: 0, - time_ms: 0, - memory_kb: 0, + time: 0, + memory: 0, message: '', }, addProgress: 100 / ctx.config.count, @@ -76,7 +74,7 @@ function judgeCase(c: Case, sid: string) { user_stdout: stdout, user_stderr: stderr, checker_type: ctx.config.checker_type, - score: ctxSubtask.subtask.score, + score: c.score, detail: ctx.config.detail ?? true, env: { ...ctx.env, HYDRO_TESTCASE: c.id.toString() }, }); @@ -100,10 +98,11 @@ function judgeCase(c: Case, sid: string) { ctx.total_memory_usage_kb = Math.max(ctx.total_memory_usage_kb, memory_usage_kb); ctx.next({ case: { + subtaskId: ctxSubtask.subtask.id, status, - score: 0, - time_ms: time_usage_ms, - memory_kb: memory_usage_kb, + score, + time: time_usage_ms, + memory: memory_usage_kb, message, }, addProgress: 100 / ctx.config.count, @@ -126,7 +125,7 @@ function judgeCase(c: Case, sid: string) { env: ctx.env, }); const out = r.stdout.toString(); - if (out.length) ctx.next({ compiler_text: out.substring(0, 1024) }); + if (out.length) ctx.next({ compilerText: out.substring(0, 1024) }); if (process.env.DEV) console.log(r); } catch (e) { logger.info('Failed to run analysis'); @@ -137,7 +136,7 @@ function judgeCase(c: Case, sid: string) { }; } -function judgeSubtask(subtask: SubTask, sid: string) { +function judgeSubtask(subtask: NormalizedSubtask, sid: string) { return async (ctx: Context) => { subtask.type = subtask.type || 'min'; const ctxSubtask = { @@ -178,21 +177,17 @@ export const judge = async (ctx: Context, startPromise = Promise.resolve()) => { ), ctx.next, ), - (() => { - if (['default', 'strict'].includes(ctx.config.checker_type || 'default')) { - return { execute: '', copyIn: {}, clean: () => Promise.resolve(null) }; - } - const copyIn = { user_code: ctx.code }; - for (const file of ctx.config.judge_extra_files) { - copyIn[parseFilename(file)] = { src: file }; - } - return compileChecker( - ctx.getLang, - ctx.config.checker_type, - ctx.config.checker, - copyIn, - ); - })(), + compileChecker( + ctx.getLang, + ctx.config.checker_type, + ctx.config.checker, + { + user_code: ctx.code, + ...Object.fromEntries( + (ctx.config.judge_extra_files || []).map((i) => [parseFilename(i), { src: i }]), + ), + }, + ), ]); ctx.clean.push(ctx.execute.clean, ctx.checker.clean); await startPromise; @@ -203,7 +198,7 @@ export const judge = async (ctx: Context, startPromise = Promise.resolve()) => { ctx.total_memory_usage_kb = 0; ctx.total_time_usage_ms = 0; ctx.rerun = getConfig('rerun') || 0; - ctx.queue = new Queue({ concurrency: getConfig('parallelism') }); + ctx.queue = new Queue({ concurrency: getConfig('singleTaskParallelism') }); ctx.failed = {}; for (const sid in ctx.config.subtasks) tasks.push(judgeSubtask(ctx.config.subtasks[sid], sid)(ctx)); const scores = await Promise.all(tasks); @@ -219,7 +214,7 @@ export const judge = async (ctx: Context, startPromise = Promise.resolve()) => { ctx.end({ status: ctx.total_status, score: ctx.total_score, - time_ms: Math.floor(ctx.total_time_usage_ms * 1000000) / 1000000, - memory_kb: ctx.total_memory_usage_kb, + time: Math.floor(ctx.total_time_usage_ms * 1000000) / 1000000, + memory: ctx.total_memory_usage_kb, }); }; diff --git a/packages/hydrojudge/src/judge/interactive.ts b/packages/hydrojudge/src/judge/interactive.ts index e99b33713..2e556ad07 100644 --- a/packages/hydrojudge/src/judge/interactive.ts +++ b/packages/hydrojudge/src/judge/interactive.ts @@ -5,10 +5,10 @@ import { getConfig } from '../config'; import { runPiped } from '../sandbox'; import signals from '../signals'; import { parse } from '../testlib'; -import { findFileSync, parseFilename } from '../utils'; import { - Case, Context, ContextSubTask, SubTask, -} from './interface'; + findFileSync, NormalizedCase, NormalizedSubtask, parseFilename, +} from '../utils'; +import { Context, ContextSubTask } from './interface'; const testlibSrc = findFileSync('@hydrooj/hydrojudge/vendor/testlib/testlib.h'); const Score = { @@ -17,7 +17,7 @@ const Score = { min: Math.min, }; -function judgeCase(c: Case) { +function judgeCase(c: NormalizedCase) { return async (ctx: Context, ctxSubtask: ContextSubTask) => { ctx.executeInteractor.copyIn.in = c.input ? { src: c.input } : { content: '' }; ctx.executeInteractor.copyIn.out = c.output ? { src: c.output } : { content: '' }; @@ -50,7 +50,7 @@ function judgeCase(c: Case) { if (code < 32) message = signals[code]; else message = { message: 'Your program returned {0}.', params: [code] }; } else { - const result = parse(resInteractor.stderr, ctxSubtask.subtask.score); + const result = parse(resInteractor.stderr, c.score); status = result.status; score = result.score; message = result.message; @@ -63,10 +63,11 @@ function judgeCase(c: Case) { ctx.next({ status: STATUS.STATUS_JUDGING, case: { + subtaskId: ctxSubtask.subtask.id, status, score, - time_ms: time_usage_ms, - memory_kb: memory_usage_kb, + time: time_usage_ms, + memory: memory_usage_kb, message, }, progress: Math.floor((c.id * 100) / ctx.config.count), @@ -74,7 +75,7 @@ function judgeCase(c: Case) { }; } -function judgeSubtask(subtask: SubTask) { +function judgeSubtask(subtask: NormalizedSubtask) { return async (ctx: Context) => { subtask.type = subtask.type || 'min'; const ctxSubtask = { @@ -134,7 +135,7 @@ export const judge = async (ctx: Context, startPromise = Promise.resolve()) => { ctx.end({ status: ctx.total_status, score: ctx.total_score, - time_ms: ctx.total_time_usage_ms, - memory_kb: ctx.total_memory_usage_kb, + time: ctx.total_time_usage_ms, + memory: ctx.total_memory_usage_kb, }); }; diff --git a/packages/hydrojudge/src/judge/interface.ts b/packages/hydrojudge/src/judge/interface.ts index b5f5f45d5..1ebb1f78b 100644 --- a/packages/hydrojudge/src/judge/interface.ts +++ b/packages/hydrojudge/src/judge/interface.ts @@ -1,31 +1,15 @@ import PQueue from 'p-queue'; -import { LangConfig } from '@hydrooj/utils/lib/lang'; +import type { NormalizedSubtask } from '@hydrooj/utils/lib/common'; +import type { LangConfig } from '@hydrooj/utils/lib/lang'; +import type { JudgeResultBody } from 'hydrooj'; import { Execute } from '../interface'; import { CopyInFile } from '../sandbox/interface'; export type Context = JudgeTaskInterface & RuntimeContext; export interface JudgeTaskInterface { - next(arg0: { - compiler_text?: string; - status?: number; - message?: string; - progress?: number; - addProgress?: number; - case?: { - status: number; - score?: number; - time_ms: number; - memory_kb: number; - message: string; - }; - }, arg1?: number): void; - end(arg0: { - status: number; - score: number; - time_ms: number; - memory_kb: number; - }): void; + next(data: Partial, id?: number): void; + end(data: Partial): void; getLang: (name: string) => LangConfig; stat: Record; @@ -56,22 +40,20 @@ export interface RuntimeContext { executeUser?: Execute; } -type ExtraFile = string[]; - export interface Config { type: string; subType?: string; time: string; memory: string; - subtasks?: SubTask[]; + subtasks?: NormalizedSubtask[]; count?: number; checker_type?: string; detail?: boolean; filename?: string; - judge_extra_files?: ExtraFile; - user_extra_files?: ExtraFile; + judge_extra_files: string[]; + user_extra_files: string[]; template?: Record; checker?: string; @@ -86,23 +68,8 @@ export interface Config { }[]; } -export interface SubTask { - time: number; - memory: number; - type: 'sum' | 'max' | 'min'; - score: number; - cases: Record; - if: string[]; -} - export interface ContextSubTask { - subtask: SubTask; + subtask: NormalizedSubtask; score: number; status: number; } - -export interface Case { - id: number; - input: string; - output: string; -} diff --git a/packages/hydrojudge/src/judge/objective.ts b/packages/hydrojudge/src/judge/objective.ts index 278c1b538..0d6a374fb 100644 --- a/packages/hydrojudge/src/judge/objective.ts +++ b/packages/hydrojudge/src/judge/objective.ts @@ -29,11 +29,11 @@ export async function judge({ status: totalStatus, progress: (100 * (+i + 1)) / config.outputs.length, case: { - status, score, time_ms: 0, memory_kb: 0, message: '', + status, score, time: 0, memory: 0, message: '', }, }, +i + 1); } return end({ - status: totalStatus, score: totalScore, time_ms: 0, memory_kb: 0, + status: totalStatus, score: totalScore, time: 0, memory: 0, }); } diff --git a/packages/hydrojudge/src/judge/run.ts b/packages/hydrojudge/src/judge/run.ts index c746ea2d8..c87503ec8 100644 --- a/packages/hydrojudge/src/judge/run.ts +++ b/packages/hydrojudge/src/judge/run.ts @@ -12,8 +12,8 @@ import { Context } from './interface'; const failure = (status: number, message?: string) => ({ status, score: 0, - time_ms: 0, - memory_kb: 0, + time: 0, + memory: 0, message, }); @@ -88,9 +88,11 @@ export const judge = async (ctx: Context) => { ctx.next({ status, case: { + subtaskId: 0, status, - time_ms: time_usage_ms, - memory_kb: memory_usage_kb, + score: 100, + time: time_usage_ms, + memory: memory_usage_kb, message: message.join('\n').substring(0, 102400), }, }); @@ -115,7 +117,7 @@ export const judge = async (ctx: Context) => { memory: 256, }); const out = r.stdout.toString(); - if (out.length) ctx.next({ compiler_text: out.substring(0, 1024) }); + if (out.length) ctx.next({ compilerText: out.substring(0, 1024) }); if (process.env.DEV) console.log(r); } catch (e) { logger.info('Failed to run analysis'); @@ -128,7 +130,7 @@ export const judge = async (ctx: Context) => { ctx.end({ status, score: status === STATUS.STATUS_ACCEPTED ? 100 : 0, - time_ms: Math.floor(time_usage_ms * 1000000) / 1000000, - memory_kb: memory_usage_kb, + time: Math.floor(time_usage_ms * 1000000) / 1000000, + memory: memory_usage_kb, }); }; diff --git a/packages/hydrojudge/src/judge/submit_answer.ts b/packages/hydrojudge/src/judge/submit_answer.ts index 67096be5c..699f58cdd 100644 --- a/packages/hydrojudge/src/judge/submit_answer.ts +++ b/packages/hydrojudge/src/judge/submit_answer.ts @@ -5,10 +5,8 @@ import { check, compileChecker } from '../check'; import { getConfig } from '../config'; import { FormatError } from '../error'; import { del, run } from '../sandbox'; -import { parseFilename } from '../utils'; -import { - Case, Context, ContextSubTask, SubTask, -} from './interface'; +import { NormalizedCase, NormalizedSubtask, parseFilename } from '../utils'; +import { Context, ContextSubTask } from './interface'; const Score = { sum: (a: number, b: number) => (a + b), @@ -16,18 +14,18 @@ const Score = { min: Math.min, }; -function judgeCase(c: Case, sid: string) { +function judgeCase(c: NormalizedCase, sid: string) { return async (ctx: Context, ctxSubtask: ContextSubTask) => { if (ctx.errored || (ctx.failed[sid] && ctxSubtask.subtask.type === 'min') || (ctxSubtask.subtask.type === 'max' && ctxSubtask.score === ctxSubtask.subtask.score) - || ((ctxSubtask.subtask.if || []).filter((i: string) => ctx.failed[i]).length) + || ((ctxSubtask.subtask.if || []).filter((i) => ctx.failed[i]).length) ) { ctx.next({ case: { status: STATUS.STATUS_CANCELED, score: 0, - time_ms: 0, - memory_kb: 0, + time: 0, + memory: 0, message: '', }, addProgress: 100 / ctx.config.count, @@ -74,7 +72,7 @@ function judgeCase(c: Case, sid: string) { user_stdout: file, user_stderr: { content: '' }, checker_type: ctx.config.checker_type, - score: ctxSubtask.subtask.score, + score: c.score, detail: ctx.config.detail ?? true, env: { ...ctx.env, HYDRO_TESTCASE: c.id.toString() }, }); @@ -87,8 +85,8 @@ function judgeCase(c: Case, sid: string) { case: { status, score: 0, - time_ms: 0, - memory_kb: 0, + time: 0, + memory: 0, message, }, addProgress: 100 / ctx.config.count, @@ -96,7 +94,7 @@ function judgeCase(c: Case, sid: string) { }; } -function judgeSubtask(subtask: SubTask, sid: string) { +function judgeSubtask(subtask: NormalizedSubtask, sid: string) { return async (ctx: Context) => { subtask.type = subtask.type || 'min'; const ctxSubtask = { @@ -124,9 +122,6 @@ export const judge = async (ctx: Context, startPromise = Promise.resolve()) => { if (!ctx.config.subtasks.length) throw new FormatError('Problem data not found.'); startPromise.then(() => ctx.next({ status: STATUS.STATUS_COMPILING })); ctx.checker = await (() => { - if (['default', 'strict'].includes(ctx.config.checker_type || 'default')) { - return { execute: '', copyIn: {}, clean: () => Promise.resolve(null) }; - } const copyIn = { user_code: ctx.code }; for (const file of ctx.config.judge_extra_files) { copyIn[parseFilename(file)] = { src: file }; @@ -160,7 +155,7 @@ export const judge = async (ctx: Context, startPromise = Promise.resolve()) => { ctx.end({ status: ctx.total_status, score: ctx.total_score, - time_ms: 0, - memory_kb: 0, + time: 0, + memory: 0, }); }; diff --git a/packages/hydrojudge/src/log.ts b/packages/hydrojudge/src/log.ts index 611e5da29..b67edf409 100644 --- a/packages/hydrojudge/src/log.ts +++ b/packages/hydrojudge/src/log.ts @@ -1,29 +1,9 @@ import { format, inspect, InspectOptions } from 'util'; import cac from 'cac'; +import { Time } from '@hydrooj/utils/lib/utils'; const argv = cac().parse(); -namespace Time { - export const second = 1000; - export const minute = second * 60; - export const hour = minute * 60; - export const day = hour * 24; - export const week = day * 7; - export function formatTimeShort(ms: number) { - const abs = Math.abs(ms); - if (abs >= day - hour / 2) { - return `${Math.round(ms / day)}d`; - } if (abs >= hour - minute / 2) { - return `${Math.round(ms / hour)}h`; - } if (abs >= minute - second / 2) { - return `${Math.round(ms / minute)}m`; - } if (abs >= second) { - return `${Math.round(ms / second)}s`; - } - return `${ms}ms`; - } -} - const colors = [ 20, 21, 26, 27, 32, 33, 38, 39, 40, 41, 42, 43, 44, 45, 56, 57, 62, 63, 68, 69, 74, 75, 76, 77, 78, 79, 80, 81, 92, 93, 98, 99, 112, 113, diff --git a/packages/hydrojudge/src/sandbox.ts b/packages/hydrojudge/src/sandbox.ts index 86d7f1f3f..feb6a5a0c 100644 --- a/packages/hydrojudge/src/sandbox.ts +++ b/packages/hydrojudge/src/sandbox.ts @@ -1,5 +1,6 @@ import cac from 'cac'; import fs from 'fs-extra'; +import PQueue from 'p-queue'; import { ParseEntry } from 'shell-quote'; import { STATUS } from '@hydrooj/utils/lib/status'; import { getConfig } from './config'; @@ -191,3 +192,9 @@ export async function run(execute: string, params?: Parameter): Promise run(execute, params), { priority }); +} diff --git a/packages/hydrojudge/src/service.ts b/packages/hydrojudge/src/service.ts index cace7bd8f..a8ac4f3c0 100644 --- a/packages/hydrojudge/src/service.ts +++ b/packages/hydrojudge/src/service.ts @@ -7,9 +7,10 @@ import fs from 'fs-extra'; import { noop } from 'lodash'; import { ObjectID } from 'mongodb'; import { STATUS } from '@hydrooj/utils/lib/status'; +import type { JudgeResultBody } from 'hydrooj'; import { Logger } from 'hydrooj/src/logger'; import * as monitor from 'hydrooj/src/service/monitor'; -import readCases from './cases'; +import readCases, { processTestdata } from './cases'; import { getConfig } from './config'; import { CompileError, FormatError, SystemError } from './error'; import { CopyInFile } from './sandbox/interface'; @@ -22,7 +23,6 @@ declare module 'hydrooj/src/interface' { 'hydrojudge.cache_dir': string; 'hydrojudge.tmp_dir': string; 'hydrojudge.tmpfs_size': string; - 'hydrojudge.retry_delay_sec': number; 'hydrojudge.sandbox_host': string; 'hydrojudge.memoryMax': string; 'hydrojudge.testcases_max': number; @@ -53,28 +53,6 @@ async function postInit() { monitor.updateJudge({ mid, ...inf }); }, 1200000); - async function processData(folder: string) { - let files = await fs.readdir(folder); - let ini = false; - for (const i of files) { - if (i.toLowerCase() === 'config.ini') { - ini = true; - await fs.rename(`${folder}/${i}`, `${folder}/config.ini`); - break; - } - } - if (ini) { - for (const i of files) { - if (i.toLowerCase() === 'input') await fs.rename(`${folder}/${i}`, `${folder}/input`); - else if (i.toLowerCase() === 'output') await fs.rename(`${folder}/${i}`, `${folder}/output`); - } - files = await fs.readdir(`${folder}/input`); - for (const i of files) await fs.rename(`${folder}/input/${i}`, `${folder}/input/${i.toLowerCase()}`); - files = await fs.readdir(`${folder}/output`); - for (const i of files) await fs.rename(`${folder}/output/${i}`, `${folder}/output/${i.toLowerCase()}`); - } - } - async function fetchCodeFile(name: string) { const target = path.join('/tmp/hydro/judge', name.replace(/\//g, '_')); await storage.get(`submission/${name}`, target); @@ -103,39 +81,23 @@ async function postInit() { } fs.writeFileSync(path.join(filePath, 'etags'), JSON.stringify(version)); fs.writeFileSync(path.join(filePath, 'lastUsage'), new Date().getTime().toString()); - await processData(filePath).catch(noop); + await processTestdata(filePath); return filePath; } function getNext(that) { - return (data, id = 0) => { + return (data: Partial, id = 0) => { logger.debug('Next: %d %o', id, data); data.rid = new ObjectID(that.rid); - if (data.time_ms) data.time = data.time_ms; - if (data.memory_kb) data.memory = data.memory_kb; - if (data.compiler_text) data.compilerText = data.compiler_text; - delete data.time_ms; - delete data.memory_kb; - delete data.compiler_text; - if (data.case) { - data.case = { - id, - status: data.case.status, - time: data.case.time_ms || data.case.time, - memory: data.case.memory_kb || data.case.memory, - message: data.case.message || data.case.judgeText || '', - }; - } + if (data.case) data.case.id ||= id; _judge.next(data); }; } function getEnd(rid: string) { - return (data) => { + return (data: Partial) => { data.key = 'end'; data.rid = new ObjectID(rid); - data.time = data.time_ms ?? data.time; - data.memory = data.memory_kb ?? data.memory; logger.info('End: status=%d score=%d time=%dms memory=%dkb', data.status, data.score, data.time, data.memory); _judge.end(data); }; @@ -160,8 +122,8 @@ async function postInit() { config: any; env: any; input?: string; - next: (data: any, id?: number) => void; - end: (data: any) => void; + next: ReturnType; + end: ReturnType; tmpdir: string; clean: (() => Promise)[]; folder: string; @@ -204,20 +166,20 @@ async function postInit() { await this.submission(); } catch (e) { if (e instanceof CompileError) { - this.next({ compiler_text: compilerText(e.stdout, e.stderr) }); + this.next({ compilerText: compilerText(e.stdout, e.stderr) }); this.end({ - status: STATUS.STATUS_COMPILE_ERROR, score: 0, time_ms: 0, memory_kb: 0, + status: STATUS.STATUS_COMPILE_ERROR, score: 0, time: 0, memory: 0, }); } else if (e instanceof FormatError) { this.next({ message: { message: e.message, params: e.params } }); this.end({ - status: STATUS.STATUS_FORMAT_ERROR, score: 0, time_ms: 0, memory_kb: 0, + status: STATUS.STATUS_FORMAT_ERROR, score: 0, time: 0, memory: 0, }); } else { logger.error(e); this.next({ message: { message: e.message, params: e.params, ...process.env.DEV ? { stack: e.stack } : {} } }); this.end({ - status: STATUS.STATUS_SYSTEM_ERROR, score: 0, time_ms: 0, memory_kb: 0, + status: STATUS.STATUS_SYSTEM_ERROR, score: 0, time: 0, memory: 0, }); } } diff --git a/packages/hydrojudge/src/sysinfo.ts b/packages/hydrojudge/src/sysinfo.ts index 1c4650a57..ac2c0f63d 100644 --- a/packages/hydrojudge/src/sysinfo.ts +++ b/packages/hydrojudge/src/sysinfo.ts @@ -1,3 +1,4 @@ +// TODO this should be moved into web import path from 'path'; import fs from 'fs-extra'; import { noop } from 'lodash'; @@ -30,6 +31,8 @@ int main(){ type: 'default', time: '3s', memory: '256m', + user_extra_files: [], + judge_extra_files: [], }, stat: {}, clean: [], diff --git a/packages/hydrojudge/src/testlib.ts b/packages/hydrojudge/src/testlib.ts index d940a89b2..de824998a 100644 --- a/packages/hydrojudge/src/testlib.ts +++ b/packages/hydrojudge/src/testlib.ts @@ -29,7 +29,7 @@ export function parse(output: string, fullscore: number) { if (p === 1) { status = STATUS.STATUS_ACCEPTED; score = fullscore; - const base = output.split('points ')[1] || ''; + const base = output.replace('points ', '') || ''; message = base.substring(base.indexOf(' '), 1024); } else score = Math.floor(fullscore * p); } diff --git a/packages/hydrooj/package.json b/packages/hydrooj/package.json index ceb433fb3..4cc64ebed 100644 --- a/packages/hydrooj/package.json +++ b/packages/hydrooj/package.json @@ -1,6 +1,6 @@ { "name": "hydrooj", - "version": "3.12.1", + "version": "3.13.8", "bin": "bin/hydrooj.js", "main": "src/loader", "module": "src/loader", @@ -12,7 +12,7 @@ }, "preferUnplugged": true, "dependencies": { - "@graphql-tools/schema": "^8.3.6", + "@graphql-tools/schema": "^8.3.11", "@hydrooj/utils": "workspace:*", "adm-zip": "0.5.5", "ajv": "^8.11.0", @@ -23,32 +23,32 @@ "emoji-regex": "^10.1.0", "emojis-list": "2.1.0", "esbuild": "0.14.3", - "fs-extra": "^10.0.1", - "graphql": "^16.3.0", + "fs-extra": "^10.1.0", + "graphql": "^16.5.0", "graphql-scalars": "1.17.0", "isbinaryfile": "^5.0.0", "js-yaml": "^4.1.0", "koa": "^2.13.4", - "koa-body": "^4.2.0", + "koa-body": "^5.0.0", "koa-compress": "^5.1.0", "koa-proxies": "^0.12.2", "koa-router": "^10.1.1", "koa-static-cache": "^5.1.4", "lodash": "^4.17.21", - "lru-cache": "7.7.3", + "lru-cache": "7.9.0", "mime-types": "^2.1.35", "minio": "7.0.25", "moment-timezone": "^0.5.34", "mongodb": "^3.7.3", - "nanoid": "^3.3.2", - "nodemailer": "^6.7.3", + "nanoid": "^3.3.4", + "nodemailer": "^6.7.5", "notp": "^2.0.3", "p-queue": "^7.2.0", "require-resolve-hook": "^1.1.0", - "semver": "^7.3.6", + "semver": "^7.3.7", "serialize-javascript": "^6.0.0", "source-map-support": "^0.5.21", - "superagent": "^7.1.2", + "superagent": "^7.1.3", "thirty-two": "^1.0.2", "tx2": "^1.0.5" }, @@ -60,7 +60,7 @@ "@types/koa-compress": "^4.0.3", "@types/koa-router": "^7.4.4", "@types/koa-static-cache": "^5.1.1", - "@types/lodash": "^4.14.181", + "@types/lodash": "^4.14.182", "@types/lru-cache": "^7.6.1", "@types/mime-types": "^2.1.1", "@types/minio": "^7.0.12", @@ -71,7 +71,7 @@ "@types/serialize-javascript": "^5.0.2", "@types/source-map-support": "^0.5.4", "@types/superagent": "^4.1.15", - "moment": "^2.29.2", + "moment": "^2.29.3", "pm2": "^5.2.0" }, "peerDependencies": { diff --git a/packages/hydrooj/src/entry/common.ts b/packages/hydrooj/src/entry/common.ts index 5a15bb04a..4cfb71851 100644 --- a/packages/hydrooj/src/entry/common.ts +++ b/packages/hydrooj/src/entry/common.ts @@ -178,6 +178,7 @@ export async function service(pending: string[], fail: string[]) { } } for (const key in global.Hydro.service) { + if (key === 'server') continue; const srv = global.Hydro.service[key]; if (!srv.started && srv.start) await srv.start(); } diff --git a/packages/hydrooj/src/handler/compat.ts b/packages/hydrooj/src/handler/compat.ts new file mode 100644 index 000000000..f25fb23d6 --- /dev/null +++ b/packages/hydrooj/src/handler/compat.ts @@ -0,0 +1,11 @@ +import { Handler, Route } from '../service/server'; + +class ProblemCategoryCompatHandler extends Handler { + async get({ category }) { + this.response.redirect = this.url('problem_main', { query: { q: `category:${category}` } }); + } +} + +global.Hydro.handler.compat = () => { + Route('problem_category_compat', '/p/category/:category', ProblemCategoryCompatHandler); +}; diff --git a/packages/hydrooj/src/handler/discussion.ts b/packages/hydrooj/src/handler/discussion.ts index 98b39e9f4..7b4743445 100644 --- a/packages/hydrooj/src/handler/discussion.ts +++ b/packages/hydrooj/src/handler/discussion.ts @@ -240,7 +240,7 @@ class DiscussionDetailHandler extends DiscussionHandler { const uids = Object.keys(await user.getList(domainId, Array.from(targets))).map((i) => +i); const msg = JSON.stringify({ message: 'User {0} mentioned you in {1:link}', - params: [this.user.uname, this.request.path], + params: [this.user.uname, `/d/${domainId}${this.request.path}`], }); for (const uid of uids) { message.send(1, uid, msg, message.FLAG_RICHTEXT | message.FLAG_UNREAD); @@ -259,7 +259,7 @@ class DiscussionDetailHandler extends DiscussionHandler { const uids = Object.keys(await user.getList(domainId, Array.from(targets))).map((i) => +i); const msg = JSON.stringify({ message: 'User {0} mentioned you in {1:link}', - params: [this.user.uname, this.request.path], + params: [this.user.uname, `/d/${domainId}${this.request.path}`], }); for (const uid of uids) { message.send(1, uid, msg, message.FLAG_RICHTEXT | message.FLAG_UNREAD); diff --git a/packages/hydrooj/src/handler/import.ts b/packages/hydrooj/src/handler/import.ts index 73b4d3cf1..b59d092ab 100644 --- a/packages/hydrooj/src/handler/import.ts +++ b/packages/hydrooj/src/handler/import.ts @@ -37,7 +37,7 @@ class ProblemImportHydroHandler extends Handler { if (keepUser) this.checkPriv(PRIV.PRIV_EDIT_SYSTEM); if (!this.request.files.file) throw new ValidationError('file'); const tmpdir = path.join(os.tmpdir(), 'hydro', `${Math.random()}.import`); - const zip = new AdmZip(this.request.files.file.path); + const zip = new AdmZip(this.request.files.file.filepath); await new Promise((resolve, reject) => { zip.extractAllToAsync(tmpdir, true, (err) => { if (err) reject(err); diff --git a/packages/hydrooj/src/handler/index.ts b/packages/hydrooj/src/handler/index.ts index c72beb7f5..d70cd464f 100644 --- a/packages/hydrooj/src/handler/index.ts +++ b/packages/hydrooj/src/handler/index.ts @@ -14,3 +14,4 @@ import './domain'; import './status'; import './api'; import './blog'; +import './compat'; diff --git a/packages/hydrooj/src/handler/judge.ts b/packages/hydrooj/src/handler/judge.ts index e1fb46ee9..283289c52 100644 --- a/packages/hydrooj/src/handler/judge.ts +++ b/packages/hydrooj/src/handler/judge.ts @@ -41,19 +41,19 @@ export async function postJudge(rdoc: RecordDoc) { await bus.serial('record/judge', rdoc, updated); } -export async function next(body: JudgeResultBody) { +export async function next(body: Partial) { body.rid = new ObjectID(body.rid); let rdoc = await record.get(body.rid); if (!rdoc) return; const $set: Partial = {}; const $push: any = {}; if (body.case) { - const c: TestCase = { + const c: Required = { + ...body.case, id: body.case.id || 0, - memory: body.case.memory, - time: body.case.time, + subtaskId: body.case.subtaskId || 0, + score: body.case.score || 0, message: body.case.message || '', - status: body.case.status, }; rdoc.testCases.push(c); $push.testCases = c; @@ -75,7 +75,7 @@ export async function next(body: JudgeResultBody) { bus.broadcast('record/change', rdoc!, $set, $push); } -export async function end(body: JudgeResultBody) { +export async function end(body: Partial) { if (body.rid) body.rid = new ObjectID(body.rid); let rdoc = await record.get(body.rid); if (!rdoc) return; diff --git a/packages/hydrooj/src/handler/misc.ts b/packages/hydrooj/src/handler/misc.ts index 5c1a98d00..f2776ca0f 100644 --- a/packages/hydrooj/src/handler/misc.ts +++ b/packages/hydrooj/src/handler/misc.ts @@ -49,16 +49,17 @@ export class FilesHandler extends Handler { if ((this.user._files?.length || 0) >= system.get('limit.user_files')) { throw new ForbiddenError('File limit exceeded.'); } - if (!this.request.files.file) throw new ValidationError('file'); - const f = statSync(this.request.files.file.path); + const file = this.request.files?.file; + if (!file) throw new ValidationError('file'); + const f = statSync(file.filepath); const size = Math.sum((this.user._files || []).map((i) => i.size)) + f.size; if (size >= system.get('limit.user_files_size')) { throw new ForbiddenError('File size limit exceeded.'); } - if (!filename) filename = this.request.files.file.name || String.random(16); + if (!filename) filename = file.originalFilename || String.random(16); if (filename.includes('/') || filename.includes('..')) throw new ValidationError('filename', null, 'Bad filename'); if (this.user._files.filter((i) => i.name === filename).length) throw new BadRequestError('file exists'); - await storage.put(`user/${this.user._id}/${filename}`, this.request.files.file.path, this.user._id); + await storage.put(`user/${this.user._id}/${filename}`, file.filepath, this.user._id); const meta = await storage.getMeta(`user/${this.user._id}/${filename}`); const payload = { name: filename, ...pick(meta, ['size', 'lastModified', 'etag']) }; if (!meta) throw new Error('Upload failed'); @@ -90,7 +91,7 @@ export class FSDownloadHandler extends Handler { @param('noDisposition', Types.Boolean) async get(domainId: string, uid: number, filename: string, noDisposition = false) { const targetUser = await user.getById('system', uid); - if (!targetUser.hasPriv(PRIV.PRIV_CREATE_FILE)) throw new ForbiddenError('Access denied'); + if (this.user._id !== uid && !targetUser.hasPriv(PRIV.PRIV_CREATE_FILE)) throw new ForbiddenError('Access denied'); this.response.addHeader('Cache-Control', 'public'); const target = `user/${uid}/${filename}`; const file = await storage.getMeta(target); diff --git a/packages/hydrooj/src/handler/problem.ts b/packages/hydrooj/src/handler/problem.ts index 36b747ba3..00760623b 100644 --- a/packages/hydrooj/src/handler/problem.ts +++ b/packages/hydrooj/src/handler/problem.ts @@ -1,10 +1,10 @@ import AdmZip from 'adm-zip'; import { readFile, statSync } from 'fs-extra'; import { isBinaryFile } from 'isbinaryfile'; -import { intersection, isSafeInteger } from 'lodash'; +import { flattenDeep, intersection, isSafeInteger } from 'lodash'; import { FilterQuery, ObjectID } from 'mongodb'; import { nanoid } from 'nanoid'; -import { sortFiles } from '@hydrooj/utils/lib/utils'; +import { sortFiles, streamToBuffer } from '@hydrooj/utils/lib/utils'; import { BadRequestError, ContestNotAttendedError, ContestNotEndedError, ContestNotFoundError, ContestNotLiveError, @@ -125,7 +125,7 @@ export class ProblemHandler extends Handler { page, ppcount, pcount, pdocs, psdict, qs, }), this.renderHTML('partials/problem_stat.html', { pcount, pcountRelation }), - this.renderHTML('partials/problem_lucky.html', { category }), + this.renderHTML('partials/problem_lucky.html', { qs }), ])).map((i) => ({ html: i })), raw: { page, pcount, ppcount, pdocs, psdict, category, @@ -138,8 +138,7 @@ export class ProblemHandler extends Handler { export class ProblemMainHandler extends ProblemHandler { @param('page', Types.PositiveInt, true) @param('q', Types.Content, true) - @param('category', Types.Name, true, null, parseCategory) - async get(domainId: string, page = 1, q = '', category = []) { + async get(domainId: string, page = 1, q = '') { this.response.template = 'problem_main.html'; // eslint-disable-next-line @typescript-eslint/no-shadow const query = buildQuery(this.user); @@ -148,11 +147,15 @@ export class ProblemMainHandler extends ProblemHandler { let sort: string[]; let fail = false; let pcountRelation = 'eq'; + const category = flattenDeep(decodeURIComponent(q).split(' ') + .filter((i) => i.startsWith('category:')) + .map((i) => i.split('category:')[1]?.split(','))); + const text = q.split(' ').filter((i) => !i.startsWith('category:')).join(' '); if (category.length) query.$and = category.map((tag) => ({ tag })); - if (q) category.push(q); + if (text) category.push(text); if (category.length) this.UiContext.extraTitleContent = category.join(','); let total = 0; - if (q) { + if (text) { if (search) { const result = await search(domainId, q, { skip: (page - 1) * system.get('pagination.problem') }); total = result.total; @@ -166,7 +169,7 @@ export class ProblemMainHandler extends ProblemHandler { }), }); sort = result.hits; - } else query.$text = { $search: q }; + } else query.$text = { $search: text }; } await bus.serial('problem/list', query, this); // eslint-disable-next-line prefer-const @@ -209,7 +212,7 @@ export class ProblemMainHandler extends ProblemHandler { pdocs, psdict, category: category.join(','), - qs: q ? `q=${encodeURIComponent(q)}` : '', + qs: q, }; } @@ -281,8 +284,11 @@ export class ProblemMainHandler extends ProblemHandler { } export class ProblemRandomHandler extends ProblemHandler { - @param('category', Types.Name, true, null, parseCategory) - async get(domainId: string, category: string[] = []) { + @param('q', Types.Content, true) + async get(domainId: string, qs = '') { + const category = flattenDeep(decodeURIComponent(qs).split(' ') + .filter((i) => i.startsWith('category:')) + .map((i) => i.split('category:')[1]?.split(','))); const q = buildQuery(this.user); if (category.length) q.$and = category.map((tag) => ({ tag })); await bus.serial('problem/list', q, this); @@ -486,13 +492,13 @@ export class ProblemSubmitHandler extends ProblemDetailHandler { if (typeof config !== 'object' || config === null) throw new BadRequestError('Incorrect problem config'); const sizeLimit = config.type === 'submit_answer' ? 128 * 1024 * 1024 : 65535; if (file.size > sizeLimit) throw new ValidationError('file'); - if (file.size > 65535 || await isBinaryFile(file.path, file.size)) { + if (file.size > 65535 || await isBinaryFile(file.filepath, file.size)) { const id = nanoid(); - await storage.put(`submission/${this.user._id}/${id}`, file.path, this.user._id); - code = `@@hydro_submission_file@@${this.user._id}/${id}#${file.name}`; + await storage.put(`submission/${this.user._id}/${id}`, file.filepath, this.user._id); + code = `@@hydro_submission_file@@${this.user._id}/${id}#${file.originalFilename}`; } else { // TODO auto detect & convert encoding - code = await readFile(file.path, 'utf-8'); + code = await readFile(file.filepath, 'utf-8'); } } else if (code.startsWith('@@hydro_submission_file@@')) throw new ValidationError('code'); await this.limitRate('add_record', 60, system.get('limit.submission')); @@ -525,6 +531,7 @@ export class ProblemManageHandler extends ProblemDetailHandler { export class ProblemEditHandler extends ProblemManageHandler { async get() { + this.response.body.additional_file = sortFiles(this.pdoc.additional_file || []); this.response.template = 'problem_edit.html'; } @@ -550,6 +557,23 @@ export class ProblemEditHandler extends ProblemManageHandler { } } +export class ProblemConfigHandler extends ProblemManageHandler { + async get() { + if (this.pdoc.reference) throw new ForbiddenError('Cannot edit config of a referenced problem.'); + this.response.body.testdata = sortFiles(this.pdoc.data || []); + const configFile = (this.pdoc.data || []).filter((i) => i.name.toLowerCase() === 'config.yaml'); + this.response.body.config = ''; + if (configFile.length > 0) { + try { + this.response.body.config = (await streamToBuffer( + await storage.get(`problem/${this.pdoc.domainId}/${this.pdoc.docId}/testdata/${configFile[0].name}`), + )).toString(); + } catch (e) { /* ignore */ } + } + this.response.template = 'problem_config.html'; + } +} + export class ProblemFilesHandler extends ProblemDetailHandler { notUsage = true; @@ -608,12 +632,12 @@ export class ProblemFilesHandler extends ProblemDetailHandler { async postUploadFile(domainId: string, filename: string, type = 'testdata') { if (this.pdoc.reference) throw new ForbiddenError('Cannot edit files of a referenced problem.'); if (!this.request.files.file) throw new ValidationError('file'); - if (!filename) filename = this.request.files.file.name || String.random(16); + if (!filename) filename = this.request.files.file.originalFilename || String.random(16); if (filename.includes('/') || filename.includes('..')) throw new ValidationError('filename', null, 'Bad filename'); if (!this.user.own(this.pdoc, PERM.PERM_EDIT_PROBLEM_SELF)) this.checkPerm(PERM.PERM_EDIT_PROBLEM); const files = []; if (filename.endsWith('.zip')) { - const zip = new AdmZip(this.request.files.file.path); + const zip = new AdmZip(this.request.files.file.filepath); const entries = zip.getEntries(); for (const entry of entries) { if (!entry.name) continue; @@ -628,8 +652,8 @@ export class ProblemFilesHandler extends ProblemDetailHandler { files.push({ type, name: filename, - size: statSync(this.request.files.file.path).size, - data: () => this.request.files.file.path, + size: statSync(this.request.files.file.filepath).size, + data: () => this.request.files.file.filepath, }); } if (!this.user.hasPriv(PRIV.PRIV_EDIT_SYSTEM)) { @@ -831,6 +855,7 @@ export class ProblemCreateHandler extends Handler { this.response.template = 'problem_edit.html'; this.response.body = { page_name: 'problem_create', + additional_file: [], }; } @@ -887,11 +912,11 @@ export class ProblemPrefixListHandler extends Handler { export async function apply() { Route('problem_main', '/p', ProblemMainHandler, PERM.PERM_VIEW_PROBLEM); - Route('problem_category', '/p/category/:category', ProblemMainHandler, PERM.PERM_VIEW_PROBLEM); Route('problem_random', '/problem/random', ProblemRandomHandler, PERM.PERM_VIEW_PROBLEM); Route('problem_detail', '/p/:pid', ProblemDetailHandler, PERM.PERM_VIEW_PROBLEM); Route('problem_submit', '/p/:pid/submit', ProblemSubmitHandler, PERM.PERM_SUBMIT_PROBLEM); Route('problem_edit', '/p/:pid/edit', ProblemEditHandler); + Route('problem_config', '/p/:pid/config', ProblemConfigHandler); Route('problem_files', '/p/:pid/files', ProblemFilesHandler, PERM.PERM_VIEW_PROBLEM); Route('problem_file_download', '/p/:pid/file/:filename', ProblemFileDownloadHandler, PERM.PERM_VIEW_PROBLEM); Route('problem_solution', '/p/:pid/solution', ProblemSolutionHandler, PERM.PERM_VIEW_PROBLEM); diff --git a/packages/hydrooj/src/handler/record.ts b/packages/hydrooj/src/handler/record.ts index 6a90f5411..b21152259 100644 --- a/packages/hydrooj/src/handler/record.ts +++ b/packages/hydrooj/src/handler/record.ts @@ -183,7 +183,7 @@ class RecordDetailHandler extends Handler { time: 0, memory: 0, testCases: [{ - status: 9, score: 0, time: 0, memory: 0, message: 'score canceled', + id: 0, subtaskId: 0, status: 9, score: 0, time: 0, memory: 0, message: 'score canceled', }], }; const [latest] = await Promise.all([ diff --git a/packages/hydrooj/src/interface.ts b/packages/hydrooj/src/interface.ts index f6ea50295..34a7d902a 100644 --- a/packages/hydrooj/src/interface.ts +++ b/packages/hydrooj/src/interface.ts @@ -2,7 +2,6 @@ import type fs from 'fs'; import type { Dictionary, NumericDictionary } from 'lodash'; import type { ItemBucketMetadata } from 'minio'; import type { Cursor, ObjectID } from 'mongodb'; -import type { Readable, Writable } from 'stream'; import type { ProblemDoc } from './model/problem'; type document = typeof import('./model/document'); @@ -127,10 +126,11 @@ export interface FileInfo { } export interface TestCaseConfig { - input: string, - output: string, - time?: number, - memory?: number, + input: string; + output: string; + time?: string; + memory?: string; + score?: number; } export enum ProblemType { @@ -147,8 +147,8 @@ export enum SubtaskType { } export interface SubtaskConfig { - time?: number; - memory?: number; + time?: string; + memory?: string; score?: number; if?: number[]; id?: number; @@ -207,6 +207,7 @@ export interface SampleContentNode { sectionTitle: string, payload: [string, string], } +// TODO drop contentNode support export type ContentNode = PlainContentNode | TextContentNode | SampleContentNode; export type Content = string | ContentNode[] | Record; @@ -271,6 +272,8 @@ export interface ProblemStatusDoc extends StatusDoc { export interface TestCase { id?: number; + subtaskId?: number; + score?: number; time: number; memory: number; status: number; @@ -287,9 +290,9 @@ export interface RecordDoc { score: number; memory: number; time: number; - judgeTexts: string[]; + judgeTexts: (string | JudgeMessage)[]; compilerTexts: string[]; - testCases: TestCase[]; + testCases: Required[]; rejudged: boolean; source?: string; /** judge uid */ @@ -496,24 +499,25 @@ export interface Script { validate: any, } +export interface JudgeMessage { + message: string; + params?: string[]; + stack?: string; +} + export interface JudgeResultBody { + key: string; domainId: string; rid: ObjectID; judger?: number; progress?: number; addProgress?: number; - case?: { - id?: number; - status: number; - time: number; - memory: number; - message?: string; - }, + case?: TestCase, status?: number; score?: number; time?: number; memory?: number; - message?: string; + message?: string | JudgeMessage; compilerText?: string, } @@ -526,19 +530,6 @@ export interface Task { [key: string]: any; } -export interface UploadStream extends Writable { - id: ObjectID; -} - -export interface HydroFileSystem { - openUploadStream: (filename: string) => UploadStream - openDownloadStream: (_id: ObjectID) => Readable - // TODO - find: (...args: any[]) => any -} - -export type PathComponent = [string, string, Dictionary?, boolean?]; - export interface BaseService { started: boolean; error?: Error | string; @@ -636,7 +627,6 @@ export interface Model { export interface Service { bus: typeof import('./service/bus'), db: typeof import('./service/db'), - fs: HydroFileSystem, monitor: typeof import('./service/monitor'), server: typeof import('./service/server'), storage: typeof import('./service/storage'), diff --git a/packages/hydrooj/src/lib/difficulty.ts b/packages/hydrooj/src/lib/difficulty.ts index c168f47c1..87d924d34 100644 --- a/packages/hydrooj/src/lib/difficulty.ts +++ b/packages/hydrooj/src/lib/difficulty.ts @@ -6,7 +6,7 @@ const _CACHE_INFO = { function _LOGP(x: number) { const sqrtPi = 2.506628274631; // Sqrt[Pi] - return (2 * Math.exp(-1.0 * (Math.log(x) ** 2) * 2)) / x / sqrtPi; + return (2 * Math.exp(-2.0 * (Math.log(x) ** 2))) / x / sqrtPi; } function _intergrateEnsureCache(y: number) { @@ -27,7 +27,7 @@ function _intergrateEnsureCache(y: number) { return _CACHE_INFO; } -_intergrateEnsureCache(1000000); +_intergrateEnsureCache(10000); function _integrate(y: number) { _intergrateEnsureCache(y); @@ -38,7 +38,7 @@ function difficultyAlgorithm(nSubmit: number, nAccept: number) { if (!nSubmit) return null; const s = _integrate(nSubmit); const acRate = nAccept / nSubmit; - const ans = Math.round(10.0 - 1.30 * s * 10.0 * acRate); + const ans = Math.round(10 - 13 * s * acRate); return Math.max(ans, 1); } diff --git a/packages/hydrooj/src/lib/testdataConfig.ts b/packages/hydrooj/src/lib/testdataConfig.ts index 3607b5d06..c6a1fde45 100644 --- a/packages/hydrooj/src/lib/testdataConfig.ts +++ b/packages/hydrooj/src/lib/testdataConfig.ts @@ -1,13 +1,12 @@ import { load } from 'js-yaml'; import readYamlCases from '@hydrooj/utils/lib/cases'; import type { ProblemConfig, ProblemConfigFile } from '../interface'; +import { normalizeSubtasks, parseMemoryMB, parseTimeMS } from '../utils'; export async function parseConfig(config: string | ProblemConfigFile = {}) { - let cfg: ProblemConfigFile = {}; - if (typeof config === 'string') { - // TODO should validate here? - cfg = await readYamlCases(load(config) as Record); - } else if (typeof config === 'object') cfg = await readYamlCases(config); + const cfg: ProblemConfigFile = typeof config === 'string' + ? await readYamlCases(load(config) as Record) + : await readYamlCases(config); const result: ProblemConfig = { count: 0, memoryMin: Number.MAX_SAFE_INTEGER, @@ -18,23 +17,19 @@ export async function parseConfig(config: string | ProblemConfigFile = {}) { }; if (cfg.subType) result.subType = cfg.subType; if (cfg.target) result.target = cfg.target; - if (cfg.subtasks.length) { - for (const subtask of cfg.subtasks) { - result.memoryMax = Math.max(result.memoryMax, subtask.memory); - result.memoryMin = Math.min(result.memoryMin, subtask.memory); - result.timeMax = Math.max(result.timeMax, subtask.time); - result.timeMin = Math.min(result.timeMin, subtask.time); + if (cfg.subtasks?.length) { + for (const subtask of normalizeSubtasks(cfg.subtasks as any || [], (i) => i, cfg.time, cfg.memory)) { + result.memoryMax = Math.max(result.memoryMax, ...subtask.cases.map((i) => parseMemoryMB(i.memory))); + result.memoryMin = Math.min(result.memoryMin, ...subtask.cases.map((i) => parseMemoryMB(i.memory))); + result.timeMax = Math.max(result.timeMax, ...subtask.cases.map((i) => parseTimeMS(i.time))); + result.timeMin = Math.min(result.timeMin, ...subtask.cases.map((i) => parseTimeMS(i.time))); } - } else if (cfg.time || cfg.memory) { + } else { if (cfg.time) result.timeMax = result.timeMin = cfg.time as unknown as number; if (cfg.memory) result.memoryMax = result.memoryMin = cfg.memory as unknown as number; } - if (result.memoryMax < result.memoryMin) { - result.memoryMax = result.memoryMin = 256; - } - if (result.timeMax < result.timeMin) { - result.timeMax = result.timeMin = 1000; - } + if (result.memoryMax < result.memoryMin) result.memoryMax = result.memoryMin = 256; + if (result.timeMax < result.timeMin) result.timeMax = result.timeMin = 1000; if (cfg.langs) result.langs = cfg.langs; if (cfg.redirect) result.redirect = cfg.redirect.split('/', 2) as any; if (cfg.filename && result.type === 'default') { diff --git a/packages/hydrooj/src/model/contest.ts b/packages/hydrooj/src/model/contest.ts index 4e355638d..3703e8b96 100644 --- a/packages/hydrooj/src/model/contest.ts +++ b/packages/hydrooj/src/model/contest.ts @@ -50,7 +50,7 @@ const acm = buildContestRule({ check: () => { }, statusSort: { accept: -1, time: 1 }, submitAfterAccept: false, - showScoreboard: () => true, + showScoreboard: (tdoc, now) => now > tdoc.beginAt, showSelfRecord: () => true, showRecord: (tdoc, now) => now > tdoc.endAt, stat(tdoc, journal: AcmJournal[], ignoreLock = false) { @@ -297,7 +297,7 @@ const ioi = buildContestRule({ submitAfterAccept: false, showRecord: (tdoc, now) => now > tdoc.endAt, showSelfRecord: () => true, - showScoreboard: () => true, + showScoreboard: (tdoc, now) => now > tdoc.beginAt, }); const homework = buildContestRule({ diff --git a/packages/hydrooj/src/service/layers/renderer.ts b/packages/hydrooj/src/service/layers/renderer.ts index 566fab208..e7544fcce 100644 --- a/packages/hydrooj/src/service/layers/renderer.ts +++ b/packages/hydrooj/src/service/layers/renderer.ts @@ -40,7 +40,7 @@ export default (router, logger) => async (ctx: KoaContext, next) => { } for (const key in kwargs.query || {}) { if (query[key] instanceof ObjectID) query[key] = kwargs.query[key].toHexString(); - else query[key] = encodeURIComponent(kwargs.query[key].toString()); + else query[key] = kwargs.query[key].toString(); } try { const { anchor } = args; @@ -52,7 +52,7 @@ export default (router, logger) => async (ctx: KoaContext, next) => { || (host instanceof Array ? (!host.includes(request.host)) : request.host !== host) - )) withDomainId = domainId; + )) withDomainId ||= domainId; res = router.url(name, args, { query }).toString(); if (anchor) res = `${res}#${anchor}`; if (withDomainId) res = `/d/${withDomainId}${res}`; diff --git a/packages/hydrooj/src/service/server.ts b/packages/hydrooj/src/service/server.ts index c15ceaee7..417ed376c 100644 --- a/packages/hydrooj/src/service/server.ts +++ b/packages/hydrooj/src/service/server.ts @@ -95,7 +95,7 @@ app.on('error', (error) => { } }); wsServer.on('error', (error) => { - console.log(123, error); + console.log('Websocket server error:', error); }); const ignoredLimit = `,${argv.options.ignoredLimit},`; @@ -139,7 +139,7 @@ ${ctx.response.status} ${endTime - startTime}ms ${ctx.response.length}`); })); const layers = [baseLayer, rendererLayer(router, logger), responseLayer(router), userLayer]; app.use(async (ctx, next) => await next().catch(console.error)).use(domainLayer); - layers.forEach((layer) => router.use(layer)); + layers.forEach((layer) => router.use(layer as any)); wsServer.on('connection', async (socket, request) => { const ctx: any = app.createContext(request, {} as any); await domainLayer(ctx, () => baseLayer(ctx, () => layers[1](ctx, () => userLayer(ctx, () => { })))); diff --git a/packages/import-qduoj/handler.ts b/packages/import-qduoj/handler.ts index d34319add..44758be22 100644 --- a/packages/import-qduoj/handler.ts +++ b/packages/import-qduoj/handler.ts @@ -119,9 +119,9 @@ class ImportQduojHandler extends Handler { async post({ domainId }) { if (!this.request.files.file) throw new ValidationError('file'); - const stat = await fs.stat(this.request.files.file.path); + const stat = await fs.stat(this.request.files.file.filepath); if (stat.size > 128 * 1024 * 1024) throw new ValidationError('file', 'File too large'); - await this.fromFile(domainId, this.request.files.file.path); + await this.fromFile(domainId, this.request.files.file.filepath); this.response.redirect = this.url('problem_main'); } } diff --git a/packages/import-qduoj/package.json b/packages/import-qduoj/package.json index cd260cd91..35ab4d7cb 100644 --- a/packages/import-qduoj/package.json +++ b/packages/import-qduoj/package.json @@ -1,6 +1,6 @@ { "name": "@hydrooj/import-qduoj", - "version": "1.3.3", + "version": "1.3.4", "description": "Import QDUOJ problem export", "main": "package.json", "repository": "https://github.com/hydro-dev/Hydro.git", @@ -10,6 +10,6 @@ "dependencies": { "adm-zip": "0.5.5", "decode-html": "^2.0.0", - "fs-extra": "^10.0.1" + "fs-extra": "^10.1.0" } } diff --git a/packages/login-with-github/package.json b/packages/login-with-github/package.json index 2e5bbcca4..7df12780e 100644 --- a/packages/login-with-github/package.json +++ b/packages/login-with-github/package.json @@ -13,6 +13,6 @@ "hydrooj": "*" }, "dependencies": { - "superagent": "^7.1.2" + "superagent": "^7.1.3" } } diff --git a/packages/login-with-google/package.json b/packages/login-with-google/package.json index e951bdd1c..c4e588642 100644 --- a/packages/login-with-google/package.json +++ b/packages/login-with-google/package.json @@ -13,6 +13,6 @@ "hydrooj": "*" }, "dependencies": { - "superagent": "^7.1.2" + "superagent": "^7.1.3" } } diff --git a/packages/migrate-hustoj/README.md b/packages/migrate-hustoj/README.md deleted file mode 100644 index 7fea86811..000000000 --- a/packages/migrate-hustoj/README.md +++ /dev/null @@ -1,12 +0,0 @@ -# migrate-hustoj - -这个模块用于从HustOJ的数据库导入数据。 - -## 迁移须知 - -迁移前,您需要指定数据来源。 -迁移过程中,Hydro 的内容会被**清空**,以下内容将被迁移: - -题目与测试数据,用户列表。 - -迁移过程中请保持网络畅通。 diff --git a/packages/migrate-hustoj/locale/zh.yaml b/packages/migrate-hustoj/locale/zh.yaml deleted file mode 100644 index 989f26537..000000000 --- a/packages/migrate-hustoj/locale/zh.yaml +++ /dev/null @@ -1 +0,0 @@ -migrate from hustoj: 从 HustOJ 导入 \ No newline at end of file diff --git a/packages/migrate-vijos/lib.ts b/packages/migrate-vijos/lib.ts deleted file mode 100644 index b0eac4509..000000000 --- a/packages/migrate-vijos/lib.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { } from 'hydrooj'; - -const { md5, sha1 } = global.Hydro.lib; - -interface Udoc { - uname: string, - unameLower: string, -} - -function hash(password: string, salt: string, udoc: Udoc) { - const { uname, unameLower } = udoc; - const pmd5 = md5(password); - const mixedSha1 = sha1(md5(unameLower + pmd5) + salt + sha1(pmd5 + salt)); - return `${Buffer.from(uname).toString('base64')}|${mixedSha1}`; -} - -global.Hydro.lib['hash.vj2'] = hash; diff --git a/packages/migrate-vijos/locale/zh.yaml b/packages/migrate-vijos/locale/zh.yaml deleted file mode 100644 index e66715a60..000000000 --- a/packages/migrate-vijos/locale/zh.yaml +++ /dev/null @@ -1 +0,0 @@ -migrate from vijos: 从 Vijos 导入 \ No newline at end of file diff --git a/packages/migrate-vijos/package.json b/packages/migrate-vijos/package.json deleted file mode 100644 index dce316e16..000000000 --- a/packages/migrate-vijos/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "@hydrooj/migrate-vijos", - "version": "1.5.1", - "main": "package.json", - "repository": "git@github.com:hydro-dev/Hydro.git", - "author": "undefined ", - "preferUnplugged": true, - "license": "AGPL-3.0-or-later", - "devDependencies": { - "@types/mongodb": "^3.6.20" - }, - "dependencies": { - "mongodb": "^3.7.3" - } -} diff --git a/packages/migrate-vijos/README.md b/packages/migrate/README.md similarity index 57% rename from packages/migrate-vijos/README.md rename to packages/migrate/README.md index 146a77293..cf08632fb 100644 --- a/packages/migrate-vijos/README.md +++ b/packages/migrate/README.md @@ -1,17 +1,21 @@ -# migrate-vijos +# migrate -这个模块用于从Vijos4.0的数据库导入数据。 -## 迁移须知 +这个模块用于从其他系统批量导入数据。 +迁移过程中请保持网络畅通。 +## migrate-vijos + +从Vijos4.0的数据库导入数据。 迁移前,您需要指定数据来源。 **请不要**将 Hydro 正在使用的数据库设置为数据来源的数据库。 -迁移过程中,Hydro 的以下内容会被**清空**: - +迁移过程中,Hydro 的以下内容会被**清空**: 题目列表,提交记录,用户列表,比赛列表,比赛成绩表,训练列表,训练进度,站内消息,题解,讨论 - 以下内容将被迁移: - 题目与测试数据、题解,讨论与回复,比赛、训练、作业相关数据,提交记录,用户列表,站内消息。 -迁移过程中请保持网络畅通。 +## migrate-hustoj + +从HustOJ的数据库导入数据。 +迁移过程中,Hydro 的内容会被**清空**,以下内容将被迁移: +题目与测试数据,用户列表。 diff --git a/packages/migrate-hustoj/lib.ts b/packages/migrate/lib.ts similarity index 55% rename from packages/migrate-hustoj/lib.ts rename to packages/migrate/lib.ts index ce21fa5ff..b4266f256 100644 --- a/packages/migrate-hustoj/lib.ts +++ b/packages/migrate/lib.ts @@ -2,7 +2,7 @@ import { md5, sha1 } from 'hydrooj/src/lib/crypto'; const RE_MD5 = /^[\da-f]{32}$/; -function hash($password: string, $saved: string) { +global.Hydro.lib['hash.hust'] = ($password: string, $saved: string) => { $password = md5($password); if (RE_MD5.test($saved)) return $password === $saved; const $svd = Buffer.from($saved, 'base64').toString('hex'); @@ -13,6 +13,11 @@ function hash($password: string, $saved: string) { ]).toString('base64'); if ($hash.trim() === $saved.trim()) return true; return false; -} +}; -global.Hydro.lib['hash.hust'] = hash; +global.Hydro.lib['hash.vj2'] = (password: string, salt: string, udoc: { uname: string; unameLower: string }) => { + const { uname, unameLower } = udoc; + const pmd5 = md5(password); + const mixedSha1 = sha1(md5(unameLower + pmd5) + salt + sha1(pmd5 + salt)); + return `${Buffer.from(uname).toString('base64')}|${mixedSha1}`; +}; diff --git a/packages/migrate/locale/zh.yaml b/packages/migrate/locale/zh.yaml new file mode 100644 index 000000000..915ee05a2 --- /dev/null +++ b/packages/migrate/locale/zh.yaml @@ -0,0 +1,2 @@ +migrate from hustoj: 从 HustOJ 导入 +migrate from vijos: 从 Vijos 导入 \ No newline at end of file diff --git a/packages/migrate-hustoj/package.json b/packages/migrate/package.json similarity index 63% rename from packages/migrate-hustoj/package.json rename to packages/migrate/package.json index dead68f3b..9da63c31e 100644 --- a/packages/migrate-hustoj/package.json +++ b/packages/migrate/package.json @@ -1,20 +1,17 @@ { - "name": "@hydrooj/migrate-hustoj", - "version": "0.1.4", + "name": "@hydrooj/migrate", + "version": "0.1.0", "main": "package.json", "repository": "git@github.com:hydro-dev/Hydro.git", "author": "undefined ", "license": "AGPL-3.0-or-later", - "preferUnplugged": true, - "scripts": { - "lint": "eslint lib.ts script.ts --fix", - "build": "tsc" - }, "devDependencies": { + "@types/mongodb": "^3.6.20", "@types/mysql": "^2.15.21" }, "dependencies": { "@hydrooj/utils": "workspace:*", + "mongodb": "^3.7.3", "mysql": "^2.18.1" } } diff --git a/packages/migrate/script.ts b/packages/migrate/script.ts new file mode 100644 index 000000000..adb212de4 --- /dev/null +++ b/packages/migrate/script.ts @@ -0,0 +1,2 @@ +import './scripts/hustoj'; +import './scripts/vijos'; diff --git a/packages/migrate-hustoj/script.ts b/packages/migrate/scripts/hustoj.ts similarity index 100% rename from packages/migrate-hustoj/script.ts rename to packages/migrate/scripts/hustoj.ts diff --git a/packages/migrate-vijos/script.ts b/packages/migrate/scripts/vijos.ts similarity index 99% rename from packages/migrate-vijos/script.ts rename to packages/migrate/scripts/vijos.ts index 0aa6ffea7..4af1515a6 100644 --- a/packages/migrate-vijos/script.ts +++ b/packages/migrate/scripts/vijos.ts @@ -200,9 +200,13 @@ const tasks = { }, record: async (doc) => { if (doc.hidden) return null; - const testCases: TestCase[] = []; + const testCases: Required[] = []; + let i = 1; for (const c of doc.cases || []) { testCases.push({ + id: i++, + subtaskId: 0, + score: 0, status: c.status, time: c.time_ms || c.time, memory: c.memory_kb || c.memory, diff --git a/packages/recaptcha/package.json b/packages/recaptcha/package.json index aae34fa89..daefa1486 100644 --- a/packages/recaptcha/package.json +++ b/packages/recaptcha/package.json @@ -8,6 +8,6 @@ "license": "AGPL-3.0-or-later", "preferUnplugged": true, "dependencies": { - "superagent": "^7.1.2" + "superagent": "^7.1.3" } } diff --git a/packages/ui-default/.eslintrc.js b/packages/ui-default/.eslintrc.js index 09bf98b73..20983ca61 100644 --- a/packages/ui-default/.eslintrc.js +++ b/packages/ui-default/.eslintrc.js @@ -81,7 +81,7 @@ module.exports = { indent: [ 'error', 2, - { SwitchCase: 0 }, + { SwitchCase: 1 }, ], 'max-len': ['error', 150], quotes: 'warn', @@ -115,6 +115,7 @@ module.exports = { 'no-underscore-dangle': 'off', 'no-await-in-loop': 'off', 'no-lonely-if': 'off', + 'no-nested-ternary': 'off', 'no-param-reassign': 'off', 'no-script-url': 'off', 'no-bitwise': 'off', diff --git a/packages/ui-default/build/config/webpack.js b/packages/ui-default/build/config/webpack.js index 44712341f..f9b4a8f8c 100644 --- a/packages/ui-default/build/config/webpack.js +++ b/packages/ui-default/build/config/webpack.js @@ -81,6 +81,7 @@ export default function (env = {}) { mode: (env.production || env.measure) ? 'production' : 'development', profile: true, context: root(), + devtool: env.production ? 'source-map' : false, entry: { hydro: './entry.js', polyfill: './polyfill.ts', @@ -100,8 +101,6 @@ export default function (env = {}) { extensions: ['.js', '.jsx', '.ts', '.tsx'], alias: { vj: root(), - 'js-yaml': root('utils/yamlCompact'), - 'real-js-yaml': require.resolve('js-yaml'), }, }, module: { @@ -205,12 +204,12 @@ export default function (env = {}) { patterns: [ { from: root('static') }, { from: `${dirname(require.resolve('monaco-themes/package.json'))}/themes`, to: 'monaco/themes/' }, - { from: root('.build/sharedworker.js'), to: 'sharedworker.js' }, ], }), new webpack.DefinePlugin({ 'process.env': { NODE_ENV: env.production ? '"production"' : '"debug"', + VERSION: JSON.stringify(require('@hydrooj/ui-default/package.json').version), }, }), new webpack.LoaderOptionsPlugin({ @@ -224,10 +223,10 @@ export default function (env = {}) { filename: '[name].[hash:10].worker.js', customLanguages: [{ label: 'yaml', - entry: require.resolve('@undefined-moe/monaco-yaml/lib/esm/monaco.contribution'), + entry: require.resolve('monaco-yaml/index.js'), worker: { id: 'vs/language/yaml/yamlWorker', - entry: require.resolve('@undefined-moe/monaco-yaml/lib/esm/yaml.worker.js'), + entry: require.resolve('monaco-yaml/yaml.worker.js'), }, }], }), diff --git a/packages/ui-default/build/main.ts b/packages/ui-default/build/main.ts index 916ce631a..c343cb8a6 100644 --- a/packages/ui-default/build/main.ts +++ b/packages/ui-default/build/main.ts @@ -3,7 +3,6 @@ import cac from 'cac'; import fs from 'fs-extra'; import webpack from 'webpack'; -import esbuild from 'esbuild'; import WebpackDevServer from 'webpack-dev-server'; import gulp from 'gulp'; import log from 'fancy-log'; @@ -18,15 +17,6 @@ const argv = cac().parse(); async function runWebpack({ watch, production, measure, dev, }) { - const result = esbuild.build({ - bundle: true, - format: 'iife', - entryPoints: [root('sharedworker.ts')], - outfile: root('.build/sharedworker.js'), - watch: dev || watch, - minify: production, - }); - if (!dev && !watch) await result; const compiler = webpack(webpackConfig({ watch, production, measure })); if (dev) { const server = new WebpackDevServer(compiler, { @@ -36,7 +26,7 @@ async function runWebpack({ stats: 'none', index: root('public'), proxy: { - context: (path) => !path.includes('sharedworker.js'), + context: (path) => !path.includes('sockjs-node'), target: 'http://localhost:2333', ws: true, }, @@ -98,6 +88,10 @@ async function main() { if (fs.existsSync('public/default.theme.css')) { fs.copyFileSync('public/default.theme.css', `public/default-${pkg.version}.theme.css`); } + if (argv.options.production) { + const files = fs.readdirSync('public'); + files.filter((i) => /(^[in]\..+|worker)\.js\.map$/.test(i)).forEach((i) => fs.removeSync(`public/${i}`)); + } } process.chdir(dir); } diff --git a/packages/ui-default/components/autocomplete/DomainSelectAutoComplete.tsx b/packages/ui-default/components/autocomplete/DomainSelectAutoComplete.tsx index ca54c1b3b..9b2987419 100644 --- a/packages/ui-default/components/autocomplete/DomainSelectAutoComplete.tsx +++ b/packages/ui-default/components/autocomplete/DomainSelectAutoComplete.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import ReactDOM from 'react-dom'; +import ReactDOM from 'react-dom/client'; import { assign } from 'lodash'; import DOMAttachedObject from 'vj/components/DOMAttachedObject'; import AutoComplete from '.'; @@ -33,15 +33,12 @@ export default class DomainSelectAutoComplete extends AutoComplete { attach() { const value = this.$dom.val(); - ReactDOM.render( - { this.ref = ref; }} - value={value} - onChange={this.onChange} - multi={this.options.multi} - />, - this.container, - ); + ReactDOM.createRoot(this.container).render( { this.ref = ref; }} + value={value} + onChange={this.onChange} + multi={this.options.multi} + />); } } diff --git a/packages/ui-default/components/autocomplete/ProblemSelectAutoComplete.tsx b/packages/ui-default/components/autocomplete/ProblemSelectAutoComplete.tsx index 8d5500960..b50261df1 100644 --- a/packages/ui-default/components/autocomplete/ProblemSelectAutoComplete.tsx +++ b/packages/ui-default/components/autocomplete/ProblemSelectAutoComplete.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import ReactDOM from 'react-dom'; +import ReactDOM from 'react-dom/client'; import { assign } from 'lodash'; import DOMAttachedObject from 'vj/components/DOMAttachedObject'; import AutoComplete from '.'; @@ -33,14 +33,13 @@ export default class ProblemSelectAutoComplete extends AutoComplete { attach() { const value = this.$dom.val(); - ReactDOM.render( + ReactDOM.createRoot(this.container).render( { this.ref = ref; }} value={value} multi={this.options.multi} onChange={this.onChange} />, - this.container, ); } } diff --git a/packages/ui-default/components/autocomplete/UserSelectAutoComplete.tsx b/packages/ui-default/components/autocomplete/UserSelectAutoComplete.tsx index 7fd6c1ca7..01652016a 100644 --- a/packages/ui-default/components/autocomplete/UserSelectAutoComplete.tsx +++ b/packages/ui-default/components/autocomplete/UserSelectAutoComplete.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import ReactDOM from 'react-dom'; +import ReactDOM from 'react-dom/client'; import { assign } from 'lodash'; import DOMAttachedObject from 'vj/components/DOMAttachedObject'; import AutoComplete, { AutoCompleteOptions } from '.'; @@ -38,14 +38,13 @@ export default class UserSelectAutoComplete extends AutoC attach() { const value = this.$dom.val(); - ReactDOM.render( + ReactDOM.createRoot(this.container).render( { this.ref = ref; }} value={value} multi={this.options.multi} onChange={this.onChange} />, - this.container, ); } } diff --git a/packages/ui-default/components/autocomplete/autocomplete.page.styl b/packages/ui-default/components/autocomplete/autocomplete.page.styl index dd4fc3e28..ff150f4fe 100644 --- a/packages/ui-default/components/autocomplete/autocomplete.page.styl +++ b/packages/ui-default/components/autocomplete/autocomplete.page.styl @@ -53,7 +53,7 @@ white-space: nowrap text-overflow: ellipsis - &>span.bp3-icon>svg + &>span.bp4-icon>svg font-size: 12px cursor: pointer padding: 4px @@ -78,19 +78,19 @@ &>div flex-grow: 1 - &>span.bp3-icon>svg + &>span.bp4-icon>svg color: transparent &[aria-selected='true'] background-color: #fafafa font-weight: 600 - &>span.bp3-icon>svg + &>span.bp4-icon>svg color: #1890ff &[data-focus='true'] background-color: #e6f7ff cursor: pointer - &>span.bp3-icon>svg + &>span.bp4-icon>svg color: currentColor diff --git a/packages/ui-default/components/autocomplete/components/AutoComplete.tsx b/packages/ui-default/components/autocomplete/components/AutoComplete.tsx index 5b79bc4cc..f23a4bf50 100644 --- a/packages/ui-default/components/autocomplete/components/AutoComplete.tsx +++ b/packages/ui-default/components/autocomplete/components/AutoComplete.tsx @@ -17,6 +17,7 @@ export interface AutoCompleteProps { */ height?: string; disabled?: boolean; + placeholder?: string; disabledHint?: string; listStyle?: React.CSSProperties; cacheKey?: string; @@ -128,8 +129,10 @@ const AutoComplete = forwardRef(function Impl(props: AutoCompleteProps, re else onChange(selectedKeys.join(',')); }; + let first = !multi; useEffect(() => { - dispatchChange(); + if (first) first = false; + else dispatchChange(); const ids = []; for (const key of selectedKeys) if (!valueCache[key]) ids.push(key); if (!ids.length) return; @@ -293,6 +296,7 @@ const AutoComplete = forwardRef(function Impl(props: AutoCompleteProps, re if (allowEmptyQuery) handleInputChange(); setFocused(true); }} + placeholder={props.placeholder} onBlur={() => setFocused(false)} onKeyDown={handleInputKeyDown} defaultValue={multi ? '' : selectedKeys.join(',')} @@ -342,6 +346,7 @@ AutoComplete.propTypes = { allowEmptyQuery: PropTypes.bool, freeSolo: PropTypes.bool, freeSoloConverter: PropTypes.func, + placeholder: PropTypes.string, }; AutoComplete.defaultProps = { diff --git a/packages/ui-default/components/autocomplete/components/CustomSelectAutoComplete.tsx b/packages/ui-default/components/autocomplete/components/CustomSelectAutoComplete.tsx index 70d185a00..f8cd5acd8 100644 --- a/packages/ui-default/components/autocomplete/components/CustomSelectAutoComplete.tsx +++ b/packages/ui-default/components/autocomplete/components/CustomSelectAutoComplete.tsx @@ -33,7 +33,7 @@ CustomSelectAutoComplete.propTypes = { allowEmptyQuery: PropTypes.bool, freeSolo: PropTypes.bool, freeSoloConverter: PropTypes.func, - data: PropTypes.any, + data: PropTypes.arrayOf(PropTypes.any), }; CustomSelectAutoComplete.displayName = 'CustomSelectAutoComplete'; diff --git a/packages/ui-default/components/autocomplete/components/FileSelectAutoComplete.tsx b/packages/ui-default/components/autocomplete/components/FileSelectAutoComplete.tsx new file mode 100644 index 000000000..4ff950c0c --- /dev/null +++ b/packages/ui-default/components/autocomplete/components/FileSelectAutoComplete.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import AutoComplete from './AutoComplete'; + +type DefaultProps = { + _id: string; + name: string; +} & string; + +const FileSelectAutoComplete = React.forwardRef((props, ref) => ( + + ref={ref as any} + fetchItems={(keys) => props.data.filter((i) => (i._id ? keys.includes(i._id) : keys.includes(i)))} + queryItems={(query) => props.data.filter((i) => (i.name + ? i.name.toLowerCase().match(query.toLowerCase()) + : i.toString().toLowerCase().match(query.toLowerCase()) + ))} + itemText={(item) => `${item.name || item}`} + itemKey={(item) => `${item._id?.toString() || item.name || item}`} + renderItem={(item) => item.name || item._id || item} // TODO icon + allowEmptyQuery + {...props} + /> +)); + +FileSelectAutoComplete.propTypes = { + width: PropTypes.string, + height: PropTypes.string, + listStyle: PropTypes.object, + onChange: PropTypes.func.isRequired, + multi: PropTypes.bool, + selectedKeys: PropTypes.arrayOf(PropTypes.string), + allowEmptyQuery: PropTypes.bool, + freeSolo: PropTypes.bool, + freeSoloConverter: PropTypes.func, + data: PropTypes.arrayOf(PropTypes.any), +}; + +FileSelectAutoComplete.displayName = 'FileSelectAutoComplete'; + +FileSelectAutoComplete.defaultProps = { + width: '100%', + height: 'auto', + listStyle: {}, + multi: false, + selectedKeys: [], + freeSolo: false, + freeSoloConverter: (input) => input, +}; + +export default FileSelectAutoComplete; diff --git a/packages/ui-default/components/autocomplete/index.tsx b/packages/ui-default/components/autocomplete/index.tsx index bee7074a1..1e1ad8361 100644 --- a/packages/ui-default/components/autocomplete/index.tsx +++ b/packages/ui-default/components/autocomplete/index.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import ReactDOM from 'react-dom'; +import ReactDOM from 'react-dom/client'; import { assign } from 'lodash'; import DOMAttachedObject from 'vj/components/DOMAttachedObject'; import AutoCompleteFC from './components/AutoComplete'; @@ -64,7 +64,7 @@ export default class AutoComplete extends DOMAttachedObject { attach() { const value = this.$dom.val(); - ReactDOM.render( + ReactDOM.createRoot(this.container).render( { this.ref = ref; }} height="34px" @@ -76,7 +76,6 @@ export default class AutoComplete extends DOMAttachedObject { multi={this.options.multi} freeSolo={this.options.multi} />, - this.container, ); } diff --git a/packages/ui-default/components/discussion/comments.page.tsx b/packages/ui-default/components/discussion/comments.page.tsx index 2cd6cc709..2ca04b4a0 100644 --- a/packages/ui-default/components/discussion/comments.page.tsx +++ b/packages/ui-default/components/discussion/comments.page.tsx @@ -1,7 +1,7 @@ import 'jquery.easing'; import * as React from 'react'; -import ReactDOM from 'react-dom'; +import ReactDOM from 'react-dom/client'; import { Popover } from '@blueprintjs/core'; import { AutoloadPage } from 'vj/misc/Page'; import CommentBox from 'vj/components/discussion/CommentBox'; @@ -256,7 +256,7 @@ const commentsPage = new AutoloadPage('commentsPage', () => { const canUseReaction = $('[data-op="react"]').length > 0; $('[data-op="react"]').each((i, e) => { - ReactDOM.render(, e); + ReactDOM.createRoot(e).render(); }); $(document).on('click', '.reaction', async (e) => { if (!canUseReaction) { diff --git a/packages/ui-default/components/editor/index.ts b/packages/ui-default/components/editor/index.ts index 285d05d7a..ce1eb0ecf 100644 --- a/packages/ui-default/components/editor/index.ts +++ b/packages/ui-default/components/editor/index.ts @@ -62,7 +62,7 @@ export default class VjEditor extends DOMAttachedObject { ? monaco.editor.getModel(monaco.Uri.parse(model)) || monaco.editor.createModel(value, language === 'auto' ? undefined : language, monaco.Uri.parse(model)) : model; - this.model.setValue(value); + if (!this.options.model) this.model.setValue(value); const cfg: import('../monaco').default.editor.IStandaloneEditorConstructionOptions = { theme, lineNumbers: 'on', diff --git a/packages/ui-default/components/hotkey/hotkey.page.js b/packages/ui-default/components/hotkey/hotkey.page.js index ab174d564..e4537a6df 100644 --- a/packages/ui-default/components/hotkey/hotkey.page.js +++ b/packages/ui-default/components/hotkey/hotkey.page.js @@ -38,15 +38,15 @@ function testElementHotkey(hotkey, $element, attr) { const [defStr, trigger] = singleDef.split(':'); if (isHotkeyMatch(hotkey, defStr)) { switch (trigger) { - case 'submit': - $element.closest('form').trigger('submit'); - break; - case undefined: - $element.trigger('click'); - break; - default: - $element.trigger(trigger); - break; + case 'submit': + $element.closest('form').trigger('submit'); + break; + case undefined: + $element.trigger('click'); + break; + default: + $element.trigger(trigger); + break; } } }); diff --git a/packages/ui-default/components/menu/menu.page.styl b/packages/ui-default/components/menu/menu.page.styl index 4b301cc37..48777ce75 100644 --- a/packages/ui-default/components/menu/menu.page.styl +++ b/packages/ui-default/components/menu/menu.page.styl @@ -42,6 +42,9 @@ opacity: 0.85 a.menu__link, button.menu__link + &.half + padding-right: 0 + width: 50% &.disabled cursor: default opacity: 0.5 diff --git a/packages/ui-default/components/message/index.page.js b/packages/ui-default/components/message/index.page.js deleted file mode 100644 index a396f23a5..000000000 --- a/packages/ui-default/components/message/index.page.js +++ /dev/null @@ -1,67 +0,0 @@ -import { AutoloadPage } from 'vj/misc/Page'; -import { InfoDialog } from 'vj/components/dialog'; -import VjNotification from 'vj/components/notification/index'; -import { FLAG_ALERT } from 'vj/constant/message'; -import i18n from 'vj/utils/i18n'; -import tpl from 'vj/utils/tpl'; - -const messagePage = new AutoloadPage('messagePage', (pagename) => { - if (pagename === 'home_messages' || !UserContext._id) return; - - if (UserContext.unreadMsg) { - new VjNotification({ - type: 'info', - message: i18n(`You have ${UserContext.unreadMsg > 1 ? 'new messages' : 'a new message'}.`), - duration: 5000, - action: () => window.open('/home/messages', '_blank'), - }).show(); - } - - const onmessage = (message) => { - const msg = JSON.parse(message); - console.log('Received message', msg); - if (msg.mdoc.flag & FLAG_ALERT) { - // Is alert - return new InfoDialog({ - cancelByClickingBack: false, - $body: tpl` -
- ${{ templateRaw: true, html: i18n('Message from {0}:', `
${msg.mdoc.from}
`) }} -

${i18n(msg.mdoc.content)}

-
`, - }).open(); - } - if (msg.udoc._id === 1 && msg.mdoc.flag & 4) { - // System notification - return new VjNotification({ - message: i18n('You received a system message, click here to view.'), - duration: 15000, - action: () => window.open(`/home/messages?uid=${msg.udoc._id}`, '_blank'), - }).show(); - } - // Is message - return new VjNotification({ - title: msg.udoc.uname, - avatar: msg.udoc.avatarUrl, - message: msg.mdoc.content, - duration: 15000, - action: () => window.open(`/home/messages?uid=${msg.udoc._id}`, '_blank'), - }).show(); - }; - - const path = new URL('/home/messages-conn', window.location.href).toString(); - const worker = new SharedWorker('/sharedworker.js', { name: 'Hydro Messages Shared Worker' }); - worker.port.start(); - worker.port.postMessage({ type: 'init', path, cookie: document.cookie }); - worker.port.onmessage = (e) => { - if (e.data.type !== 'message') return; - if (document.hidden) { - worker.port.postMessage({ type: 'fail' }); - } else { - worker.port.postMessage({ type: 'success' }); - onmessage(e.data.payload); - } - }; -}); - -export default messagePage; diff --git a/packages/ui-default/components/message/index.page.ts b/packages/ui-default/components/message/index.page.ts new file mode 100644 index 000000000..b6728dff6 --- /dev/null +++ b/packages/ui-default/components/message/index.page.ts @@ -0,0 +1,122 @@ +import { AutoloadPage } from 'vj/misc/Page'; +import { InfoDialog } from 'vj/components/dialog'; +import VjNotification from 'vj/components/notification/index'; +import ReconnectingWebsocket from 'reconnecting-websocket'; +import { FLAG_ALERT } from 'vj/constant/message'; +import i18n from 'vj/utils/i18n'; +import tpl from 'vj/utils/tpl'; +import { nanoid } from 'nanoid'; + +const onmessage = (msg) => { + console.log('Received message', msg); + if (msg.mdoc.flag & FLAG_ALERT) { + // Is alert + return new InfoDialog({ + cancelByClickingBack: false, + $body: tpl` +
+ ${{ templateRaw: true, html: i18n('Message from {0}:', `
${msg.mdoc.from}
`) }} +

${i18n(msg.mdoc.content)}

+
`, + }).open(); + } + // Is message + return new VjNotification({ + ...(msg.udoc._id === 1 && msg.mdoc.flag & 4) + ? { message: i18n('You received a system message, click here to view.') } + : { + title: msg.udoc.uname, + avatar: msg.udoc.avatarUrl, + message: msg.mdoc.content, + }, + duration: 15000, + action: () => window.open(`/home/messages?uid=${msg.udoc._id}`, '_blank'), + }).show(); +}; + +const messagePage = new AutoloadPage('messagePage', (pagename) => { + if (pagename === 'home_messages' || !UserContext._id) return; + if (UserContext.unreadMsg) { + new VjNotification({ + type: 'info', + message: i18n(`You have ${UserContext.unreadMsg > 1 ? 'new messages' : 'a new message'}.`), + duration: 5000, + action: () => window.open('/home/messages', '_blank'), + }).show(); + } + if (!window.BroadcastChannel) { + console.error('BoardcastChannel not supported'); + return; + } + + let isMaster = false; + const selfId = nanoid(); + const channel = new BroadcastChannel('hydro-messages'); + const pages = JSON.parse(localStorage.getItem('pages') || '[]') as string[]; + pages.push(selfId); + pages.sort(); + localStorage.setItem('pages', JSON.stringify(pages)); + + window.onunload = () => { + const c = JSON.parse(localStorage.getItem('pages') || '[]') as string[]; + localStorage.setItem('pages', JSON.stringify(c.filter((i) => i !== selfId))); + if (!isMaster) return; + localStorage.removeItem('page.master'); + channel?.postMessage({ type: 'master' }); + }; + + function asMaster() { + console.log('init as master'); + isMaster = true; + localStorage.setItem('page.master', selfId); + const masterChannel = new BroadcastChannel('hydro-messages'); + const url = new URL('/home/messages-conn', window.location.href.replace('http', 'ws')); + // TODO handle a better way for cookie + url.searchParams.append('sid', document.cookie); + const sock = new ReconnectingWebsocket(url.toString()); + const pending = {}; + sock.onopen = () => console.log('Connected'); + sock.onerror = console.error; + sock.onclose = (...args) => console.log('Closed', ...args); + sock.onmessage = async (message) => { + if (process.env.NODE_ENV !== 'production') console.log('onmessage: ', message); + const payload = JSON.parse(message.data); + const id = nanoid(); + masterChannel.postMessage({ type: 'message', id, payload }); + const success = await new Promise((resolve) => { + pending[id] = resolve; + setTimeout(() => { + delete pending[id]; + resolve(false); + }, 1000); + }); + if (!success && window.Notification?.permission === 'granted') { + const notification = new window.Notification( + payload.udoc.uname || 'Hydro Notification', + { + icon: payload.udoc.avatarUrl || '/android-chrome-192x192.png', + body: payload.mdoc.content, + }, + ); + notification.onclick = () => window.open('/home/messages'); + } + }; + masterChannel.onmessage = (msg) => { + if (msg.data.type === 'message-push') pending[msg.data.id]?.(true); + }; + } + + channel.onmessage = (msg) => { + if (msg.data.type === 'message' && !document.hidden) { + channel.postMessage({ type: 'message-push', id: msg.data.id }); + onmessage(msg.data.payload); + } + if (msg.data.type === 'master') { + const c = JSON.parse(localStorage.getItem('pages') || '[]') as string[]; + if (c[0] === selfId) asMaster(); + } + }; + if (!localStorage.getItem('page.master')) asMaster(); +}); + +export default messagePage; diff --git a/packages/ui-default/components/messagepad/reducers/activeId.js b/packages/ui-default/components/messagepad/reducers/activeId.js index 036642c14..8af2782c8 100644 --- a/packages/ui-default/components/messagepad/reducers/activeId.js +++ b/packages/ui-default/components/messagepad/reducers/activeId.js @@ -21,14 +21,14 @@ function scrollToViewport() { export default function reducer(state = null, action) { switch (action.type) { - case 'DIALOGUES_SWITCH_TO': { - scrollToViewport(); - return action.payload; - } - case 'DIALOGUES_POST_SEND_FULFILLED': { - return action.meta.dialogueId; - } - default: - return state; + case 'DIALOGUES_SWITCH_TO': { + scrollToViewport(); + return action.payload; + } + case 'DIALOGUES_POST_SEND_FULFILLED': { + return action.meta.dialogueId; + } + default: + return state; } } diff --git a/packages/ui-default/components/messagepad/reducers/dialogues.js b/packages/ui-default/components/messagepad/reducers/dialogues.js index e56009a5a..d6e55d7bf 100644 --- a/packages/ui-default/components/messagepad/reducers/dialogues.js +++ b/packages/ui-default/components/messagepad/reducers/dialogues.js @@ -2,52 +2,52 @@ import _ from 'lodash'; export default function reducer(state = {}, action) { switch (action.type) { - case 'DIALOGUES_LOAD_DIALOGUES_FULFILLED': { - const { messages } = action.payload; - return { ...state, ..._.keyBy(messages, 'udoc._id') }; - } - case 'DIALOGUES_CREATE': { - const { user } = action.payload; - if (state[user._id]) return state; - return { - ...state, - [user._id]: { - _id: user._id, - udoc: { ...user }, - messages: [], - }, - }; - } - case 'DIALOGUES_POST_SEND_FULFILLED': { - const id = action.meta.dialogueId; - const { mdoc } = action.payload; - return { - ...state, - [id]: { - ...state[id], - messages: [ - ...state[id].messages, - mdoc, - ], - }, - }; - } - case 'DIALOGUES_MESSAGE_PUSH': { - const { mdoc, udoc } = action.payload; - const to = mdoc.from === UserContext._id ? mdoc.to : mdoc.from; - return { - ...state, - [to]: { - ...state[to] || {}, - udoc, - messages: [ - ...state[to]?.messages || [], - mdoc, - ], - }, - }; - } - default: - return state; + case 'DIALOGUES_LOAD_DIALOGUES_FULFILLED': { + const { messages } = action.payload; + return { ...state, ..._.keyBy(messages, 'udoc._id') }; + } + case 'DIALOGUES_CREATE': { + const { user } = action.payload; + if (state[user._id]) return state; + return { + ...state, + [user._id]: { + _id: user._id, + udoc: { ...user }, + messages: [], + }, + }; + } + case 'DIALOGUES_POST_SEND_FULFILLED': { + const id = action.meta.dialogueId; + const { mdoc } = action.payload; + return { + ...state, + [id]: { + ...state[id], + messages: [ + ...state[id].messages, + mdoc, + ], + }, + }; + } + case 'DIALOGUES_MESSAGE_PUSH': { + const { mdoc, udoc } = action.payload; + const to = mdoc.from === UserContext._id ? mdoc.to : mdoc.from; + return { + ...state, + [to]: { + ...state[to] || {}, + udoc, + messages: [ + ...state[to]?.messages || [], + mdoc, + ], + }, + }; + } + default: + return state; } } diff --git a/packages/ui-default/components/messagepad/reducers/inputs.js b/packages/ui-default/components/messagepad/reducers/inputs.js index a0856a741..a188073e6 100644 --- a/packages/ui-default/components/messagepad/reducers/inputs.js +++ b/packages/ui-default/components/messagepad/reducers/inputs.js @@ -2,42 +2,42 @@ import _ from 'lodash'; export default function reducer(state = {}, action) { switch (action.type) { - case 'DIALOGUES_LOAD_DIALOGUES_FULFILLED': { - const dialogues = action.payload.messages; - return { ...state, ..._.fromPairs(_.map(dialogues, (d) => [d._id, ''])) }; - } - case 'DIALOGUES_CREATE': { - const { user } = action.payload; - return { - ...state, - [user._id]: '', - }; - } - case 'DIALOGUES_INPUT_CHANGED': { - const id = action.meta.dialogueId; - return { - ...state, - [id]: action.payload, - }; - } - case 'DIALOGUES_POST_SEND_FULFILLED': { - const id = action.meta.dialogueId; - return { - ...state, - [id]: '', - }; - } - case 'DIALOGUES_MESSAGE_PUSH': { - const { udoc } = action.payload; - if (!state[udoc._id]) { + case 'DIALOGUES_LOAD_DIALOGUES_FULFILLED': { + const dialogues = action.payload.messages; + return { ...state, ..._.fromPairs(_.map(dialogues, (d) => [d._id, ''])) }; + } + case 'DIALOGUES_CREATE': { + const { user } = action.payload; return { ...state, - [udoc._id]: '', + [user._id]: '', }; } - return state; - } - default: - return state; + case 'DIALOGUES_INPUT_CHANGED': { + const id = action.meta.dialogueId; + return { + ...state, + [id]: action.payload, + }; + } + case 'DIALOGUES_POST_SEND_FULFILLED': { + const id = action.meta.dialogueId; + return { + ...state, + [id]: '', + }; + } + case 'DIALOGUES_MESSAGE_PUSH': { + const { udoc } = action.payload; + if (!state[udoc._id]) { + return { + ...state, + [udoc._id]: '', + }; + } + return state; + } + default: + return state; } } diff --git a/packages/ui-default/components/messagepad/reducers/isPosting.js b/packages/ui-default/components/messagepad/reducers/isPosting.js index 4bb841122..ecd04cbff 100644 --- a/packages/ui-default/components/messagepad/reducers/isPosting.js +++ b/packages/ui-default/components/messagepad/reducers/isPosting.js @@ -3,47 +3,47 @@ import Notification from 'vj/components/notification'; export default function reducer(state = {}, action) { switch (action.type) { - case 'DIALOGUES_LOAD_DIALOGUES_FULFILLED': { - const dialogues = action.payload.messages; - return { ...state, ..._.fromPairs(_.map(dialogues, (d) => [d._id, false])) }; - } - case 'DIALOGUES_CREATE': { - const { user } = action.payload; - return { - ...state, - [user._id]: false, - }; - } - case 'DIALOGUES_POST_SEND_PENDING': { - const id = action.meta.dialogueId; - return { - ...state, - [id]: true, - }; - } - case 'DIALOGUES_POST_SEND_REJECTED': { - Notification.error(action.payload.message); - const id = action.meta.dialogueId; - return { - ...state, - [id]: false, - }; - } - case 'DIALOGUES_POST_SEND_FULFILLED': { - const id = action.meta.dialogueId; - return { - ...state, - [id]: false, - }; - } - case 'DIALOGUES_MESSAGE_PUSH': { - const { udoc } = action.payload; - return { - ...state, - [udoc._id]: false, - }; - } - default: - return state; + case 'DIALOGUES_LOAD_DIALOGUES_FULFILLED': { + const dialogues = action.payload.messages; + return { ...state, ..._.fromPairs(_.map(dialogues, (d) => [d._id, false])) }; + } + case 'DIALOGUES_CREATE': { + const { user } = action.payload; + return { + ...state, + [user._id]: false, + }; + } + case 'DIALOGUES_POST_SEND_PENDING': { + const id = action.meta.dialogueId; + return { + ...state, + [id]: true, + }; + } + case 'DIALOGUES_POST_SEND_REJECTED': { + Notification.error(action.payload.message); + const id = action.meta.dialogueId; + return { + ...state, + [id]: false, + }; + } + case 'DIALOGUES_POST_SEND_FULFILLED': { + const id = action.meta.dialogueId; + return { + ...state, + [id]: false, + }; + } + case 'DIALOGUES_MESSAGE_PUSH': { + const { udoc } = action.payload; + return { + ...state, + [udoc._id]: false, + }; + } + default: + return state; } } diff --git a/packages/ui-default/components/monaco/languages/yaml.ts b/packages/ui-default/components/monaco/languages/yaml.ts index 544d0fd85..84a576182 100644 --- a/packages/ui-default/components/monaco/languages/yaml.ts +++ b/packages/ui-default/components/monaco/languages/yaml.ts @@ -1,4 +1,4 @@ -import * as monaco from 'monaco-editor/esm/vs/editor/editor.api'; +import { setDiagnosticsOptions } from 'monaco-yaml'; /** @type {Record} */ const problemConfigSchemaDef = { @@ -8,6 +8,9 @@ const problemConfigSchemaDef = { properties: { input: { type: 'string' }, output: { type: 'string' }, + time: { $ref: '#/def/time' }, + memory: { $ref: '#/def/memory' }, + score: { $ref: '#/def/score', description: 'score' }, }, required: ['input'], additionalProperties: false, @@ -28,7 +31,7 @@ const problemConfigSchemaDef = { additionalProperties: false, }, time: { type: 'string', pattern: '^([0-9]+(?:\\.[0-9]*)?)([mu]?)s?$' }, - memory: { type: 'string', pattern: '^([0-9]+(?:\\.[0-9]*)?)([kmg])b?$' }, + memory: { type: 'string', pattern: '^([0-9]+(?:\\.[0-9]*)?)([kKmMgG])[bB]?$' }, score: { type: 'integer', maximum: 100, minimum: 1 }, }; @@ -37,7 +40,7 @@ const problemConfigSchema = { type: 'object', def: problemConfigSchemaDef, properties: { - redirect: { type: 'string', pattern: '[0-9a-z_-]+\\/[0-9]+' }, + redirect: { type: 'string', pattern: '[0-9a-zA-Z_-]+\\/[0-9]+' }, key: { type: 'string', pattern: '[0-9a-f]{32}' }, type: { enum: ['default', 'interactive', 'submit_answer', 'objective', 'remote_judge'] }, subType: { type: 'string' }, @@ -75,8 +78,7 @@ const problemConfigSchema = { additionalProperties: false, }; -// @ts-ignore -monaco.languages.yaml.yamlDefaults.setDiagnosticsOptions({ +setDiagnosticsOptions({ validate: true, enableSchemaRequest: true, hover: true, @@ -86,7 +88,7 @@ monaco.languages.yaml.yamlDefaults.setDiagnosticsOptions({ { uri: 'https://hydro.js.org/schema/problemConfig.json', fileMatch: ['hydro://problem/file/config.yaml'], - schema: problemConfigSchema, + schema: problemConfigSchema as any, }, { uri: `${UiContext.cdn_prefix}manage/setting/schema.json`, diff --git a/packages/ui-default/components/objective-question/index.jsx b/packages/ui-default/components/objective-question/index.jsx index 8a81c97c7..c810c25a3 100644 --- a/packages/ui-default/components/objective-question/index.jsx +++ b/packages/ui-default/components/objective-question/index.jsx @@ -1,6 +1,7 @@ import React from 'react'; import { sortBy } from 'lodash'; import request from 'vj/utils/request'; +import Notification from 'vj/components/notification'; export default class ObjectiveContainer extends React.PureComponent { constructor(args) { diff --git a/packages/ui-default/components/problemconfig/BasicForm.tsx b/packages/ui-default/components/problemconfig/BasicForm.tsx new file mode 100644 index 000000000..0d34abcaf --- /dev/null +++ b/packages/ui-default/components/problemconfig/BasicForm.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import i18n from 'vj/utils/i18n'; +import type { RootState } from './reducer/index'; +import FileSelectAutoComplete from '../autocomplete/components/FileSelectAutoComplete'; + +export function FormItem({ + columns, label, children, helpText = '', disableLabel = false, ...props +}) { + return ( +
+ +
+ ); +} + +type KeyType = { + [Q in keyof K]: K[Q] extends T ? Q : never; +}[keyof K]; + +export function ManagedInput({ placeholder, formKey }: { placeholder?: string, formKey: KeyType }) { + const value = useSelector((state: RootState) => state.config[formKey]); + const dispatch = useDispatch(); + return ( + { + dispatch({ type: 'CONFIG_FORM_UPDATE', key: formKey, value: ev.currentTarget.value }); + }} + className="textbox" + /> + ); +} + +export function ManagedSelect({ options, formKey }: { options: string[], formKey: KeyType }) { + const value = useSelector((state: RootState) => state.config[formKey]); + const dispatch = useDispatch(); + return ( + + ); +} + +export function SingleFileSelect({ formKey }: { formKey: KeyType }) { + const value = useSelector((state: RootState) => state.config[formKey]); + const Files = useSelector((state: RootState) => state.testdata); + const dispatch = useDispatch(); + return ( + dispatch({ type: 'CONFIG_FORM_UPDATE', key: formKey, value: val })} + /> + ); +} diff --git a/packages/ui-default/components/problemconfig/ProblemConfigEditor.tsx b/packages/ui-default/components/problemconfig/ProblemConfigEditor.tsx new file mode 100644 index 000000000..27ab9d6e1 --- /dev/null +++ b/packages/ui-default/components/problemconfig/ProblemConfigEditor.tsx @@ -0,0 +1,162 @@ +import React from 'react'; +import type { editor } from 'monaco-editor'; +import { connect } from 'react-redux'; +import { load } from 'vj/components/monaco/loader'; +import Editor from 'vj/components/editor'; +import { diffLines } from 'diff'; +import yaml from 'js-yaml'; +import type { ProblemConfigFile, TestCaseConfig } from 'hydrooj/src/interface'; + +const mapStateToProps = (state) => ({ + config: state.config, +}); +const mapDispatchToProps = (dispatch) => ({ + handleUpdateCode: (code) => { + dispatch({ + type: 'CONFIG_CODE_UPDATE', + payload: code, + }); + }, +}); + +interface Props { + config: object; + handleUpdateCode: Function; +} + +const configKey = [ + 'type', 'subType', 'target', 'score', 'time', + 'memory', 'filename', 'checker_type', 'checker', 'interactor', + 'user_extra_files', 'judge_extra_files', 'detail', 'outputs', 'redirect', + 'cases', 'subtasks', 'langs', +]; + +const subtasksKey = [ + 'time', 'memory', 'score', 'if', 'id', + 'type', 'cases', +]; + +const casesKey = ['time', 'memory', 'input', 'output']; + +export function configYamlFormat(config: ProblemConfigFile) { + const formatConfig: ProblemConfigFile = {}; + configKey.forEach((key) => { + if (config[key] !== undefined) { + if (key === 'subType' && ['single', 'multi'].includes(config[key]) && config.type !== 'submit_answer') return; + if (key === 'checker_type' && config.type !== 'default') return; + if (key === 'checker' + && (['default', 'strict'].includes(formatConfig.checker_type) || !formatConfig.checker_type)) return; + if (key === 'interactor' && config.type !== 'interactive') return; + if (key === 'subtasks') { + formatConfig[key] = []; + config[key].forEach((subtask) => { + const formatSubtask: object = {}; + subtasksKey.forEach((subtaskKey) => { + if (subtask && subtask[subtaskKey] !== undefined) { + formatSubtask[subtaskKey] = subtask[subtaskKey]; + } + }); + formatConfig[key].push(formatSubtask); + }); + } else if (key === 'cases') { + formatConfig[key] = []; + config[key].forEach((caseItem) => { + const formatCase: TestCaseConfig = { + time: '1000ms', memory: '256MB', input: '', output: '', + }; + casesKey.forEach((caseKey) => { + if (caseItem[caseKey] !== undefined) formatCase[caseKey] = caseItem[caseKey]; + else delete formatCase[caseKey]; + }); + formatConfig[key].push(formatCase); + }); + } else formatConfig[key] = config[key]; + } + }); + if (formatConfig.type === 'objective') { + Object.keys(formatConfig).filter((i) => !['type', 'outputs'].includes(i)).forEach((i) => delete formatConfig[i]); + formatConfig.outputs = formatConfig.outputs || []; + } + Object.keys(formatConfig).filter((i) => i.startsWith('__')).forEach((i) => delete formatConfig[i]); + return formatConfig; +} + +export default connect(mapStateToProps, mapDispatchToProps)(class MonacoEditor extends React.PureComponent { + disposable = []; + containerElement: HTMLElement; + private __preventUpdate = false; + private __preventFormat = false; + + editor: editor.IStandaloneCodeEditor; + model: editor.ITextModel; + vjEditor: Editor; + + async componentDidMount() { + const { monaco } = await load(['yaml']); + const uri = monaco.Uri.parse('hydro://problem/file/config.yaml'); + this.model = monaco.editor.createModel(yaml.dump(configYamlFormat(this.props.config)), 'yaml', uri); + this.vjEditor = Editor.getOrConstruct($(this.containerElement), { + language: 'yaml', + model: this.model, + onChange: (value: string) => { + this.__preventUpdate = true; + if (!this.__preventFormat) this.props.handleUpdateCode(value); + this.__preventUpdate = false; + }, + }) as Editor; + this.editor = this.vjEditor.editor; + } + + componentDidUpdate(prevProps) { + if (this.__preventUpdate || !this.model) return; + if (yaml.dump(prevProps.config) !== yaml.dump(this.props.config)) { + this.__preventFormat = true; + const curValue = this.model.getValue(); + const diff = diffLines(curValue, yaml.dump(configYamlFormat(this.props.config))); + const ops = []; + let cursor = 1; + for (const line of diff) { + if (line.added) { + let range = this.model.getFullModelRange(); + range = range.setStartPosition(cursor, 0); + range = range.setEndPosition(cursor, 0); + ops.push({ range, text: line.value }); + } else if (line.removed) { + let range = this.model.getFullModelRange(); + range = range.setStartPosition(cursor, 0); + cursor += line.count; + range = range.setEndPosition(cursor, 0); + ops.push({ range, text: '' }); + } else cursor += line.count; + } + this.model.pushEditOperations([], ops, undefined); + this.__preventFormat = false; + } + } + + componentWillUnmount() { + if (this.vjEditor) this.vjEditor.destory(); + if (this.model) this.model.dispose(); + if (this.editor) this.editor.dispose(); + this.disposable.map((i) => i.dispose()); + } + + assignRef = (component) => { + this.containerElement = component; + }; + + render() { + return ( +
+
+ ); + } +}); diff --git a/packages/ui-default/components/problemconfig/ProblemConfigForm.tsx b/packages/ui-default/components/problemconfig/ProblemConfigForm.tsx new file mode 100644 index 000000000..5aeb8c490 --- /dev/null +++ b/packages/ui-default/components/problemconfig/ProblemConfigForm.tsx @@ -0,0 +1,128 @@ +import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import i18n from 'vj/utils/i18n'; +import { + Card, Tab, Tabs, InputGroup, Tag, +} from '@blueprintjs/core'; +import type { RootState } from './reducer/index'; +import CustomSelectAutoComplete from '../autocomplete/components/CustomSelectAutoComplete'; +import FileSelectAutoComplete from '../autocomplete/components/FileSelectAutoComplete'; +import { FormItem } from './BasicForm'; +import ProblemType from './ProblemType'; +import { TaskConfig } from './SubtaskTable'; + +function FileIOConfig() { + const filename = useSelector((state: RootState) => state.config.filename); + const dispatch = useDispatch(); + return ( + + +
+ + .in/.out} + value={filename || ''} + onChange={(ev) => { + dispatch({ type: 'CONFIG_FORM_UPDATE', key: 'filename', value: ev.currentTarget.value }); + }} + fill + /> + +
+
+
+ ); +} + +function ExtraFilesConfig() { + const Files = useSelector((state: RootState) => state.testdata); + const userExtraFiles = useSelector((state: RootState) => state.config.user_extra_files) || []; + const judgeExtraFiles = useSelector((state: RootState) => state.config.judge_extra_files) || []; + const dispatch = useDispatch(); + const userRef = React.useRef(); + const judgeRef = React.useRef(); + React.useEffect(() => { + userRef.current.setSelectedKeys(userExtraFiles); + }, [JSON.stringify(userExtraFiles)]); + React.useEffect(() => { + judgeRef.current.setSelectedKeys(judgeExtraFiles); + }, [JSON.stringify(judgeExtraFiles)]); + return ( + + + dispatch({ type: 'CONFIG_FORM_UPDATE', key: 'user_extra_files', value: val.split(',') })} + multi + /> + )} + /> + dispatch({ type: 'CONFIG_FORM_UPDATE', key: 'judge_extra_files', value: val.split(',') })} + multi + /> + )} + /> + + + ); +} + +function LangConfig() { + const langs = useSelector((state: RootState) => state.config.langs) || []; + const prefixes = new Set(Object.keys(LANGS).filter((i) => i.includes('.')).map((i) => i.split('.')[0])); + const data = Object.keys(LANGS).filter((i) => !prefixes.has(i)) + .map((i) => ({ name: LANGS[i].display, _id: i })); + const dispatch = useDispatch(); + const ref = React.useRef(); + const selectedKeys = langs.filter((i) => !prefixes.has(i)); + React.useEffect(() => { + ref.current.setSelectedKeys(selectedKeys); + }, [JSON.stringify(selectedKeys)]); + return ( + + { + const value = val.split(','); + value.push(...Array.from(new Set(value.filter((i) => i.includes('.')).map((i) => i.split('.')[0])))); + dispatch({ type: 'CONFIG_FORM_UPDATE', key: 'langs', value }); + }} + multi + /> + + ); +} + +export default function ProblemConfigForm() { + const Type = useSelector((state: RootState) => state.config.type); + return ( +
+ + {Type === 'default' && } + {!['submit_answer', 'objective'].includes(Type) && ( + <> + + + + + )} +
+ ); +} diff --git a/packages/ui-default/components/problemconfig/ProblemType.tsx b/packages/ui-default/components/problemconfig/ProblemType.tsx new file mode 100644 index 000000000..3525a6d80 --- /dev/null +++ b/packages/ui-default/components/problemconfig/ProblemType.tsx @@ -0,0 +1,129 @@ +import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import i18n from 'vj/utils/i18n'; +import { + Card, Switch, Tab, Tabs, +} from '@blueprintjs/core'; +import type { RootState } from './reducer/index'; +import { FormItem, ManagedSelect, SingleFileSelect } from './BasicForm'; + +export default function ProblemType() { + const Type = useSelector((state: RootState) => state.config.type); + const checkerType = useSelector((state: RootState) => state.config.checker_type); + const filename = useSelector((state: RootState) => state.config.filename); + const subType = useSelector((state: RootState) => state.config.subType); + const dispatch = useDispatch(); + const dispatcher = (base) => (value) => dispatch({ ...base, value }); + return ( + + + + {i18n('Problem Type')} + + + {i18n('CheckerType')} + + + + { + dispatch({ type: 'CONFIG_FORM_UPDATE', key: 'checker_type', value: checkerType === 'strict' ? 'default' : 'strict' }); + }} + /> + + + )} + /> + {['testlib', 'other'].map((i) => ( + + {i === 'other' && ( + + + + )} + + + + + )} + /> + ))} + + )} + /> + + + + + + )} + /> + + + { + dispatch({ type: 'CONFIG_FORM_UPDATE', key: 'subType', value: subType === 'multi' ? 'single' : 'multi' }); + }} + /> + + + dispatch(({ type: 'CONFIG_FORM_UPDATE', key: 'filename', value: ev.currentTarget.value }))} + className="textbox" + /> + + + )} + /> + {i18n('Unsupported configure this type of problem. Please refer to the documentation.')}

)} + /> + +
+
+ ); +} diff --git a/packages/ui-default/components/problemconfig/SubtaskTable.tsx b/packages/ui-default/components/problemconfig/SubtaskTable.tsx new file mode 100644 index 000000000..9bc1583c3 --- /dev/null +++ b/packages/ui-default/components/problemconfig/SubtaskTable.tsx @@ -0,0 +1,186 @@ +import { Card, NumericInput, Tag } from '@blueprintjs/core'; +import { pick, isEqual } from 'lodash'; +import React from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import { SubtaskConfig } from 'hydrooj/src/interface'; +import { parseTimeMS, parseMemoryMB } from '@hydrooj/utils/lib/common'; +import i18n from 'vj/utils/i18n'; +import CustomSelectAutoComplete from '../autocomplete/components/CustomSelectAutoComplete'; +import { FormItem } from './BasicForm'; +import { RootState } from './reducer'; +import { CasesTable } from './TestCasesTable'; + +const eq = (a: SubtaskConfig, b: SubtaskConfig) => isEqual(a, b); + +export function SubtasksIds({ index }) { + const ids = useSelector((state: RootState) => state.config.subtasks.map((i) => (i ? i.id : undefined)).filter((i) => i !== undefined).join(',')); + const subtaskIf = useSelector((state: RootState) => state.config.subtasks[index].if); + const dispatch = useDispatch(); + return ( + + if + + i.length)} + selectedKeys={subtaskIf?.map(String) || []} + onChange={(val) => dispatch({ + type: 'CONFIG_SUBTASK_UPDATE', id: index, key: 'if', value: val.split(','), + })} + multi + /> + + + ); +} + +export function SubtasksTable({ index }) { + const keys = ['id', 'type', 'score', 'time', 'memory']; + const subtask = useSelector((state: RootState) => pick(state.config.subtasks[index], keys), eq); + const defaultTime = useSelector((state: RootState) => state.config.time); + const defaultMemory = useSelector((state: RootState) => state.config.memory); + const dispatch = useDispatch(); + const dispatcher = (key: string, suffix = '') => (ev: React.ChangeEvent | number) => { + let value = typeof ev !== 'object' ? ev : ev.currentTarget?.value; + if (value === 0) value = ''; + if (value && suffix) value += suffix; + dispatch({ + type: 'CONFIG_SUBTASK_UPDATE', id: index, key, value, + }); + }; + if (!subtask || Object.keys(subtask).length === 0) { + return ( + + Subtasks #{index + 1} +

{i18n('Failed to parse subtask.')}

+
+ ); + } + return ( + + Subtasks #{index + 1} + dispatch({ type: 'CONFIG_SUBTASK_UPDATE', id: index, key: 'add' })}> + dispatch({ type: 'CONFIG_SUBTASK_UPDATE', id: index, key: 'delete' })} + > + + + + + + + + + + + + + + + {['id', 'score'].map((key) => ( + + ))} + + + + + + +
#ScoreTypeTimeMemory
+ + + + + ms} + value={subtask.time ? parseTimeMS(subtask.time, false).toString() : ''} + placeholder={parseTimeMS(defaultTime || '1000ms', false).toString()} + onValueChange={dispatcher('time', 'ms')} + buttonPosition="none" + fill + /> + + MB} + value={subtask.memory ? parseMemoryMB(subtask.memory, false).toString() : ''} + placeholder={parseMemoryMB(defaultMemory || '256m', false).toString()} + onValueChange={dispatcher('memory', 'MB')} + buttonPosition="none" + fill + /> +
+ +
+ ); +} + +function GlobalTaskConfig() { + const time = useSelector((state: RootState) => state.config.time); + const memory = useSelector((state: RootState) => state.config.memory); + const dispatch = useDispatch(); + const dispatcher = (key: string, suffix = '') => (ev: React.ChangeEvent | number) => { + let value = typeof ev !== 'object' ? ev : ev.currentTarget?.value; + if (value === 0) value = ''; + if (value && suffix) value += suffix; + dispatch({ type: 'CONFIG_FORM_UPDATE', key, value }); + }; + return ( + <> + + ms} + value={time ? parseTimeMS(time, false).toString() : ''} + placeholder={parseTimeMS('1000ms').toString()} + onValueChange={dispatcher('time', 'ms')} + buttonPosition="none" + fill + /> + + + MB} + value={memory ? parseMemoryMB(memory, false).toString() : ''} + placeholder={parseMemoryMB('256m').toString()} + onValueChange={dispatcher('memory', 'MB')} + buttonPosition="none" + fill + /> + + + ); +} + +export function TaskConfig() { + const len = useSelector((state: RootState) => state.config.subtasks?.length); + const dispatch = useDispatch(); + return ( + +
+ + + {len > 0 + ? [...Array(len).keys()].map((i) => ) + : ( + <> + Subtasks # + dispatch({ type: 'CONFIG_SUBTASK_UPDATE', id: 0, key: 'add' })}> + + + + )} + +
+
+ ); +} diff --git a/packages/ui-default/components/problemconfig/TestCasesTable.tsx b/packages/ui-default/components/problemconfig/TestCasesTable.tsx new file mode 100644 index 000000000..689f46be9 --- /dev/null +++ b/packages/ui-default/components/problemconfig/TestCasesTable.tsx @@ -0,0 +1,128 @@ +import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import i18n from 'vj/utils/i18n'; +import { isEqual } from 'lodash'; +import { Tag, NumericInput } from '@blueprintjs/core'; +import { parseTimeMS, parseMemoryMB } from '@hydrooj/utils/lib/common'; +import type { SubtaskConfig } from 'hydrooj/src/interface'; +import type { RootState } from './reducer/index'; +import FileSelectAutoComplete from '../autocomplete/components/FileSelectAutoComplete'; + +const eq = (a: SubtaskConfig, b: SubtaskConfig) => isEqual(a, b); +const eqArr = (a: any[], b: any[]) => isEqual(a, b); + +export function TestCaseEntry({ index, subindex }) { + const testcase = useSelector((state: RootState) => state.config.subtasks[index].cases[subindex], eq); + const Files = useSelector((state: RootState) => state.testdata, eqArr); + const defaultTime = useSelector((state: RootState) => state.config.subtasks[index].time || state.config.time); + const defaultMemory = useSelector((state: RootState) => state.config.subtasks[index].memory || state.config.memory); + const dispatch = useDispatch(); + const dispatcher = (casesKey: string, valueSuffix = '') => (ev: React.ChangeEvent | number) => { + let value = typeof ev !== 'object' ? ev : ev.currentTarget.value; + if (value === 0) value = ''; + if (valueSuffix && value) value += valueSuffix; + dispatch({ + type: 'CONFIG_SUBTASK_UPDATE', + id: index, + key: 'cases-edit', + casesId: subindex, + casesKey, + value, + }); + }; + if (!testcase || Object.keys(testcase).length === 0) { + return ( + {i18n('Failed to parse testcase.')} + ); + } + return ( + + + ms} + value={testcase.time ? parseTimeMS(testcase.time, false).toString() : ''} + placeholder={parseTimeMS(defaultTime || '1000ms', false).toString()} + onValueChange={dispatcher('time', 'ms')} + buttonPosition="none" + fill + /> + + + MB} + value={testcase.memory ? parseMemoryMB(testcase.memory, false).toString() : ''} + placeholder={parseMemoryMB(defaultMemory || '256m', false).toString()} + onValueChange={dispatcher('memory', 'MB')} + buttonPosition="none" + fill + /> + + {['input', 'output'].map((t) => ( + + + + ))} + + dispatch({ + type: 'CONFIG_SUBTASK_UPDATE', id: index, key: 'cases-delete', value: subindex, + })} + > + + + + ); +} + +export function CasesTable({ index }) { + const len = useSelector((state: RootState) => state.config.subtasks[index].cases?.length); + const dispatch = useDispatch(); + return ( + + + + + + + + + + + + + + + + + + + {len > 0 && [...Array(len).keys()].map((i) => )} + +
{i18n('Time')}{i18n('Memory')}{i18n('Input')}{i18n('Output')} + dispatch({ + type: 'CONFIG_SUBTASK_UPDATE', + id: index, + key: 'cases-add', + value: { input: '', output: '' }, + })} + > + +
{i18n('Time')}{i18n('Memory')}{i18n('Input')}{i18n('Output')} + dispatch({ + type: 'CONFIG_SUBTASK_UPDATE', + id: index, + key: 'cases-add', + value: { input: '', output: '' }, + })} + > + +
+ ); +} diff --git a/packages/ui-default/components/problemconfig/reducer/config.ts b/packages/ui-default/components/problemconfig/reducer/config.ts new file mode 100644 index 000000000..711168ca8 --- /dev/null +++ b/packages/ui-default/components/problemconfig/reducer/config.ts @@ -0,0 +1,69 @@ +import yaml from 'js-yaml'; +import type { ProblemConfigFile } from 'hydrooj/src/interface'; +import { cloneDeep } from 'lodash'; +import { parseMemoryMB, parseTimeMS } from '@hydrooj/utils/lib/common'; + +type State = ProblemConfigFile & { __loaded: boolean }; + +export default function reducer(state = { type: 'default', __loaded: false } as State, action): State { + switch (action.type) { + case 'CONFIG_LOAD_FULFILLED': { + return { ...state, ...yaml.load(action.payload.config) as object, __loaded: true }; + } + case 'CONFIG_FORM_UPDATE': { + const next = { ...state, [action.key]: action.value }; + if (action.key === 'score' && action.value) next.score = +next.score; + if (action.key === 'checker_type' && action.value === 'other') next.checker_type = 'syzoj'; + if (!action.value || (typeof action.value === 'object' && !action.value.join(''))) delete next[action.key]; + return next; + } + case 'CONFIG_CODE_UPDATE': { + try { + return { ...state, ...yaml.load(action.payload) as object }; + } catch { + return state; + } + } + case 'CONFIG_AUTOCASES_UPDATE': { + const next = { ...state }; + const { subtasks } = action; + for (const subtask of subtasks) { + if (subtask.time === parseTimeMS(state.time || '1s')) delete subtask.time; + if (subtask.memory === parseMemoryMB(state.memory || '256m')) delete subtask.memory; + if (subtask.time) subtask.time += 'ms'; + if (subtask.memory) subtask.memory += 'MB'; + } + if (subtasks.length === 0) next.subtasks = []; + else { + next.subtasks = subtasks.map((subtask) => ( + { ...subtask, ...{ cases: subtask.cases.map((i) => ({ input: i.input, output: i.output })) } })); + } + return next; + } + case 'CONFIG_SUBTASK_UPDATE': { + const subtasks = cloneDeep(state.subtasks); + const subsubtasks = cloneDeep(state.subtasks[action.id]); + if (action.value !== '' && ['score', 'id'].includes(action.key)) action.value = +action.value; + if (action.key === 'if' && action.value.join('') !== '') action.value = action.value.map((i) => +i); + if (action.key.split('-')[0] === 'cases') { + if (action.key === 'cases-add') subsubtasks.cases.push(action.value); + else if (action.key === 'cases-edit') { + if (action.value === '') delete subsubtasks.cases[action.casesId][action.casesKey]; + else subsubtasks.cases[action.casesId][action.casesKey] = action.value; + } else if (action.key === 'cases-delete') { + subsubtasks.cases = subsubtasks.cases.filter((k, v) => v !== action.value); + } + } else if (action.key === 'add') { + return { ...state, subtasks: [...subtasks, { time: state.time || '1s', memory: state.memory || '256m', cases: [] }] }; + } else if (action.key === 'delete') return { ...state, subtasks: subtasks.filter((k, v) => v !== action.id) }; + else { + if (action.value === '' || (action.key === 'if' && action.value.join('') === '')) delete subsubtasks[action.key]; + else subsubtasks[action.key] = action.value; + } + subtasks[action.id] = subsubtasks; + return { ...state, subtasks }; + } + default: + return state; + } +} diff --git a/packages/ui-default/components/problemconfig/reducer/index.ts b/packages/ui-default/components/problemconfig/reducer/index.ts new file mode 100644 index 000000000..3ddfbafbd --- /dev/null +++ b/packages/ui-default/components/problemconfig/reducer/index.ts @@ -0,0 +1,11 @@ +import { combineReducers } from 'redux'; +import config from './config'; +import testdata from './testdata'; + +const reducer = combineReducers({ + config, + testdata, +}); + +export default reducer; +export type RootState = ReturnType; diff --git a/packages/ui-default/components/problemconfig/reducer/testdata.ts b/packages/ui-default/components/problemconfig/reducer/testdata.ts new file mode 100644 index 000000000..34fb0fb88 --- /dev/null +++ b/packages/ui-default/components/problemconfig/reducer/testdata.ts @@ -0,0 +1,16 @@ +export default function reducer(state = [], action) { + switch (action.type) { + case 'CONFIG_LOAD_FULFILLED': { + return state.concat(action.payload.testdata); + } + case 'CONFIG_DELETE_TESTDATA': { + const next = state.filter((i) => i.name !== action.value[0]); + return next; + } + case 'CONFIG_ADD_TESTDATA': { + return state.concat([action.value]); + } + default: + return state; + } +} diff --git a/packages/ui-default/components/scratchpad/reducers/records.ts b/packages/ui-default/components/scratchpad/reducers/records.ts index 4c44b09e6..3b8b0d655 100644 --- a/packages/ui-default/components/scratchpad/reducers/records.ts +++ b/packages/ui-default/components/scratchpad/reducers/records.ts @@ -5,36 +5,36 @@ export default function reducer(state = { items: {}, }, action) { switch (action.type) { - case 'SCRATCHPAD_RECORDS_LOAD_SUBMISSIONS_FULFILLED': { - const { rdocs } = action.payload; - return { - ...state, - rows: _.map(rdocs, '_id'), - items: _.keyBy(rdocs, '_id'), - }; - } - case 'SCRATCHPAD_RECORDS_PUSH': { - const { rdoc } = action.payload; - const rows = [...state.rows]; - if (!rows.includes(rdoc._id)) { + case 'SCRATCHPAD_RECORDS_LOAD_SUBMISSIONS_FULFILLED': { + const { rdocs } = action.payload; + return { + ...state, + rows: _.map(rdocs, '_id'), + items: _.keyBy(rdocs, '_id'), + }; + } + case 'SCRATCHPAD_RECORDS_PUSH': { + const { rdoc } = action.payload; + const rows = [...state.rows]; + if (!rows.includes(rdoc._id)) { + return { + ...state, + rows: [rdoc._id, ...state.rows], + items: { + ...state.items, + [rdoc._id]: rdoc, + }, + }; + } return { ...state, - rows: [rdoc._id, ...state.rows], items: { ...state.items, [rdoc._id]: rdoc, }, }; } - return { - ...state, - items: { - ...state.items, - [rdoc._id]: rdoc, - }, - }; - } - default: - return state; + default: + return state; } } diff --git a/packages/ui-default/components/scratchpad/reducers/ui.ts b/packages/ui-default/components/scratchpad/reducers/ui.ts index 257868f14..ca374c480 100644 --- a/packages/ui-default/components/scratchpad/reducers/ui.ts +++ b/packages/ui-default/components/scratchpad/reducers/ui.ts @@ -19,93 +19,93 @@ export default function reducer(state = { isPosting: false, }, action) { switch (action.type) { - case 'SCRATCHPAD_UI_CHANGE_SIZE': { - const { uiElement, size } = action.payload; - return { - ...state, - [uiElement]: { - ...state[uiElement], - size, - }, - }; - } - case 'SCRATCHPAD_UI_SET_VISIBILITY': { - const { uiElement, visibility } = action.payload; - localStorage.setItem(`scratchpad/${uiElement}`, visibility.toString()); - return { - ...state, - [uiElement]: { - ...state[uiElement], - visible: visibility, - }, - }; - } - case 'SCRATCHPAD_UI_TOGGLE_VISIBILITY': { - const { uiElement } = action.payload; - localStorage.setItem(`scratchpad/${uiElement}`, (!state[uiElement].visible).toString()); - return { - ...state, - [uiElement]: { - ...state[uiElement], - visible: !state[uiElement].visible, - }, - }; - } - case 'SCRATCHPAD_POST_PRETEST_PENDING': - case 'SCRATCHPAD_POST_SUBMIT_PENDING': { - return { - ...state, - isPosting: true, - }; - } - case 'SCRATCHPAD_POST_PRETEST_FULFILLED': - case 'SCRATCHPAD_POST_SUBMIT_FULFILLED': { - Notification.success(i18n('Submitted.')); - if (action.type === 'SCRATCHPAD_POST_SUBMIT_FULFILLED' && UiContext.canViewRecord) { - state.records.visible = true; + case 'SCRATCHPAD_UI_CHANGE_SIZE': { + const { uiElement, size } = action.payload; + return { + ...state, + [uiElement]: { + ...state[uiElement], + size, + }, + }; } - return { - ...state, - isPosting: false, - }; - } - case 'SCRATCHPAD_POST_PRETEST_REJECTED': - case 'SCRATCHPAD_POST_SUBMIT_REJECTED': { - Notification.error(action.payload.message); - return { - ...state, - isPosting: false, - }; - } - case 'SCRATCHPAD_RECORDS_LOAD_SUBMISSIONS_PENDING': { - return { - ...state, - records: { - ...state.records, - isLoading: true, - }, - }; - } - case 'SCRATCHPAD_RECORDS_LOAD_SUBMISSIONS_REJECTED': { - Notification.error(action.payload.message); - return { - ...state, - records: { - ...state.records, - isLoading: false, - }, - }; - } - case 'SCRATCHPAD_RECORDS_LOAD_SUBMISSIONS_FULFILLED': { - return { - ...state, - records: { - ...state.records, - isLoading: false, - }, - }; - } - default: - return state; + case 'SCRATCHPAD_UI_SET_VISIBILITY': { + const { uiElement, visibility } = action.payload; + localStorage.setItem(`scratchpad/${uiElement}`, visibility.toString()); + return { + ...state, + [uiElement]: { + ...state[uiElement], + visible: visibility, + }, + }; + } + case 'SCRATCHPAD_UI_TOGGLE_VISIBILITY': { + const { uiElement } = action.payload; + localStorage.setItem(`scratchpad/${uiElement}`, (!state[uiElement].visible).toString()); + return { + ...state, + [uiElement]: { + ...state[uiElement], + visible: !state[uiElement].visible, + }, + }; + } + case 'SCRATCHPAD_POST_PRETEST_PENDING': + case 'SCRATCHPAD_POST_SUBMIT_PENDING': { + return { + ...state, + isPosting: true, + }; + } + case 'SCRATCHPAD_POST_PRETEST_FULFILLED': + case 'SCRATCHPAD_POST_SUBMIT_FULFILLED': { + Notification.success(i18n('Submitted.')); + if (action.type === 'SCRATCHPAD_POST_SUBMIT_FULFILLED' && UiContext.canViewRecord) { + state.records.visible = true; + } + return { + ...state, + isPosting: false, + }; + } + case 'SCRATCHPAD_POST_PRETEST_REJECTED': + case 'SCRATCHPAD_POST_SUBMIT_REJECTED': { + Notification.error(action.payload.message); + return { + ...state, + isPosting: false, + }; + } + case 'SCRATCHPAD_RECORDS_LOAD_SUBMISSIONS_PENDING': { + return { + ...state, + records: { + ...state.records, + isLoading: true, + }, + }; + } + case 'SCRATCHPAD_RECORDS_LOAD_SUBMISSIONS_REJECTED': { + Notification.error(action.payload.message); + return { + ...state, + records: { + ...state.records, + isLoading: false, + }, + }; + } + case 'SCRATCHPAD_RECORDS_LOAD_SUBMISSIONS_FULFILLED': { + return { + ...state, + records: { + ...state.records, + isLoading: false, + }, + }; + } + default: + return state; } } diff --git a/packages/ui-default/components/socket/index.js b/packages/ui-default/components/socket/index.js index 0435d062b..6bb9b0e3b 100644 --- a/packages/ui-default/components/socket/index.js +++ b/packages/ui-default/components/socket/index.js @@ -5,7 +5,6 @@ export default class Sock { const i = new URL(url, window.location.href); i.protocol = i.protocol.replace('http', 'ws'); this.url = i.toString(); - this.closed = false; this.sock = new ReconnectingWebSocket(this.url); this.sock.onopen = () => { console.log('Connected'); @@ -13,17 +12,14 @@ export default class Sock { }; this.sock.onclose = ({ code, reason }) => { console.warn('Connection closed, ', code, reason); - if (code >= 4000) this.closed = true; + if (code >= 4000) this.close(); this.onclose?.(code, reason); }; this.sock.onmessage = (message) => { if (process.env.NODE_ENV !== 'production') console.log('Sock.onmessage: ', message); const msg = JSON.parse(message.data); - if (msg.event === 'auth') { - if (msg.error === 'PermissionError' || msg.error === 'PrivilegeError') { - this.close(); - } - } else this.onmessage?.(message); + if (msg.error === 'PermissionError' || msg.error === 'PrivilegeError') this.close(); + else this.onmessage?.(message); }; } @@ -32,7 +28,6 @@ export default class Sock { } close() { - this.closed = true; this.sock?.close?.(); } } diff --git a/packages/ui-default/components/zipDownloader/index.js b/packages/ui-default/components/zipDownloader/index.js index 56ea2f88d..eb3c3e183 100644 --- a/packages/ui-default/components/zipDownloader/index.js +++ b/packages/ui-default/components/zipDownloader/index.js @@ -51,6 +51,7 @@ export default async function download(filename, targets) { stream, }; } catch (e) { + window.captureException?.(e); stopDownload(); Notification.error(i18n('Download Error', [target.filename, e.toString()])); } @@ -144,6 +145,7 @@ export async function downloadProblemSet(pids, name = 'Export') { } await download(`${name}.zip`, targets); } catch (e) { + window.captureException?.(e); Notification.error(`${e.message} ${e.params?.[0]}`); } } diff --git a/packages/ui-default/entry.js b/packages/ui-default/entry.js index 8cc02a54c..2e2524581 100644 --- a/packages/ui-default/entry.js +++ b/packages/ui-default/entry.js @@ -4,6 +4,7 @@ window.Hydro = { components: {}, utils: {}, node_modules: {}, + version: process.env.VERSION, }; window.externalModules = {}; diff --git a/packages/ui-default/hydro.ts b/packages/ui-default/hydro.ts index 848db2ab4..20d45eaae 100644 --- a/packages/ui-default/hydro.ts +++ b/packages/ui-default/hydro.ts @@ -64,6 +64,7 @@ async function load() { try { await func(currentPageName, loadPage(1, type)); } catch (e) { + (window as any).captureException?.(e); Notification.warn(`Failed to call '${type}Loading' of ${page.name}`); console.error(`Failed to call '${type}Loading' of ${page.name}\n${e.stack}`); console.error(e); diff --git a/packages/ui-default/locales/zh.yaml b/packages/ui-default/locales/zh.yaml index 429064326..4a7ae97f2 100644 --- a/packages/ui-default/locales/zh.yaml +++ b/packages/ui-default/locales/zh.yaml @@ -86,6 +86,7 @@ Attend Contest: 参加比赛 Attend contests: 参加比赛 Attended: 已参加 author: 作者 +Auto Read Tasks: 自动识别任务 Be Copied: 被复制 Begin at: 开始于 Begin Date: 开始日期 @@ -117,6 +118,8 @@ Change Email: 修改电子邮件 Change Password: 修改密码 Change Theme: 切换主题 Check In: 签到 +Checker: 比较器 +CheckerType: 比较器类型 Chinese: 中文 Choose the background image in your profile page.: 选择您资料页面的背景图片。 Claim homework: 认领作业 @@ -138,6 +141,7 @@ Compile Error: 编译错误 Compilers' Version and Parameters: 编译器版本及参数 Complete: 完成 Completed: 已完成 +Config: 配置 Confirm deleting the selected roles?: 您确定删除所选角色吗? Confirm deleting this comment? Its replies will be deleted as well.: 确认删除这个评论吗?回复会被同时删除。 Confirm deleting this reply?: 确认删除这个回复吗? @@ -268,6 +272,7 @@ domain: 域 Domain: 域 Don't have an account?: 还没有账户? Done: 已结束 +Download All: 下载全部 Download Dataset: 下载数据集 Download Selected: 下载选中 Download: 下载 @@ -318,7 +323,10 @@ Export as CSV: 导出为 CSV Export as HTML: 导出为 HTML Extension (days): 最长延期 (日) Extension Score Penalty: 延期递交扣分规则 +Extra Files Config: 额外文件设置 Failed to join the domain. You are already a member.: 加入域失败,您已是该域的成员。 +Failed to parse subtask.: 无法解析子任务信息。 +Failed to parse testcase.: 无法解析测试点信息。 Feedback: 反馈 Field {0} or {1} validation failed.: 字段 {0} 或 {1} 验证失败。 Field {0} validation failed.: 字段 {0} 验证失败。 @@ -382,6 +390,7 @@ Host: 主持人 hour(s): 小时 If enabled, source code will be emailed to you after the submission is accepted.: 如果启用,在您通过题目后,源代码会以 Email 的形式发送给您。 If left blank, the built-in template of the corresponding language will be used.: 若留空,则将使用对应语言的内置代码模板。 +Ignore trailing space and enter.: 忽略行末空格与文件尾回车。 Images With Size: 指定大小的图片 Images: 图片 Import from: 导入自 @@ -400,6 +409,7 @@ Information: 信息 Initialize: 初始化 Inline codes: 行内代码 Inline formulas: 行内公式 +Input: 输入 Insert Code: 插入代码 Insert Image: 插入图像 Installed Modules: 已安装的模块 @@ -414,12 +424,16 @@ Is rated: 计入排名 It works!: 它成了! Italic: 斜体 Join: 加入 +Judge Config: 评测设置 Judge Status: 评测状态 +judge_extra_files: 评测额外文件 judge_playground: 评测游乐场 Judged At: 评测时间 Judged By: 评测机 Judging Queue: 评测队列 Keep current expiration: 保持当前过期设置 +Langs Config: 语言设置 +langs: 语言 Language: 语言 last active at: 最后活动于 last login at: 最后登录于 @@ -531,6 +545,7 @@ Or, with automatically filled invitation code: 或者,这是可以自动填写 Ordered List: 有序列表 Original Score: 原始分数 OS Info: 系统信息 +Output: 输出 Over {0} problems: 超过 {0} 道题目 Owner: 所有者 page.problem_detail.sidebar.show_category: 点击显示 @@ -582,6 +597,8 @@ Problem Solution List: 题解列表 Problem Solution: 题解列表 Problem Tags Visibility: 题目标签可见性 Problem Tags: 题目标签 +Problem Type: 题目类型 +problem_config: 评测设置 problem_create: 创建题目 problem_edit: 编辑题目 problem_import: 导入题目 @@ -755,6 +772,7 @@ SuperUser: 超级管理员 Support: 支持 Switch account: 切换账户 Tags: 标签 +Task Settings: 任务设置 tasks_list: 任务列表 Technical Information: 技术信息 Temporary session: 临时会话 @@ -811,9 +829,11 @@ Uncompleted: 未完成 Unique: 唯一 Unknown field {0}.: 未知字段 {0}。 Unknown: 未知 +Unlimited: 无限制 Unordered List: 无序列表 Unstar Topic: 取消收藏 Unstar: 取消收藏 +Unsupported configure this type of problem. Please refer to the documentation.: 暂不支持快捷配置此类题目。请参照文档操作。 Update Permission: 更新权限 Update Settings: 更新设置 Update: 更新 @@ -830,12 +850,14 @@ Usage exceeded.: 用量超限。 Use admin specificed: 由管理员指定 Use algorithm calculated: 使用算法计算 Use average of above: 使用上面的平均值 +Use Subtasks Mode: 使用子任务模式 User {0} already exists.: 用户 {0} 已存在。 User {0} not found.: 用户 {0} 不存在。 User can join this domain by visiting the following URL: 用户可以访问此链接来加入此域 User ID: 用户 ID User will no longer be allowed to join the domain after expiration.: 过期后,用户将不能加入此域。 user_detail: 用户 +user_extra_files: 编译额外文件 user_login: 登录 user_logout: 登出 user_lostpass: 忘记密码 diff --git a/packages/ui-default/package.json b/packages/ui-default/package.json index 255ab91a7..e23d6985d 100644 --- a/packages/ui-default/package.json +++ b/packages/ui-default/package.json @@ -1,24 +1,24 @@ { "name": "@hydrooj/ui-default", - "version": "4.35.0", + "version": "4.37.6", "author": "undefined ", "license": "AGPL-3.0", "main": "hydro.js", "repository": "https://github.com/hydro-dev/Hydro.git", "preferUnplugged": true, "scripts": { - "lint": "eslint ." + "lint": "eslint" }, "devDependencies": { - "@blueprintjs/core": "^3.54.0", - "@fontsource/dm-mono": "^4.5.7", - "@fontsource/fira-code": "^4.5.8", - "@fontsource/inconsolata": "^4.5.4", - "@fontsource/jetbrains-mono": "^4.5.5", - "@fontsource/pt-mono": "^4.5.5", - "@fontsource/roboto-mono": "^4.5.5", - "@fontsource/source-code-pro": "^4.5.6", - "@fontsource/ubuntu-mono": "^4.5.6", + "@blueprintjs/core": "^4.3.0", + "@fontsource/dm-mono": "^4.5.8", + "@fontsource/fira-code": "^4.5.9", + "@fontsource/inconsolata": "^4.5.6", + "@fontsource/jetbrains-mono": "^4.5.8", + "@fontsource/pt-mono": "^4.5.8", + "@fontsource/roboto-mono": "^4.5.7", + "@fontsource/source-code-pro": "^4.5.9", + "@fontsource/ubuntu-mono": "^4.5.9", "@hydrooj/utils": "workspace:*", "@milkdown/core": "^6.0.1", "@milkdown/plugin-clipboard": "^6.0.1", @@ -42,8 +42,8 @@ "@types/json-schema": "^7.0.11", "@types/katex": "^0.14.0", "@types/qrcode": "^1.4.2", + "@types/react-dom": "^18.0.3", "@types/redux-logger": "^3.0.9", - "@undefined-moe/monaco-yaml": "^2.5.1", "ansi_up": "^5.1.0", "autoprefixer": "^9.8.8", "browser-update": "^3.3.38", @@ -51,19 +51,19 @@ "chunk-progress-webpack-plugin": "^2.0.1", "classnames": "^2.3.1", "clean-webpack-plugin": "^4.0.0", - "clipboard": "^2.0.10", + "clipboard": "^2.0.11", "copy-webpack-plugin": "^6.4.1", "css-loader": "^4.3.0", + "diff": "^5.0.0", "diff-dom": "^4.2.3", "echarts": "^5.3.2", "emojis-keywords": "2.0.0", "emojis-list": "2.1.0", "esbuild-loader": "^2.18.0", - "eslint": "^8.12.0", "fancy-log": "^2.0.0", "file-loader": "^6.2.0", "friendly-errors-webpack-plugin": "^1.7.0", - "graphiql": "^1.8.3", + "graphiql": "^1.8.9", "gulp": "^4.0.2", "gulp-cli": "^2.3.0", "gulp-iconfont": "^11.0.1", @@ -76,31 +76,32 @@ "jquery.transit": "^0.9.12", "material-icons": "^1.10.9", "mini-css-extract-plugin": "^1.6.2", - "moment": "^2.29.1", - "monaco-editor": "^0.31.1", + "moment": "^2.29.3", + "monaco-editor": "^0.33.0", "monaco-editor-nls": "^2.0.0", "monaco-editor-webpack-plugin": "^7.0.1", - "monaco-themes": "^0.4.0", - "nanoid": "^3.3.2", + "monaco-themes": "^0.4.2", + "monaco-yaml": "^4.0.0-alpha.3", + "nanoid": "^3.3.4", "normalize.css": "^8.0.1", "nprogress": "^0.2.0", "pickadate": "^3.6.4", "plugin-error": "^1.0.1", "postcss-loader": "^3.0.0", "prism-themes": "^1.9.0", - "prismjs": "^1.27.0", + "prismjs": "^1.28.0", "prop-types": "^15.8.1", "qrcode": "^1.5.0", "queue-microtask": "^1.2.3", "raw-loader": "^4.0.2", - "react": "^17.0.2", - "react-dnd": "^16.0.0", - "react-dnd-html5-backend": "^16.0.0", - "react-dom": "^17.0.2", - "react-redux": "^7.2.8", + "react": "^18.1.0", + "react-dnd": "^16.0.1", + "react-dnd-html5-backend": "^16.0.1", + "react-dom": "^18.1.0", + "react-redux": "^8.0.1", "react-split-pane": "^0.1.92", "reconnecting-websocket": "^4.4.0", - "redux": "^4.1.2", + "redux": "^4.2.0", "redux-logger": "^3.0.6", "redux-promise-middleware": "^6.1.2", "redux-thunk": "^2.4.1", @@ -118,7 +119,7 @@ "timeago-react": "^3.0.4", "timeago.js": "^4.0.2", "vinyl-buffer": "^1.0.1", - "web-streams-polyfill": "^3.2.0", + "web-streams-polyfill": "^3.2.1", "webpack": "^4.46.0", "webpack-bundle-analyzer": "^4.5.0", "webpack-dev-server": "^3.11.3", @@ -126,13 +127,13 @@ }, "dependencies": { "esbuild": "0.14.3", - "fs-extra": "^10.0.1", + "fs-extra": "^10.1.0", "js-yaml": "^4.1.0", "jsesc": "^3.0.2", "katex": "^0.15.3", "lodash": "^4.17.21", - "markdown-it": "^12.3.2", - "markdown-it-anchor": "^8.4.1", + "markdown-it": "^13.0.1", + "markdown-it-anchor": "^8.6.4", "markdown-it-footnote": "^3.0.3", "markdown-it-imsize": "^2.0.1", "markdown-it-mark": "^3.0.1", diff --git a/packages/ui-default/pages/api.page.tsx b/packages/ui-default/pages/api.page.tsx index e2fcef103..87f5ab589 100644 --- a/packages/ui-default/pages/api.page.tsx +++ b/packages/ui-default/pages/api.page.tsx @@ -2,7 +2,7 @@ import { NamedPage } from 'vj/misc/Page'; import request from 'vj/utils/request'; import React from 'react'; -import { render } from 'react-dom'; +import ReactDOM from 'react-dom/client'; const Logo = () => Hydro API Console ; const defaultQuery = `\ @@ -43,7 +43,7 @@ const page = new NamedPage('api', async () => { /> ); - render(, document.getElementById('graphiql')); + ReactDOM.createRoot(document.getElementById('graphiql')).render(); }); export default page; diff --git a/packages/ui-default/pages/home_files.page.js b/packages/ui-default/pages/home_files.page.js index 4b8a816cd..e2f3ee0c2 100644 --- a/packages/ui-default/pages/home_files.page.js +++ b/packages/ui-default/pages/home_files.page.js @@ -40,12 +40,12 @@ async function handleClickUpload(files) { const dialog = new Dialog({ $body: `
-
-
+
+
-
-
+
+
`, }); try { @@ -88,6 +88,7 @@ async function handleClickUpload(files) { Notification.success(i18n('File uploaded successfully.')); await pjax.request({ push: false }); } catch (e) { + window.captureException?.(e); console.error(e); Notification.error(i18n('File upload failed: {0}', e.toString())); } finally { diff --git a/packages/ui-default/pages/home_messages.page.jsx b/packages/ui-default/pages/home_messages.page.jsx index 698492aea..d6b2258f4 100644 --- a/packages/ui-default/pages/home_messages.page.jsx +++ b/packages/ui-default/pages/home_messages.page.jsx @@ -1,4 +1,6 @@ import { NamedPage } from 'vj/misc/Page'; +import React from 'react'; +import { createRoot } from 'react-dom/client'; import api, { gql } from 'vj/utils/api'; import loadReactRedux from 'vj/utils/loadReactRedux'; import parseQueryString from 'vj/utils/parseQueryString'; @@ -26,9 +28,7 @@ const page = new NamedPage('home_messages', () => { const { default: WebSocket } = await import('../components/socket'); const { default: MessagePadApp } = await import('../components/messagepad'); const { default: MessagePadReducer } = await import('../components/messagepad/reducers'); - const { - React, render, Provider, store, - } = await loadReactRedux(MessagePadReducer); + const { Provider, store } = await loadReactRedux(MessagePadReducer); reduxStore = store; @@ -71,7 +71,7 @@ const page = new NamedPage('home_messages', () => { return this; }; - render( + createRoot($('#messagePad').get(0)).render( { @@ -84,7 +84,6 @@ const page = new NamedPage('home_messages', () => { }} /> , - $('#messagePad').get(0), ); } diff --git a/packages/ui-default/pages/homework_main.page.js b/packages/ui-default/pages/homework_main.page.js index 4dc9a6d58..842e223b2 100644 --- a/packages/ui-default/pages/homework_main.page.js +++ b/packages/ui-default/pages/homework_main.page.js @@ -19,16 +19,16 @@ const page = new NamedPage('homework_main', () => { calendar.getDom().appendTo('[name="calendar_entry"]'); $('[name="homework_display"]').change((ev) => { switch (ev.currentTarget.value) { - case 'calendar': - $('.homework__list').hide(); - $('[name="calendar_entry"]').show(); - break; - case 'list': - $('.homework__list').show(); - $('[name="calendar_entry"]').hide(); - break; - default: - throw new Error('Unexpected display parameter'); + case 'calendar': + $('.homework__list').hide(); + $('[name="calendar_entry"]').show(); + break; + case 'list': + $('.homework__list').show(); + $('[name="calendar_entry"]').hide(); + break; + default: + throw new Error('Unexpected display parameter'); } }); } diff --git a/packages/ui-default/pages/problem_config.page.styl b/packages/ui-default/pages/problem_config.page.styl new file mode 100644 index 000000000..ef43e08a1 --- /dev/null +++ b/packages/ui-default/pages/problem_config.page.styl @@ -0,0 +1,66 @@ +.page--problem_config + .section--problem-sidebar-testdata + .problem-sidebar-testdata__detail + max-height: 270px + overflow-y: auto + + .col--size + width: 80px + .col--operation + width: 40px + text-align: center + + .textbox,.select + margin: 0 + + .bp4-tab-panel + margin-top: 5px + + .problem-config-form + font-family: var(--code-font-family) + + input::-webkit-outer-spin-button, input::-webkit-inner-spin-button + -webkit-appearance: none !important + input[type="number"] + -moz-appearance: textfield + + .bp4-tag.bp4-minimal + background: transparent + .bp4-input-action + pointer-events: none + .bp4-input-group + input + appearance: none + display: block + width: 100% + font-size: 0.875rem + height: 2.1875rem + line-height: 1.2 + padding: 0.3125rem; + border: 1px solid #ddd + color: inherit + border-radius: 0 + background-color: transparent + outline: 2px solid transparent + transition: outline-color 0.2s, border-color 0.2s + transition-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1) + box-shadow: none + + + .data-table + tr + border-top: 0 + border-bottom: 0 + height: auto + td + padding: 0 + .thead td + text-align: center + td input, td div + background: transparent + + .hover-row.status-bar + display: none + + input::placeholder + color: rgba(95,107,124,.6)!important \ No newline at end of file diff --git a/packages/ui-default/pages/problem_config.page.tsx b/packages/ui-default/pages/problem_config.page.tsx new file mode 100644 index 000000000..f73dbad86 --- /dev/null +++ b/packages/ui-default/pages/problem_config.page.tsx @@ -0,0 +1,203 @@ +import { NamedPage } from 'vj/misc/Page'; +import request from 'vj/utils/request'; +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import loadReactRedux from 'vj/utils/loadReactRedux'; +import i18n from 'vj/utils/i18n'; +import yaml from 'js-yaml'; +import Notification from 'vj/components/notification'; +import Dialog, { ConfirmDialog } from 'vj/components/dialog/index'; +import download from 'vj/components/zipDownloader'; +import { size, readSubtasksFromFiles, normalizeSubtasks } from '@hydrooj/utils/lib/common'; +import tpl from 'vj/utils/tpl'; +import { SubtaskType } from 'hydrooj/src/interface'; +import { configYamlFormat } from 'vj/components/problemconfig/ProblemConfigEditor'; + +function onBeforeUnload(e) { + e.returnValue = ''; +} + +const page = new NamedPage('problem_config', () => { + let reduxStore; + + async function handleClickUpload() { + const input = document.createElement('input'); + input.type = 'file'; + input.multiple = true; + input.click(); + await new Promise((resolve) => { input.onchange = resolve; }); + const { files } = input; + const dialog = new Dialog({ + $body: ` +
+
+
+
+
+
+
+
`, + }); + try { + Notification.info(i18n('Uploading files...')); + window.addEventListener('beforeunload', onBeforeUnload); + dialog.open(); + const $uploadLabel = dialog.$dom.find('.dialog__body .upload-label'); + const $uploadProgress = dialog.$dom.find('.dialog__body .upload-progress'); + const $fileLabel = dialog.$dom.find('.dialog__body .file-label'); + const $fileProgress = dialog.$dom.find('.dialog__body .file-progress'); + for (const i in files) { + if (Number.isNaN(+i)) continue; + const file = files[i]; + const data = new FormData(); + data.append('filename', file.name); + data.append('file', file); + data.append('type', 'testdata'); + data.append('operation', 'upload_file'); + await request.postFile('./files', data, { + xhr() { + const xhr = new XMLHttpRequest(); + xhr.upload.addEventListener('loadstart', () => { + $fileLabel.text(`[${+i + 1}/${files.length}] ${file.name}`); + $fileProgress.width(`${Math.round((+i + 1) / files.length * 100)}%`); + $uploadLabel.text(i18n('Uploading... ({0}%)', 0)); + $uploadProgress.width(0); + }); + xhr.upload.addEventListener('progress', (e) => { + if (e.lengthComputable) { + const percentComplete = Math.round((e.loaded / e.total) * 100); + if (percentComplete === 100) $uploadLabel.text(i18n('Processing...')); + else $uploadLabel.text(i18n('Uploading... ({0}%)', percentComplete)); + $uploadProgress.width(`${percentComplete}%`); + } + }, false); + return xhr; + }, + }); + reduxStore.dispatch({ + type: 'CONFIG_ADD_TESTDATA', + value: { + _id: file.name, + name: file.name, + size: file.size, + }, + }); + $('.testdata-table tbody').append( + $(tpl` + ${file.name} + ${size(file.size)} + + `), + ); + } + window.removeEventListener('beforeunload', onBeforeUnload); + Notification.success(i18n('File uploaded successfully.')); + } catch (e) { + console.error(e); + Notification.error(i18n('File upload failed: {0}', e.toString())); + } finally { + dialog.close(); + } + } + + async function handleClickRemove(ev: JQuery.ClickEvent) { + const file = [$(ev.currentTarget).parent().parent().attr('data-filename')]; + const action = await new ConfirmDialog({ + $body: tpl.typoMsg(i18n('Confirm to delete the file?')), + }).open(); + if (action !== 'yes') return; + try { + await request.post('./files', { + operation: 'delete_files', + files: file, + type: 'testdata', + }); + Notification.success(i18n('File have been deleted.')); + reduxStore.dispatch({ + type: 'CONFIG_DELETE_TESTDATA', + value: file, + }); + $(ev.currentTarget).parent().parent().remove(); + } catch (error) { + Notification.error(error.message); + } + } + + async function handleClickDownloadAll() { + const files = reduxStore.getState().testdata.map((i) => i.name); + const { links, pdoc } = await request.post('./files', { operation: 'get_links', files, type: 'testdata' }); + const targets = []; + for (const filename of Object.keys(links)) targets.push({ filename, url: links[filename] }); + await download(`${pdoc.docId} ${pdoc.title}.zip`, targets); + } + + async function uploadConfig(config: object) { + const configYaml = yaml.dump(configYamlFormat(config)); + Notification.info(i18n('Saving file...')); + const data = new FormData(); + data.append('filename', 'config.yaml'); + data.append('file', new Blob([configYaml], { type: 'text/plain' })); + data.append('type', 'testdata'); + data.append('operation', 'upload_file'); + await request.postFile('./files', data); + Notification.success(i18n('File saved.')); + window.location.reload(); + } + + async function mountComponent() { + const [{ default: ProblemConfigEditor }, { default: ProblemConfigForm }, { default: ProblemConfigReducer }] = await Promise.all([ + import('vj/components/problemconfig/ProblemConfigEditor'), + import('vj/components/problemconfig/ProblemConfigForm'), + import('vj/components/problemconfig/reducer'), + ]); + + const { Provider, store } = await loadReactRedux(ProblemConfigReducer); + + reduxStore = store; + + store.dispatch({ + type: 'CONFIG_LOAD', + payload: request.get(), + }); + const unsubscribe = store.subscribe(() => { + // TODO set yaml schema + const state = store.getState(); + if (!state.config.__loaded) return; + if (state.config.cases) { + const score = state.config.score * state.config.cases.length; + state.config.subtasks = [{ type: 'sum' as SubtaskType, score: score && score < 100 ? score : 100, cases: state.config.cases }]; + delete state.config.cases; + delete state.config.score; + } + if (state.config.subtasks) return; + const testdata = (state.testdata || []).map((i) => i.name); + unsubscribe(); + const subtasks = readSubtasksFromFiles(testdata, state.config); + store.dispatch({ + type: 'CONFIG_AUTOCASES_UPDATE', + subtasks: normalizeSubtasks(subtasks, (i) => i, state.config.time, state.config.memory, true), + }); + }); + createRoot($('#ProblemConfig').get(0)).render( + +
+
+ +
+
+ +
+
+ +
, + ); + } + + mountComponent(); + + $(document).on('click', '[name="testdata__upload"]', () => handleClickUpload()); + $(document).on('click', '[name="testdata__delete"]', (ev) => handleClickRemove(ev)); + $(document).on('click', '[name="testdata__download__all"]', () => handleClickDownloadAll()); +}); + +export default page; diff --git a/packages/ui-default/pages/problem_detail.page.jsx b/packages/ui-default/pages/problem_detail.page.jsx index 4d2890a8d..49fc75686 100644 --- a/packages/ui-default/pages/problem_detail.page.jsx +++ b/packages/ui-default/pages/problem_detail.page.jsx @@ -1,4 +1,6 @@ import yaml from 'js-yaml'; +import ReactDOM, { createRoot } from 'react-dom/client'; +import React from 'react'; import { getScoreColor } from '@hydrooj/utils/lib/status'; import { NamedPage } from 'vj/misc/Page'; import { downloadProblemSet } from 'vj/components/zipDownloader'; @@ -132,9 +134,7 @@ const page = new NamedPage(['problem_detail', 'contest_detail_problem', 'homewor const { default: WebSocket } = await import('../components/socket'); const { default: ScratchpadApp } = await import('../components/scratchpad'); const { default: ScratchpadReducer } = await import('../components/scratchpad/reducers'); - const { - React, render, unmountComponentAtNode, Provider, store, - } = await loadReactRedux(ScratchpadReducer); + const { Provider, store } = await loadReactRedux(ScratchpadReducer); const sock = new WebSocket(UiContext.pretestConnUrl); sock.onmessage = (message) => { @@ -145,17 +145,15 @@ const page = new NamedPage(['problem_detail', 'contest_detail_problem', 'homewor }); }; + const root = createRoot($('#scratchpad').get(0)); renderReact = () => { - render( + root.render( , - $('#scratchpad').get(0), ); }; - unmountReact = () => { - unmountComponentAtNode($('#scratchpad').get(0)); - }; + unmountReact = () => root.unmount(); reactLoaded = true; $('.loader-container').hide(); } @@ -192,17 +190,12 @@ const page = new NamedPage(['problem_detail', 'contest_detail_problem', 'homewor const props = yaml.load(s); if (!(props instanceof Array)) return; $('.outer-loader-container').show(); - const [{ default: Objective }, React, ReactDOM] = await Promise.all([ - import('vj/components/objective-question/index'), - import('react'), - import('react-dom'), - ]); + const { default: Objective } = await import('vj/components/objective-question/index'); - ReactDOM.render( + ReactDOM.createRoot($('.problem-content').get(0)).render(
, - $('.problem-content').get(0), ); $('.outer-loader-container').hide(); } catch (e) { } diff --git a/packages/ui-default/pages/problem_edit.page.js b/packages/ui-default/pages/problem_edit.page.js index ec97f3928..759ccec00 100644 --- a/packages/ui-default/pages/problem_edit.page.js +++ b/packages/ui-default/pages/problem_edit.page.js @@ -3,10 +3,13 @@ import { NamedPage } from 'vj/misc/Page'; import request from 'vj/utils/request'; import tpl from 'vj/utils/tpl'; import i18n from 'vj/utils/i18n'; -import { ConfirmDialog } from 'vj/components/dialog'; +import Dialog, { ConfirmDialog } from 'vj/components/dialog'; import Dropdown from 'vj/components/dropdown/Dropdown'; import Editor from 'vj/components/editor/index'; import Notification from 'vj/components/notification'; +import { slideDown, slideUp } from 'vj/utils/slide'; +import download from 'vj/components/zipDownloader'; +import { size } from '@hydrooj/utils/lib/common'; const categories = {}; const dirtyCategories = []; @@ -18,6 +21,10 @@ function setDomSelected($dom, selected) { else $dom.removeClass('selected'); } +function onBeforeUnload(e) { + e.returnValue = ''; +} + async function updateSelection() { dirtyCategories.forEach(({ type, category, subcategory }) => { let item = categories[category]; @@ -143,6 +150,21 @@ function buildCategoryFilter() { }); } +async function handleSection(ev, sidebar, type) { + const $section = $(ev.currentTarget).closest(`.section--problem-sidebar-${sidebar}`); + if ($section.is(`.${type}d, .animating`)) return; + $section.addClass('animating'); + const $detail = $section.find(`.section--problem-sidebar-${sidebar}__detail`); + if (type === 'expand') { + await slideDown($detail, 300, { opacity: 0 }, { opacity: 1 }); + } else { + await slideUp($detail, 300, { opacity: 1 }, { opacity: 0 }); + } + $section.addClass(type === 'expand' ? 'expanded' : 'collapsed'); + $section.removeClass(type === 'expand' ? 'collapsed' : 'expanded'); + $section.removeClass('animating'); +} + export default new NamedPage(['problem_create', 'problem_edit'], (pagename) => { let confirmed = false; $(document).on('click', '[name="operation"]', (ev) => { @@ -170,6 +192,105 @@ export default new NamedPage(['problem_create', 'problem_edit'], (pagename) => { buildCategoryFilter(); parseCategorySelection(); + async function handleClickUpload() { + const input = document.createElement('input'); + input.type = 'file'; + input.multiple = true; + input.click(); + await new Promise((resolve) => { input.onchange = resolve; }); + const { files } = input; + const dialog = new Dialog({ + $body: ` +
+
+
+
+
+
+
+
`, + }); + try { + Notification.info(i18n('Uploading files...')); + window.addEventListener('beforeunload', onBeforeUnload); + dialog.open(); + const $uploadLabel = dialog.$dom.find('.dialog__body .upload-label'); + const $uploadProgress = dialog.$dom.find('.dialog__body .upload-progress'); + const $fileLabel = dialog.$dom.find('.dialog__body .file-label'); + const $fileProgress = dialog.$dom.find('.dialog__body .file-progress'); + for (const i in files) { + if (Number.isNaN(+i)) continue; + const file = files[i]; + const data = new FormData(); + data.append('filename', file.name); + data.append('file', file); + data.append('type', 'additional_file'); + data.append('operation', 'upload_file'); + await request.postFile('./files', data, { + xhr() { + const xhr = new XMLHttpRequest(); + xhr.upload.addEventListener('loadstart', () => { + $fileLabel.text(`[${+i + 1}/${files.length}] ${file.name}`); + $fileProgress.width(`${Math.round((+i + 1) / files.length * 100)}%`); + $uploadLabel.text(i18n('Uploading... ({0}%)', 0)); + $uploadProgress.width(0); + }); + xhr.upload.addEventListener('progress', (e) => { + if (e.lengthComputable) { + const percentComplete = Math.round((e.loaded / e.total) * 100); + if (percentComplete === 100) $uploadLabel.text(i18n('Processing...')); + else $uploadLabel.text(i18n('Uploading... ({0}%)', percentComplete)); + $uploadProgress.width(`${percentComplete}%`); + } + }, false); + return xhr; + }, + }); + $('.additionalfile-table tbody').append( + $(tpl` + ${file.name} + ${size(file.size)} + + `), + ); + } + window.removeEventListener('beforeunload', onBeforeUnload); + Notification.success(i18n('File uploaded successfully.')); + } catch (e) { + console.error(e); + Notification.error(i18n('File upload failed: {0}', e.toString())); + } finally { + dialog.close(); + } + } + + async function handleClickRemove(ev) { + const file = [$(ev.currentTarget).parent().parent().attr('data-filename')]; + const action = await new ConfirmDialog({ + $body: tpl.typoMsg(i18n('Confirm to delete the file?')), + }).open(); + if (action !== 'yes') return; + try { + await request.post('./files', { + operation: 'delete_files', + files: file, + type: 'testdata', + }); + Notification.success(i18n('File have been deleted.')); + $(ev.currentTarget).parent().parent().remove(); + } catch (error) { + Notification.error(error.message); + } + } + + async function handleClickDownloadAll() { + const files = $('.additionalfile-table tr').map(function () { return $(this).attr('data-filename'); }).get(); + const { links, pdoc } = await request.post('./files', { operation: 'get_links', files, type: 'additional_file' }); + const targets = []; + for (const filename of Object.keys(links)) targets.push({ filename, url: links[filename] }); + await download(`${pdoc.docId} ${pdoc.title}.zip`, targets); + } + setInterval(() => { $('img').each(function () { if (this.src.startsWith('file://')) { @@ -232,4 +353,11 @@ export default new NamedPage(['problem_create', 'problem_edit'], (pagename) => { ev.preventDefault(); } }); + $(document).on('click', '[name="additional_file__upload"]', () => handleClickUpload()); + $(document).on('click', '[name="additional_file__delete"]', (ev) => handleClickRemove(ev)); + $(document).on('click', '[name="additional_file__download"]', () => handleClickDownloadAll()); + $(document).on('click', '[name="additional_file__section__expand"]', (ev) => handleSection(ev, 'additional_file', 'expand')); + $(document).on('click', '[name="additional_file__section__collapse"]', (ev) => handleSection(ev, 'additional_file', 'collapse')); + $(document).on('click', '[name="tags__section__expand"]', (ev) => handleSection(ev, 'tags', 'expand')); + $(document).on('click', '[name="tags__section__collapse"]', (ev) => handleSection(ev, 'tags', 'collapse')); }); diff --git a/packages/ui-default/pages/problem_edit.page.styl b/packages/ui-default/pages/problem_edit.page.styl index ca626adbc..7662dc97f 100644 --- a/packages/ui-default/pages/problem_edit.page.styl +++ b/packages/ui-default/pages/problem_edit.page.styl @@ -1,4 +1,4 @@ -.page--problem_edit +.page--problem_edit,.page--problem_create .section__tab-header-item border-top: 3px solid transparent; transition: border-color 0.2s, background 0.2s, color 0.2s; @@ -10,4 +10,14 @@ background: #f8f8f8; &.tab--active - border-color: #ed5f82; \ No newline at end of file + border-color: #ed5f82; + + .section--problem-sidebar-additional_file + .problem-sidebar-additional_file__detail + height: 270px + overflow-y: auto + + .col--size + width: 80px + .col--operation + width: 40px \ No newline at end of file diff --git a/packages/ui-default/pages/problem_files.page.js b/packages/ui-default/pages/problem_files.page.js index 4692c389f..1f633665f 100644 --- a/packages/ui-default/pages/problem_files.page.js +++ b/packages/ui-default/pages/problem_files.page.js @@ -51,12 +51,12 @@ const page = new NamedPage('problem_files', () => { const dialog = new Dialog({ $body: `
-
-
+
+
-
-
+
+
`, }); try { @@ -100,6 +100,7 @@ const page = new NamedPage('problem_files', () => { Notification.success(i18n('File uploaded successfully.')); await pjax.request({ push: false }); } catch (e) { + window.captureException?.(e); console.error(e); Notification.error(i18n('File upload failed: {0}', e.toString())); } finally { @@ -238,6 +239,7 @@ const page = new NamedPage('problem_files', () => { const res = await request.get(link); content = await request.get(res.url, undefined, { dataType: 'text' }); } catch (e) { + window.captureException?.(e); Notification.error(i18n('Failed to load file: {0}', e.message)); throw e; } diff --git a/packages/ui-default/pages/problem_main.page.js b/packages/ui-default/pages/problem_main.page.js index 33f299941..0620ec4f2 100644 --- a/packages/ui-default/pages/problem_main.page.js +++ b/packages/ui-default/pages/problem_main.page.js @@ -7,7 +7,6 @@ import Dropdown from 'vj/components/dropdown/Dropdown'; import createHint from 'vj/components/hint'; import { downloadProblemSet } from 'vj/components/zipDownloader'; import pjax from 'vj/utils/pjax'; -import substitute from 'vj/utils/substitute'; import request from 'vj/utils/request'; import tpl from 'vj/utils/tpl'; import delay from 'vj/utils/delay'; @@ -52,19 +51,14 @@ async function updateSelection(sendRequest = true) { .map((s) => s.split(',')[0])); // drop the category if its subcategory is selected const requestTags = _.uniq(_.pullAll(selections, requestCategoryTags)); - let url; - if (requestTags.length === 0) { - url = UiContext.getProblemUrlWithoutCategory; - } else { - url = substitute(decodeURIComponent(UiContext.getProblemUrlWithCategory), { - category: requestTags - .map((tag) => tag.split(',').map(encodeURIComponent).join(',')) - .join(','), // build a beautiful URL - }); - } - const q = $('[name="q"]').val(); - if (q) url += `?q=${q}`; - pjax.request({ url }); + let q = $('[name="q"]').val().split(' ').filter((i) => !i.startsWith('category:')).join(' '); + if (requestTags.length) q = requestTags.map((i) => `category:${i}`).join(' ') + (q ? ` ${q}` : ''); + const url = new URL(window.location.href); + if (!q) url.searchParams.delete('q'); + else url.searchParams.set('q', q); + url.searchParams.delete('page'); + pjax.request({ url: url.toString() }); + $('[name="q"]').val(q); } } @@ -230,7 +224,7 @@ function processElement(ele) { createHint('Hint::icon::difficulty', $(ele).find('th.col--difficulty')); } -const page = new NamedPage(['problem_main', 'problem_category'], () => { +const page = new NamedPage(['problem_main'], () => { const doc = document.body; doc.className += ' display-mode'; $('.section.display-mode').removeClass('display-mode'); diff --git a/packages/ui-default/sharedworker.ts b/packages/ui-default/sharedworker.ts deleted file mode 100644 index 798ed7045..000000000 --- a/packages/ui-default/sharedworker.ts +++ /dev/null @@ -1,54 +0,0 @@ -import ReconnectingWebsocket from 'reconnecting-websocket'; - -console.log('SharedWorker init'); -const ports: MessagePort[] = []; -let conn: ReconnectingWebsocket; - -function initSharedConn(path: string, sid: string) { - if (conn) return; - console.log('Init connection for', path); - const url = new URL(path); - url.protocol = url.protocol.replace('http', 'ws'); - url.searchParams.append('sid', sid); - const sock = new ReconnectingWebsocket(url.toString()); - conn = sock; - sock.onopen = () => console.log('Connected'); - sock.onerror = console.error; - sock.onclose = (...args) => console.log('Closed', ...args); - sock.onmessage = async (message) => { - if (process.env.NODE_ENV !== 'production') console.log('SharedWorker.port.onmessage: ', message); - const payload = JSON.parse(message.data); - if (payload.event === 'auth') { - if (['PermissionError', 'PrivilegeError'].includes(payload.error)) { - conn?.close(); - conn = null; - } - return; - } - for (const port of ports) { - port.postMessage({ type: 'message', payload: message.data }); - const success = await new Promise((resolve) => { - const handle = (msg) => { - if (['success', 'fail'].includes(msg.data.type)) { - port.removeEventListener('message', handle); - resolve(msg.data.type === 'success'); - } - }; - port.addEventListener('message', handle); - }); - if (success) return; - } - console.log('Failed to push notification'); - // TODO try system notification - }; -} - -// @ts-ignore -onconnect = function (e) { // eslint-disable-line no-undef - const port: MessagePort = e.ports[0]; - port.addEventListener('message', (msg) => { - initSharedConn(msg.data.path, msg.data.cookie); - }); - port.start(); - ports.push(port); -}; diff --git a/packages/ui-default/templates/components/problem.html b/packages/ui-default/templates/components/problem.html index fda06daca..d81ca4366 100644 --- a/packages/ui-default/templates/components/problem.html +++ b/packages/ui-default/templates/components/problem.html @@ -21,7 +21,7 @@ {%- if pdoc.tag|length > 0 %} {%- if not inline %}
    {% endif -%} {%- for tag in pdoc['tag'] %} -
  • {{ tag }}
  • +
  • {{ tag }}
  • {%- endfor -%} {%- if not inline %}
{% endif -%} {%- else -%} diff --git a/packages/ui-default/templates/contest_detail.html b/packages/ui-default/templates/contest_detail.html index f22c726fa..64b521654 100644 --- a/packages/ui-default/templates/contest_detail.html +++ b/packages/ui-default/templates/contest_detail.html @@ -11,14 +11,14 @@

{{ tdoc.title }}

- {{ _(model.contest.statusText(tdoc)) }} - {{ _(model.contest.RULES[tdoc.rule].TEXT) }} - {{ _('Start at') }}: {{ contest.render_time(tdoc.beginAt) }} - {{ contest.render_duration(tdoc) }} {{ _('hour(s)') }} - {{ _('Host') }}: {{ user.render_inline(udict[tdoc.owner], badge=false) }} - {{ tdoc['attend']|default(0) }} + {{ _(model.contest.statusText(tdoc)) }} + {{ _(model.contest.RULES[tdoc.rule].TEXT) }} + {{ _('Start at') }}: {{ contest.render_time(tdoc.beginAt) }} + {{ contest.render_duration(tdoc) }} {{ _('hour(s)') }} + {{ _('Host') }}: {{ user.render_inline(udict[tdoc.owner], badge=false) }} + {{ tdoc['attend']|default(0) }} {% if tsdoc.attend %} - {{ _('Attended') }} + {{ _('Attended') }} {% endif %}
diff --git a/packages/ui-default/templates/domain_join_applications.html b/packages/ui-default/templates/domain_join_applications.html index ef504dba1..bbb4f1a03 100644 --- a/packages/ui-default/templates/domain_join_applications.html +++ b/packages/ui-default/templates/domain_join_applications.html @@ -7,10 +7,10 @@

{{ _('User can join this domain by visiting the following URL') }}:

-
{{ url_prefix|safe }}domain/join?domainId={{ handler.args.domainId }}
+
{{ url_prefix|safe }}d/{{ handler.args.domainId }}/domain/join
{% if joinSettings['method'] == model.domain.JOIN_METHOD_CODE %}

{{ _('Or, with automatically filled invitation code') }}:

-
{{ url_prefix|safe }}domain/join?domainId={{ handler.args.domainId }}&code={{ joinSettings.code|urlencode }}
+
{{ url_prefix|safe }}d/{{ handler.args.domainId }}/domain/join?code={{ joinSettings.code|urlencode }}
{% endif %} {% if joinSettings.expire %}
diff --git a/packages/ui-default/templates/partials/category.html b/packages/ui-default/templates/partials/category.html index d7ec78f0b..fd507f421 100644 --- a/packages/ui-default/templates/partials/category.html +++ b/packages/ui-default/templates/partials/category.html @@ -2,13 +2,13 @@ {%- for category, sub_categories in model.system.get('problem.categories')|parseYaml -%}
  • - {{ category }} + {{ category }}

    {% if sub_categories | length > 0 %}
      {%- for sub_category in sub_categories -%}
    1. - {{ sub_category }} + {{ sub_category }}
    2. {%- endfor -%}
    diff --git a/packages/ui-default/templates/partials/problem-sidebar-information.html b/packages/ui-default/templates/partials/problem-sidebar-information.html index 7700ade61..42c5d28a6 100644 --- a/packages/ui-default/templates/partials/problem-sidebar-information.html +++ b/packages/ui-default/templates/partials/problem-sidebar-information.html @@ -11,24 +11,6 @@
    {{ pdoc.config.memoryMax if pdoc.config.memoryMax == pdoc.config.memoryMin else pdoc.config.memoryMin+'~'+pdoc.config.memoryMax }}MiB
    {{ _('Difficulty') }}
    {{ pdoc['difficulty'] or lib.difficulty(pdoc.nSubmit, pdoc.nAccept) or _('(None)') }}
    -
    {{ _('Category') }}
    -
    - {% if pdoc['category'] %} - - {%- for category in pdoc['category'] -%} - {{ category }} - {% if loop.index < pdoc['category']|length %} - {{ _('problem-subcategory-delim') }} - {% endif %} - {%- endfor -%} - - - {{ _('page.problem_detail.sidebar.show_category') }} - - {% else %} - {{ _('(None)') }} - {% endif %} -
    {{ _('Tags') }}
    {{ problem.render_problem_tags(pdoc, true) }} diff --git a/packages/ui-default/templates/partials/problem_list.html b/packages/ui-default/templates/partials/problem_list.html index 5796bd017..ad6c49d33 100644 --- a/packages/ui-default/templates/partials/problem_list.html +++ b/packages/ui-default/templates/partials/problem_list.html @@ -6,7 +6,7 @@ {% if not pdocs.length %} {{ nothing.render('Sorry, there is no problem in the problem set') }} {% else %} - {{ paginator.render(page, ppcount, position='top', add_qs=qs) }} + {{ paginator.render(page, ppcount, position='top', add_qs=('q=' + qs if qs else '')) }} @@ -73,6 +73,6 @@ {%- endfor -%}
    - {{ paginator.render(page, ppcount, add_qs=qs) }} + {{ paginator.render(page, ppcount, add_qs=('q=' + qs if qs else '')) }} {% endif %}
  • diff --git a/packages/ui-default/templates/partials/problem_lucky.html b/packages/ui-default/templates/partials/problem_lucky.html index 19e5c466d..9413a2526 100644 --- a/packages/ui-default/templates/partials/problem_lucky.html +++ b/packages/ui-default/templates/partials/problem_lucky.html @@ -1,6 +1,6 @@

    - + {{ _('Lucky') }}

    diff --git a/packages/ui-default/templates/partials/problem_sidebar_additional_file.html b/packages/ui-default/templates/partials/problem_sidebar_additional_file.html new file mode 100644 index 000000000..4d4ef2413 --- /dev/null +++ b/packages/ui-default/templates/partials/problem_sidebar_additional_file.html @@ -0,0 +1,48 @@ +{% import "components/nothing.html" as nothing with context %} +{% import "components/problem.html" as problem with context %} +
    +
    +

    {{ additional_file.length|default(0) }} {{_('Additional File')}}

    +
    + +
    + {% if additional_file.length %} + + + + + + + + + + + + + + + {%- for file in additional_file -%} + + + + + + {%- endfor -%} + +
    {{ _('Filename') }}{{ _('Size') }}
    + {{ file.name }} + {{ size(file.size) }}
    + {% else %} + {{ nothing.render('No additional file at current.',compact = true) }} + {% endif %} +
    +
    \ No newline at end of file diff --git a/packages/ui-default/templates/partials/problem_sidebar_normal.html b/packages/ui-default/templates/partials/problem_sidebar_normal.html index 3aa8a43c1..89c960cb6 100644 --- a/packages/ui-default/templates/partials/problem_sidebar_normal.html +++ b/packages/ui-default/templates/partials/problem_sidebar_normal.html @@ -83,6 +83,9 @@ + {% endif %} {% if page_name == 'problem_detail' %} @@ -100,14 +103,14 @@
    -{% if page_name != 'problem_detail' and page_name != 'homework_detail_problem' and page_name != 'contest_detail_problem' %} +{% if page_name not in ['problem_config', 'problem_detail', 'homework_detail_problem', 'contest_detail_problem'] %}

    {{ _('Information') }}

    {% include "partials/problem-sidebar-information.html" %}
    -{% elif page_name != 'contest_detail_problem' %} +{% elif page_name != 'contest_detail_problem' and page_name != 'problem_config' %}

    {{ _('Statistics') }}

    diff --git a/packages/ui-default/templates/partials/problem_sidebar_testdata.html b/packages/ui-default/templates/partials/problem_sidebar_testdata.html new file mode 100644 index 000000000..5d87c94db --- /dev/null +++ b/packages/ui-default/templates/partials/problem_sidebar_testdata.html @@ -0,0 +1,47 @@ +{% import "components/nothing.html" as nothing with context %} +{% import "components/problem.html" as problem with context %} +
    +
    +

    {{ testdata.length if testdata.length else 0 }} {{_('Testdata')}}

    +
    + +
    + {% if testdata.length %} + + + + + + + + + + + + + + + {%- for file in testdata -%} + + + + + + {%- endfor -%} + +
    {{ _('Filename') }}{{ _('Size') }}
    {{ file.name }}{{ size(file.size) }}
    + {% else %} + {{ nothing.render('No testdata at current.', compact = true) }} + {% endif %} +
    +
    \ No newline at end of file diff --git a/packages/ui-default/templates/problem_config.html b/packages/ui-default/templates/problem_config.html new file mode 100644 index 000000000..a894fd765 --- /dev/null +++ b/packages/ui-default/templates/problem_config.html @@ -0,0 +1,21 @@ +{% extends "layout/basic.html" %} +{% block content %} +
    +
    +
    +
    +

    {{_('Judge Config')}}(Beta)

    +
    +
    + {{ noscript_note.render() }} +
    +
    +
    +
    +
    + {% include "partials/problem_sidebar_testdata.html" %} + {% set owner_udoc = udoc %} + {% include "partials/problem_sidebar.html" %} +
    +
    +{% endblock %} \ No newline at end of file diff --git a/packages/ui-default/templates/problem_detail.html b/packages/ui-default/templates/problem_detail.html index 69b0fdb26..5929c7849 100644 --- a/packages/ui-default/templates/problem_detail.html +++ b/packages/ui-default/templates/problem_detail.html @@ -49,22 +49,22 @@
      - {{ _('ID') }}: {{ pdoc.docId }} - {{ _('problem_type.' + pdoc.config.type).format(pdoc.config.subType) }} + {{ _('ID') }}: {{ pdoc.docId }} + {{ _('problem_type.' + pdoc.config.type).format(pdoc.config.subType) }} {% if pdoc.config.type != 'objective' and pdoc.config.type != 'submit_answer' %} - {{ pdoc.config.timeMax if pdoc.config.timeMax == pdoc.config.timeMin else pdoc.config.timeMin+'~'+pdoc.config.timeMax }}ms - {{ pdoc.config.memoryMax if pdoc.config.memoryMax == pdoc.config.memoryMin else pdoc.config.memoryMin+'~'+pdoc.config.memoryMax }}MiB + {{ pdoc.config.timeMax if pdoc.config.timeMax == pdoc.config.timeMin else pdoc.config.timeMin+'~'+pdoc.config.timeMax }}ms + {{ pdoc.config.memoryMax if pdoc.config.memoryMax == pdoc.config.memoryMin else pdoc.config.memoryMin+'~'+pdoc.config.memoryMax }}MiB {% endif %} {% if not tdoc %} - {{ _('Tried') }}: {{ pdoc.nSubmit }} - {{ _('Accepted') }}: {{ pdoc.nAccept if ((not tdoc) or showAccept) else '?' }} - {{ _('Difficulty') }}: {{ pdoc['difficulty'] or lib.difficulty(pdoc.nSubmit, pdoc.nAccept) or _('(None)') }} + {{ _('Tried') }}: {{ pdoc.nSubmit }} + {{ _('Accepted') }}: {{ pdoc.nAccept if ((not tdoc) or showAccept) else '?' }} + {{ _('Difficulty') }}: {{ pdoc['difficulty'] or lib.difficulty(pdoc.nSubmit, pdoc.nAccept) or _('(None)') }} {% endif %} - {{ _('Uploaded By') }}: {{ user.render_inline(udoc, badge=false) }} + {{ _('Uploaded By') }}: {{ user.render_inline(udoc, badge=false) }} {% if pdoc.tag.length %} - {{ _('Tags') + '>' }} + {{ _('Tags') + '>' }} {%- for tag in pdoc.tag -%} - {{ tag }} + {{ tag }} {%- endfor -%} {% endif %}
    diff --git a/packages/ui-default/templates/problem_edit.html b/packages/ui-default/templates/problem_edit.html index f1e2bd4fc..25bf26976 100644 --- a/packages/ui-default/templates/problem_edit.html +++ b/packages/ui-default/templates/problem_edit.html @@ -92,14 +92,15 @@
    -
    +

    {{ _('Categories') }} ({{ _('click to add') }})

    -
    +
    {% include "partials/category.html" %}
    + {% include "partials/problem_sidebar_additional_file.html" %} {% if page_name == 'problem_edit' %} {% set owner_udoc = udoc %} {% include "partials/problem_sidebar.html" %} diff --git a/packages/ui-default/templates/problem_files.html b/packages/ui-default/templates/problem_files.html index 927144131..6e531d559 100644 --- a/packages/ui-default/templates/problem_files.html +++ b/packages/ui-default/templates/problem_files.html @@ -7,8 +7,9 @@

    {{ _('Testdata') }}

    {% if not reference and (handler.user.own(pdoc, perm.PERM_EDIT_PROBLEM_SELF) or handler.user.hasPerm(perm.PERM_EDIT_PROBLEM)) %} - - + {{ _('Config') }} + + {% endif %}
    diff --git a/packages/ui-default/templates/problem_main.html b/packages/ui-default/templates/problem_main.html index 9621521ab..f80888050 100644 --- a/packages/ui-default/templates/problem_main.html +++ b/packages/ui-default/templates/problem_main.html @@ -3,8 +3,6 @@ {% extends "layout/basic.html" %} {% block content %} {{ set(UiContext, { - getProblemUrlWithCategory: url('problem_category', category='{category}'), - getProblemUrlWithoutCategory: url('problem_main'), currentCategory: category }) }}
    @@ -13,7 +11,7 @@