diff --git a/build/prepare.js b/build/prepare.js index 85246554..ef06846f 100644 --- a/build/prepare.js +++ b/build/prepare.js @@ -62,7 +62,7 @@ for (const name of ['plugins', 'modules']) { if (!fs.existsSync(path.resolve(process.cwd(), name))) { fs.mkdirSync(path.resolve(process.cwd(), name)); // Write an empty file to make eslint happy - fs.writeFileSync(path.resolve(process.cwd(), name, 'eslint.ts'), ''); + fs.writeFileSync(path.resolve(process.cwd(), name, 'nop.ts'), 'export default {};\n'); } } diff --git a/package.json b/package.json index 7ad7d4e2..4b458918 100644 --- a/package.json +++ b/package.json @@ -77,8 +77,9 @@ "wtfnode": "^0.9.1" }, "resolutions": { - "@types/node": "18.11.18", + "@types/node": "18.14.1", "@types/react": "18.0.28", - "cosmokit": "1.4.1" + "cosmokit": "1.4.1", + "prettier": "npm:not-installable-package@1.0.0" } } diff --git a/packages/ui-default/build/config/webpack.ts b/packages/ui-default/build/config/webpack.ts index ecdaa89d..14e2cef4 100644 --- a/packages/ui-default/build/config/webpack.ts +++ b/packages/ui-default/build/config/webpack.ts @@ -243,7 +243,6 @@ export default function (env: { watch?: boolean, production?: boolean, measure?: filename: '[name].css?[fullhash:6]', }), new WebpackManifestPlugin({}), - new DuplicatesPlugin(), new webpack.IgnorePlugin({ resourceRegExp: /(^\.\/locale$|mathjax|abcjs|vditor.+\.d\.ts)/ }), new CopyWebpackPlugin({ patterns: [ @@ -263,6 +262,7 @@ export default function (env: { watch?: boolean, production?: boolean, measure?: minChunkSize: 128000, }), new webpack.NormalModuleReplacementPlugin(/\/(vscode-)?nls\.js/, require.resolve('../../components/monaco/nls')), + new webpack.NormalModuleReplacementPlugin(/^prettier[$/]/, root('../../modules/nop.ts')), new MonacoWebpackPlugin({ filename: '[name].[hash:6].worker.js', customLanguages: [{ @@ -274,7 +274,10 @@ export default function (env: { watch?: boolean, production?: boolean, measure?: }, }], }), - ...env.measure ? [new BundleAnalyzerPlugin({ analyzerPort: 'auto' })] : [], + ...env.measure ? [ + new BundleAnalyzerPlugin({ analyzerPort: 'auto' }), + new DuplicatesPlugin(), + ] : [], ], }; diff --git a/packages/ui-default/build/main.ts b/packages/ui-default/build/main.ts index e1312871..6a069496 100644 --- a/packages/ui-default/build/main.ts +++ b/packages/ui-default/build/main.ts @@ -87,11 +87,11 @@ async function runWebpack({ } async function runGulp() { - function handleError(err) { + function errorHandler(err) { log(chalk.red('Error: %s'), chalk.reset(err.toString() + err.stack)); process.exit(1); } - const gulpTasks = gulpConfig({ production: true, errorHandler: handleError }); + const gulpTasks = gulpConfig({ errorHandler }); return new Promise((resolve) => { const taskList = {}; diff --git a/packages/ui-default/components/dialog/index.tsx b/packages/ui-default/components/dialog/index.tsx index 9fe68943..101e9c5f 100644 --- a/packages/ui-default/components/dialog/index.tsx +++ b/packages/ui-default/components/dialog/index.tsx @@ -21,7 +21,7 @@ export class Dialog { this.$dom = $(tpl(
-
+
diff --git a/packages/ui-default/components/editor/index.ts b/packages/ui-default/components/editor/index.ts index 5fbe7d66..8d8d1bd7 100644 --- a/packages/ui-default/components/editor/index.ts +++ b/packages/ui-default/components/editor/index.ts @@ -41,6 +41,7 @@ interface MonacoOptions { autoLayout?: boolean; value?: string; hide?: string[]; + lineNumbers?: 'on' | 'off' | 'relative' | 'interval'; } interface VditorOptions { theme?: 'classic' | 'dark' @@ -68,7 +69,7 @@ export default class Editor extends DOMAttachedObject { theme = UserContext.monacoTheme || 'vs-light', model = `file://model-${Math.random().toString(16)}`, autoResize = true, autoLayout = true, - hide = [], + hide = [], lineNumbers = 'on', } = this.options; const { monaco, registerAction } = await load([language]); const { $dom } = this; @@ -87,7 +88,7 @@ export default class Editor extends DOMAttachedObject { if (!this.options.model) this.model.setValue(value); const cfg: import('../monaco').default.editor.IStandaloneEditorConstructionOptions = { theme, - lineNumbers: 'on', + lineNumbers, glyphMargin: true, lightbulb: { enabled: true }, model: this.model, diff --git a/packages/ui-default/components/monaco/languages/yaml.ts b/packages/ui-default/components/monaco/languages/yaml.ts index fe9faede..931123f9 100644 --- a/packages/ui-default/components/monaco/languages/yaml.ts +++ b/packages/ui-default/components/monaco/languages/yaml.ts @@ -1,102 +1,17 @@ import { setDiagnosticsOptions } from 'monaco-yaml'; - -/** @type {Record} */ -const problemConfigSchemaDef = { - cases: { type: 'array', items: { $ref: '#/def/case' } }, - case: { - type: 'object', - 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, - }, - subtask: { - description: 'Subtask Info', - type: 'object', - properties: { - type: { enum: ['min', 'max', 'sum'] }, - time: { $ref: '#/def/time' }, - memory: { $ref: '#/def/memory' }, - score: { $ref: '#/def/score', description: 'score' }, - cases: { $ref: '#/def/cases' }, - if: { type: 'array', items: { type: 'integer' } }, - id: { type: 'integer' }, - }, - required: ['score'], - additionalProperties: false, - }, - time: { type: 'string', pattern: '^([0-9]+(?:\\.[0-9]*)?)([mu]?)s?$' }, - memory: { type: 'string', pattern: '^([0-9]+(?:\\.[0-9]*)?)([kKmMgG])[bB]?$' }, - score: { type: 'integer', maximum: 100, minimum: 1 }, -}; - -/** @type {import('json-schema').JSONSchema7Definition} */ -const problemConfigSchema = { - type: 'object', - def: problemConfigSchemaDef, - properties: { - 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' }, - langs: { type: 'array', items: { type: 'string' } }, - target: { type: 'string' }, - checker_type: { enum: ['default', 'lemon', 'syzoj', 'hustoj', 'testlib', 'strict', 'qduoj'] }, - checker: { type: 'string', pattern: '\\.' }, - interactor: { type: 'string', pattern: '\\.' }, - validator: { type: 'string', pattern: '\\.' }, - user_extra_files: { type: 'array', items: { type: 'string' } }, - judge_extra_files: { type: 'array', items: { type: 'string' } }, - cases: { $ref: '#/def/cases' }, - subtasks: { type: 'array', items: { $ref: '#/def/subtask' } }, - filename: { type: 'string' }, - detail: { type: 'boolean' }, - time: { $ref: '#/def/time' }, - memory: { $ref: '#/def/memory' }, - score: { $ref: '#/def/score' }, - template: { - type: 'object', - patternProperties: { - '^.*$': { - type: 'array', - minLength: 2, - maxLength: 2, - items: { type: 'string' }, - }, - }, - additionalProperties: false, - }, - answers: { - type: 'object', - patternProperties: { - '^\\d+(-\\d+)?$': { - type: 'array', - minLength: 2, - maxLength: 2, - }, - }, - additionalProperties: false, - }, - }, - additionalProperties: false, -}; +import problemConfigSchema from '../schema/problemconfig'; setDiagnosticsOptions({ validate: true, enableSchemaRequest: true, hover: true, completion: true, - format: true, + format: false, schemas: [ { uri: 'https://hydro.js.org/schema/problemConfig.json', fileMatch: ['hydro://problem/file/config.yaml'], - schema: problemConfigSchema as any, + schema: problemConfigSchema, }, { uri: new URL('/manage/config/schema.json', window.location.href).toString(), diff --git a/packages/ui-default/components/monaco/schema/problemconfig.ts b/packages/ui-default/components/monaco/schema/problemconfig.ts new file mode 100644 index 00000000..af20f714 --- /dev/null +++ b/packages/ui-default/components/monaco/schema/problemconfig.ts @@ -0,0 +1,85 @@ +import type { JSONSchema7 } from 'json-schema'; + +const problemConfigSchema: JSONSchema7 = { + type: 'object', + definitions: { + cases: { type: 'array', items: { $ref: '#/definitions/case' } }, + case: { + type: 'object', + properties: { + input: { type: 'string' }, + output: { type: 'string' }, + time: { $ref: '#/definitions/time' }, + memory: { $ref: '#/definitions/memory' }, + score: { $ref: '#/definitions/score', description: 'score' }, + }, + required: ['input'], + additionalProperties: false, + }, + subtask: { + description: 'Subtask Info', + type: 'object', + properties: { + type: { enum: ['min', 'max', 'sum'] }, + time: { $ref: '#/definitions/time' }, + memory: { $ref: '#/definitions/memory' }, + score: { $ref: '#/definitions/score', description: 'score' }, + cases: { $ref: '#/definitions/cases' }, + if: { type: 'array', items: { type: 'integer' } }, + id: { type: 'integer' }, + }, + required: ['score'], + additionalProperties: false, + }, + time: { type: 'string', pattern: '^([0-9]+(?:\\.[0-9]*)?)([mu]?)s?$' }, + memory: { type: 'string', pattern: '^([0-9]+(?:\\.[0-9]*)?)([kKmMgG])[bB]?$' }, + score: { type: 'integer', maximum: 100, minimum: 1 }, + }, + properties: { + 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' }, + langs: { type: 'array', items: { type: 'string' } }, + target: { type: 'string' }, + checker_type: { enum: ['default', 'lemon', 'syzoj', 'hustoj', 'testlib', 'strict', 'qduoj'] }, + checker: { type: 'string', pattern: '\\.' }, + interactor: { type: 'string', pattern: '\\.' }, + validator: { type: 'string', pattern: '\\.' }, + user_extra_files: { type: 'array', items: { type: 'string' } }, + judge_extra_files: { type: 'array', items: { type: 'string' } }, + cases: { $ref: '#/definitions/cases' }, + subtasks: { type: 'array', items: { $ref: '#/definitions/subtask' } }, + filename: { type: 'string' }, + detail: { type: 'boolean' }, + time: { $ref: '#/definitions/time' }, + memory: { $ref: '#/definitions/memory' }, + score: { $ref: '#/definitions/score' }, + template: { + type: 'object', + patternProperties: { + '^.*$': { + type: 'array', + minLength: 2, + maxLength: 2, + items: { type: 'string' }, + }, + }, + additionalProperties: false, + }, + answers: { + type: 'object', + patternProperties: { + '^\\d+(-\\d+)?$': { + type: 'array', + minLength: 2, + maxLength: 2, + }, + }, + additionalProperties: false, + }, + }, + additionalProperties: false, +}; + +export default problemConfigSchema; diff --git a/packages/ui-default/components/preview/preview.page.ts b/packages/ui-default/components/preview/preview.page.ts index 16d20b06..6096b1c5 100644 --- a/packages/ui-default/components/preview/preview.page.ts +++ b/packages/ui-default/components/preview/preview.page.ts @@ -71,10 +71,10 @@ async function previewPDF(link) { const id = nanoid(); const dialog = new InfoDialog({ $body: tpl` -
+
- + diff --git a/packages/ui-default/components/problemconfig/ProblemConfigEditor.tsx b/packages/ui-default/components/problemconfig/ProblemConfigEditor.tsx index 3be7646a..15df4898 100644 --- a/packages/ui-default/components/problemconfig/ProblemConfigEditor.tsx +++ b/packages/ui-default/components/problemconfig/ProblemConfigEditor.tsx @@ -2,6 +2,7 @@ import { diffLines } from 'diff'; import type { ProblemConfigFile, TestCaseConfig } from 'hydrooj/src/interface'; import $ from 'jquery'; import yaml from 'js-yaml'; +import { isEqual } from 'lodash'; import type { editor } from 'monaco-editor'; import React from 'react'; import { connect } from 'react-redux'; @@ -21,7 +22,7 @@ const mapDispatchToProps = (dispatch) => ({ }); interface Props { - config: object; + config: any; handleUpdateCode: Function; } @@ -29,7 +30,7 @@ const configKey = [ 'type', 'subType', 'target', 'score', 'time', 'memory', 'filename', 'checker_type', 'checker', 'interactor', 'validator', 'user_extra_files', 'judge_extra_files', 'detail', 'outputs', - 'redirect', 'cases', 'subtasks', 'langs', + 'redirect', 'cases', 'subtasks', 'langs', 'key', ]; const subtasksKey = [ @@ -99,6 +100,7 @@ export default connect(mapStateToProps, mapDispatchToProps)(class MonacoEditor e this.vjEditor = Editor.getOrConstruct($(this.containerElement), { language: 'yaml', model: this.model, + lineNumbers: 'off', onChange: (value: string) => { this.__preventUpdate = true; if (!this.__preventFormat) this.props.handleUpdateCode(value); @@ -109,30 +111,34 @@ export default connect(mapStateToProps, mapDispatchToProps)(class MonacoEditor e } 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; + if (this.__preventUpdate || !this.model || !this.props.config.__valid) return; + if (yaml.dump(prevProps.config) === yaml.dump(this.props.config)) return; + const curValue = this.model.getValue(); + const pending = configYamlFormat(this.props.config); + try { + const curConfig = yaml.load(curValue); + if (isEqual(curConfig, pending)) return; + } catch { } + this.__preventFormat = true; + const diff = diffLines(curValue, yaml.dump(pending)); + 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() { diff --git a/packages/ui-default/components/problemconfig/ProblemConfigForm.tsx b/packages/ui-default/components/problemconfig/ProblemConfigForm.tsx index 316bd699..d2d584ef 100644 --- a/packages/ui-default/components/problemconfig/ProblemConfigForm.tsx +++ b/packages/ui-default/components/problemconfig/ProblemConfigForm.tsx @@ -1,5 +1,5 @@ import { - Card, InputGroup, Tab, Tabs, Tag, + Card, InputGroup, Tag, } from '@blueprintjs/core'; import React from 'react'; import { useDispatch, useSelector } from 'react-redux'; @@ -9,7 +9,6 @@ import FileSelectAutoComplete from '../autocomplete/components/FileSelectAutoCom import { FormItem } from './BasicForm'; import ProblemType from './ProblemType'; import type { RootState } from './reducer/index'; -import { TaskConfig } from './SubtaskTable'; function FileIOConfig() { const filename = useSelector((state: RootState) => state.config.filename); @@ -42,12 +41,10 @@ function ExtraFilesConfig() { const userRef = React.useRef(); const judgeRef = React.useRef(); 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 /> - )} - /> - + +
+
); } @@ -79,24 +72,30 @@ function LangConfig() { const langs = useSelector((state: RootState) => state.config.langs) || []; const prefixes = new Set(Object.keys(window.LANGS).filter((i) => i.includes('.')).map((i) => i.split('.')[0])); const data = Object.keys(window.LANGS).filter((i) => !prefixes.has(i)) - .map((i) => ({ name: window.LANGS[i].display, _id: i })); + .map((i) => ({ name: `${i.includes('.') ? `${window.LANGS[i.split('.')[0]].display || ''}/` : ''}${window.LANGS[i].display}`, _id: i })); const dispatch = useDispatch(); const ref = React.useRef(); const selectedKeys = langs.filter((i) => !prefixes.has(i)); 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 - /> + + +
+ + { + 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 + /> + +
+
); } @@ -109,7 +108,6 @@ export default function ProblemConfigForm() { {Type === 'default' && } {!['submit_answer', 'objective'].includes(Type) && ( <> - diff --git a/packages/ui-default/components/problemconfig/ProblemConfigTree.tsx b/packages/ui-default/components/problemconfig/ProblemConfigTree.tsx new file mode 100644 index 00000000..873e29b3 --- /dev/null +++ b/packages/ui-default/components/problemconfig/ProblemConfigTree.tsx @@ -0,0 +1,140 @@ +import { Icon, TreeNode } from '@blueprintjs/core'; +import { normalizeSubtasks, readSubtasksFromFiles } from '@hydrooj/utils/lib/common'; +import { TestCaseConfig } from 'hydrooj'; +import { isEqual } from 'lodash'; +import React from 'react'; +import { DndProvider, useDrop } from 'react-dnd'; +import { HTML5Backend } from 'react-dnd-html5-backend'; +import { useDispatch, useSelector, useStore } from 'react-redux'; +import { RootState } from './reducer'; +import { AddTestcase } from './tree/AddTestcase'; +import { SelectionManager } from './tree/SelectionManager'; +import { GlobalSettings, SubtaskSettings } from './tree/SubtaskSettings'; + +interface TestcasesDndItem { + cases: TestCaseConfig[]; + subtaskId: number; +} + +export function SubtaskNode(props: { subtaskId: number }) { + const { subtaskId } = props; + const subtaskIds = useSelector((s: RootState) => Object.values(s.config?.subtasks || []).map((i) => i.id), isEqual); + const clen = useSelector((state: RootState) => (subtaskId === -1 + ? state.config.__cases.length + : state.config.subtasks.find((i) => i.id === subtaskId).cases?.length || 0)); + const time = useSelector((s: RootState) => s.config?.time); + const memory = useSelector((s: RootState) => s.config?.memory); + const dispatch = useDispatch(); + const [expand, setExpand] = React.useState(true); + const [, drop] = useDrop(() => ({ + accept: 'cases', + canDrop(item) { + return item.subtaskId !== subtaskId; + }, + drop(item) { + dispatch({ + type: 'problemconfig/moveTestcases', + payload: { + target: subtaskId, + source: item.subtaskId, + cases: item.cases, + }, + }); + }, + })); + + return ( +
  • + {subtaskId !== -1 &&
    setExpand((e) => !e)}> +   + Subtask {subtaskId} + + + +
    } +
      + {subtaskId !== -1 && } + {expand + ? + : setExpand(false)} + icon="layers" + label={<> {clen} testcases.} + path={[0]} + />} + {!clen && ( +
    • +
      + + {subtaskId === -1 + ? 'No testcase here' + : 'Drag and drop testcases here:'} +
      +
    • + )} +
    +
  • + ); +} + +export function SubtaskConfigTree() { + const ids = useSelector((s: RootState) => Object.values(s.config?.subtasks || []).map((i) => i.id), isEqual); + const dispatch = useDispatch(); + const store = useStore(); + function autoConfigure() { + const state = store.getState(); + const subtasks = readSubtasksFromFiles(state.testdata, state.config); + dispatch({ + type: 'CONFIG_AUTOCASES_UPDATE', + subtasks: normalizeSubtasks(subtasks, (i) => i, state.config.time, state.config.memory, true), + }); + } + return ( +
    +
      +
    • +
      +   + Auto Configure +
      +
    • + + {ids.map((id) => )} +
    • dispatch({ type: 'CONFIG_SUBTASK_UPDATE', id: 0, key: 'add' })} + > +
      +   + Add New Subtask +
      +
    • +
    +
    + ); +} + +export function ProblemConfigTree() { + return ( + +
    +
    + +
    +
    +
    +
      + + +
    +
    +
    +
    +
    + ); +} diff --git a/packages/ui-default/components/problemconfig/index.tsx b/packages/ui-default/components/problemconfig/index.tsx new file mode 100644 index 00000000..1bff2034 --- /dev/null +++ b/packages/ui-default/components/problemconfig/index.tsx @@ -0,0 +1,41 @@ +import { Tab, Tabs } from '@blueprintjs/core'; +import React from 'react'; +import { useSelector } from 'react-redux'; +import { i18n } from 'vj/utils'; +import ProblemConfigEditor from './ProblemConfigEditor'; +import ProblemConfigForm from './ProblemConfigForm'; +import { ProblemConfigTree } from './ProblemConfigTree'; +import { RootState } from './reducer'; + +interface Props { + onSave: () => void +} + +export default function ProblemConfig(props: Props) { + const [selected, setSelected] = React.useState('basic'); + const valid = useSelector((state: RootState) => state.config.__valid); + const errors = useSelector((state: RootState) => state.config.__errors, (a, b) => JSON.stringify(a) === JSON.stringify(b)); + + function save() { + // eslint-disable-next-line + if (!valid && !confirm('Errors detected in the config. Still save?')) return; + props.onSave(); + } + + return (<> +
    +
    + +
    +
    + (t !== 'errors' && setSelected(t.toString()))} selectedTabId={valid ? selected : 'errors'}> + } /> + } /> + {errors.map((i) => (
    {i}
    ))}
    } /> + +
    +
    + + ); +} diff --git a/packages/ui-default/components/problemconfig/reducer/config.ts b/packages/ui-default/components/problemconfig/reducer/config.ts index 19093922..f90d1d7c 100644 --- a/packages/ui-default/components/problemconfig/reducer/config.ts +++ b/packages/ui-default/components/problemconfig/reducer/config.ts @@ -1,11 +1,22 @@ -import { parseMemoryMB, parseTimeMS } from '@hydrooj/utils/lib/common'; -import type { ProblemConfigFile } from 'hydrooj/src/interface'; +import { parseMemoryMB, parseTimeMS, sortFiles } from '@hydrooj/utils/lib/common'; +import Ajv from 'ajv'; +import type { ProblemConfigFile, TestCaseConfig } from 'hydrooj/src/interface'; import yaml from 'js-yaml'; import { cloneDeep } from 'lodash'; +import schema from '../../monaco/schema/problemconfig'; -type State = ProblemConfigFile & { __loaded: boolean }; +type State = ProblemConfigFile & { + __loaded: boolean; + __valid: boolean; + __errors: string[]; + __cases: TestCaseConfig[]; +}; +const ajv = new Ajv(); +const validate = ajv.compile(schema); -export default function reducer(state = { type: 'default', __loaded: false } as State, action: any = {}): State { +export default function reducer(state = { + type: 'default', __loaded: false, __valid: true, __errors: [], __cases: [], +} as State, action: any = {}): State { switch (action.type) { case 'CONFIG_LOAD_FULFILLED': { return { ...state, ...yaml.load(action.payload.config) as object, __loaded: true }; @@ -19,9 +30,15 @@ export default function reducer(state = { type: 'default', __loaded: false } as } case 'CONFIG_CODE_UPDATE': { try { - return { ...state, ...yaml.load(action.payload) as object }; - } catch { - return state; + const data = yaml.load(action.payload); + if (!validate(data)) { + return { ...state, __valid: false, __errors: validate.errors.map((i) => `${i.instancePath}: ${i.message}`) }; + } + return { + ...state, ...data as object, __valid: true, __errors: [], + }; + } catch (e) { + return { ...state, __valid: false, __errors: [e.message] }; } } case 'CONFIG_AUTOCASES_UPDATE': { @@ -42,27 +59,72 @@ export default function reducer(state = { type: 'default', __loaded: false } as } case 'CONFIG_SUBTASK_UPDATE': { const subtasks = cloneDeep(state.subtasks); - const subsubtasks = cloneDeep(state.subtasks[action.id]); + const subtask = state.subtasks.find((i) => i.id === 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); + if (action.key === 'cases-add') subtask.cases.push(action.value); else if (action.key === 'cases-edit') { - if (action.value === '' && !['input', 'output'].includes(action.casesKey)) delete subsubtasks.cases[action.casesId][action.casesKey]; - else subsubtasks.cases[action.casesId][action.casesKey] = action.value; + if (action.value === '' && !['input', 'output'].includes(action.casesKey)) delete subtask.cases[action.casesId][action.casesKey]; + else subtask.cases[action.casesId][action.casesKey] = action.value; } else if (action.key === 'cases-delete') { - subsubtasks.cases = subsubtasks.cases.filter((k, v) => v !== action.value); + subtask.cases = subtask.cases.filter((k, v) => v !== action.value); } } else if (action.key === 'add') { - return { ...state, subtasks: [...subtasks, { time: state.time || '1s', memory: state.memory || '256m', cases: [] }] }; + subtasks.push({ + time: state.time || '1s', + memory: state.memory || '256m', + cases: [], + score: 0, + id: Object.keys(subtasks).map((i) => subtasks[i].id).reduce((a, b) => Math.max(+a, +b), 0) + 1, + }); + return { ...state, subtasks }; } 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; + if (action.value === '' || (action.key === 'if' && action.value.join('') === '')) delete subtask[action.key]; + else subtask[action.key] = action.value; } - subtasks[action.id] = subsubtasks; return { ...state, subtasks }; } + case 'problemconfig/updateGlobalConfig': { + const n = { ...state, time: action.time, memory: action.memory }; + if (!n.time) delete n.time; + if (!n.memory) delete n.memory; + return n; + } + case 'problemconfig/updateSubtaskConfig': { + if (!state.subtasks.find((i) => i.id === action.id)) return state; + const subtask = { ...state.subtasks.find((i) => i.id === action.id) }; + const subtasks = [...state.subtasks]; + subtasks.splice(state.subtasks.findIndex((i) => i.id === action.id), 1, subtask); + subtask.time = action.time; + subtask.memory = action.memory; + subtask.score = +action.score || 0; + if (!subtask.time) delete subtask.time; + if (!subtask.memory) delete subtask.memory; + return { ...state, subtasks }; + } + case 'problemconfig/addTestcases': { + return { ...state, __cases: sortFiles([...state.__cases, ...action.cases], 'input') }; + } + case 'problemconfig/moveTestcases': { + const testcases = action.payload.cases; + const subtasks = cloneDeep(state.subtasks); + const __cases = action.payload.source === -1 + ? state.__cases.filter((i) => !testcases.find((j) => i.input === j.input && i.output === j.output)) + : action.payload.target === -1 + ? sortFiles([...state.__cases, ...testcases], 'input') + : state.__cases; + for (const key in subtasks) { + const subtask = subtasks[key]; + if (subtask.id === action.payload.source) { + subtask.cases = subtask.cases.filter((i) => !testcases.find((j) => i.input === j.input && i.output === j.output)); + } else if (subtask.id === action.payload.target) { + subtask.cases = sortFiles([...subtask.cases, ...testcases], 'input'); + } + } + return { ...state, subtasks, __cases }; + } default: return state; } diff --git a/packages/ui-default/components/problemconfig/tree/AddTestcase.tsx b/packages/ui-default/components/problemconfig/tree/AddTestcase.tsx new file mode 100644 index 00000000..7f2f40e1 --- /dev/null +++ b/packages/ui-default/components/problemconfig/tree/AddTestcase.tsx @@ -0,0 +1,88 @@ +import { + Button, ControlGroup, Dialog, DialogBody, DialogFooter, Icon, InputGroup, +} from '@blueprintjs/core'; +import { readSubtasksFromFiles } from '@hydrooj/utils/lib/common'; +import React, { useEffect } from 'react'; +import { useSelector, useStore } from 'react-redux'; +import { RootState } from '../reducer'; + +export function AddTestcase() { + const [open, setOpen] = React.useState(false); + const [input, setInput] = React.useState(''); + const [output, setOutput] = React.useState(''); + const [valid, setValid] = React.useState(false); + const testdata = useSelector((state: RootState) => state.testdata); + const store = useStore(); + + useEffect(() => { + setValid(testdata.find((i) => i.name === input) && testdata.find((i) => i.name === output)); + }, [input, output]); + + function onConfirm() { + store.dispatch({ + type: 'problemconfig/addTestcases', + cases: [{ input, output }], + }); + setOpen(false); + } + + function auto() { + const state = store.getState(); + const subtasks = readSubtasksFromFiles(state.testdata.map((i) => i.name), {}); + const current = state.config.subtasks.flatMap((i) => i.cases).concat(state.config.__cases); + const pending = []; + for (const c of subtasks.flatMap((s) => s.cases)) { + if (!current.find((i) => i.input === c.input && i.output === c.output)) { + pending.push({ + input: c.input, + output: c.output, + }); + } + } + store.dispatch({ + type: 'problemconfig/addTestcases', + cases: pending, + }); + } + + return (<> +
  • +
    +   + Auto detect +
    +
  • +
  • setOpen(true)} + > +
    +   + Add testcase +
    +
  • + setOpen(false)}> + + + {/* TODO: autocomplete */} + } + onChange={(e) => setInput(e.target.value)} + placeholder="Input" + value={input || ''} + /> + } + onChange={(e) => setOutput(e.target.value)} + placeholder="Output" + value={output || ''} + /> + + + } /> + + ); +} diff --git a/packages/ui-default/components/problemconfig/tree/SelectionManager.tsx b/packages/ui-default/components/problemconfig/tree/SelectionManager.tsx new file mode 100644 index 00000000..53602da3 --- /dev/null +++ b/packages/ui-default/components/problemconfig/tree/SelectionManager.tsx @@ -0,0 +1,130 @@ +import React from 'react'; +import { useSelector } from 'react-redux'; +import { RootState } from '../reducer'; +import { TestcaseGroup } from './Testcase'; + +interface SelectionManagerProps { + subtaskId: number; + subtaskIds: number[]; +} + +function collide(rect1: any, rect2: any): boolean { + if ('getBoundingClientRect' in rect1) rect1 = rect1.getBoundingClientRect(); + if ('getBoundingClientRect' in rect2) rect2 = rect2.getBoundingClientRect(); + const maxX = Math.max(rect1.x + rect1.width, rect2.x + rect2.width); + const maxY = Math.max(rect1.y + rect1.height, rect2.y + rect2.height); + const minX = Math.min(rect1.x, rect2.x); + const minY = Math.min(rect1.y, rect2.y); + return maxX - minX <= rect1.width + rect2.width && maxY - minY <= rect1.height + rect2.height; +} + +export function SelectionManager(props: SelectionManagerProps) { + const { subtaskIds, subtaskId } = props; + const cases = useSelector((state: RootState) => (subtaskId === -1 + ? state.config.__cases || [] + : state.config.subtasks.find((i) => i.id === subtaskId).cases || [])); + // Don't need to trigger a re-render for this property change + const pos = React.useMemo(() => ({ + x: 0, y: 0, endX: 0, endY: 0, + }), []); + const [start, setStart] = React.useState(0); + const [end, setEnd] = React.useState(0); + React.useEffect(() => { + setStart(0); + setEnd(0); + }, [JSON.stringify(cases)]); + + const handleMouseDown = React.useCallback((event: React.MouseEvent) => { + pos.x = event.pageX; + pos.y = event.pageY; + // Check if clicking on a selected testcase + const selected = Array.from($('[data-selected="true"]')); + for (const el of selected) { + if (collide(el, { ...pos, width: 1, height: 1 })) return; + } + // eslint-disable-next-line @typescript-eslint/no-use-before-define + document.body.addEventListener('mousemove', handleMouseMove); + // eslint-disable-next-line @typescript-eslint/no-use-before-define + document.body.addEventListener('mouseup', handleMouseUp); + $('body').css('cursor', 'crosshair') + .append('
    '); + $('#divSelectArea').css({ + top: event.pageY, + left: event.pageX, + zIndex: 9999999, + }).fadeTo(12, 0.2); + }, [JSON.stringify(cases)]); + const handleMouseMove = React.useCallback((event) => { + pos.endX = event.pageX; + pos.endY = event.pageY; + $('#divSelectArea').css({ + top: Math.min(event.pageY, pos.y), + left: Math.min(event.pageX, pos.x), + height: Math.abs(event.pageY - pos.y), + width: Math.abs(event.pageX - pos.x), + }); + }, [JSON.stringify(cases)]); + const handleMouseUp = React.useCallback(() => { + document.body.removeEventListener('mousemove', handleMouseMove); + document.body.removeEventListener('mouseup', handleMouseUp); + const caseEntries = Array.from($(`[data-subtaskid="${subtaskId}"]`)); + const selected = []; + for (let i = 0; i < caseEntries.length; i += 1) { + if (collide(caseEntries[i], $('#divSelectArea')[0])) { + selected.push(cases.indexOf(cases.find((c) => c.input === caseEntries[i].dataset.index))); + } + } + const sorted = selected.sort((a, b) => a - b); + $('#divSelectArea').remove(); + $('body').css('cursor', 'default'); + pos.x = pos.y = pos.endX = pos.endY = 0; + if (!sorted.length) return; + setStart(sorted[0]); + setEnd(sorted[selected.length - 1] + 1); + }, [JSON.stringify(cases)]); + + return ( + <> + {end > start && cases.slice(0, start).map((c, id) => ( + { + setStart(id); + setEnd(id + 1); + }} + /> + ))} + {start <= end && ( + + )} + {end < cases.length && cases.slice(end).map((c, id) => ( + { + setStart(id + end); + setEnd(id + end + 1); + }} + /> + ))} + + ); +} diff --git a/packages/ui-default/components/problemconfig/tree/SubtaskSettings.tsx b/packages/ui-default/components/problemconfig/tree/SubtaskSettings.tsx new file mode 100644 index 00000000..0fb13cb9 --- /dev/null +++ b/packages/ui-default/components/problemconfig/tree/SubtaskSettings.tsx @@ -0,0 +1,139 @@ +import { + Button, ControlGroup, + Dialog, DialogBody, DialogFooter, + Icon, InputGroup, +} from '@blueprintjs/core'; +import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { RootState } from '../reducer'; + +interface SubtaskSettingsProps { + subtaskId: number; + time: string; + memory: string; +} + +export function SubtaskSettings(props: SubtaskSettingsProps) { + const [open, setOpen] = React.useState(false); + const score = useSelector((state: RootState) => state.config.subtasks.find((i) => i.id === props.subtaskId).score); + const time = useSelector((state: RootState) => state.config.subtasks.find((i) => i.id === props.subtaskId).time); + const memory = useSelector((state: RootState) => state.config.subtasks.find((i) => i.id === props.subtaskId).memory); + + const [ctime, setTime] = React.useState(time); + const [cmemory, setMemory] = React.useState(memory); + const [cscore, setScore] = React.useState(score); + const dispatcher = (func, key) => (ev: React.ChangeEvent | number) => { + let value = typeof ev !== 'object' ? ev : ev.currentTarget?.value; + if (key === 'score') value = +value; + func(value); + }; + + const dispatch = useDispatch(); + function onConfirm() { + dispatch({ + type: 'problemconfig/updateSubtaskConfig', + id: props.subtaskId, + time: ctime, + memory: cmemory, + score: cscore, + }); + setOpen(false); + } + + return (<> + setOpen(false)}> + + + } + onChange={dispatcher(setTime, 'time')} + placeholder={`Inherit (${props.time || '1s'})`} + value={ctime || ''} + /> + } + onChange={dispatcher(setMemory, 'memory')} + placeholder={`Inherit (${props.memory || '256m'})`} + value={cmemory || ''} + /> + } + onChange={dispatcher(setScore, 'score')} + placeholder="Score" + type="number" + value={cscore.toString()} + /> + + + } /> + +
  • setOpen(true)}> +
    + + +    + {time || props.time || '1s'} + +    + {memory || props.memory || '256m'} + + {' '} + {score || 0} +
    +
  • + ); +} + +export function GlobalSettings() { + const time = useSelector((s: RootState) => s.config?.time); + const memory = useSelector((s: RootState) => s.config?.memory); + const [open, setOpen] = React.useState(false); + const [ctime, setTime] = React.useState(time); + const [cmemory, setMemory] = React.useState(memory); + React.useEffect(() => { + setTime(time); + }, [time]); + React.useEffect(() => { + setMemory(memory); + }, [memory]); + const dispatch = useDispatch(); + function onConfirm() { + dispatch({ + type: 'problemconfig/updateGlobalConfig', + time: ctime, + memory: cmemory, + }); + setOpen(false); + } + return (<> + setOpen(false)}> + + + } + onChange={(ev) => setTime(ev.currentTarget.value)} + placeholder="1s" + value={ctime || ''} + /> + } + onChange={(ev) => setMemory(ev.currentTarget.value)} + placeholder="256m" + value={cmemory || ''} + /> + + + } /> + +
  • setOpen(true)}> +
    + +    + {time || '1s'} + + {' '} + {memory || '256m'} +
    +
  • + ); +} diff --git a/packages/ui-default/components/problemconfig/tree/Testcase.tsx b/packages/ui-default/components/problemconfig/tree/Testcase.tsx new file mode 100644 index 00000000..f80c9eff --- /dev/null +++ b/packages/ui-default/components/problemconfig/tree/Testcase.tsx @@ -0,0 +1,77 @@ +import { Menu, MenuItem, TreeNode } from '@blueprintjs/core'; +import { ContextMenu2 } from '@blueprintjs/popover2'; +import { TestCaseConfig } from 'hydrooj'; +import { omit } from 'lodash'; +import React from 'react'; +import { useDrag } from 'react-dnd'; + +interface TestcaseNodeProps { + c: TestCaseConfig; + index: number; + time?: string; + memory?: string; + onClick?: () => void; + selected: boolean; + subtaskId: number; + subtaskIds: number[]; +} + +export function TestcaseNode(props: TestcaseNodeProps) { + const { + c, selected, onClick, subtaskIds, subtaskId, + } = props; + return ( + + + {subtaskIds.filter((i) => i !== subtaskId).map((i) => ( + + ))} + {subtaskIds.length <= 1 && ( + + )} + + + } + > +  {c.input} / {c.output}} + path={[0]} + /> + + ); +} + +interface TestcaseGroupProps extends Omit { + cases: TestCaseConfig[]; + onMouseDown?: (event: React.MouseEvent) => void; +} + +export function TestcaseGroup(props: TestcaseGroupProps) { + const { + cases, subtaskId, onClick, index, + } = props; + const [collected, drag] = useDrag(() => ({ + type: 'cases', + collect: (monitor) => ({ + isDragging: monitor.isDragging(), + }), + canDrag: props.selected, + item: { cases, subtaskId }, + }), [JSON.stringify(cases), subtaskId]); + return
    + {cases.map((c, id) => ( + + ))} +
    ; +} diff --git a/packages/ui-default/components/selectUser.tsx b/packages/ui-default/components/selectUser.tsx index 9cf75c95..c9a3da30 100644 --- a/packages/ui-default/components/selectUser.tsx +++ b/packages/ui-default/components/selectUser.tsx @@ -6,15 +6,17 @@ import createHint from './hint'; let hintInserted = false; $(tpl( -
    -
    -

    {i18n('Select User')}

    -
    -
    -
    - +
    +
    +
    +

    {i18n('Select User')}

    +
    +
    +
    + +
    , diff --git a/packages/ui-default/entry.js b/packages/ui-default/entry.js index ad19e1b4..edd988d3 100644 --- a/packages/ui-default/entry.js +++ b/packages/ui-default/entry.js @@ -47,7 +47,7 @@ const prefetch = Promise.all([ ]); document.addEventListener('DOMContentLoaded', async () => { - Object.assign(window.UiContext, JSON.parse(window.UiContextNew)) + Object.assign(window.UiContext, JSON.parse(window.UiContextNew)); const [data, HydroExports] = await prefetch; Object.assign(window, { HydroExports }); eval(data); // eslint-disable-line no-eval diff --git a/packages/ui-default/package.json b/packages/ui-default/package.json index ab085941..27f4aaa3 100644 --- a/packages/ui-default/package.json +++ b/packages/ui-default/package.json @@ -15,6 +15,7 @@ }, "devDependencies": { "@blueprintjs/core": "4.17.4", + "@blueprintjs/popover2": "^1.13.8", "@fontsource/dm-mono": "^4.5.10", "@fontsource/fira-code": "^4.5.13", "@fontsource/inconsolata": "^4.5.10", @@ -40,6 +41,7 @@ "@types/sharedworker": "^0.0.93", "@types/webpack-env": "^1.18.0", "@vscode/codicons": "^0.0.32", + "ajv": "^8.12.0", "allotment": "^1.18.1", "autoprefixer": "^10.4.14", "browser-update": "^3.3.44", diff --git a/packages/ui-default/pages/problem_config.page.tsx b/packages/ui-default/pages/problem_config.page.tsx index 284d2ac9..8254f206 100644 --- a/packages/ui-default/pages/problem_config.page.tsx +++ b/packages/ui-default/pages/problem_config.page.tsx @@ -85,9 +85,8 @@ const page = new NamedPage('problem_config', () => { } async function mountComponent() { - const [{ default: ProblemConfigEditor }, { default: ProblemConfigForm }, { default: ProblemConfigReducer }] = await Promise.all([ - import('vj/components/problemconfig/ProblemConfigEditor'), - import('vj/components/problemconfig/ProblemConfigForm'), + const [{ default: ProblemConfig }, { default: ProblemConfigReducer }] = await Promise.all([ + import('vj/components/problemconfig/index'), import('vj/components/problemconfig/reducer'), ]); @@ -120,15 +119,7 @@ const page = new NamedPage('problem_config', () => { }); createRoot(document.getElementById('ProblemConfig')!).render( -
    -
    - -
    -
    - -
    -
    - + uploadConfig(store.getState().config)} />
    , ); } diff --git a/packages/ui-default/templates/record_detail_status.html b/packages/ui-default/templates/record_detail_status.html index e6a9fcd2..430642cc 100644 --- a/packages/ui-default/templates/record_detail_status.html +++ b/packages/ui-default/templates/record_detail_status.html @@ -71,8 +71,8 @@ {{ model.builtin.STATUS_TEXTS[rcdoc['status']] }} - {% if (rdoc.subtask && _key != 'id' and rcdocs.length == 1 ) or (_key == 'id' and rdoc.subtasks[rcdoc.subtaskId].type == 'sum') %} - + {% if (rdoc.subtask and _key != 'id' and rcdocs.length == 1) or (_key == 'id' and rdoc.subtasks[rcdoc.subtaskId].type == 'sum') %} + {{ (rdoc.subtasks[subtaskId]['score'] if _key != 'id' else rcdoc.score)|default(0) }} {% endif %} diff --git a/packages/ui-default/theme/default.js b/packages/ui-default/theme/default.js index 0c7a2a81..d8321005 100644 --- a/packages/ui-default/theme/default.js +++ b/packages/ui-default/theme/default.js @@ -6,6 +6,7 @@ import 'pickadate/lib/themes/classic.date.css'; import 'pickadate/lib/themes/classic.time.css'; import 'katex/dist/katex.min.css'; import '@blueprintjs/core/lib/css/blueprint.css'; +import '@blueprintjs/popover2/lib/css/blueprint-popover2.css'; import '@fontsource/fira-code'; import '@fontsource/source-code-pro'; import '@fontsource/roboto-mono'; diff --git a/packages/utils/lib/common.ts b/packages/utils/lib/common.ts index e2817824..6f833687 100644 --- a/packages/utils/lib/common.ts +++ b/packages/utils/lib/common.ts @@ -199,6 +199,37 @@ export function size(s: number, base = 1) { return `${Math.round(s * unit)} ${unitNames[unitNames.length - 1]}`; } +export type StringKeys = { + [K in keyof O]: string extends O[K] ? K : never +}[keyof O]; +const fSortR = /[^\d]+|\d+/g; +export function sortFiles(files: string[]): string[]; +export function sortFiles(files: { _id: string }[], key?: '_id'): { _id: string }[]; +export function sortFiles>(files: T[], key: StringKeys): T[]; +export function sortFiles(files: Record[] | string[], key = '_id') { + if (!files?.length) return []; + const isString = typeof files[0] === 'string'; + const result = files + .map((i) => (isString ? { name: i, _weights: i.match(fSortR) } : { ...i, _weights: (i[key] || i.name).match(fSortR) })) + .sort((a, b) => { + let pos = 0; + const weightsA = a._weights; + const weightsB = b._weights; + let weightA = weightsA[pos]; + let weightB = weightsB[pos]; + while (weightA && weightB) { + const v = weightA - weightB; + if (!Number.isNaN(v) && v !== 0) return v; + if (weightA !== weightB) return weightA > weightB ? 1 : -1; + pos += 1; + weightA = weightsA[pos]; + weightB = weightsB[pos]; + } + return weightA ? 1 : -1; + }); + return result.map((x) => (isString ? x.name : (delete x._weights && x))); +} + interface MatchRule { regex: RegExp; output: ((a: RegExpExecArray) => string)[]; diff --git a/packages/utils/lib/utils.ts b/packages/utils/lib/utils.ts index 4b588d78..083c218e 100644 --- a/packages/utils/lib/utils.ts +++ b/packages/utils/lib/utils.ts @@ -255,31 +255,6 @@ export function CallableInstance(property = '__call__') { CallableInstance.prototype = Object.create(Function.prototype); -const fSortR = /[^\d]+|\d+/g; -export function sortFiles(files: { _id: string }[] | string[]) { - if (!files?.length) return []; - const isString = typeof files[0] === 'string'; - const result = files - .map((i) => (isString ? { name: i, weights: i.match(fSortR) } : { ...i, weights: (i._id || i.name).match(fSortR) })) - .sort((a, b) => { - let pos = 0; - const weightsA = a.weights; - const weightsB = b.weights; - let weightA = weightsA[pos]; - let weightB = weightsB[pos]; - while (weightA && weightB) { - const v = weightA - weightB; - if (!Number.isNaN(v) && v !== 0) return v; - if (weightA !== weightB) return weightA > weightB ? 1 : -1; - pos += 1; - weightA = weightsA[pos]; - weightB = weightsB[pos]; - } - return weightA ? 1 : -1; - }); - return isString ? result.map((x) => x.name) : result; -} - export const htmlEncode = (str: string) => str.replace(/[&<>'"]/g, (tag: string) => ({ '&': '&',