From 4ccb838a06b663e74222c6c4fba83f8ac31ebf3a Mon Sep 17 00:00:00 2001 From: panda Date: Mon, 11 Apr 2022 04:23:56 +0800 Subject: [PATCH] utils&ui: move public utils & add upload --- packages/hydrojudge/src/cases.ts | 3 +- packages/hydrooj/src/handler/problem.ts | 11 - .../problemconfig/ProblemConfigForm.tsx | 133 +++++--- .../problemconfig/reducer/config.ts | 37 ++- .../problemconfig/reducer/testdata.ts | 6 +- .../ui-default/pages/problem_config.page.styl | 2 +- .../ui-default/pages/problem_config.page.tsx | 145 +++++++- .../ui-default/pages/problem_edit.page.js | 110 ++++++- .../problem_sidebar_additional_file.html | 14 +- .../partials/problem_sidebar_testdata.html | 19 +- packages/utils/lib/cases.ts | 147 --------- packages/utils/lib/common.ts | 309 ++++++++++++++++++ packages/utils/lib/utils.ts | 164 +--------- 13 files changed, 703 insertions(+), 397 deletions(-) create mode 100644 packages/utils/lib/common.ts diff --git a/packages/hydrojudge/src/cases.ts b/packages/hydrojudge/src/cases.ts index cc0fd0bb7..6edf22aa3 100644 --- a/packages/hydrojudge/src/cases.ts +++ b/packages/hydrojudge/src/cases.ts @@ -2,7 +2,8 @@ import path from 'path'; import fs from 'fs-extra'; import yaml from 'js-yaml'; import { max, sum } from 'lodash'; -import readYamlCases, { convertIniConfig, readCasesFromFiles, readSubtasksFromFiles } from '@hydrooj/utils/lib/cases'; +import readYamlCases, { convertIniConfig } from '@hydrooj/utils/lib/cases'; +import { readCasesFromFiles, readSubtasksFromFiles } from '@hydrooj/utils/lib/common'; import { changeErrorType } from '@hydrooj/utils/lib/utils'; import { getConfig } from './config'; import { FormatError, SystemError } from './error'; diff --git a/packages/hydrooj/src/handler/problem.ts b/packages/hydrooj/src/handler/problem.ts index a37cd78bb..5c1a8638b 100644 --- a/packages/hydrooj/src/handler/problem.ts +++ b/packages/hydrooj/src/handler/problem.ts @@ -4,7 +4,6 @@ import { isBinaryFile } from 'isbinaryfile'; import { intersection, isSafeInteger } from 'lodash'; import { FilterQuery, ObjectID } from 'mongodb'; import { nanoid } from 'nanoid'; -import { readCasesFromFiles, readSubtasksFromFiles } from '@hydrooj/utils/lib/cases'; import { sortFiles, streamToBuffer } from '@hydrooj/utils/lib/utils'; import { BadRequestError, ContestNotAttendedError, ContestNotEndedError, @@ -39,9 +38,6 @@ 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!'], @@ -568,13 +564,6 @@ export class ProblemConfigHandler extends ProblemManageHandler { )).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'; } } diff --git a/packages/ui-default/components/problemconfig/ProblemConfigForm.tsx b/packages/ui-default/components/problemconfig/ProblemConfigForm.tsx index d9e0402f3..e2e1cd929 100644 --- a/packages/ui-default/components/problemconfig/ProblemConfigForm.tsx +++ b/packages/ui-default/components/problemconfig/ProblemConfigForm.tsx @@ -1,9 +1,9 @@ -import React, { useState } from 'react'; +import React, { useRef, 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 { SubtaskConfig, TestCaseConfig } from 'hydrooj/src/interface'; import type { RootState } from './reducer/index'; import CustomSelectAutoComplete from '../autocomplete/components/CustomSelectAutoComplete'; @@ -13,7 +13,8 @@ const SelectValue = { task_type: ['min', 'max', 'sum'], }; -const eq = (a: TestCaseConfig[], b: TestCaseConfig[]) => isEqual(a, b); +const eq = (a: SubtaskConfig[], b: SubtaskConfig[]) => isEqual(a, b); +const eq1 = (a: TestCaseConfig, b: TestCaseConfig) => isEqual(a, b); function FormItem({ columns, label, children, helpText = '', disableLabel = false, ...props @@ -185,14 +186,90 @@ function ExtraFilesConfig() { ); } +function CasesSubCasesTable({ index, subindex }) { + const subcases = useSelector((state: RootState) => (index === -1 + ? state.config.cases[subindex] : state.config.subtasks[index].cases[subindex]), eq1); + const Files = useSelector((state: RootState) => state.testdata); + const dispatch = useDispatch(); + const dispatcher = (casesKey: string): React.ChangeEventHandler => (ev) => { + dispatch({ + type: index === -1 ? 'CONFIG_CASES_UPDATE' : 'CONFIG_SUBTASK_UPDATE', + id: index, + key: 'cases-edit', + casesId: subindex, + casesKey, + value: ev.currentTarget.value, + }); + }; + const dispatcher1 = (casesKey: string): React.ChangeEventHandler => (val) => { + dispatch({ + type: index === -1 ? 'CONFIG_CASES_UPDATE' : 'CONFIG_SUBTASK_UPDATE', + id: index, + key: 'cases-edit', + casesId: subindex, + casesKey, + value: val, + }); + }; + return ( + + { + index === -1 && ( + <> + + + + + + + + ) + } + + + + + + + + dispatch({ + type: index === -1 ? 'CONFIG_CASES_UPDATE' : 'CONFIG_SUBTASK_UPDATE', id: index, key: 'cases-delete', value: subindex, + })} + > + + + + ); +} + 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 inputRef = useRef(null); + const outputRef = useRef(null); const dispatch = useDispatch(); return ( @@ -223,29 +300,29 @@ function CasesTable({ index }) { - - {cases && cases.map((k, v) => ( - - { - index === -1 && ( - <> - - - - ) - } - - - - - ))} + {cases && cases.map((k, v) => )}
{i18n('Input')}
{i18n('Output')}
+
{ setTime(''); setMemory(''); - setInput(''); - setOutput(''); + inputRef?.current.setQuery(''); + outputRef?.current.setQuery(''); dispatch({ type: index === -1 ? 'CONFIG_CASES_UPDATE' : 'CONFIG_SUBTASK_UPDATE', id: index, @@ -255,34 +332,13 @@ function CasesTable({ index }) { } : { 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, - })} - > - -
); @@ -298,7 +354,7 @@ function SubtasksTable({ data, index }) { }; return (
- Subtasks #{index} + Subtasks #{index + 1} { dispatch({ type: 'CONFIG_SUBTASK_UPDATE', id: index, key: 'add' }); }}> state.config.subtasks, eq); const cases = useSelector((state: RootState) => state.config.cases, eq); const dispatch = useDispatch(); @@ -399,9 +455,8 @@ function TaskConfig() { ? subtasks &&
    {subtasks.map((k, v) => )}
|| cases && () : ( -
dispatch({ type: 'CONFIG_AUTOCASES_UPDATE' })} - > {i18n('Auto Read Tasks')} + onAutoLoad()}> + {i18n('Auto Read Tasks')} ) } @@ -443,11 +498,11 @@ function LangConfig() { ); } -export default function ProblemConfigForm() { +export default function ProblemConfigForm({ onAutoLoad }) { return (
- +
diff --git a/packages/ui-default/components/problemconfig/reducer/config.ts b/packages/ui-default/components/problemconfig/reducer/config.ts index a6eb70031..f8e707873 100644 --- a/packages/ui-default/components/problemconfig/reducer/config.ts +++ b/packages/ui-default/components/problemconfig/reducer/config.ts @@ -1,13 +1,11 @@ import yaml from 'js-yaml'; import type { ProblemConfigFile } from 'hydrooj/src/interface'; - -let autocases = { subtasks: [] }; +import { cloneDeep } from 'lodash'; 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': { @@ -30,7 +28,9 @@ export default function reducer(state = { type: 'default' } as ProblemConfigFile } case 'CONFIG_AUTOCASES_UPDATE': { const next = { ...state }; - if (autocases.subtasks.length === 1) { + const autocases = action.value; + if (autocases.subtasks.length === 0) next.cases = []; + else if (autocases.subtasks.length === 1) { next.score = autocases.subtasks[0].score; next.cases = []; autocases.subtasks[0].cases.map((i) => next.cases.push({ @@ -50,21 +50,32 @@ export default function reducer(state = { type: 'default' } as ProblemConfigFile 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); + if (action.key === 'cases-add') return { ...state, cases: [...state.cases, action.value] }; + if (action.key === 'cases-edit') { + const cases = cloneDeep(state.cases); + cases[action.casesId][action.casesKey] = action.value; + return { ...state, cases }; + } if (action.key === 'cases-delete') { + return { ...state, cases: state.cases.filter((k, v) => v !== action.value) }; } - return next; + return state; } case 'CONFIG_SUBTASK_UPDATE': { + if (action.key.split('-')[0] === 'cases') { + const subtasks = cloneDeep(state.subtasks); + const subsubtasks = cloneDeep(state.subtasks[action.id]); + if (action.key === 'cases-add') subsubtasks.cases.push(action.value); + else if (action.key === 'cases-edit') subsubtasks.cases[action.casesId][action.casesKey] = action.value; + else if (action.key === 'cases-delete') { + subsubtasks.cases = subsubtasks[action.id].cases.filter((k, v) => v !== action.value); + } + subtasks[action.id] = subsubtasks; + return { ...state, subtasks }; + } 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; + else next.subtasks[action.id][action.key] = action.value; return next; } default: diff --git a/packages/ui-default/components/problemconfig/reducer/testdata.ts b/packages/ui-default/components/problemconfig/reducer/testdata.ts index a4fe74ff3..924766e41 100644 --- a/packages/ui-default/components/problemconfig/reducer/testdata.ts +++ b/packages/ui-default/components/problemconfig/reducer/testdata.ts @@ -4,11 +4,11 @@ export default function reducer(state = [], action) { return state.concat(action.payload.testdata); } case 'CONFIG_DELETE_TESTDATA': { - return state.filter((i) => i.name !== action.payload.name); + const next = state.filter((i) => i.name !== action.value[0]); + return next; } case 'CONFIG_ADD_TESTDATA': { - state.push(action.payload); - return state; + return state.concat([action.value]); } default: return state; diff --git a/packages/ui-default/pages/problem_config.page.styl b/packages/ui-default/pages/problem_config.page.styl index 7beb78b9d..304845f7e 100644 --- a/packages/ui-default/pages/problem_config.page.styl +++ b/packages/ui-default/pages/problem_config.page.styl @@ -1,7 +1,7 @@ .page--problem_config .section--problem-sidebar-testdata .problem-sidebar-testdata__detail - height: 270px + max-height: 270px overflow-y: auto &.collapsed .collapsed--hidden, diff --git a/packages/ui-default/pages/problem_config.page.tsx b/packages/ui-default/pages/problem_config.page.tsx index 0e907693e..51334c9f8 100644 --- a/packages/ui-default/pages/problem_config.page.tsx +++ b/packages/ui-default/pages/problem_config.page.tsx @@ -5,6 +5,10 @@ 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, readCasesFromFiles, readSubtasksFromFiles } from '@hydrooj/utils/lib/common'; +import tpl from 'vj/utils/tpl'; async function handleSection(ev: JQuery.ClickEvent, type: string) { const $section = $(ev.currentTarget).closest('.section--problem-sidebar-testdata'); @@ -21,7 +25,128 @@ async function handleSection(ev: JQuery.ClickEvent testdata.filter((i) => i === file)[0]; +} + 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(config); Notification.info(i18n('Saving file...')); @@ -44,6 +169,8 @@ const page = new NamedPage('problem_config', () => { React, render, Provider, store, } = await loadReactRedux(ProblemConfigReducer); + reduxStore = store; + store.dispatch({ type: 'CONFIG_LOAD', payload: request.get(), @@ -55,7 +182,20 @@ const page = new NamedPage('problem_config', () => {
- + { + const testdata = (reduxStore.getState().testdata || []).map((i) => i.name); + const checkFile = ensureFile(testdata); + let autocases = await readCasesFromFiles(testdata, checkFile, {}); + if (!autocases.count) { + autocases = await readSubtasksFromFiles(testdata, checkFile, {}, { subtasks: [] }); + } + reduxStore.dispatch({ + type: 'CONFIG_AUTOCASES_UPDATE', + value: autocases, + }); + }} + />
@@ -66,6 +206,9 @@ const page = new NamedPage('problem_config', () => { 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()); $(document).on('click', '[name="testdata__section__expand"]', (ev) => handleSection(ev, 'expand')); $(document).on('click', '[name="testdata__section__collapse"]', (ev) => handleSection(ev, 'collapse')); }); diff --git a/packages/ui-default/pages/problem_edit.page.js b/packages/ui-default/pages/problem_edit.page.js index e7e9c62cd..3e5c4f8e0 100644 --- a/packages/ui-default/pages/problem_edit.page.js +++ b/packages/ui-default/pages/problem_edit.page.js @@ -3,11 +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 = []; @@ -19,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]; @@ -186,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://')) { @@ -248,6 +353,9 @@ 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')); diff --git a/packages/ui-default/templates/partials/problem_sidebar_additional_file.html b/packages/ui-default/templates/partials/problem_sidebar_additional_file.html index 1c651dd19..44ff10b3e 100644 --- a/packages/ui-default/templates/partials/problem_sidebar_additional_file.html +++ b/packages/ui-default/templates/partials/problem_sidebar_additional_file.html @@ -2,16 +2,16 @@ {% import "components/problem.html" as problem with context %}
-

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

+

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

{% if additional_file.length %} - +
@@ -45,7 +45,7 @@ {{ file.name }} - + {%- endfor -%} diff --git a/packages/ui-default/templates/partials/problem_sidebar_testdata.html b/packages/ui-default/templates/partials/problem_sidebar_testdata.html index a63838e3d..fcebdadcf 100644 --- a/packages/ui-default/templates/partials/problem_sidebar_testdata.html +++ b/packages/ui-default/templates/partials/problem_sidebar_testdata.html @@ -6,29 +6,26 @@
{% if testdata.length %} -
{{ size(file.size) }}
+
@@ -46,7 +43,7 @@ - + {%- endfor -%} diff --git a/packages/utils/lib/cases.ts b/packages/utils/lib/cases.ts index b0494dc53..a9e841bda 100644 --- a/packages/utils/lib/cases.ts +++ b/packages/utils/lib/cases.ts @@ -1,44 +1,5 @@ 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 = { @@ -137,111 +98,3 @@ 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; -} diff --git a/packages/utils/lib/common.ts b/packages/utils/lib/common.ts new file mode 100644 index 000000000..9469bb70b --- /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) { + 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 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 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], + }, +]; + +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; +} 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';
{{ file.name }} {{ size(file.size) }}