diff --git a/package.json b/package.json index d00626301..c42cf827a 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "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", @@ -64,5 +64,8 @@ "supertest": "^6.2.2", "typedoc": "^0.22.13", "typescript": "4.6.2" + }, + "resolutions": { + "esbuild": "0.14.3" } } diff --git a/packages/hydrojudge/src/cases.ts b/packages/hydrojudge/src/cases.ts index 9c71ed897..ca824114b 100644 --- a/packages/hydrojudge/src/cases.ts +++ b/packages/hydrojudge/src/cases.ts @@ -3,231 +3,75 @@ 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 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), checkFile, 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/judge/default.ts b/packages/hydrojudge/src/judge/default.ts index 14a589783..976cb5867 100644 --- a/packages/hydrojudge/src/judge/default.ts +++ b/packages/hydrojudge/src/judge/default.ts @@ -76,7 +76,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() }, }); diff --git a/packages/hydrojudge/src/judge/interactive.ts b/packages/hydrojudge/src/judge/interactive.ts index e99b33713..cc7d492a4 100644 --- a/packages/hydrojudge/src/judge/interactive.ts +++ b/packages/hydrojudge/src/judge/interactive.ts @@ -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; diff --git a/packages/hydrojudge/src/judge/interface.ts b/packages/hydrojudge/src/judge/interface.ts index b5f5f45d5..448ae7cef 100644 --- a/packages/hydrojudge/src/judge/interface.ts +++ b/packages/hydrojudge/src/judge/interface.ts @@ -105,4 +105,5 @@ export interface Case { id: number; input: string; output: string; + score: number; } diff --git a/packages/hydrojudge/src/judge/submit_answer.ts b/packages/hydrojudge/src/judge/submit_answer.ts index 67096be5c..d0623a795 100644 --- a/packages/hydrojudge/src/judge/submit_answer.ts +++ b/packages/hydrojudge/src/judge/submit_answer.ts @@ -74,7 +74,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() }, }); diff --git a/packages/hydrooj/package.json b/packages/hydrooj/package.json index 430c41b69..5fd810d26 100644 --- a/packages/hydrooj/package.json +++ b/packages/hydrooj/package.json @@ -1,6 +1,6 @@ { "name": "hydrooj", - "version": "3.12.3", + "version": "3.13.0", "bin": "bin/hydrooj.js", "main": "src/loader", "module": "src/loader", 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/problem.ts b/packages/hydrooj/src/handler/problem.ts index 36b747ba3..5c1a8638b 100644 --- a/packages/hydrooj/src/handler/problem.ts +++ b/packages/hydrooj/src/handler/problem.ts @@ -4,7 +4,7 @@ import { isBinaryFile } from 'isbinaryfile'; import { 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, @@ -525,6 +525,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 +551,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; @@ -831,6 +849,7 @@ export class ProblemCreateHandler extends Handler { this.response.template = 'problem_edit.html'; this.response.body = { page_name: 'problem_create', + additional_file: [], }; } @@ -892,6 +911,7 @@ export async function apply() { 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/interface.ts b/packages/hydrooj/src/interface.ts index f6ea50295..dc33df041 100644 --- a/packages/hydrooj/src/interface.ts +++ b/packages/hydrooj/src/interface.ts @@ -127,10 +127,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 +148,8 @@ export enum SubtaskType { } export interface SubtaskConfig { - time?: number; - memory?: number; + time?: string; + memory?: string; score?: number; if?: number[]; id?: number; 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/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/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/editor/index.ts b/packages/ui-default/components/editor/index.ts index 001da120b..7e328687e 100644 --- a/packages/ui-default/components/editor/index.ts +++ b/packages/ui-default/components/editor/index.ts @@ -81,7 +81,7 @@ export default class Editor 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/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..783524435 100644 --- a/packages/ui-default/components/monaco/languages/yaml.ts +++ b/packages/ui-default/components/monaco/languages/yaml.ts @@ -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' }, 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..78bb8106a --- /dev/null +++ b/packages/ui-default/components/problemconfig/ProblemConfigForm.tsx @@ -0,0 +1,118 @@ +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(); + 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..94339d245 --- /dev/null +++ b/packages/ui-default/components/problemconfig/SubtaskTable.tsx @@ -0,0 +1,176 @@ +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); + return ( + +
+ + + {len && [...Array(len).keys()].map((i) => )} + +
+
+ ); +} diff --git a/packages/ui-default/components/problemconfig/TestCasesTable.tsx b/packages/ui-default/components/problemconfig/TestCasesTable.tsx new file mode 100644 index 000000000..e587798cf --- /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 && [...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..5c1aa47f8 --- /dev/null +++ b/packages/ui-default/components/problemconfig/reducer/config.ts @@ -0,0 +1,68 @@ +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') subtasks.push({ 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/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 5a3abccdd..dbac78c0e 100644 --- a/packages/ui-default/package.json +++ b/packages/ui-default/package.json @@ -1,6 +1,6 @@ { "name": "@hydrooj/ui-default", - "version": "4.35.6", + "version": "4.36.0", "author": "undefined ", "license": "AGPL-3.0", "main": "hydro.js", @@ -10,7 +10,7 @@ "lint": "eslint ." }, "devDependencies": { - "@blueprintjs/core": "^3.54.0", + "@blueprintjs/core": "^4.2.1", "@fontsource/dm-mono": "^4.5.7", "@fontsource/fira-code": "^4.5.8", "@fontsource/inconsolata": "^4.5.4", @@ -37,6 +37,7 @@ "clipboard": "^2.0.10", "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", diff --git a/packages/ui-default/pages/home_files.page.js b/packages/ui-default/pages/home_files.page.js index 1aca098df..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 { 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..43d7eed60 --- /dev/null +++ b/packages/ui-default/pages/problem_config.page.tsx @@ -0,0 +1,205 @@ +import { NamedPage } from 'vj/misc/Page'; +import request from 'vj/utils/request'; +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 { + React, render, 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); + const checkFile = (file: string) => (testdata.includes(file) ? file : null); + unsubscribe(); + const subtasks = readSubtasksFromFiles(testdata, checkFile, state.config); + store.dispatch({ + type: 'CONFIG_AUTOCASES_UPDATE', + subtasks: normalizeSubtasks(subtasks, (i) => i, state.config.time, state.config.memory, true), + }); + }); + render( + +
+
+ +
+
+ +
+
+ +
, + $('#ProblemConfig').get(0), + ); + } + + 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_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 1b4e08311..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 { 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/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/theme/dark.styl b/packages/ui-default/theme/dark.styl index 93f58cdf1..f0f03f2b9 100644 --- a/packages/ui-default/theme/dark.styl +++ b/packages/ui-default/theme/dark.styl @@ -212,7 +212,7 @@ $hover-background-color = #424242 .menu__link.highlight background: rgb(40, 11, 20) - .bp3-tag.bp3-minimal:not([class*="bp3-intent-"]) + .bp4-tag.bp4-minimal:not([class*="bp4-intent-"]) background-color: rgba(87, 100, 117, .2) color: #d9dfe7 diff --git a/packages/ui-default/utils/i18n.js b/packages/ui-default/utils/i18n.js index bc5b6464d..7cb5aab10 100644 --- a/packages/ui-default/utils/i18n.js +++ b/packages/ui-default/utils/i18n.js @@ -1,6 +1,7 @@ import substitute from './substitute'; export default function i18n(str, ...params) { + if (!str) return ''; return substitute(window.LOCALES?.[str] || str, params); } diff --git a/packages/utils/lib/cases.ts b/packages/utils/lib/cases.ts index a9e841bda..faa5a0bea 100644 --- a/packages/utils/lib/cases.ts +++ b/packages/utils/lib/cases.ts @@ -3,13 +3,10 @@ import { findFileSync, parseMemoryMB, parseTimeMS } from './utils'; // eslint-disable-next-line @typescript-eslint/no-unused-vars export default async function readYamlCases(cfg: Record = {}, checkFile = (s: string, errMsg: string) => s) { const config: any = { - checker_type: 'default', - count: 0, - subtasks: [], + checker_type: cfg.checker_type || 'default', judge_extra_files: [], user_extra_files: [], }; - config.checker_type = cfg.checker_type || 'default'; if (cfg.checker) { if (!cfg.checker.includes('.')) { config.checker = findFileSync(`@hydrooj/hydrojudge/vendor/testlib/checkers/${cfg.checker}.cpp`, false); @@ -17,62 +14,19 @@ export default async function readYamlCases(cfg: Record = {}, check if (!config.checker) config.checker = checkFile(cfg.checker, 'Cannot find checker {0}.'); } if (cfg.interactor) config.interactor = checkFile(cfg.interactor, 'Cannot find interactor {0}.'); - if (cfg.judge_extra_files) { - if (typeof cfg.judge_extra_files === 'string') { - config.judge_extra_files = [checkFile(cfg.judge_extra_files, 'Cannot find judge extra file {0}.')]; - } else if (cfg.judge_extra_files instanceof Array) { - for (const file of cfg.judge_extra_files) { - config.judge_extra_files.push(checkFile(file, 'Cannot find judge extra file {0}.')); - } - } else throw new Error('Invalid judge_extra_files config.'); - } - if (cfg.user_extra_files) { - if (typeof cfg.user_extra_files === 'string') { - config.user_extra_files = [checkFile(cfg.user_extra_files, 'Cannot find user extra file {0}.')]; - } else if (cfg.user_extra_files instanceof Array) { - for (const file of cfg.user_extra_files) { - config.user_extra_files.push(checkFile(file, 'Cannot find user extra file {0}.')); - } - } else throw new Error('Invalid user_extra_files config.'); - } - if (cfg.outputs) { - config.type = cfg.type || 'objective'; - } else if (cfg.cases?.length) { + ['judge', 'user'].forEach((n) => { + const conf = cfg[`${n}_extra_files`]; + if (!conf) return; + if (conf instanceof Array) { + config[`${n}_extra_files`] = conf.map((file) => checkFile(file, `Cannot find ${n} extra file {0}.`)); + } else throw new Error(`Invalid ${n}_extra_files config.`); + }); + if (cfg.outputs) config.type = cfg.type || 'objective'; + if (cfg.cases?.length) { config.subtasks = [{ - score: +cfg.score || Math.floor(100 / cfg.cases.length), - time: parseTimeMS(cfg.time || '1s'), - memory: parseMemoryMB(cfg.memory || '256m'), - cases: [], + cases: cfg.cases, type: 'sum', }]; - for (const c of cfg.cases) { - config.count++; - config.subtasks[0].cases.push({ - input: c.input ? checkFile(c.input, 'Cannot find input file {0}.') : '/dev/null', - output: c.output ? checkFile(c.output, 'Cannot find output file {0}.') : '/dev/null', - id: config.count, - }); - } - } else if (cfg.subtasks?.length) { - for (const subtask of cfg.subtasks) { - const cases = []; - for (const c of subtask.cases || []) { - config.count++; - cases.push({ - input: c.input ? checkFile(c.input, 'Cannot find input file {0}.') : '/dev/null', - output: c.output ? checkFile(c.output, 'Cannot find output file {0}.') : '/dev/null', - id: config.count, - }); - } - config.subtasks.push({ - score: Number.isSafeInteger(+subtask.score) ? +subtask.score : 100, - if: subtask.if || [], - cases, - type: subtask.type || 'min', - time: parseTimeMS(subtask.time || cfg.time || '1s'), - memory: parseMemoryMB(subtask.memory || cfg.memory || '256m'), - }); - } } if (cfg.time) config.time = parseTimeMS(cfg.time); if (cfg.memory) config.memory = parseMemoryMB(cfg.memory); diff --git a/packages/utils/lib/common.ts b/packages/utils/lib/common.ts new file mode 100644 index 000000000..d8c7437f5 --- /dev/null +++ b/packages/utils/lib/common.ts @@ -0,0 +1,309 @@ +import { inspect } from 'util'; + +declare global { + interface StringConstructor { + random: (digit?: number) => string; + } + interface String { + format: (...args: Array) => string; + formatFromArray: (args: any[]) => string; + rawformat: (object: any) => string; + } + interface ArrayConstructor { + isDiff: (a: any[], b: any[]) => boolean; + } + interface Date { + format: (fmt?: string) => string; + } + interface Math { + sum: (...args: Array) => number; + } + interface SetConstructor { + isSuperset: (set: Set, subset: Set) => boolean; + intersection: (setA: Set | Array, setB: Set | Array) => Set; + union: (setA: Set | Array, setB: Set | Array) => Set; + } +} + +const defaultDict = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890'; + +String.random = function random(digit = 32, dict = defaultDict) { + let str = ''; + for (let i = 1; i <= digit; i++) str += dict[Math.floor(Math.random() * dict.length)]; + return str; +}; + +String.prototype.format = function formatStr(...args) { + let result = this; + if (args.length) { + if (args.length === 1 && typeof args[0] === 'object') { + const t = args[0]; + for (const key in t) { + if (!key.startsWith('_') && t[key] !== undefined) { + if (t._inspect && typeof t[key] === 'object') { + t[key] = inspect(t[key], { colors: process?.stderr?.isTTY }); + } + const reg = new RegExp(`(\\{${key}\\})`, 'g'); + result = result.replace(reg, t[key]); + } + } + } else return this.formatFromArray(args); + } + return result; +}; + +String.prototype.formatFromArray = function formatStr(args) { + let result = this; + for (let i = 0; i < args.length; i++) { + if (args[i] !== undefined) { + const reg = new RegExp(`(\\{)${i}(\\})`, 'g'); + result = result.replace(reg, args[i]); + } + } + return result; +}; + +String.prototype.rawformat = function rawFormat(object) { + const res = this.split('{@}'); + return [res[0], object, res[1]].join(); +}; + +Array.isDiff = function isDiff(a, b) { + if (a.length !== b.length) return true; + a.sort(); + b.sort(); + for (const i in a) { + if (a[i] !== b[i]) return true; + } + return false; +}; + +Date.prototype.format = function formatDate(fmt = '%Y-%m-%d %H:%M:%S') { + let m = this.getMonth() + 1; + if (m < 10) m = `0${m}`; + let d = this.getDate(); + if (d < 10) d = `0${d}`; + let H = this.getHours(); + if (H < 10) H = `0${H}`; + let M = this.getMinutes(); + if (M < 10) M = `0${M}`; + let S = this.getSeconds(); + if (S < 10) S = `0${S}`; + return fmt + .replace('%Y', this.getFullYear()) + .replace('%m', m) + .replace('%d', d) + .replace('%H', H) + .replace('%M', M) + .replace('%S', S); +}; + +Math.sum = function sum(...args) { + let s = 0; + for (const i of args) { + if (i instanceof Array) { + for (const j of i) { + s += j; + } + } else s += i; + } + return s; +}; + +Set.isSuperset = function isSuperset(set, subset) { + for (const elem of subset) { + if (!set.has(elem)) return false; + } + return true; +}; + +Set.union = function Union(setA: Set | Array, setB: Set | Array) { + const union = new Set(setA); + for (const elem of setB) union.add(elem); + return union; +}; + +Set.intersection = function Intersection(A: Set | Array = [], B: Set | Array = []) { + const intersection = new Set(); + if (A instanceof Array) A = new Set(A); + if (B instanceof Array) B = new Set(B); + for (const elem of B) if (A.has(elem)) intersection.add(elem); + return intersection; +}; + +const TIME_RE = /^([0-9]+(?:\.[0-9]*)?)([mu]?)s?$/i; +const TIME_UNITS = { '': 1000, m: 1, u: 0.001 }; +const MEMORY_RE = /^([0-9]+(?:\.[0-9]*)?)([kmg])b?$/i; +const MEMORY_UNITS = { k: 1 / 1024, m: 1, g: 1024 }; + +export function parseTimeMS(str: string | number, throwOnError = true) { + if (typeof str === 'number' || Number.isSafeInteger(+str)) return +str; + const match = TIME_RE.exec(str); + if (!match && throwOnError) throw new Error(`${str} error parsing time`); + if (!match) return 1000; + return Math.floor(parseFloat(match[1]) * TIME_UNITS[match[2].toLowerCase()]); +} + +export function parseMemoryMB(str: string | number, throwOnError = true) { + if (typeof str === 'number' || Number.isSafeInteger(+str)) return +str; + const match = MEMORY_RE.exec(str); + if (!match && throwOnError) throw new Error(`${str} error parsing memory`); + if (!match) return 256; + return Math.ceil(parseFloat(match[1]) * MEMORY_UNITS[match[2].toLowerCase()]); +} + +export function size(s: number, base = 1) { + s *= base; + const unit = 1024; + const unitNames = ['Bytes', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']; + for (const unitName of unitNames) { + if (s < unit) return '{0} {1}'.format(Math.round(s * 10) / 10, unitName); + s /= unit; + } + return `${Math.round(s * unit)} ${unitNames[unitNames.length - 1]}`; +} + +interface MatchRule { + regex: RegExp; + output: ((a: RegExpExecArray) => string)[]; + id: (a: RegExpExecArray) => number; + subtask: (a: RegExpExecArray) => number; + preferredScorerType: 'min' | 'max' | 'sum'; +} + +const SubtaskMatcher: MatchRule[] = [ + { + regex: /^([^\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], + subtask: () => 0, + preferredScorerType: 'sum', + }, + { + regex: /^([^\d]*)\.in(\d+)$/, + output: [ + (a) => `${a[1]}.ou${a[2]}`, + (a) => `${a[1]}.ou${a[2]}`.replace(/input/g, 'output'), + ], + id: (a) => +a[2], + subtask: () => 0, + preferredScorerType: 'sum', + }, + { + regex: /^([^\d]*)([0-9]+)([-_])([0-9]+).in$/, + output: [(a) => `${a[1] + a[2]}${a[3]}${a[4]}.out`], + id: (a) => +a[4], + subtask: (a) => +a[2], + preferredScorerType: 'min', + }, +]; + +function* getScore(totalScore: number, count: number) { + const base = Math.floor(totalScore / count); + const extra = count - (100 % count); + for (let i = 0; i < count; i++) { + if (i >= extra) yield base + 1; + else yield base; + } +} + +interface ParsedCase { + id?: number; + time?: number | string; + memory?: number | string; + score?: number; + input?: string; + output?: string; +} +interface ParsedSubtask { + cases: ParsedCase[]; + type: 'min' | 'max' | 'sum'; + time?: number | string; + memory?: number | string; + score?: number; + id?: number; + if?: number[]; +} + +export function readSubtasksFromFiles(files: string[], checkFile, config) { + const subtask: Record = {}; + for (const s of config.subtasks || []) if (s.id) subtask[s.id] = s; + for (const file of files) { + for (const rule of SubtaskMatcher) { + const data = rule.regex.exec(file); + if (!data) continue; + const sid = rule.subtask(data); + const c = { input: file, output: '', id: rule.id(data) }; + for (const func of rule.output) { + if (config.noOutputFile) c.output = '/dev/null'; + else c.output = func(data); + if (c.output === '/dev/null' || checkFile(c.output)) { + if (!subtask[sid]) { + subtask[sid] = { + time: config.time, + memory: config.memory, + type: rule.preferredScorerType, + cases: [c], + }; + } else if (!subtask[sid].cases) subtask[sid].cases = [c]; + else subtask[sid].cases.push(c); + break; + } + } + } + } + return Object.values(subtask); +} + +type NormalizedCase = Required; +interface NormalizedSubtask extends Required { + cases: NormalizedCase[]; +} + +export function normalizeSubtasks( + subtasks: ParsedSubtask[], checkFile: (name: string, errMsg: string) => string, + time: number | string, memory: number | string, ignoreParseError = false, +): NormalizedSubtask[] { + subtasks.sort((a, b) => (a.id - b.id)); + const subtaskScore = getScore( + Math.max(100 - Math.sum(subtasks.map((i) => i.score || 0)), 0), + subtasks.filter((i) => !i.score).length, + ); + let id = 0; + let count = 0; + return subtasks.map((s) => { + id++; + s.cases.sort((a, b) => (a.id - b.id)); + const score = subtaskScore.next().value as number; + const caseScore = getScore( + Math.max(score - Math.sum(s.cases.map((i) => i.score || 0)), 0), + s.cases.filter((i) => !i.score).length, + ); + return { + id, + score, + type: 'min', + if: [], + ...s, + time: parseTimeMS(s.time || time, !ignoreParseError), + memory: parseMemoryMB(s.memory || memory, !ignoreParseError), + cases: s.cases.map((c) => { + count++; + return { + id: count, + score: s.type === 'sum' ? caseScore.next().value as number : score, + ...c, + time: parseTimeMS(c.time || s.time || time, !ignoreParseError), + memory: parseMemoryMB(c.memory || s.memory || memory, !ignoreParseError), + input: c.input ? checkFile(c.input, 'Cannot find input file {0}.') : '/dev/null', + output: c.output ? checkFile(c.output, 'Cannot find output file {0}.') : '/dev/null', + }; + }) as NormalizedCase[], + }; + }); +} diff --git a/packages/utils/lib/utils.ts b/packages/utils/lib/utils.ts index bec176ca4..696bfa502 100644 --- a/packages/utils/lib/utils.ts +++ b/packages/utils/lib/utils.ts @@ -1,143 +1,11 @@ import os from 'os'; import path from 'path'; import { Duplex } from 'stream'; -import { inspect } from 'util'; import fs from 'fs-extra'; import type { Moment } from 'moment-timezone'; import { isMoment } from 'moment-timezone'; import { ObjectID } from 'mongodb'; -declare global { - interface StringConstructor { - random: (digit?: number) => string; - } - interface String { - format: (...args: Array) => string; - formatFromArray: (args: any[]) => string; - rawformat: (object: any) => string; - } - interface ArrayConstructor { - isDiff: (a: any[], b: any[]) => boolean; - } - interface Date { - format: (fmt?: string) => string; - } - interface Math { - sum: (...args: Array) => number; - } - interface SetConstructor { - isSuperset: (set: Set, subset: Set) => boolean; - intersection: (setA: Set | Array, setB: Set | Array) => Set; - union: (setA: Set | Array, setB: Set | Array) => Set; - } -} - -const defaultDict = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890'; - -String.random = function random(digit = 32, dict = defaultDict) { - let str = ''; - for (let i = 1; i <= digit; i++) str += dict[Math.floor(Math.random() * dict.length)]; - return str; -}; - -String.prototype.format = function formatStr(...args) { - let result = this; - if (args.length) { - if (args.length === 1 && typeof args[0] === 'object') { - const t = args[0]; - for (const key in t) { - if (!key.startsWith('_') && t[key] !== undefined) { - if (t._inspect && typeof t[key] === 'object') { - t[key] = inspect(t[key], { colors: process?.stderr?.isTTY }); - } - const reg = new RegExp(`(\\{${key}\\})`, 'g'); - result = result.replace(reg, t[key]); - } - } - } else return this.formatFromArray(args); - } - return result; -}; - -String.prototype.formatFromArray = function formatStr(args) { - let result = this; - for (let i = 0; i < args.length; i++) { - if (args[i] !== undefined) { - const reg = new RegExp(`(\\{)${i}(\\})`, 'g'); - result = result.replace(reg, args[i]); - } - } - return result; -}; - -String.prototype.rawformat = function rawFormat(object) { - const res = this.split('{@}'); - return [res[0], object, res[1]].join(); -}; - -Array.isDiff = function isDiff(a, b) { - if (a.length !== b.length) return true; - a.sort(); - b.sort(); - for (const i in a) { - if (a[i] !== b[i]) return true; - } - return false; -}; - -Date.prototype.format = function formatDate(fmt = '%Y-%m-%d %H:%M:%S') { - let m = this.getMonth() + 1; - if (m < 10) m = `0${m}`; - let d = this.getDate(); - if (d < 10) d = `0${d}`; - let H = this.getHours(); - if (H < 10) H = `0${H}`; - let M = this.getMinutes(); - if (M < 10) M = `0${M}`; - let S = this.getSeconds(); - if (S < 10) S = `0${S}`; - return fmt - .replace('%Y', this.getFullYear()) - .replace('%m', m) - .replace('%d', d) - .replace('%H', H) - .replace('%M', M) - .replace('%S', S); -}; - -Math.sum = function sum(...args) { - let s = 0; - for (const i of args) { - if (i instanceof Array) { - for (const j of i) { - s += j; - } - } else s += i; - } - return s; -}; - -Set.isSuperset = function isSuperset(set, subset) { - for (const elem of subset) { - if (!set.has(elem)) return false; - } - return true; -}; - -Set.union = function Union(setA: Set | Array, setB: Set | Array) { - const union = new Set(setA); - for (const elem of setB) union.add(elem); - return union; -}; - -Set.intersection = function Intersection(A: Set | Array = [], B: Set | Array = []) { - const intersection = new Set(); - if (A instanceof Array) A = new Set(A); - if (B instanceof Array) B = new Set(B); - for (const elem of B) if (A.has(elem)) intersection.add(elem); - return intersection; -}; - export function folderSize(folderPath: string) { // eslint-disable-next-line @typescript-eslint/no-shadow let size = 0; @@ -161,25 +29,6 @@ export function folderSize(folderPath: string) { return size; } -const TIME_RE = /^([0-9]+(?:\.[0-9]*)?)([mu]?)s?$/i; -const TIME_UNITS = { '': 1000, m: 1, u: 0.001 }; -const MEMORY_RE = /^([0-9]+(?:\.[0-9]*)?)([kmg])b?$/i; -const MEMORY_UNITS = { k: 1 / 1024, m: 1, g: 1024 }; - -export function parseTimeMS(str: string | number) { - if (typeof str === 'number') return str; - const match = TIME_RE.exec(str); - if (!match) throw new Error(`${str} error parsing time`); - return Math.floor(parseFloat(match[1]) * TIME_UNITS[match[2].toLowerCase()]); -} - -export function parseMemoryMB(str: string | number) { - if (typeof str === 'number') return str; - const match = MEMORY_RE.exec(str); - if (!match) throw new Error(`${str} error parsing memory`); - return Math.ceil(parseFloat(match[1]) * MEMORY_UNITS[match[2].toLowerCase()]); -} - export function isClass(obj: any, strict = false) { if (typeof obj !== 'function') return false; const str = obj.toString(); @@ -290,17 +139,6 @@ export function errorMessage(err: Error | string) { return err; } -export function size(s: number, base = 1) { - s *= base; - const unit = 1024; - const unitNames = ['Bytes', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']; - for (const unitName of unitNames) { - if (s < unit) return '{0} {1}'.format(Math.round(s * 10) / 10, unitName); - s /= unit; - } - return `${Math.round(s * unit)} ${unitNames[unitNames.length - 1]}`; -} - function _digit2(number: number) { return number < 10 ? `0${number}` : number.toString(); } @@ -449,3 +287,5 @@ export function Counter() { }, }) as Record; } + +export * from '@hydrooj/utils/lib/common'; diff --git a/packages/utils/package.json b/packages/utils/package.json index e9e782ba2..0c5b58539 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -1,6 +1,6 @@ { "name": "@hydrooj/utils", - "version": "1.1.17", + "version": "1.2.0", "description": "hydrooj utils", "main": "package.json", "repository": "https://github.com/hydro-dev/Hydro.git",