ui: new judge config (#533)

Co-authored-by: panda <panda_dtdyy@outlook.com>
pull/541/head
undefined 2 years ago committed by GitHub
parent f4d8cd7a5d
commit 85cb1d6dff
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -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');
}
}

@ -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"
}
}

@ -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(),
] : [],
],
};

@ -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 = {};

@ -21,7 +21,7 @@ export class Dialog {
this.$dom = $(tpl(
<div className={`dialog withBg ${this.options.classes}`} style={{ display: 'none' }}>
<div className="dialog__content" style={box}>
<div className="dialog__body" style={{ height: 'calc(100% - 45px);' }} />
<div className="dialog__body" style={{ height: 'calc(100% - 45px)' }} />
<div className="row"><div className="columns clearfix">
<div className="float-right dialog__action" />
</div></div>

@ -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,

@ -1,102 +1,17 @@
import { setDiagnosticsOptions } from 'monaco-yaml';
/** @type {Record<string, import('json-schema').JSONSchema7Definition>} */
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(),

@ -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;

@ -71,10 +71,10 @@ async function previewPDF(link) {
const id = nanoid();
const dialog = new InfoDialog({
$body: tpl`
<div class="typo">
<div class="typo" style="height: 100%;">
<object classid="clsid:${(uuid.substring(uuid.lastIndexOf('/') + 1))}">
<param name="SRC" value="${link}" >
<embed width="100%" style="height: 70vh;border: none;" src="${link}">
<embed width="100%" style="height: 100%;border: none;" src="${link}">
<noembed></noembed>
</embed>
</object>

@ -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() {

@ -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<any>();
const judgeRef = React.useRef<any>();
return (
<FormItem columns={12} label="ExtraFilesTabs" disableLabel>
<Tabs id="ExtraFilesTabs">
<Tab
id="user_extra_files"
title={i18n('user_extra_files')}
panel={(
<FormItem columns={12} label="ExtraFilesConfig" disableLabel>
<Card style={{ padding: 10 }}>
<div className="row">
<FormItem columns={12} label={i18n('user_extra_files')}>
<FileSelectAutoComplete
ref={userRef}
data={Files}
@ -55,12 +52,8 @@ function ExtraFilesConfig() {
onChange={(val) => dispatch({ type: 'CONFIG_FORM_UPDATE', key: 'user_extra_files', value: val.split(',') })}
multi
/>
)}
/>
<Tab
id="judge_extra_files"
title={i18n('judge_extra_files')}
panel={(
</FormItem>
<FormItem columns={12} label={i18n('judge_extra_files')}>
<FileSelectAutoComplete
ref={judgeRef}
data={Files}
@ -68,9 +61,9 @@ function ExtraFilesConfig() {
onChange={(val) => dispatch({ type: 'CONFIG_FORM_UPDATE', key: 'judge_extra_files', value: val.split(',') })}
multi
/>
)}
/>
</Tabs>
</FormItem>
</div>
</Card>
</FormItem>
);
}
@ -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<any>();
const selectedKeys = langs.filter((i) => !prefixes.has(i));
return (
<FormItem columns={12} label="langs">
<CustomSelectAutoComplete
ref={ref}
data={data}
placeholder={i18n('Unlimited')}
selectedKeys={selectedKeys}
onChange={(val) => {
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
/>
<FormItem columns={12} label="langs" disableLabel>
<Card style={{ padding: 10 }}>
<div className="row">
<FormItem columns={12} label="langs">
<CustomSelectAutoComplete
ref={ref}
data={data}
placeholder={i18n('Unlimited')}
selectedKeys={selectedKeys}
onChange={(val) => {
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
/>
</FormItem>
</div>
</Card>
</FormItem>
);
}
@ -109,7 +108,6 @@ export default function ProblemConfigForm() {
{Type === 'default' && <FileIOConfig />}
{!['submit_answer', 'objective'].includes(Type) && (
<>
<TaskConfig />
<ExtraFilesConfig />
<LangConfig />
</>

@ -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<TestcasesDndItem>(() => ({
accept: 'cases',
canDrop(item) {
return item.subtaskId !== subtaskId;
},
drop(item) {
dispatch({
type: 'problemconfig/moveTestcases',
payload: {
target: subtaskId,
source: item.subtaskId,
cases: item.cases,
},
});
},
}));
return (
<li className="bp4-tree-node bp4-tree-node-expanded">
{subtaskId !== -1 && <div className="bp4-tree-node-content" onClick={() => setExpand((e) => !e)}>
<Icon icon={expand ? 'folder-open' : 'folder-close'} />&nbsp;
<span className="bp4-tree-node-label">Subtask {subtaskId}</span>
<span className="bp4-tree-node-secondary-label">
<Icon icon="trash"></Icon>
</span>
</div>}
<ul className="bp4-tree-node-list" ref={drop}>
{subtaskId !== -1 && <SubtaskSettings subtaskId={subtaskId} time={time} memory={memory} />}
{expand
? <SelectionManager subtaskId={subtaskId} subtaskIds={subtaskIds} />
: <TreeNode
depth={0}
id={`s${subtaskId}`}
onClick={() => setExpand(false)}
icon="layers"
label={<>&nbsp;{clen} testcases.</>}
path={[0]}
/>}
{!clen && (
<li className="bp4-tree-node">
<div className="bp4-tree-node-content">
<span className="bp4-tree-node-caret-none bp4-icon-standard"></span>
<span className="bp4-tree-node-label">{subtaskId === -1
? 'No testcase here'
: 'Drag and drop testcases here:'}</span>
</div>
</li>
)}
</ul>
</li>
);
}
export function SubtaskConfigTree() {
const ids = useSelector((s: RootState) => Object.values(s.config?.subtasks || []).map((i) => i.id), isEqual);
const dispatch = useDispatch();
const store = useStore<RootState>();
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 (
<div className="bp4-tree">
<ul className="bp4-tree-node-list bp4-tree-root">
<li
className="bp4-tree-node"
onClick={autoConfigure}
>
<div className="bp4-tree-node-content bp4-tree-node-content-0">
<Icon icon="clean" />&nbsp;
<span className="bp4-tree-node-label">Auto Configure</span>
</div>
</li>
<GlobalSettings />
{ids.map((id) => <SubtaskNode key={id} subtaskId={id} />)}
<li
className="bp4-tree-node"
onClick={() => dispatch({ type: 'CONFIG_SUBTASK_UPDATE', id: 0, key: 'add' })}
>
<div className="bp4-tree-node-content bp4-tree-node-content-0">
<Icon icon="folder-new" />&nbsp;
<span className="bp4-tree-node-label">Add New Subtask</span>
</div>
</li>
</ul>
</div>
);
}
export function ProblemConfigTree() {
return (
<DndProvider backend={HTML5Backend}>
<div className="row">
<div className="medium-6 columns">
<SubtaskConfigTree />
</div>
<div className="medium-6 columns">
<div className="bp4-tree">
<ul className="bp4-tree-node-list bp4-tree-root">
<AddTestcase />
<SubtaskNode subtaskId={-1} />
</ul>
</div>
</div>
</div>
</DndProvider>
);
}

@ -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 (<>
<div className="row">
<div className="medium-4 columns">
<ProblemConfigEditor />
</div>
<div className="medium-8 columns">
<Tabs onChange={(t) => (t !== 'errors' && setSelected(t.toString()))} selectedTabId={valid ? selected : 'errors'}>
<Tab id="basic" disabled={!valid} title="Basic" panel={<ProblemConfigForm />} />
<Tab id="subtasks" disabled={!valid} title="Subtasks" panel={<ProblemConfigTree />} />
<Tab id="errors" disabled={valid} title={errors.length ? `Errors(${errors.length})` : 'No Errors'} panel={
<div>{errors.map((i) => (<pre key={i}>{i}</pre>))}</div>} />
</Tabs>
</div>
</div>
<button className="rounded primary button" onClick={save}>{i18n('Submit')}</button>
</>);
}

@ -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;
}

@ -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<RootState>();
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 (<>
<li
className="bp4-tree-node"
onClick={auto}
>
<div className="bp4-tree-node-content bp4-tree-node-content-0">
<Icon icon="clean" />&nbsp;
<span className="bp4-tree-node-label">Auto detect</span>
</div>
</li>
<li
className="bp4-tree-node"
onClick={() => setOpen(true)}
>
<div className="bp4-tree-node-content bp4-tree-node-content-0">
<Icon icon="clean" />&nbsp;
<span className="bp4-tree-node-label">Add testcase</span>
</div>
</li>
<Dialog title="Add testcasse" icon="cog" isOpen={open} onClose={() => setOpen(false)}>
<DialogBody>
<ControlGroup fill={true} vertical={false}>
{/* TODO: autocomplete */}
<InputGroup
leftElement={<Icon icon="import" />}
onChange={(e) => setInput(e.target.value)}
placeholder="Input"
value={input || ''}
/>
<InputGroup
leftElement={<Icon icon="export" />}
onChange={(e) => setOutput(e.target.value)}
placeholder="Output"
value={output || ''}
/>
</ControlGroup>
</DialogBody>
<DialogFooter actions={<Button onClick={onConfirm} disabled={!valid} intent="primary" text="Save" />} />
</Dialog>
</>);
}

@ -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<HTMLDivElement, 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('<div id="divSelectArea" style="position:absolute;background-color:#e073d4;"></div>');
$('#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) => (
<TestcaseGroup
subtaskId={subtaskId}
cases={[c]}
key={`${c.input}@${id}`}
selected={false}
subtaskIds={subtaskIds}
index={id}
onMouseDown={handleMouseDown}
onClick={() => {
setStart(id);
setEnd(id + 1);
}}
/>
))}
{start <= end && (
<TestcaseGroup
cases={cases.slice(start, end)}
subtaskId={subtaskId}
subtaskIds={subtaskIds}
onMouseDown={handleMouseDown}
selected={true}
index={start}
/>
)}
{end < cases.length && cases.slice(end).map((c, id) => (
<TestcaseGroup
subtaskId={subtaskId}
cases={[c]}
key={`${c.input}@${id}`}
subtaskIds={subtaskIds}
selected={false}
onMouseDown={handleMouseDown}
index={id + end}
onClick={() => {
setStart(id + end);
setEnd(id + end + 1);
}}
/>
))}
</>
);
}

@ -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<HTMLInputElement | HTMLSelectElement> | 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 (<>
<Dialog title="Set limits" icon="cog" isOpen={open} onClose={() => setOpen(false)}>
<DialogBody>
<ControlGroup fill={true} vertical={false}>
<InputGroup
leftElement={<Icon icon="time" />}
onChange={dispatcher(setTime, 'time')}
placeholder={`Inherit (${props.time || '1s'})`}
value={ctime || ''}
/>
<InputGroup
leftElement={<Icon icon="comparison" />}
onChange={dispatcher(setMemory, 'memory')}
placeholder={`Inherit (${props.memory || '256m'})`}
value={cmemory || ''}
/>
<InputGroup
leftElement={<Icon icon="star" />}
onChange={dispatcher(setScore, 'score')}
placeholder="Score"
type="number"
value={cscore.toString()}
/>
</ControlGroup>
</DialogBody>
<DialogFooter actions={<Button onClick={onConfirm} intent="primary" text="Save" />} />
</Dialog>
<li className="bp4-tree-node" onClick={() => setOpen(true)}>
<div className="bp4-tree-node-content">
<span className="bp4-tree-node-caret-none bp4-icon-standard"></span>
<Icon icon="time" />
&nbsp;&nbsp;
<span className={`bp4-tree-node-label${!(time || props.time) ? ' text-gray' : ''}`}>{time || props.time || '1s'}</span>
<Icon icon="comparison" />
&nbsp;&nbsp;
<span className={`bp4-tree-node-label${!(memory || props.memory) ? ' text-gray' : ''}`}>{memory || props.memory || '256m'}</span>
<Icon icon="star" />
{' '}
<span className="bp4-tree-node-secondary-label">{score || 0}</span>
</div>
</li>
</>);
}
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 (<>
<Dialog title="Set limits" icon="cog" isOpen={open} onClose={() => setOpen(false)}>
<DialogBody>
<ControlGroup fill={true} vertical={false}>
<InputGroup
leftElement={<Icon icon="time" />}
onChange={(ev) => setTime(ev.currentTarget.value)}
placeholder="1s"
value={ctime || ''}
/>
<InputGroup
leftElement={<Icon icon="comparison" />}
onChange={(ev) => setMemory(ev.currentTarget.value)}
placeholder="256m"
value={cmemory || ''}
/>
</ControlGroup>
</DialogBody>
<DialogFooter actions={<Button onClick={onConfirm} intent="primary" text="Save" />} />
</Dialog>
<li className="bp4-tree-node" onClick={() => setOpen(true)}>
<div className="bp4-tree-node-content">
<Icon icon="time" />
&nbsp;&nbsp;
<span className={`bp4-tree-node-label${!time ? ' text-gray' : ''}`}>{time || '1s'}</span>
<Icon icon="comparison" />
{' '}
<span className={`bp4-tree-node-secondary-label${!memory ? ' text-gray' : ''}`}>{memory || '256m'}</span>
</div>
</li>
</>);
}

@ -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 (
<ContextMenu2
onContextMenu={onClick}
data-subtaskid={subtaskId}
data-index={c.input}
data-selected={selected}
content={
<Menu>
<MenuItem icon="drawer-left" text="Move to subtask" >
{subtaskIds.filter((i) => i !== subtaskId).map((i) => (
<MenuItem key={i} text={`Subtask ${i}`} />
))}
{subtaskIds.length <= 1 && (
<MenuItem icon="disable" disabled text="No target available" />
)}
</MenuItem>
</Menu>
}
>
<TreeNode
depth={0}
id={c.input}
isSelected={selected}
onClick={onClick}
icon="document"
label={<>&nbsp;{c.input} / {c.output}</>}
path={[0]}
/>
</ContextMenu2>
);
}
interface TestcaseGroupProps extends Omit<TestcaseNodeProps, 'c'> {
cases: TestCaseConfig[];
onMouseDown?: (event: React.MouseEvent<HTMLDivElement, 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 <div ref={drag} onClick={onClick} onMouseDown={props.onMouseDown} style={{ opacity: collected.isDragging ? 0.5 : 1 }}>
{cases.map((c, id) => (
<TestcaseNode c={c} key={`${c.input}@${index + id}`} {...omit(props, 'onClick')} index={index + id} />
))}
</div>;
}

@ -6,15 +6,17 @@ import createHint from './hint';
let hintInserted = false;
$(tpl(
<div style={{ display: 'none' }} className="dialog__body--user-select">
<div className="row"><div className="columns">
<h1 id="select_user_hint">{i18n('Select User')}</h1>
</div></div>
<div className="row">
<div className="columns">
<label>{i18n('Username / UID')}
<input name="user" type="text" className="textbox" autoComplete="off" data-autofocus />
</label>
<div style={{ display: 'none' }}>
<div className="dialog__body--user-select">
<div className="row"><div className="columns">
<h1 id="select_user_hint">{i18n('Select User')}</h1>
</div></div>
<div className="row">
<div className="columns">
<label>{i18n('Username / UID')}
<input name="user" type="text" className="textbox" autoComplete="off" data-autofocus />
</label>
</div>
</div>
</div>
</div>,

@ -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

@ -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",

@ -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(
<Provider store={store}>
<div className="row">
<div className="medium-5 columns">
<ProblemConfigEditor />
</div>
<div className="medium-7 columns">
<ProblemConfigForm />
</div>
</div>
<button className="rounded primary button" onClick={() => uploadConfig(store.getState().config)}>{i18n('Submit')}</button>
<ProblemConfig onSave={() => uploadConfig(store.getState().config)} />
</Provider>,
);
}

@ -71,8 +71,8 @@
<span class="record-status--text {{ model.builtin.STATUS_CODES[rcdoc['status']] }}">
{{ model.builtin.STATUS_TEXTS[rcdoc['status']] }}
</span>
{% if (rdoc.subtask && _key != 'id' and rcdocs.length == 1 ) or (_key == 'id' and rdoc.subtasks[rcdoc.subtaskId].type == 'sum') %}
<span class="float-right record-status--text {{ model.builtin.STATUS_CODES[rdoc.subtasks[subtaskId]['status'] if _key != 'id' else rcdoc.status] }}">
{% if (rdoc.subtask and _key != 'id' and rcdocs.length == 1) or (_key == 'id' and rdoc.subtasks[rcdoc.subtaskId].type == 'sum') %}
<span class="float-right record-status--text {{ model.builtin.STATUS_CODES[(rdoc.subtasks[subtaskId]['status'] if _key != 'id' else rcdoc.status)] }}">
{{ (rdoc.subtasks[subtaskId]['score'] if _key != 'id' else rcdoc.score)|default(0) }}
</span>
{% endif %}

@ -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';

@ -199,6 +199,37 @@ export function size(s: number, base = 1) {
return `${Math.round(s * unit)} ${unitNames[unitNames.length - 1]}`;
}
export type StringKeys<O> = {
[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<T extends Record<string, any>>(files: T[], key: StringKeys<T>): T[];
export function sortFiles(files: Record<string, any>[] | 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)[];

@ -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) => ({
'&': '&amp;',

Loading…
Cancel
Save