diff --git a/packages/hydrojudge/src/cases.ts b/packages/hydrojudge/src/cases.ts index 9c71ed897..cc0fd0bb7 100644 --- a/packages/hydrojudge/src/cases.ts +++ b/packages/hydrojudge/src/cases.ts @@ -2,158 +2,11 @@ import path from 'path'; import fs from 'fs-extra'; import yaml from 'js-yaml'; import { max, sum } from 'lodash'; -import readYamlCases, { convertIniConfig } from '@hydrooj/utils/lib/cases'; +import readYamlCases, { convertIniConfig, readCasesFromFiles, readSubtasksFromFiles } from '@hydrooj/utils/lib/cases'; 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; -} +import { ensureFile, parseMemoryMB } from './utils'; async function readAutoCases(folder: string, { next }, cfg, rst) { const config = { @@ -174,8 +27,8 @@ async function readAutoCases(folder: string, { next }, cfg, rst) { 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); + let result = await readCasesFromFiles(files, checkFile, cfg); + if (!result.count) result = await readSubtasksFromFiles(files, checkFile, cfg, rst); Object.assign(config, result); if (cfg.isSelfSubmission) next({ message: { message: 'Found {0} testcases.', params: [config.count] } }); } catch (e) { diff --git a/packages/hydrooj/src/handler/problem.ts b/packages/hydrooj/src/handler/problem.ts index 36b747ba3..a37cd78bb 100644 --- a/packages/hydrooj/src/handler/problem.ts +++ b/packages/hydrooj/src/handler/problem.ts @@ -4,7 +4,8 @@ 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 { readCasesFromFiles, readSubtasksFromFiles } from '@hydrooj/utils/lib/cases'; +import { sortFiles, streamToBuffer } from '@hydrooj/utils/lib/utils'; import { BadRequestError, ContestNotAttendedError, ContestNotEndedError, ContestNotFoundError, ContestNotLiveError, @@ -38,6 +39,9 @@ import { registerResolver, registerValue } from './api'; export const parseCategory = (value: string) => value.replace(/,/g, ',').split(',').map((e) => e.trim()); export const parsePid = (value: string) => (isSafeInteger(value) ? +value : value); +function ensureFile(testdata) { + return (file: string) => testdata.filter((i) => i === file)[0]; +} registerValue('FileInfo', [ ['_id', 'String!'], @@ -525,6 +529,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 +555,30 @@ 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 */ } + } + const testdata = (this.pdoc.data || []).map((i) => i.name); + const checkFile = ensureFile(testdata); + let autocases = await readCasesFromFiles(testdata, checkFile, {}); + if (!autocases.count) { + autocases = await readSubtasksFromFiles(testdata, checkFile, {}, { subtasks: [] }); + } + this.response.body.autocases = autocases; + this.response.template = 'problem_config.html'; + } +} + export class ProblemFilesHandler extends ProblemDetailHandler { notUsage = true; @@ -831,6 +860,7 @@ export class ProblemCreateHandler extends Handler { this.response.template = 'problem_edit.html'; this.response.body = { page_name: 'problem_create', + additional_file: [], }; } @@ -892,6 +922,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/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/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/problemconfig/ProblemConfigEditor.tsx b/packages/ui-default/components/problemconfig/ProblemConfigEditor.tsx new file mode 100644 index 000000000..0028f8c1b --- /dev/null +++ b/packages/ui-default/components/problemconfig/ProblemConfigEditor.tsx @@ -0,0 +1,89 @@ +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 yaml from 'js-yaml'; + +const mapStateToProps = (state) => ({ + config: state.config, +}); +const mapDispatchToProps = (dispatch) => ({ + handleUpdateCode: (code) => { + dispatch({ + type: 'CONFIG_CODE_UPDATE', + payload: code, + }); + }, +}); + +interface Props { + config: object; + handleUpdateCode: Function; +} + +export default connect(mapStateToProps, mapDispatchToProps)(class MonacoEditor extends React.PureComponent { + disposable = []; + containerElement: HTMLElement; + private __preventUpdate = 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(this.props.config), 'yaml', uri); + this.vjEditor = Editor.getOrConstruct($(this.containerElement), { + language: 'yaml', + model: this.model, + onChange: (value: string) => { + this.__preventUpdate = true; + this.props.handleUpdateCode(value); + this.__preventUpdate = false; + }, + }) as Editor; + this.editor = this.vjEditor.editor; + } + + componentDidUpdate(prevProps) { + if (this.__preventUpdate) return; + if (yaml.dump(prevProps.config) !== yaml.dump(this.props.config)) { + this.model?.pushEditOperations( + [], + [{ + range: this.model.getFullModelRange(), + text: yaml.dump(this.props.config), + }], + undefined, + ); + } + } + + 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..d9e0402f3 --- /dev/null +++ b/packages/ui-default/components/problemconfig/ProblemConfigForm.tsx @@ -0,0 +1,455 @@ +import React, { useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import i18n from 'vj/utils/i18n'; +import { isEqual } from 'lodash'; +import { Switch, Tab, Tabs } from '@blueprintjs/core'; +import type { TestCaseConfig } from 'hydrooj/src/interface'; +import type { RootState } from './reducer/index'; +import CustomSelectAutoComplete from '../autocomplete/components/CustomSelectAutoComplete'; + +const SelectValue = { + type: ['default', 'interactive', 'submit_answer', 'objective'], + checker_type: ['default', 'lemon', 'syzoj', 'hustoj', 'testlib', 'qduoj'], + task_type: ['min', 'max', 'sum'], +}; + +const eq = (a: TestCaseConfig[], b: TestCaseConfig[]) => isEqual(a, b); + +function FormItem({ + columns, label, children, helpText = '', disableLabel = false, ...props +}) { + return ( +
+ +
+ ); +} + +type KeyType = { + [Q in keyof K]: K[Q] extends T ? Q : never; +}[keyof K]; + +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" + /> + ); +} + +function ManagedSelect({ placeholder, formKey }: { placeholder: string, formKey: KeyType }) { + const value = useSelector((state: RootState) => state.config[formKey]); + const dispatch = useDispatch(); + return ( + + ); +} + +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 })} + /> + ); +} + +function BasicInfo() { + const Type = useSelector((state: RootState) => state.config.type); + const checkerType = useSelector((state: RootState) => state.config.checker_type); + const dispatch = useDispatch(); + return ( + <> + + + + + + + + { + dispatch({ type: 'CONFIG_FORM_UPDATE', key: 'checker_type', value }); + }} + renderActiveTabPanelOnly + > + {i18n('CheckerType')} + + { + SelectValue.checker_type.map((i) => ( + + {['default', 'strict'].includes(i) + ? ( + { + dispatch({ type: 'CONFIG_FORM_UPDATE', key: 'checker_type', value: checkerType === 'strict' ? 'default' : 'strict' }); + } + } + /> + ) + : ( + + )} + + )} + /> + )) + } + + + + + + + ); +} + +function ExtraFilesConfig() { + const [showTab, setshowTab] = useState(false); + 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 ( + + setshowTab(!showTab)} /> +
+ + 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 CasesTable({ index }) { + const [time, setTime] = useState(''); + const [memory, setMemory] = useState(''); + const [input, setInput] = useState(''); + const [output, setOutput] = useState(''); + const cases = useSelector((state: RootState) => (index === -1 ? state.config.cases : state.config.subtasks[index].cases), eq); + console.log(cases); + const Files = useSelector((state: RootState) => state.testdata); + const dispatch = useDispatch(); + return ( + + + + { + index === -1 && ( + <> + + + + ) + } + + + + + + + {cases && cases.map((k, v) => ( + + { + index === -1 && ( + <> + + + + ) + } + + + + + ))} + +
+ {i18n('Time')}
+ setTime(ev.currentTarget.value)} + className="textbox" + /> +
+ {i18n('Memory')}
+ setMemory(ev.currentTarget.value)} + className="textbox" + /> +
+ {i18n('Input')}
+ +
+ {i18n('Output')}
+ +
+
+ { + setTime(''); + setMemory(''); + setInput(''); + setOutput(''); + dispatch({ + type: index === -1 ? 'CONFIG_CASES_UPDATE' : 'CONFIG_SUBTASK_UPDATE', + id: index, + key: 'cases-add', + value: index === -1 ? { + time, memory, input, output, + } : { input, output }, + }); + }} + >{i18n('Add')} + +
{k.time}{k.memory}{k.input}{k.output} + dispatch({ + type: index === -1 ? 'CONFIG_CASES_UPDATE' : 'CONFIG_SUBTASK_UPDATE', id: index, key: 'cases-delete', value: v, + })} + > + +
+ ); +} + +function SubtasksTable({ data, index }) { + const subtasks = useSelector((state: RootState) => state.config.subtasks, eq); + const dispatch = useDispatch(); + const dispatcher = (key: string): React.ChangeEventHandler => (ev) => { + dispatch({ + type: 'CONFIG_SUBTASK_UPDATE', id: index, key, value: ev.currentTarget.value, + }); + }; + return ( +
+ Subtasks #{index} + { dispatch({ type: 'CONFIG_SUBTASK_UPDATE', id: index, key: 'add' }); }}> + { dispatch({ type: 'CONFIG_SUBTASK_UPDATE', id: index, key: 'delete' }); }} + > + + + + + + + + + + + + + + + + + + + + + + + + +
#ScoreTypeTimeMemory
+ + + + + + + + + +
if + dispatch({ + type: 'CONFIG_SUBTASK_UPDATE', id: index, key: 'if', value: val.split(','), + })} + multi + /> +
+ +
+ ); +} + +function TaskConfig() { + const subtasks = useSelector((state: RootState) => state.config.subtasks, eq); + const cases = useSelector((state: RootState) => state.config.cases, eq); + const dispatch = useDispatch(); + return ( + +
+ + + + + + + + + + + {cases && dispatch({ type: 'CONFIG_SUBTASKS_SWITCH', value: true })} />} + { + subtasks || cases + ? subtasks &&
    {subtasks.map((k, v) => )}
+ || cases && () + : ( + dispatch({ type: 'CONFIG_AUTOCASES_UPDATE' })} + > {i18n('Auto Read Tasks')} + + ) + } +
+
+
+ ); +} + +function LangConfig() { + const [showTab, setshowTab] = useState(false); + 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 ( + + setshowTab(!showTab)} /> + + { + 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() { + return ( +
+ + + + +
+ ); +} 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..a6eb70031 --- /dev/null +++ b/packages/ui-default/components/problemconfig/reducer/config.ts @@ -0,0 +1,73 @@ +import yaml from 'js-yaml'; +import type { ProblemConfigFile } from 'hydrooj/src/interface'; + +let autocases = { subtasks: [] }; + +export default function reducer(state = { type: 'default' } as ProblemConfigFile, action): ProblemConfigFile { + switch (action.type) { + case 'CONFIG_LOAD_FULFILLED': { + // TODO set yaml schema + autocases = action.payload.autocases; + return { ...state, ...yaml.load(action.payload.config) as object }; + } + case 'CONFIG_FORM_UPDATE': { + const next = { ...state, [action.key]: action.value }; + if (!action.value || (typeof action.value === 'object' && !action.value.join(''))) delete next[action.key]; + if (action.key === 'type' && action.value !== 'default') { + delete next.checker_type; + delete next.checker; + } + if (action.key === 'type' && action.value !== 'interactive') delete next.interactor; + if (action.key === 'checker_type' && ['default', 'strict'].includes(action.value)) delete next.checker; + return next; + } + case 'CONFIG_CODE_UPDATE': { + try { + return yaml.load(action.payload); + } catch { + return state; + } + } + case 'CONFIG_AUTOCASES_UPDATE': { + const next = { ...state }; + if (autocases.subtasks.length === 1) { + next.score = autocases.subtasks[0].score; + next.cases = []; + autocases.subtasks[0].cases.map((i) => next.cases.push({ + time: autocases.subtasks[0].time, memory: autocases.subtasks[0].memory, input: i.input, output: i.output, + })); + } else { + next.subtasks = autocases.subtasks.map((subtask) => ( + { ...subtask, ...{ cases: subtask.cases.map((i) => ({ input: i.input, output: i.output })) } })); + } + return next; + } + case 'CONFIG_SUBTASKS_SWITCH': { + const next = { ...state }; + next.subtasks = next.cases; + if (!next.subtasks.length) next.subtasks.push({ id: 0 }); + delete next.cases; + return next; + } + case 'CONFIG_CASES_UPDATE': { + const next = { ...state }; + if (action.key === 'cases-add') next.cases.push(action.value); + else if (action.key === 'cases-delete') { + next.cases = next.cases.filter((k, v) => v !== action.value); + } + return next; + } + case 'CONFIG_SUBTASK_UPDATE': { + const next = { ...state }; + if (action.key === 'add') next.subtasks.splice(action.id, 0, { id: 0 }); + else if (action.key === 'delete') delete next.subtasks[action.key]; + else if (action.key === 'cases-add') next.subtasks[action.id].cases.push(action.value); + else if (action.key === 'cases-delete') { + next.subtasks[action.id].cases = next.subtasks[action.id].cases.filter((k, v) => v !== action.value); + } else next.subtasks[action.id][action.key] = action.value; + return next; + } + 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..a4fe74ff3 --- /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': { + return state.filter((i) => i.name !== action.payload.name); + } + case 'CONFIG_ADD_TESTDATA': { + state.push(action.payload); + return state; + } + default: + return state; + } +} diff --git a/packages/ui-default/locales/zh.yaml b/packages/ui-default/locales/zh.yaml index 429064326..65ca01f98 100644 --- a/packages/ui-default/locales/zh.yaml +++ b/packages/ui-default/locales/zh.yaml @@ -268,6 +268,7 @@ domain: 域 Domain: 域 Don't have an account?: 还没有账户? Done: 已结束 +Download All: 下载全部 Download Dataset: 下载数据集 Download Selected: 下载选中 Download: 下载 @@ -414,6 +415,7 @@ Is rated: 计入排名 It works!: 它成了! Italic: 斜体 Join: 加入 +Judge Config: 评测设置 Judge Status: 评测状态 judge_playground: 评测游乐场 Judged At: 评测时间 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..7beb78b9d --- /dev/null +++ b/packages/ui-default/pages/problem_config.page.styl @@ -0,0 +1,23 @@ +.page--problem_config + .section--problem-sidebar-testdata + .problem-sidebar-testdata__detail + height: 270px + overflow-y: auto + + &.collapsed .collapsed--hidden, + &.expanded .expanded--hidden + display: none + + &.collapsed .problem-sidebar-testdata__detail + display: none + + .col--size + width: 80px + .col--operation + width: 40px + + .textbox,.select + margin: 0 + + .bp3-tab-panel + margin-top: 5px \ 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..0e907693e --- /dev/null +++ b/packages/ui-default/pages/problem_config.page.tsx @@ -0,0 +1,73 @@ +import { NamedPage } from 'vj/misc/Page'; +import { slideDown, slideUp } from 'vj/utils/slide'; +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'; + +async function handleSection(ev: JQuery.ClickEvent, type: string) { + const $section = $(ev.currentTarget).closest('.section--problem-sidebar-testdata'); + if ($section.is(`.${type}d, .animating`)) return; + $section.addClass('animating'); + const $detail = $section.find('.problem-sidebar-testdata__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'); +} + +const page = new NamedPage('problem_config', () => { + async function uploadConfig(config:object) { + const configYaml = yaml.dump(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 } = await import('vj/components/problemconfig/ProblemConfigEditor'); + const { default: ProblemConfigForm } = await import('vj/components/problemconfig/ProblemConfigForm'); + const { default: ProblemConfigReducer } = await import('vj/components/problemconfig/reducer'); + + const { + React, render, Provider, store, + } = await loadReactRedux(ProblemConfigReducer); + + store.dispatch({ + type: 'CONFIG_LOAD', + payload: request.get(), + }); + render( + +
+
+ +
+
+ +
+
+ +
, + $('#ProblemConfig').get(0), + ); + } + + mountComponent(); + + $(document).on('click', '[name="testdata__section__expand"]', (ev) => handleSection(ev, 'expand')); + $(document).on('click', '[name="testdata__section__collapse"]', (ev) => handleSection(ev, 'collapse')); +}); + +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..e7e9c62cd 100644 --- a/packages/ui-default/pages/problem_edit.page.js +++ b/packages/ui-default/pages/problem_edit.page.js @@ -7,6 +7,7 @@ import { 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'; const categories = {}; const dirtyCategories = []; @@ -143,6 +144,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) => { @@ -232,4 +248,8 @@ export default new NamedPage(['problem_create', 'problem_edit'], (pagename) => { ev.preventDefault(); } }); + $(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..9492e71ea 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,29 @@ background: #f8f8f8; &.tab--active - border-color: #ed5f82; \ No newline at end of file + border-color: #ed5f82; + + .section--problem-sidebar-tags + &.collapsed .collapsed--hidden, + &.expanded .expanded--hidden + display: none + + &.collapsed .problem-sidebar-tags__detail + display: none + + .section--problem-sidebar-additional_file + .problem-sidebar-additional_file__detail + height: 270px + overflow-y: auto + + &.collapsed .collapsed--hidden, + &.expanded .expanded--hidden + display: none + + &.collapsed .problem-sidebar-additional_file__detail + display: none + + .col--size + width: 80px + .col--operation + width: 40px \ No newline at end of file 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..1c651dd19 --- /dev/null +++ b/packages/ui-default/templates/partials/problem_sidebar_additional_file.html @@ -0,0 +1,57 @@ +{% import "components/nothing.html" as nothing with context %} +{% import "components/problem.html" as problem with context %} +
+
+

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

+
+ +
+ {% 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..a63838e3d --- /dev/null +++ b/packages/ui-default/templates/partials/problem_sidebar_testdata.html @@ -0,0 +1,58 @@ +{% import "components/nothing.html" as nothing with context %} +{% import "components/problem.html" as problem with context %} + \ 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..5a2753cbd --- /dev/null +++ b/packages/ui-default/templates/problem_config.html @@ -0,0 +1,21 @@ +{% extends "layout/basic.html" %} +{% block content %} +
+
+
+
+

{{_('Judge Config')}}

+
+
+ {{ 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_edit.html b/packages/ui-default/templates/problem_edit.html index f1e2bd4fc..afebdafaf 100644 --- a/packages/ui-default/templates/problem_edit.html +++ b/packages/ui-default/templates/problem_edit.html @@ -92,14 +92,23 @@
-
+

{{ _('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/utils/lib/cases.ts b/packages/utils/lib/cases.ts index a9e841bda..b0494dc53 100644 --- a/packages/utils/lib/cases.ts +++ b/packages/utils/lib/cases.ts @@ -1,5 +1,44 @@ import { findFileSync, 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], + }, +]; + // 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 = { @@ -98,3 +137,111 @@ export function convertIniConfig(ini: string) { } return res; } + +export async function readCasesFromFiles(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' || checkFile(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; +} + +export async function readSubtasksFromFiles(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' || checkFile(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; +}