From 27056c5b691c2bf8054947728d66b226fc47d766 Mon Sep 17 00:00:00 2001 From: undefined Date: Wed, 4 May 2022 12:00:07 +0800 Subject: [PATCH 01/44] judge: fix testlib points message --- packages/hydrojudge/package.json | 2 +- packages/hydrojudge/src/testlib.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/hydrojudge/package.json b/packages/hydrojudge/package.json index 6e7a8550f..61b96ee9d 100644 --- a/packages/hydrojudge/package.json +++ b/packages/hydrojudge/package.json @@ -1,7 +1,7 @@ { "name": "@hydrooj/hydrojudge", "bin": "bin/hydrojudge.js", - "version": "2.18.1", + "version": "2.18.2", "main": "package.json", "author": "undefined ", "repository": "https://github.com/hydro-dev/Hydro.git", diff --git a/packages/hydrojudge/src/testlib.ts b/packages/hydrojudge/src/testlib.ts index d940a89b2..de824998a 100644 --- a/packages/hydrojudge/src/testlib.ts +++ b/packages/hydrojudge/src/testlib.ts @@ -29,7 +29,7 @@ export function parse(output: string, fullscore: number) { if (p === 1) { status = STATUS.STATUS_ACCEPTED; score = fullscore; - const base = output.split('points ')[1] || ''; + const base = output.replace('points ', '') || ''; message = base.substring(base.indexOf(' '), 1024); } else score = Math.floor(fullscore * p); } From 87b980f87dd874a96a1b11e75d0543fa43dab8a7 Mon Sep 17 00:00:00 2001 From: undefined Date: Wed, 4 May 2022 19:57:49 +0800 Subject: [PATCH 02/44] ui: use broadcast channel to handle message update --- packages/ui-default/build/config/webpack.js | 1 - packages/ui-default/build/main.ts | 12 +- .../components/message/index.page.js | 67 --------- .../components/message/index.page.ts | 129 ++++++++++++++++++ .../ui-default/components/socket/index.js | 7 +- packages/ui-default/package.json | 2 +- packages/ui-default/sharedworker.ts | 54 -------- 7 files changed, 133 insertions(+), 139 deletions(-) delete mode 100644 packages/ui-default/components/message/index.page.js create mode 100644 packages/ui-default/components/message/index.page.ts delete mode 100644 packages/ui-default/sharedworker.ts diff --git a/packages/ui-default/build/config/webpack.js b/packages/ui-default/build/config/webpack.js index 6bd105436..d9668ddc0 100644 --- a/packages/ui-default/build/config/webpack.js +++ b/packages/ui-default/build/config/webpack.js @@ -206,7 +206,6 @@ export default function (env = {}) { { from: root('static') }, { from: root(`${dirname(require.resolve('vditor/package.json'))}/dist`), to: 'vditor/dist' }, { from: `${dirname(require.resolve('monaco-themes/package.json'))}/themes`, to: 'monaco/themes/' }, - { from: root('.build/sharedworker.js'), to: 'sharedworker.js' }, ], }), new webpack.DefinePlugin({ diff --git a/packages/ui-default/build/main.ts b/packages/ui-default/build/main.ts index e97125b83..dec6bab96 100644 --- a/packages/ui-default/build/main.ts +++ b/packages/ui-default/build/main.ts @@ -3,7 +3,6 @@ import cac from 'cac'; import fs from 'fs-extra'; import webpack from 'webpack'; -import esbuild from 'esbuild'; import WebpackDevServer from 'webpack-dev-server'; import gulp from 'gulp'; import log from 'fancy-log'; @@ -18,15 +17,6 @@ const argv = cac().parse(); async function runWebpack({ watch, production, measure, dev, }) { - const result = esbuild.build({ - bundle: true, - format: 'iife', - entryPoints: [root('sharedworker.ts')], - outfile: root('.build/sharedworker.js'), - watch: dev || watch, - minify: production, - }); - if (!dev && !watch) await result; const compiler = webpack(webpackConfig({ watch, production, measure })); if (dev) { const server = new WebpackDevServer(compiler, { @@ -36,7 +26,7 @@ async function runWebpack({ stats: 'none', index: root('public'), proxy: { - context: (path) => !path.includes('sharedworker.js'), + context: (path) => !path.includes('sockjs-node'), target: 'http://localhost:2333', ws: true, }, diff --git a/packages/ui-default/components/message/index.page.js b/packages/ui-default/components/message/index.page.js deleted file mode 100644 index a396f23a5..000000000 --- a/packages/ui-default/components/message/index.page.js +++ /dev/null @@ -1,67 +0,0 @@ -import { AutoloadPage } from 'vj/misc/Page'; -import { InfoDialog } from 'vj/components/dialog'; -import VjNotification from 'vj/components/notification/index'; -import { FLAG_ALERT } from 'vj/constant/message'; -import i18n from 'vj/utils/i18n'; -import tpl from 'vj/utils/tpl'; - -const messagePage = new AutoloadPage('messagePage', (pagename) => { - if (pagename === 'home_messages' || !UserContext._id) return; - - if (UserContext.unreadMsg) { - new VjNotification({ - type: 'info', - message: i18n(`You have ${UserContext.unreadMsg > 1 ? 'new messages' : 'a new message'}.`), - duration: 5000, - action: () => window.open('/home/messages', '_blank'), - }).show(); - } - - const onmessage = (message) => { - const msg = JSON.parse(message); - console.log('Received message', msg); - if (msg.mdoc.flag & FLAG_ALERT) { - // Is alert - return new InfoDialog({ - cancelByClickingBack: false, - $body: tpl` -
- ${{ templateRaw: true, html: i18n('Message from {0}:', `
${msg.mdoc.from}
`) }} -

${i18n(msg.mdoc.content)}

-
`, - }).open(); - } - if (msg.udoc._id === 1 && msg.mdoc.flag & 4) { - // System notification - return new VjNotification({ - message: i18n('You received a system message, click here to view.'), - duration: 15000, - action: () => window.open(`/home/messages?uid=${msg.udoc._id}`, '_blank'), - }).show(); - } - // Is message - return new VjNotification({ - title: msg.udoc.uname, - avatar: msg.udoc.avatarUrl, - message: msg.mdoc.content, - duration: 15000, - action: () => window.open(`/home/messages?uid=${msg.udoc._id}`, '_blank'), - }).show(); - }; - - const path = new URL('/home/messages-conn', window.location.href).toString(); - const worker = new SharedWorker('/sharedworker.js', { name: 'Hydro Messages Shared Worker' }); - worker.port.start(); - worker.port.postMessage({ type: 'init', path, cookie: document.cookie }); - worker.port.onmessage = (e) => { - if (e.data.type !== 'message') return; - if (document.hidden) { - worker.port.postMessage({ type: 'fail' }); - } else { - worker.port.postMessage({ type: 'success' }); - onmessage(e.data.payload); - } - }; -}); - -export default messagePage; diff --git a/packages/ui-default/components/message/index.page.ts b/packages/ui-default/components/message/index.page.ts new file mode 100644 index 000000000..088646162 --- /dev/null +++ b/packages/ui-default/components/message/index.page.ts @@ -0,0 +1,129 @@ +import { AutoloadPage } from 'vj/misc/Page'; +import { InfoDialog } from 'vj/components/dialog'; +import VjNotification from 'vj/components/notification/index'; +import ReconnectingWebsocket from 'reconnecting-websocket'; +import { FLAG_ALERT } from 'vj/constant/message'; +import i18n from 'vj/utils/i18n'; +import tpl from 'vj/utils/tpl'; +import { nanoid } from 'nanoid'; +import sleep from 'vj/utils/delay'; + +function asMaster() { + console.log('init as master'); + const channel = new BroadcastChannel('hydro-messages'); + const interval = setInterval(() => channel.postMessage({ type: 'master' }), 5000); + const url = new URL('/home/messages-conn', window.location.href.replace('http', 'ws')); + url.searchParams.append('sid', document.cookie); + const sock = new ReconnectingWebsocket(url.toString()); + function destory() { + sock.close(); + channel.close(); + clearInterval(interval); + } + const pending = {}; + sock.onopen = () => console.log('Connected'); + sock.onerror = console.error; + sock.onclose = (...args) => console.log('Closed', ...args); + sock.onmessage = async (message) => { + if (process.env.NODE_ENV !== 'production') console.log('onmessage: ', message); + const payload = JSON.parse(message.data); + if (['PermissionError', 'PrivilegeError'].includes(payload.error)) { + destory(); + return; + } + const id = nanoid(); + channel.postMessage({ type: 'message', id, payload }); + const success = await new Promise((resolve) => { + pending[id] = resolve; + setTimeout(() => { + delete pending[id]; + resolve(false); + }, 1000); + }); + if (!success && window.Notification?.permission === 'granted') { + // eslint-disable-next-line no-new + new window.Notification( + payload.udoc.uname || 'Hydro Notification', + { + icon: payload.udoc.avatarUrl || '/android-chrome-192x192.png', + body: payload.mdoc.content, + }, + ); + } + }; + channel.onmessage = (msg) => { + if (msg.data.type === 'message-push') pending[msg.data.id]?.(true); + }; +} + +const onmessage = (msg) => { + console.log('Received message', msg); + if (msg.mdoc.flag & FLAG_ALERT) { + // Is alert + return new InfoDialog({ + cancelByClickingBack: false, + $body: tpl` +
+ ${{ templateRaw: true, html: i18n('Message from {0}:', `
${msg.mdoc.from}
`) }} +

${i18n(msg.mdoc.content)}

+
`, + }).open(); + } + // Is message + return new VjNotification({ + ...(msg.udoc._id === 1 && msg.mdoc.flag & 4) + ? { message: i18n('You received a system message, click here to view.') } + : { + title: msg.udoc.uname, + avatar: msg.udoc.avatarUrl, + message: msg.mdoc.content, + }, + duration: 15000, + action: () => window.open(`/home/messages?uid=${msg.udoc._id}`, '_blank'), + }).show(); +}; + +async function asSlave() { + console.log('message load as slave'); + const channel = new BroadcastChannel('hydro-messages'); + const selfId = nanoid(); + setInterval(() => channel.postMessage({ type: 'node', id: selfId }), 5000); + let alive = false; + let nodes = new Set([selfId]); + channel.onmessage = (msg) => { + if (msg.data.target && msg.data.target !== selfId) return; + if (msg.data.type === 'master') alive = true; + if (msg.data.type === 'node') nodes.add(msg.data.id); + if (msg.data.type === 'message') { + if (!document.hidden) { + channel.postMessage({ type: 'message-push', id: msg.data.id }); + onmessage(msg.data.payload); + } + } + }; + while (true) { // eslint-disable-line no-constant-condition + alive = false; + nodes = new Set([selfId]); + await sleep(7000); + if (!alive && Array.from(nodes).sort()[0] === selfId) asMaster(); + } +} + +const messagePage = new AutoloadPage('messagePage', (pagename) => { + if (pagename === 'home_messages' || !UserContext._id) return; + if (UserContext.unreadMsg) { + new VjNotification({ + type: 'info', + message: i18n(`You have ${UserContext.unreadMsg > 1 ? 'new messages' : 'a new message'}.`), + duration: 5000, + action: () => window.open('/home/messages', '_blank'), + }).show(); + } + if (!window.BroadcastChannel) { + console.error('BoardcastChannel not supported'); + return; + } + asSlave(); +}); + +export default messagePage; diff --git a/packages/ui-default/components/socket/index.js b/packages/ui-default/components/socket/index.js index 0435d062b..52208e113 100644 --- a/packages/ui-default/components/socket/index.js +++ b/packages/ui-default/components/socket/index.js @@ -19,11 +19,8 @@ export default class Sock { this.sock.onmessage = (message) => { if (process.env.NODE_ENV !== 'production') console.log('Sock.onmessage: ', message); const msg = JSON.parse(message.data); - if (msg.event === 'auth') { - if (msg.error === 'PermissionError' || msg.error === 'PrivilegeError') { - this.close(); - } - } else this.onmessage?.(message); + if (msg.error === 'PermissionError' || msg.error === 'PrivilegeError') this.close(); + else this.onmessage?.(message); }; } diff --git a/packages/ui-default/package.json b/packages/ui-default/package.json index 70504d0e6..a879f8f0a 100644 --- a/packages/ui-default/package.json +++ b/packages/ui-default/package.json @@ -1,6 +1,6 @@ { "name": "@hydrooj/ui-default", - "version": "4.35.0", + "version": "4.35.1", "author": "undefined ", "license": "AGPL-3.0", "main": "hydro.js", diff --git a/packages/ui-default/sharedworker.ts b/packages/ui-default/sharedworker.ts deleted file mode 100644 index 798ed7045..000000000 --- a/packages/ui-default/sharedworker.ts +++ /dev/null @@ -1,54 +0,0 @@ -import ReconnectingWebsocket from 'reconnecting-websocket'; - -console.log('SharedWorker init'); -const ports: MessagePort[] = []; -let conn: ReconnectingWebsocket; - -function initSharedConn(path: string, sid: string) { - if (conn) return; - console.log('Init connection for', path); - const url = new URL(path); - url.protocol = url.protocol.replace('http', 'ws'); - url.searchParams.append('sid', sid); - const sock = new ReconnectingWebsocket(url.toString()); - conn = sock; - sock.onopen = () => console.log('Connected'); - sock.onerror = console.error; - sock.onclose = (...args) => console.log('Closed', ...args); - sock.onmessage = async (message) => { - if (process.env.NODE_ENV !== 'production') console.log('SharedWorker.port.onmessage: ', message); - const payload = JSON.parse(message.data); - if (payload.event === 'auth') { - if (['PermissionError', 'PrivilegeError'].includes(payload.error)) { - conn?.close(); - conn = null; - } - return; - } - for (const port of ports) { - port.postMessage({ type: 'message', payload: message.data }); - const success = await new Promise((resolve) => { - const handle = (msg) => { - if (['success', 'fail'].includes(msg.data.type)) { - port.removeEventListener('message', handle); - resolve(msg.data.type === 'success'); - } - }; - port.addEventListener('message', handle); - }); - if (success) return; - } - console.log('Failed to push notification'); - // TODO try system notification - }; -} - -// @ts-ignore -onconnect = function (e) { // eslint-disable-line no-undef - const port: MessagePort = e.ports[0]; - port.addEventListener('message', (msg) => { - initSharedConn(msg.data.path, msg.data.cookie); - }); - port.start(); - ports.push(port); -}; From cad010482b9c29375980e442c34d8d0107296a2f Mon Sep 17 00:00:00 2001 From: undefined Date: Wed, 4 May 2022 20:46:12 +0800 Subject: [PATCH 03/44] ui: support window.captureException --- packages/ui-default/components/zipDownloader/index.js | 2 ++ packages/ui-default/hydro.ts | 1 + packages/ui-default/package.json | 2 +- packages/ui-default/pages/home_files.page.js | 1 + packages/ui-default/pages/problem_files.page.js | 2 ++ 5 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/ui-default/components/zipDownloader/index.js b/packages/ui-default/components/zipDownloader/index.js index 56ea2f88d..eb3c3e183 100644 --- a/packages/ui-default/components/zipDownloader/index.js +++ b/packages/ui-default/components/zipDownloader/index.js @@ -51,6 +51,7 @@ export default async function download(filename, targets) { stream, }; } catch (e) { + window.captureException?.(e); stopDownload(); Notification.error(i18n('Download Error', [target.filename, e.toString()])); } @@ -144,6 +145,7 @@ export async function downloadProblemSet(pids, name = 'Export') { } await download(`${name}.zip`, targets); } catch (e) { + window.captureException?.(e); Notification.error(`${e.message} ${e.params?.[0]}`); } } diff --git a/packages/ui-default/hydro.ts b/packages/ui-default/hydro.ts index 848db2ab4..20d45eaae 100644 --- a/packages/ui-default/hydro.ts +++ b/packages/ui-default/hydro.ts @@ -64,6 +64,7 @@ async function load() { try { await func(currentPageName, loadPage(1, type)); } catch (e) { + (window as any).captureException?.(e); Notification.warn(`Failed to call '${type}Loading' of ${page.name}`); console.error(`Failed to call '${type}Loading' of ${page.name}\n${e.stack}`); console.error(e); diff --git a/packages/ui-default/package.json b/packages/ui-default/package.json index a879f8f0a..882932c39 100644 --- a/packages/ui-default/package.json +++ b/packages/ui-default/package.json @@ -1,6 +1,6 @@ { "name": "@hydrooj/ui-default", - "version": "4.35.1", + "version": "4.35.2", "author": "undefined ", "license": "AGPL-3.0", "main": "hydro.js", diff --git a/packages/ui-default/pages/home_files.page.js b/packages/ui-default/pages/home_files.page.js index 4b8a816cd..1aca098df 100644 --- a/packages/ui-default/pages/home_files.page.js +++ b/packages/ui-default/pages/home_files.page.js @@ -88,6 +88,7 @@ async function handleClickUpload(files) { Notification.success(i18n('File uploaded successfully.')); await pjax.request({ push: false }); } catch (e) { + window.captureException?.(e); console.error(e); Notification.error(i18n('File upload failed: {0}', e.toString())); } finally { diff --git a/packages/ui-default/pages/problem_files.page.js b/packages/ui-default/pages/problem_files.page.js index 4692c389f..1b4e08311 100644 --- a/packages/ui-default/pages/problem_files.page.js +++ b/packages/ui-default/pages/problem_files.page.js @@ -100,6 +100,7 @@ const page = new NamedPage('problem_files', () => { Notification.success(i18n('File uploaded successfully.')); await pjax.request({ push: false }); } catch (e) { + window.captureException?.(e); console.error(e); Notification.error(i18n('File upload failed: {0}', e.toString())); } finally { @@ -238,6 +239,7 @@ const page = new NamedPage('problem_files', () => { const res = await request.get(link); content = await request.get(res.url, undefined, { dataType: 'text' }); } catch (e) { + window.captureException?.(e); Notification.error(i18n('Failed to load file: {0}', e.message)); throw e; } From 22680fe73d925d7cb09f64d02d8fac6fa8955d41 Mon Sep 17 00:00:00 2001 From: panda Date: Wed, 4 May 2022 22:20:30 +0800 Subject: [PATCH 04/44] ui: fix availableLangs when langsList is '' (#353) --- packages/ui-default/utils/availableLangs.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui-default/utils/availableLangs.ts b/packages/ui-default/utils/availableLangs.ts index 6226b51fb..727351cdc 100644 --- a/packages/ui-default/utils/availableLangs.ts +++ b/packages/ui-default/utils/availableLangs.ts @@ -4,8 +4,8 @@ export default function getAvailableLangs(langsList?: string[]) { const Langs = {}; for (const key in LANGS) { if (prefixes.has(key)) continue; - if (langsList && !langsList.includes(key)) continue; - if (LANGS[key].hidden && !langsList?.includes(key)) continue; + if (langsList && langsList.length && langsList.join('') && !langsList.includes(key)) continue; + else if (LANGS[key].hidden) continue; Langs[key] = LANGS[key]; } return Langs; From 34aa8676727db656aa51bf84716efa85a8d0251e Mon Sep 17 00:00:00 2001 From: undefined Date: Thu, 5 May 2022 23:07:06 +0800 Subject: [PATCH 05/44] ui: desktop notification click event --- .../components/message/index.page.ts | 145 +++++++++--------- packages/ui-default/package.json | 2 +- 2 files changed, 70 insertions(+), 77 deletions(-) diff --git a/packages/ui-default/components/message/index.page.ts b/packages/ui-default/components/message/index.page.ts index 088646162..b6728dff6 100644 --- a/packages/ui-default/components/message/index.page.ts +++ b/packages/ui-default/components/message/index.page.ts @@ -6,55 +6,6 @@ import { FLAG_ALERT } from 'vj/constant/message'; import i18n from 'vj/utils/i18n'; import tpl from 'vj/utils/tpl'; import { nanoid } from 'nanoid'; -import sleep from 'vj/utils/delay'; - -function asMaster() { - console.log('init as master'); - const channel = new BroadcastChannel('hydro-messages'); - const interval = setInterval(() => channel.postMessage({ type: 'master' }), 5000); - const url = new URL('/home/messages-conn', window.location.href.replace('http', 'ws')); - url.searchParams.append('sid', document.cookie); - const sock = new ReconnectingWebsocket(url.toString()); - function destory() { - sock.close(); - channel.close(); - clearInterval(interval); - } - const pending = {}; - sock.onopen = () => console.log('Connected'); - sock.onerror = console.error; - sock.onclose = (...args) => console.log('Closed', ...args); - sock.onmessage = async (message) => { - if (process.env.NODE_ENV !== 'production') console.log('onmessage: ', message); - const payload = JSON.parse(message.data); - if (['PermissionError', 'PrivilegeError'].includes(payload.error)) { - destory(); - return; - } - const id = nanoid(); - channel.postMessage({ type: 'message', id, payload }); - const success = await new Promise((resolve) => { - pending[id] = resolve; - setTimeout(() => { - delete pending[id]; - resolve(false); - }, 1000); - }); - if (!success && window.Notification?.permission === 'granted') { - // eslint-disable-next-line no-new - new window.Notification( - payload.udoc.uname || 'Hydro Notification', - { - icon: payload.udoc.avatarUrl || '/android-chrome-192x192.png', - body: payload.mdoc.content, - }, - ); - } - }; - channel.onmessage = (msg) => { - if (msg.data.type === 'message-push') pending[msg.data.id]?.(true); - }; -} const onmessage = (msg) => { console.log('Received message', msg); @@ -83,32 +34,6 @@ const onmessage = (msg) => { }).show(); }; -async function asSlave() { - console.log('message load as slave'); - const channel = new BroadcastChannel('hydro-messages'); - const selfId = nanoid(); - setInterval(() => channel.postMessage({ type: 'node', id: selfId }), 5000); - let alive = false; - let nodes = new Set([selfId]); - channel.onmessage = (msg) => { - if (msg.data.target && msg.data.target !== selfId) return; - if (msg.data.type === 'master') alive = true; - if (msg.data.type === 'node') nodes.add(msg.data.id); - if (msg.data.type === 'message') { - if (!document.hidden) { - channel.postMessage({ type: 'message-push', id: msg.data.id }); - onmessage(msg.data.payload); - } - } - }; - while (true) { // eslint-disable-line no-constant-condition - alive = false; - nodes = new Set([selfId]); - await sleep(7000); - if (!alive && Array.from(nodes).sort()[0] === selfId) asMaster(); - } -} - const messagePage = new AutoloadPage('messagePage', (pagename) => { if (pagename === 'home_messages' || !UserContext._id) return; if (UserContext.unreadMsg) { @@ -123,7 +48,75 @@ const messagePage = new AutoloadPage('messagePage', (pagename) => { console.error('BoardcastChannel not supported'); return; } - asSlave(); + + let isMaster = false; + const selfId = nanoid(); + const channel = new BroadcastChannel('hydro-messages'); + const pages = JSON.parse(localStorage.getItem('pages') || '[]') as string[]; + pages.push(selfId); + pages.sort(); + localStorage.setItem('pages', JSON.stringify(pages)); + + window.onunload = () => { + const c = JSON.parse(localStorage.getItem('pages') || '[]') as string[]; + localStorage.setItem('pages', JSON.stringify(c.filter((i) => i !== selfId))); + if (!isMaster) return; + localStorage.removeItem('page.master'); + channel?.postMessage({ type: 'master' }); + }; + + function asMaster() { + console.log('init as master'); + isMaster = true; + localStorage.setItem('page.master', selfId); + const masterChannel = new BroadcastChannel('hydro-messages'); + const url = new URL('/home/messages-conn', window.location.href.replace('http', 'ws')); + // TODO handle a better way for cookie + url.searchParams.append('sid', document.cookie); + const sock = new ReconnectingWebsocket(url.toString()); + const pending = {}; + sock.onopen = () => console.log('Connected'); + sock.onerror = console.error; + sock.onclose = (...args) => console.log('Closed', ...args); + sock.onmessage = async (message) => { + if (process.env.NODE_ENV !== 'production') console.log('onmessage: ', message); + const payload = JSON.parse(message.data); + const id = nanoid(); + masterChannel.postMessage({ type: 'message', id, payload }); + const success = await new Promise((resolve) => { + pending[id] = resolve; + setTimeout(() => { + delete pending[id]; + resolve(false); + }, 1000); + }); + if (!success && window.Notification?.permission === 'granted') { + const notification = new window.Notification( + payload.udoc.uname || 'Hydro Notification', + { + icon: payload.udoc.avatarUrl || '/android-chrome-192x192.png', + body: payload.mdoc.content, + }, + ); + notification.onclick = () => window.open('/home/messages'); + } + }; + masterChannel.onmessage = (msg) => { + if (msg.data.type === 'message-push') pending[msg.data.id]?.(true); + }; + } + + channel.onmessage = (msg) => { + if (msg.data.type === 'message' && !document.hidden) { + channel.postMessage({ type: 'message-push', id: msg.data.id }); + onmessage(msg.data.payload); + } + if (msg.data.type === 'master') { + const c = JSON.parse(localStorage.getItem('pages') || '[]') as string[]; + if (c[0] === selfId) asMaster(); + } + }; + if (!localStorage.getItem('page.master')) asMaster(); }); export default messagePage; diff --git a/packages/ui-default/package.json b/packages/ui-default/package.json index 882932c39..7bc1b184a 100644 --- a/packages/ui-default/package.json +++ b/packages/ui-default/package.json @@ -1,6 +1,6 @@ { "name": "@hydrooj/ui-default", - "version": "4.35.2", + "version": "4.35.3", "author": "undefined ", "license": "AGPL-3.0", "main": "hydro.js", From e9e2695fdd200add499f051b1bea7798c5b2d26e Mon Sep 17 00:00:00 2001 From: undefined Date: Fri, 6 May 2022 01:42:43 +0800 Subject: [PATCH 06/44] ui: add sourcemap and version string --- packages/ui-default/build/config/webpack.js | 2 ++ packages/ui-default/build/main.ts | 2 ++ packages/ui-default/components/objective-question/index.jsx | 1 + packages/ui-default/entry.js | 1 + packages/ui-default/package.json | 2 +- 5 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/ui-default/build/config/webpack.js b/packages/ui-default/build/config/webpack.js index d9668ddc0..e55cf2877 100644 --- a/packages/ui-default/build/config/webpack.js +++ b/packages/ui-default/build/config/webpack.js @@ -81,6 +81,7 @@ export default function (env = {}) { mode: (env.production || env.measure) ? 'production' : 'development', profile: true, context: root(), + devtool: env.production ? 'source-map' : 'eval-source-map', entry: { hydro: './entry.js', polyfill: './polyfill.ts', @@ -211,6 +212,7 @@ export default function (env = {}) { new webpack.DefinePlugin({ 'process.env': { NODE_ENV: env.production ? '"production"' : '"debug"', + VERSION: JSON.stringify(require('@hydrooj/ui-default/package.json').version), }, }), new webpack.LoaderOptionsPlugin({ diff --git a/packages/ui-default/build/main.ts b/packages/ui-default/build/main.ts index dec6bab96..9251fffd9 100644 --- a/packages/ui-default/build/main.ts +++ b/packages/ui-default/build/main.ts @@ -94,6 +94,8 @@ async function main() { fs.removeSync('public/vditor/dist/js/graphviz'); fs.removeSync('public/vditor/dist/js/mermaid'); fs.removeSync('public/vditor/dist/js/abcjs'); + const files = fs.readdirSync('public'); + files.filter((i) => /(^[in]\..+|worker)\.js\.map$/.test(i)).forEach((i) => fs.removeSync(`public/${i}`)); } } process.chdir(dir); diff --git a/packages/ui-default/components/objective-question/index.jsx b/packages/ui-default/components/objective-question/index.jsx index 8a81c97c7..c810c25a3 100644 --- a/packages/ui-default/components/objective-question/index.jsx +++ b/packages/ui-default/components/objective-question/index.jsx @@ -1,6 +1,7 @@ import React from 'react'; import { sortBy } from 'lodash'; import request from 'vj/utils/request'; +import Notification from 'vj/components/notification'; export default class ObjectiveContainer extends React.PureComponent { constructor(args) { diff --git a/packages/ui-default/entry.js b/packages/ui-default/entry.js index 8cc02a54c..2e2524581 100644 --- a/packages/ui-default/entry.js +++ b/packages/ui-default/entry.js @@ -4,6 +4,7 @@ window.Hydro = { components: {}, utils: {}, node_modules: {}, + version: process.env.VERSION, }; window.externalModules = {}; diff --git a/packages/ui-default/package.json b/packages/ui-default/package.json index 7bc1b184a..db3c11d88 100644 --- a/packages/ui-default/package.json +++ b/packages/ui-default/package.json @@ -1,6 +1,6 @@ { "name": "@hydrooj/ui-default", - "version": "4.35.3", + "version": "4.35.4", "author": "undefined ", "license": "AGPL-3.0", "main": "hydro.js", From 2620286aefde79afe7632a0849f05430cf3caa31 Mon Sep 17 00:00:00 2001 From: undefined Date: Fri, 6 May 2022 10:02:43 +0800 Subject: [PATCH 07/44] ui: fix lang select --- packages/ui-default/package.json | 2 +- packages/ui-default/utils/availableLangs.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui-default/package.json b/packages/ui-default/package.json index db3c11d88..58f641b64 100644 --- a/packages/ui-default/package.json +++ b/packages/ui-default/package.json @@ -1,6 +1,6 @@ { "name": "@hydrooj/ui-default", - "version": "4.35.4", + "version": "4.35.5", "author": "undefined ", "license": "AGPL-3.0", "main": "hydro.js", diff --git a/packages/ui-default/utils/availableLangs.ts b/packages/ui-default/utils/availableLangs.ts index 727351cdc..cd9837fa4 100644 --- a/packages/ui-default/utils/availableLangs.ts +++ b/packages/ui-default/utils/availableLangs.ts @@ -5,7 +5,7 @@ export default function getAvailableLangs(langsList?: string[]) { for (const key in LANGS) { if (prefixes.has(key)) continue; if (langsList && langsList.length && langsList.join('') && !langsList.includes(key)) continue; - else if (LANGS[key].hidden) continue; + else if (LANGS[key].hidden && !langList?.includes(key)) continue; Langs[key] = LANGS[key]; } return Langs; From 91f3b9070ab15eae3663ed56f60208d15462e55a Mon Sep 17 00:00:00 2001 From: undefined Date: Fri, 6 May 2022 10:23:17 +0800 Subject: [PATCH 08/44] ui: fix typo --- packages/ui-default/utils/availableLangs.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui-default/utils/availableLangs.ts b/packages/ui-default/utils/availableLangs.ts index cd9837fa4..f35e335db 100644 --- a/packages/ui-default/utils/availableLangs.ts +++ b/packages/ui-default/utils/availableLangs.ts @@ -5,7 +5,7 @@ export default function getAvailableLangs(langsList?: string[]) { for (const key in LANGS) { if (prefixes.has(key)) continue; if (langsList && langsList.length && langsList.join('') && !langsList.includes(key)) continue; - else if (LANGS[key].hidden && !langList?.includes(key)) continue; + else if (LANGS[key].hidden && !langsList?.includes(key)) continue; Langs[key] = LANGS[key]; } return Langs; From 116415707bf8753d342a4abc2444dd57db0f66dd Mon Sep 17 00:00:00 2001 From: undefined Date: Sat, 7 May 2022 21:08:11 +0800 Subject: [PATCH 09/44] core: hide contest scoreboard when not started --- packages/hydrooj/package.json | 2 +- packages/hydrooj/src/model/contest.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/hydrooj/package.json b/packages/hydrooj/package.json index ceb433fb3..71292726a 100644 --- a/packages/hydrooj/package.json +++ b/packages/hydrooj/package.json @@ -1,6 +1,6 @@ { "name": "hydrooj", - "version": "3.12.1", + "version": "3.12.2", "bin": "bin/hydrooj.js", "main": "src/loader", "module": "src/loader", diff --git a/packages/hydrooj/src/model/contest.ts b/packages/hydrooj/src/model/contest.ts index 4e355638d..3703e8b96 100644 --- a/packages/hydrooj/src/model/contest.ts +++ b/packages/hydrooj/src/model/contest.ts @@ -50,7 +50,7 @@ const acm = buildContestRule({ check: () => { }, statusSort: { accept: -1, time: 1 }, submitAfterAccept: false, - showScoreboard: () => true, + showScoreboard: (tdoc, now) => now > tdoc.beginAt, showSelfRecord: () => true, showRecord: (tdoc, now) => now > tdoc.endAt, stat(tdoc, journal: AcmJournal[], ignoreLock = false) { @@ -297,7 +297,7 @@ const ioi = buildContestRule({ submitAfterAccept: false, showRecord: (tdoc, now) => now > tdoc.endAt, showSelfRecord: () => true, - showScoreboard: () => true, + showScoreboard: (tdoc, now) => now > tdoc.beginAt, }); const homework = buildContestRule({ From 6c274497b432718348fd19e7b363ffde987ce40c Mon Sep 17 00:00:00 2001 From: undefined Date: Sun, 8 May 2022 21:52:10 +0800 Subject: [PATCH 10/44] core: fix url generation with domainId --- packages/hydrooj/package.json | 2 +- packages/hydrooj/src/service/layers/renderer.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/hydrooj/package.json b/packages/hydrooj/package.json index 71292726a..430c41b69 100644 --- a/packages/hydrooj/package.json +++ b/packages/hydrooj/package.json @@ -1,6 +1,6 @@ { "name": "hydrooj", - "version": "3.12.2", + "version": "3.12.3", "bin": "bin/hydrooj.js", "main": "src/loader", "module": "src/loader", diff --git a/packages/hydrooj/src/service/layers/renderer.ts b/packages/hydrooj/src/service/layers/renderer.ts index 566fab208..6c6d0ae3c 100644 --- a/packages/hydrooj/src/service/layers/renderer.ts +++ b/packages/hydrooj/src/service/layers/renderer.ts @@ -52,7 +52,7 @@ export default (router, logger) => async (ctx: KoaContext, next) => { || (host instanceof Array ? (!host.includes(request.host)) : request.host !== host) - )) withDomainId = domainId; + )) withDomainId ||= domainId; res = router.url(name, args, { query }).toString(); if (anchor) res = `${res}#${anchor}`; if (withDomainId) res = `/d/${withDomainId}${res}`; From 24aee0a03357769e9dc0ecc58ca23af9ab72a1af Mon Sep 17 00:00:00 2001 From: undefined Date: Sun, 8 May 2022 21:58:13 +0800 Subject: [PATCH 11/44] core: ignore typings mismatch --- packages/hydrooj/src/service/server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/hydrooj/src/service/server.ts b/packages/hydrooj/src/service/server.ts index c15ceaee7..13719f8cf 100644 --- a/packages/hydrooj/src/service/server.ts +++ b/packages/hydrooj/src/service/server.ts @@ -139,7 +139,7 @@ ${ctx.response.status} ${endTime - startTime}ms ${ctx.response.length}`); })); const layers = [baseLayer, rendererLayer(router, logger), responseLayer(router), userLayer]; app.use(async (ctx, next) => await next().catch(console.error)).use(domainLayer); - layers.forEach((layer) => router.use(layer)); + layers.forEach((layer) => router.use(layer as any)); wsServer.on('connection', async (socket, request) => { const ctx: any = app.createContext(request, {} as any); await domainLayer(ctx, () => baseLayer(ctx, () => layers[1](ctx, () => userLayer(ctx, () => { })))); From 0b71b089e0aa12155bc7e3c7335bfd192872480d Mon Sep 17 00:00:00 2001 From: undefined Date: Mon, 9 May 2022 17:19:56 +0800 Subject: [PATCH 12/44] ui: fix domain_join link generation --- packages/ui-default/package.json | 2 +- packages/ui-default/templates/domain_join_applications.html | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/ui-default/package.json b/packages/ui-default/package.json index 58f641b64..5a3abccdd 100644 --- a/packages/ui-default/package.json +++ b/packages/ui-default/package.json @@ -1,6 +1,6 @@ { "name": "@hydrooj/ui-default", - "version": "4.35.5", + "version": "4.35.6", "author": "undefined ", "license": "AGPL-3.0", "main": "hydro.js", diff --git a/packages/ui-default/templates/domain_join_applications.html b/packages/ui-default/templates/domain_join_applications.html index ef504dba1..bbb4f1a03 100644 --- a/packages/ui-default/templates/domain_join_applications.html +++ b/packages/ui-default/templates/domain_join_applications.html @@ -7,10 +7,10 @@

{{ _('User can join this domain by visiting the following URL') }}:

-
{{ url_prefix|safe }}domain/join?domainId={{ handler.args.domainId }}
+
{{ url_prefix|safe }}d/{{ handler.args.domainId }}/domain/join
{% if joinSettings['method'] == model.domain.JOIN_METHOD_CODE %}

{{ _('Or, with automatically filled invitation code') }}:

-
{{ url_prefix|safe }}domain/join?domainId={{ handler.args.domainId }}&code={{ joinSettings.code|urlencode }}
+
{{ url_prefix|safe }}d/{{ handler.args.domainId }}/domain/join?code={{ joinSettings.code|urlencode }}
{% endif %} {% if joinSettings.expire %}
From 1f498484d2e7b73c4aeec0198b89ddf266171898 Mon Sep 17 00:00:00 2001 From: panda Date: Thu, 17 Mar 2022 00:44:55 +0800 Subject: [PATCH 13/44] core&ui: add problem_config Co-authored-by: undefined --- packages/hydrojudge/src/cases.ts | 155 +----- packages/hydrooj/src/handler/problem.ts | 33 +- .../ui-default/components/editor/index.ts | 2 +- .../ui-default/components/menu/menu.page.styl | 3 + .../problemconfig/ProblemConfigEditor.tsx | 89 ++++ .../problemconfig/ProblemConfigForm.tsx | 455 ++++++++++++++++++ .../problemconfig/reducer/config.ts | 73 +++ .../components/problemconfig/reducer/index.ts | 11 + .../problemconfig/reducer/testdata.ts | 16 + packages/ui-default/locales/zh.yaml | 2 + .../ui-default/pages/problem_config.page.styl | 23 + .../ui-default/pages/problem_config.page.tsx | 73 +++ .../ui-default/pages/problem_edit.page.js | 20 + .../ui-default/pages/problem_edit.page.styl | 29 +- .../problem_sidebar_additional_file.html | 57 +++ .../partials/problem_sidebar_normal.html | 7 +- .../partials/problem_sidebar_testdata.html | 58 +++ .../ui-default/templates/problem_config.html | 21 + .../ui-default/templates/problem_edit.html | 13 +- packages/utils/lib/cases.ts | 147 ++++++ 20 files changed, 1128 insertions(+), 159 deletions(-) create mode 100644 packages/ui-default/components/problemconfig/ProblemConfigEditor.tsx create mode 100644 packages/ui-default/components/problemconfig/ProblemConfigForm.tsx create mode 100644 packages/ui-default/components/problemconfig/reducer/config.ts create mode 100644 packages/ui-default/components/problemconfig/reducer/index.ts create mode 100644 packages/ui-default/components/problemconfig/reducer/testdata.ts create mode 100644 packages/ui-default/pages/problem_config.page.styl create mode 100644 packages/ui-default/pages/problem_config.page.tsx create mode 100644 packages/ui-default/templates/partials/problem_sidebar_additional_file.html create mode 100644 packages/ui-default/templates/partials/problem_sidebar_testdata.html create mode 100644 packages/ui-default/templates/problem_config.html diff --git a/packages/hydrojudge/src/cases.ts b/packages/hydrojudge/src/cases.ts index 9c71ed897..cc0fd0bb7 100644 --- a/packages/hydrojudge/src/cases.ts +++ b/packages/hydrojudge/src/cases.ts @@ -2,158 +2,11 @@ import path from 'path'; import fs from 'fs-extra'; import yaml from 'js-yaml'; import { max, sum } from 'lodash'; -import readYamlCases, { convertIniConfig } from '@hydrooj/utils/lib/cases'; +import readYamlCases, { convertIniConfig, readCasesFromFiles, readSubtasksFromFiles } from '@hydrooj/utils/lib/cases'; import { changeErrorType } from '@hydrooj/utils/lib/utils'; import { getConfig } from './config'; import { FormatError, SystemError } from './error'; -import { ensureFile, parseMemoryMB, parseTimeMS } from './utils'; - -interface Re0 { - reg: RegExp, - output: ((a: RegExpExecArray) => string)[], - id: (a: RegExpExecArray) => number, -} - -interface Re1 extends Re0 { - subtask: (a: RegExpExecArray) => number, -} - -const RE0: Re0[] = [ - { - reg: /^([^\d]*)(\d+).(in|txt)$/, - output: [ - (a) => `${a[1] + a[2]}.out`, - (a) => `${a[1] + a[2]}.ans`, - (a) => `${a[1] + a[2]}.out`.replace(/input/g, 'output'), - (a) => (a[1].includes('input') ? `${a[1] + a[2]}.txt`.replace(/input/g, 'output') : null), - ], - id: (a) => +a[2], - }, - { - reg: /^([^\d]*)\.in(\d+)$/, - output: [ - (a) => `${a[1]}.ou${a[2]}`, - (a) => `${a[1]}.ou${a[2]}`.replace(/input/g, 'output'), - ], - id: (a) => +a[2], - }, -]; -const RE1: Re1[] = [ - { - reg: /^([^\d]*)([0-9]+)([-_])([0-9]+).in$/, - output: [(a) => `${a[1] + a[2]}${a[3]}${a[4]}.out`], - subtask: (a) => +a[2], - id: (a) => +a[4], - }, -]; - -async function read0(folder: string, files: string[], checkFile, cfg) { - const cases = []; - for (const file of files) { - for (const REG of RE0) { - if (REG.reg.test(file)) { - const data = REG.reg.exec(file); - const c = { input: file, output: '', id: REG.id(data) }; - for (const func of REG.output) { - if (cfg.noOutputFile) c.output = '/dev/null'; - else c.output = func(data); - if (c.output && (c.output === '/dev/null' || fs.existsSync(path.resolve(folder, c.output)))) { - cases.push(c); - break; - } - } - } - } - } - cases.sort((a, b) => (a.id - b.id)); - const extra = cases.length - (100 % cases.length); - const config = { - count: 0, - subtasks: [{ - time: parseTimeMS(cfg.time || '1s'), - memory: parseMemoryMB(cfg.memory || '256m'), - type: 'sum', - cases: [], - score: Math.floor(100 / cases.length), - }], - }; - for (let i = 0; i < extra; i++) { - config.count++; - config.subtasks[0].cases.push({ - id: config.count, - input: checkFile(cases[i].input), - output: checkFile(cases[i].output), - }); - } - if (extra < cases.length) { - config.subtasks.push({ - time: parseTimeMS(cfg.time || '1s'), - memory: parseMemoryMB(cfg.memory || '256m'), - type: 'sum', - cases: [], - score: Math.floor(100 / cases.length) + 1, - }); - for (let i = extra; i < cases.length; i++) { - config.count++; - config.subtasks[1].cases.push({ - id: config.count, - input: checkFile(cases[i].input), - output: checkFile(cases[i].output), - }); - } - } - return config; -} - -async function read1(folder: string, files: string[], checkFile, cfg, rst) { - const subtask = {}; - for (const s of rst.subtasks) if (s.id) subtask[s.id] = s; - const subtasks = []; - for (const file of files) { - for (const REG of RE1) { - if (REG.reg.test(file)) { - const data = REG.reg.exec(file); - const sid = REG.subtask(data); - const c = { input: file, output: '', id: REG.id(data) }; - for (const func of REG.output) { - if (cfg.noOutputFile) c.output = '/dev/null'; - else c.output = func(data); - if (c.output === '/dev/null' || fs.existsSync(path.resolve(folder, c.output))) { - if (!subtask[sid]) { - subtask[sid] = { - time: parseTimeMS(cfg.time || '1s'), - memory: parseMemoryMB(cfg.memory || '256m'), - type: 'min', - cases: [c], - }; - } else if (!subtask[sid].cases) subtask[sid].cases = [c]; - else subtask[sid].cases.push(c); - break; - } - } - } - } - } - for (const i in subtask) { - subtask[i].cases.sort((a, b) => (a.id - b.id)); - subtasks.push(subtask[i]); - } - const base = Math.floor(100 / subtasks.length); - const extra = subtasks.length - (100 % subtasks.length); - const config = { count: 0, subtasks }; - const keys = Object.keys(subtask); - for (let i = 0; i < keys.length; i++) { - if (i >= extra) subtask[keys[i]].score = base + 1; - else subtask[keys[i]].score = base; - for (const j of subtask[keys[i]].cases) { - config.count++; - j.input = checkFile(j.input); - j.output = checkFile(j.output); - j.id = config.count; - } - } - return config; -} +import { ensureFile, parseMemoryMB } from './utils'; async function readAutoCases(folder: string, { next }, cfg, rst) { const config = { @@ -174,8 +27,8 @@ async function readAutoCases(folder: string, { next }, cfg, rst) { const outputs = await fs.readdir(path.resolve(folder, 'output')); files.push(...outputs.map((i) => `output/${i}`)); } - let result = await read0(folder, files, checkFile, cfg); - if (!result.count) result = await read1(folder, files, checkFile, cfg, rst); + let result = await readCasesFromFiles(files, checkFile, cfg); + if (!result.count) result = await readSubtasksFromFiles(files, checkFile, cfg, rst); Object.assign(config, result); if (cfg.isSelfSubmission) next({ message: { message: 'Found {0} testcases.', params: [config.count] } }); } catch (e) { diff --git a/packages/hydrooj/src/handler/problem.ts b/packages/hydrooj/src/handler/problem.ts index 36b747ba3..a37cd78bb 100644 --- a/packages/hydrooj/src/handler/problem.ts +++ b/packages/hydrooj/src/handler/problem.ts @@ -4,7 +4,8 @@ import { isBinaryFile } from 'isbinaryfile'; import { intersection, isSafeInteger } from 'lodash'; import { FilterQuery, ObjectID } from 'mongodb'; import { nanoid } from 'nanoid'; -import { sortFiles } from '@hydrooj/utils/lib/utils'; +import { readCasesFromFiles, readSubtasksFromFiles } from '@hydrooj/utils/lib/cases'; +import { sortFiles, streamToBuffer } from '@hydrooj/utils/lib/utils'; import { BadRequestError, ContestNotAttendedError, ContestNotEndedError, ContestNotFoundError, ContestNotLiveError, @@ -38,6 +39,9 @@ import { registerResolver, registerValue } from './api'; export const parseCategory = (value: string) => value.replace(/,/g, ',').split(',').map((e) => e.trim()); export const parsePid = (value: string) => (isSafeInteger(value) ? +value : value); +function ensureFile(testdata) { + return (file: string) => testdata.filter((i) => i === file)[0]; +} registerValue('FileInfo', [ ['_id', 'String!'], @@ -525,6 +529,7 @@ export class ProblemManageHandler extends ProblemDetailHandler { export class ProblemEditHandler extends ProblemManageHandler { async get() { + this.response.body.additional_file = sortFiles(this.pdoc.additional_file || []); this.response.template = 'problem_edit.html'; } @@ -550,6 +555,30 @@ export class ProblemEditHandler extends ProblemManageHandler { } } +export class ProblemConfigHandler extends ProblemManageHandler { + async get() { + if (this.pdoc.reference) throw new ForbiddenError('Cannot edit config of a referenced problem.'); + this.response.body.testdata = sortFiles(this.pdoc.data || []); + const configFile = (this.pdoc.data || []).filter((i) => i.name.toLowerCase() === 'config.yaml'); + this.response.body.config = ''; + if (configFile.length > 0) { + try { + this.response.body.config = (await streamToBuffer( + await storage.get(`problem/${this.pdoc.domainId}/${this.pdoc.docId}/testdata/${configFile[0].name}`), + )).toString(); + } catch (e) { /* ignore */ } + } + const testdata = (this.pdoc.data || []).map((i) => i.name); + const checkFile = ensureFile(testdata); + let autocases = await readCasesFromFiles(testdata, checkFile, {}); + if (!autocases.count) { + autocases = await readSubtasksFromFiles(testdata, checkFile, {}, { subtasks: [] }); + } + this.response.body.autocases = autocases; + this.response.template = 'problem_config.html'; + } +} + export class ProblemFilesHandler extends ProblemDetailHandler { notUsage = true; @@ -831,6 +860,7 @@ export class ProblemCreateHandler extends Handler { this.response.template = 'problem_edit.html'; this.response.body = { page_name: 'problem_create', + additional_file: [], }; } @@ -892,6 +922,7 @@ export async function apply() { Route('problem_detail', '/p/:pid', ProblemDetailHandler, PERM.PERM_VIEW_PROBLEM); Route('problem_submit', '/p/:pid/submit', ProblemSubmitHandler, PERM.PERM_SUBMIT_PROBLEM); Route('problem_edit', '/p/:pid/edit', ProblemEditHandler); + Route('problem_config', '/p/:pid/config', ProblemConfigHandler); Route('problem_files', '/p/:pid/files', ProblemFilesHandler, PERM.PERM_VIEW_PROBLEM); Route('problem_file_download', '/p/:pid/file/:filename', ProblemFileDownloadHandler, PERM.PERM_VIEW_PROBLEM); Route('problem_solution', '/p/:pid/solution', ProblemSolutionHandler, PERM.PERM_VIEW_PROBLEM); diff --git a/packages/ui-default/components/editor/index.ts b/packages/ui-default/components/editor/index.ts index 001da120b..7e328687e 100644 --- a/packages/ui-default/components/editor/index.ts +++ b/packages/ui-default/components/editor/index.ts @@ -81,7 +81,7 @@ export default class Editor extends DOMAttachedObject { ? monaco.editor.getModel(monaco.Uri.parse(model)) || monaco.editor.createModel(value, language === 'auto' ? undefined : language, monaco.Uri.parse(model)) : model; - this.model.setValue(value); + if (!this.options.model) this.model.setValue(value); const cfg: import('../monaco').default.editor.IStandaloneEditorConstructionOptions = { theme, lineNumbers: 'on', diff --git a/packages/ui-default/components/menu/menu.page.styl b/packages/ui-default/components/menu/menu.page.styl index 4b301cc37..48777ce75 100644 --- a/packages/ui-default/components/menu/menu.page.styl +++ b/packages/ui-default/components/menu/menu.page.styl @@ -42,6 +42,9 @@ opacity: 0.85 a.menu__link, button.menu__link + &.half + padding-right: 0 + width: 50% &.disabled cursor: default opacity: 0.5 diff --git a/packages/ui-default/components/problemconfig/ProblemConfigEditor.tsx b/packages/ui-default/components/problemconfig/ProblemConfigEditor.tsx new file mode 100644 index 000000000..0028f8c1b --- /dev/null +++ b/packages/ui-default/components/problemconfig/ProblemConfigEditor.tsx @@ -0,0 +1,89 @@ +import React from 'react'; +import type { editor } from 'monaco-editor'; +import { connect } from 'react-redux'; +import { load } from 'vj/components/monaco/loader'; +import Editor from 'vj/components/editor'; +import yaml from 'js-yaml'; + +const mapStateToProps = (state) => ({ + config: state.config, +}); +const mapDispatchToProps = (dispatch) => ({ + handleUpdateCode: (code) => { + dispatch({ + type: 'CONFIG_CODE_UPDATE', + payload: code, + }); + }, +}); + +interface Props { + config: object; + handleUpdateCode: Function; +} + +export default connect(mapStateToProps, mapDispatchToProps)(class MonacoEditor extends React.PureComponent { + disposable = []; + containerElement: HTMLElement; + private __preventUpdate = false; + + editor: editor.IStandaloneCodeEditor; + model: editor.ITextModel; + vjEditor: Editor; + + async componentDidMount() { + const { monaco } = await load(['yaml']); + const uri = monaco.Uri.parse('hydro://problem/file/config.yaml'); + this.model = monaco.editor.createModel(yaml.dump(this.props.config), 'yaml', uri); + this.vjEditor = Editor.getOrConstruct($(this.containerElement), { + language: 'yaml', + model: this.model, + onChange: (value: string) => { + this.__preventUpdate = true; + this.props.handleUpdateCode(value); + this.__preventUpdate = false; + }, + }) as Editor; + this.editor = this.vjEditor.editor; + } + + componentDidUpdate(prevProps) { + if (this.__preventUpdate) return; + if (yaml.dump(prevProps.config) !== yaml.dump(this.props.config)) { + this.model?.pushEditOperations( + [], + [{ + range: this.model.getFullModelRange(), + text: yaml.dump(this.props.config), + }], + undefined, + ); + } + } + + componentWillUnmount() { + if (this.vjEditor) this.vjEditor.destory(); + if (this.model) this.model.dispose(); + if (this.editor) this.editor.dispose(); + this.disposable.map((i) => i.dispose()); + } + + assignRef = (component) => { + this.containerElement = component; + }; + + render() { + return ( +
+
+ ); + } +}); diff --git a/packages/ui-default/components/problemconfig/ProblemConfigForm.tsx b/packages/ui-default/components/problemconfig/ProblemConfigForm.tsx new file mode 100644 index 000000000..d9e0402f3 --- /dev/null +++ b/packages/ui-default/components/problemconfig/ProblemConfigForm.tsx @@ -0,0 +1,455 @@ +import React, { useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import i18n from 'vj/utils/i18n'; +import { isEqual } from 'lodash'; +import { Switch, Tab, Tabs } from '@blueprintjs/core'; +import type { TestCaseConfig } from 'hydrooj/src/interface'; +import type { RootState } from './reducer/index'; +import CustomSelectAutoComplete from '../autocomplete/components/CustomSelectAutoComplete'; + +const SelectValue = { + type: ['default', 'interactive', 'submit_answer', 'objective'], + checker_type: ['default', 'lemon', 'syzoj', 'hustoj', 'testlib', 'qduoj'], + task_type: ['min', 'max', 'sum'], +}; + +const eq = (a: TestCaseConfig[], b: TestCaseConfig[]) => isEqual(a, b); + +function FormItem({ + columns, label, children, helpText = '', disableLabel = false, ...props +}) { + return ( +
+ +
+ ); +} + +type KeyType = { + [Q in keyof K]: K[Q] extends T ? Q : never; +}[keyof K]; + +function ManagedInput({ placeholder, formKey }: { placeholder: string, formKey: KeyType }) { + const value = useSelector((state: RootState) => state.config[formKey]); + const dispatch = useDispatch(); + return ( + { + dispatch({ type: 'CONFIG_FORM_UPDATE', key: formKey, value: ev.currentTarget.value }); + }} + className="textbox" + /> + ); +} + +function ManagedSelect({ placeholder, formKey }: { placeholder: string, formKey: KeyType }) { + const value = useSelector((state: RootState) => state.config[formKey]); + const dispatch = useDispatch(); + return ( + + ); +} + +function SingleFileSelect({ formKey }: { formKey: KeyType }) { + const value = useSelector((state: RootState) => state.config[formKey]); + const Files = useSelector((state: RootState) => state.testdata); + const dispatch = useDispatch(); + return ( + dispatch({ type: 'CONFIG_FORM_UPDATE', key: formKey, value: val })} + /> + ); +} + +function BasicInfo() { + const Type = useSelector((state: RootState) => state.config.type); + const checkerType = useSelector((state: RootState) => state.config.checker_type); + const dispatch = useDispatch(); + return ( + <> + + + + + + + + { + dispatch({ type: 'CONFIG_FORM_UPDATE', key: 'checker_type', value }); + }} + renderActiveTabPanelOnly + > + {i18n('CheckerType')} + + { + SelectValue.checker_type.map((i) => ( + + {['default', 'strict'].includes(i) + ? ( + { + dispatch({ type: 'CONFIG_FORM_UPDATE', key: 'checker_type', value: checkerType === 'strict' ? 'default' : 'strict' }); + } + } + /> + ) + : ( + + )} + + )} + /> + )) + } + + + + + + + ); +} + +function ExtraFilesConfig() { + const [showTab, setshowTab] = useState(false); + const Files = useSelector((state: RootState) => state.testdata); + const userExtraFiles = useSelector((state: RootState) => state.config.user_extra_files); + const judgeExtraFiles = useSelector((state: RootState) => state.config.judge_extra_files); + const dispatch = useDispatch(); + return ( + + setshowTab(!showTab)} /> +
+ + dispatch({ type: 'CONFIG_FORM_UPDATE', key: 'user_extra_files', value: val.split(',') })} + multi + /> + )} + /> + dispatch({ type: 'CONFIG_FORM_UPDATE', key: 'judge_extra_files', value: val.split(',') })} + multi + /> + )} + /> + +
+
+ ); +} + +function CasesTable({ index }) { + const [time, setTime] = useState(''); + const [memory, setMemory] = useState(''); + const [input, setInput] = useState(''); + const [output, setOutput] = useState(''); + const cases = useSelector((state: RootState) => (index === -1 ? state.config.cases : state.config.subtasks[index].cases), eq); + console.log(cases); + const Files = useSelector((state: RootState) => state.testdata); + const dispatch = useDispatch(); + return ( + + + + { + index === -1 && ( + <> + + + + ) + } + + + + + + + {cases && cases.map((k, v) => ( + + { + index === -1 && ( + <> + + + + ) + } + + + + + ))} + +
+ {i18n('Time')}
+ setTime(ev.currentTarget.value)} + className="textbox" + /> +
+ {i18n('Memory')}
+ setMemory(ev.currentTarget.value)} + className="textbox" + /> +
+ {i18n('Input')}
+ +
+ {i18n('Output')}
+ +
+
+ { + setTime(''); + setMemory(''); + setInput(''); + setOutput(''); + dispatch({ + type: index === -1 ? 'CONFIG_CASES_UPDATE' : 'CONFIG_SUBTASK_UPDATE', + id: index, + key: 'cases-add', + value: index === -1 ? { + time, memory, input, output, + } : { input, output }, + }); + }} + >{i18n('Add')} + +
{k.time}{k.memory}{k.input}{k.output} + dispatch({ + type: index === -1 ? 'CONFIG_CASES_UPDATE' : 'CONFIG_SUBTASK_UPDATE', id: index, key: 'cases-delete', value: v, + })} + > + +
+ ); +} + +function SubtasksTable({ data, index }) { + const subtasks = useSelector((state: RootState) => state.config.subtasks, eq); + const dispatch = useDispatch(); + const dispatcher = (key: string): React.ChangeEventHandler => (ev) => { + dispatch({ + type: 'CONFIG_SUBTASK_UPDATE', id: index, key, value: ev.currentTarget.value, + }); + }; + return ( +
+ Subtasks #{index} + { dispatch({ type: 'CONFIG_SUBTASK_UPDATE', id: index, key: 'add' }); }}> + { dispatch({ type: 'CONFIG_SUBTASK_UPDATE', id: index, key: 'delete' }); }} + > + + + + + + + + + + + + + + + + + + + + + + + + +
#ScoreTypeTimeMemory
+ + + + + + + + + +
if + dispatch({ + type: 'CONFIG_SUBTASK_UPDATE', id: index, key: 'if', value: val.split(','), + })} + multi + /> +
+ +
+ ); +} + +function TaskConfig() { + const subtasks = useSelector((state: RootState) => state.config.subtasks, eq); + const cases = useSelector((state: RootState) => state.config.cases, eq); + const dispatch = useDispatch(); + return ( + +
+ + + + + + + + + + + {cases && dispatch({ type: 'CONFIG_SUBTASKS_SWITCH', value: true })} />} + { + subtasks || cases + ? subtasks &&
    {subtasks.map((k, v) => )}
+ || cases && () + : ( + dispatch({ type: 'CONFIG_AUTOCASES_UPDATE' })} + > {i18n('Auto Read Tasks')} + + ) + } +
+
+
+ ); +} + +function LangConfig() { + const [showTab, setshowTab] = useState(false); + const langs = useSelector((state: RootState) => state.config.langs) || []; + const prefixes = new Set(Object.keys(LANGS).filter((i) => i.includes('.')).map((i) => i.split('.')[0])); + const data = Object.keys(LANGS).filter((i) => !prefixes.has(i)) + .map((i) => ({ name: LANGS[i].display, _id: i })); + const dispatch = useDispatch(); + const ref = React.useRef(); + const selectedKeys = langs.filter((i) => !prefixes.has(i)); + React.useEffect(() => { + ref.current.setSelectedKeys(selectedKeys); + }, [JSON.stringify(selectedKeys)]); + return ( + + setshowTab(!showTab)} /> + + { + const value = val.split(','); + value.push(...Array.from(new Set(value.filter((i) => i.includes('.')).map((i) => i.split('.')[0])))); + dispatch({ type: 'CONFIG_FORM_UPDATE', key: 'langs', value }); + }} + multi + /> + + + ); +} + +export default function ProblemConfigForm() { + return ( +
+ + + + +
+ ); +} diff --git a/packages/ui-default/components/problemconfig/reducer/config.ts b/packages/ui-default/components/problemconfig/reducer/config.ts new file mode 100644 index 000000000..a6eb70031 --- /dev/null +++ b/packages/ui-default/components/problemconfig/reducer/config.ts @@ -0,0 +1,73 @@ +import yaml from 'js-yaml'; +import type { ProblemConfigFile } from 'hydrooj/src/interface'; + +let autocases = { subtasks: [] }; + +export default function reducer(state = { type: 'default' } as ProblemConfigFile, action): ProblemConfigFile { + switch (action.type) { + case 'CONFIG_LOAD_FULFILLED': { + // TODO set yaml schema + autocases = action.payload.autocases; + return { ...state, ...yaml.load(action.payload.config) as object }; + } + case 'CONFIG_FORM_UPDATE': { + const next = { ...state, [action.key]: action.value }; + if (!action.value || (typeof action.value === 'object' && !action.value.join(''))) delete next[action.key]; + if (action.key === 'type' && action.value !== 'default') { + delete next.checker_type; + delete next.checker; + } + if (action.key === 'type' && action.value !== 'interactive') delete next.interactor; + if (action.key === 'checker_type' && ['default', 'strict'].includes(action.value)) delete next.checker; + return next; + } + case 'CONFIG_CODE_UPDATE': { + try { + return yaml.load(action.payload); + } catch { + return state; + } + } + case 'CONFIG_AUTOCASES_UPDATE': { + const next = { ...state }; + if (autocases.subtasks.length === 1) { + next.score = autocases.subtasks[0].score; + next.cases = []; + autocases.subtasks[0].cases.map((i) => next.cases.push({ + time: autocases.subtasks[0].time, memory: autocases.subtasks[0].memory, input: i.input, output: i.output, + })); + } else { + next.subtasks = autocases.subtasks.map((subtask) => ( + { ...subtask, ...{ cases: subtask.cases.map((i) => ({ input: i.input, output: i.output })) } })); + } + return next; + } + case 'CONFIG_SUBTASKS_SWITCH': { + const next = { ...state }; + next.subtasks = next.cases; + if (!next.subtasks.length) next.subtasks.push({ id: 0 }); + delete next.cases; + return next; + } + case 'CONFIG_CASES_UPDATE': { + const next = { ...state }; + if (action.key === 'cases-add') next.cases.push(action.value); + else if (action.key === 'cases-delete') { + next.cases = next.cases.filter((k, v) => v !== action.value); + } + return next; + } + case 'CONFIG_SUBTASK_UPDATE': { + const next = { ...state }; + if (action.key === 'add') next.subtasks.splice(action.id, 0, { id: 0 }); + else if (action.key === 'delete') delete next.subtasks[action.key]; + else if (action.key === 'cases-add') next.subtasks[action.id].cases.push(action.value); + else if (action.key === 'cases-delete') { + next.subtasks[action.id].cases = next.subtasks[action.id].cases.filter((k, v) => v !== action.value); + } else next.subtasks[action.id][action.key] = action.value; + return next; + } + default: + return state; + } +} diff --git a/packages/ui-default/components/problemconfig/reducer/index.ts b/packages/ui-default/components/problemconfig/reducer/index.ts new file mode 100644 index 000000000..3ddfbafbd --- /dev/null +++ b/packages/ui-default/components/problemconfig/reducer/index.ts @@ -0,0 +1,11 @@ +import { combineReducers } from 'redux'; +import config from './config'; +import testdata from './testdata'; + +const reducer = combineReducers({ + config, + testdata, +}); + +export default reducer; +export type RootState = ReturnType; diff --git a/packages/ui-default/components/problemconfig/reducer/testdata.ts b/packages/ui-default/components/problemconfig/reducer/testdata.ts new file mode 100644 index 000000000..a4fe74ff3 --- /dev/null +++ b/packages/ui-default/components/problemconfig/reducer/testdata.ts @@ -0,0 +1,16 @@ +export default function reducer(state = [], action) { + switch (action.type) { + case 'CONFIG_LOAD_FULFILLED': { + return state.concat(action.payload.testdata); + } + case 'CONFIG_DELETE_TESTDATA': { + return state.filter((i) => i.name !== action.payload.name); + } + case 'CONFIG_ADD_TESTDATA': { + state.push(action.payload); + return state; + } + default: + return state; + } +} diff --git a/packages/ui-default/locales/zh.yaml b/packages/ui-default/locales/zh.yaml index 429064326..65ca01f98 100644 --- a/packages/ui-default/locales/zh.yaml +++ b/packages/ui-default/locales/zh.yaml @@ -268,6 +268,7 @@ domain: 域 Domain: 域 Don't have an account?: 还没有账户? Done: 已结束 +Download All: 下载全部 Download Dataset: 下载数据集 Download Selected: 下载选中 Download: 下载 @@ -414,6 +415,7 @@ Is rated: 计入排名 It works!: 它成了! Italic: 斜体 Join: 加入 +Judge Config: 评测设置 Judge Status: 评测状态 judge_playground: 评测游乐场 Judged At: 评测时间 diff --git a/packages/ui-default/pages/problem_config.page.styl b/packages/ui-default/pages/problem_config.page.styl new file mode 100644 index 000000000..7beb78b9d --- /dev/null +++ b/packages/ui-default/pages/problem_config.page.styl @@ -0,0 +1,23 @@ +.page--problem_config + .section--problem-sidebar-testdata + .problem-sidebar-testdata__detail + height: 270px + overflow-y: auto + + &.collapsed .collapsed--hidden, + &.expanded .expanded--hidden + display: none + + &.collapsed .problem-sidebar-testdata__detail + display: none + + .col--size + width: 80px + .col--operation + width: 40px + + .textbox,.select + margin: 0 + + .bp3-tab-panel + margin-top: 5px \ No newline at end of file diff --git a/packages/ui-default/pages/problem_config.page.tsx b/packages/ui-default/pages/problem_config.page.tsx new file mode 100644 index 000000000..0e907693e --- /dev/null +++ b/packages/ui-default/pages/problem_config.page.tsx @@ -0,0 +1,73 @@ +import { NamedPage } from 'vj/misc/Page'; +import { slideDown, slideUp } from 'vj/utils/slide'; +import request from 'vj/utils/request'; +import loadReactRedux from 'vj/utils/loadReactRedux'; +import i18n from 'vj/utils/i18n'; +import yaml from 'js-yaml'; +import Notification from 'vj/components/notification'; + +async function handleSection(ev: JQuery.ClickEvent, type: string) { + const $section = $(ev.currentTarget).closest('.section--problem-sidebar-testdata'); + if ($section.is(`.${type}d, .animating`)) return; + $section.addClass('animating'); + const $detail = $section.find('.problem-sidebar-testdata__detail'); + if (type === 'expand') { + await slideDown($detail, 300, { opacity: 0 }, { opacity: 1 }); + } else { + await slideUp($detail, 300, { opacity: 1 }, { opacity: 0 }); + } + $section.addClass(type === 'expand' ? 'expanded' : 'collapsed'); + $section.removeClass(type === 'expand' ? 'collapsed' : 'expanded'); + $section.removeClass('animating'); +} + +const page = new NamedPage('problem_config', () => { + async function uploadConfig(config:object) { + const configYaml = yaml.dump(config); + Notification.info(i18n('Saving file...')); + const data = new FormData(); + data.append('filename', 'config.yaml'); + data.append('file', new Blob([configYaml], { type: 'text/plain' })); + data.append('type', 'testdata'); + data.append('operation', 'upload_file'); + await request.postFile('./files', data); + Notification.success(i18n('File saved.')); + window.location.reload(); + } + + async function mountComponent() { + const { default: ProblemConfigEditor } = await import('vj/components/problemconfig/ProblemConfigEditor'); + const { default: ProblemConfigForm } = await import('vj/components/problemconfig/ProblemConfigForm'); + const { default: ProblemConfigReducer } = await import('vj/components/problemconfig/reducer'); + + const { + React, render, Provider, store, + } = await loadReactRedux(ProblemConfigReducer); + + store.dispatch({ + type: 'CONFIG_LOAD', + payload: request.get(), + }); + render( + +
+
+ +
+
+ +
+
+ +
, + $('#ProblemConfig').get(0), + ); + } + + mountComponent(); + + $(document).on('click', '[name="testdata__section__expand"]', (ev) => handleSection(ev, 'expand')); + $(document).on('click', '[name="testdata__section__collapse"]', (ev) => handleSection(ev, 'collapse')); +}); + +export default page; diff --git a/packages/ui-default/pages/problem_edit.page.js b/packages/ui-default/pages/problem_edit.page.js index ec97f3928..e7e9c62cd 100644 --- a/packages/ui-default/pages/problem_edit.page.js +++ b/packages/ui-default/pages/problem_edit.page.js @@ -7,6 +7,7 @@ import { ConfirmDialog } from 'vj/components/dialog'; import Dropdown from 'vj/components/dropdown/Dropdown'; import Editor from 'vj/components/editor/index'; import Notification from 'vj/components/notification'; +import { slideDown, slideUp } from 'vj/utils/slide'; const categories = {}; const dirtyCategories = []; @@ -143,6 +144,21 @@ function buildCategoryFilter() { }); } +async function handleSection(ev, sidebar, type) { + const $section = $(ev.currentTarget).closest(`.section--problem-sidebar-${sidebar}`); + if ($section.is(`.${type}d, .animating`)) return; + $section.addClass('animating'); + const $detail = $section.find(`.section--problem-sidebar-${sidebar}__detail`); + if (type === 'expand') { + await slideDown($detail, 300, { opacity: 0 }, { opacity: 1 }); + } else { + await slideUp($detail, 300, { opacity: 1 }, { opacity: 0 }); + } + $section.addClass(type === 'expand' ? 'expanded' : 'collapsed'); + $section.removeClass(type === 'expand' ? 'collapsed' : 'expanded'); + $section.removeClass('animating'); +} + export default new NamedPage(['problem_create', 'problem_edit'], (pagename) => { let confirmed = false; $(document).on('click', '[name="operation"]', (ev) => { @@ -232,4 +248,8 @@ export default new NamedPage(['problem_create', 'problem_edit'], (pagename) => { ev.preventDefault(); } }); + $(document).on('click', '[name="additional_file__section__expand"]', (ev) => handleSection(ev, 'additional_file', 'expand')); + $(document).on('click', '[name="additional_file__section__collapse"]', (ev) => handleSection(ev, 'additional_file', 'collapse')); + $(document).on('click', '[name="tags__section__expand"]', (ev) => handleSection(ev, 'tags', 'expand')); + $(document).on('click', '[name="tags__section__collapse"]', (ev) => handleSection(ev, 'tags', 'collapse')); }); diff --git a/packages/ui-default/pages/problem_edit.page.styl b/packages/ui-default/pages/problem_edit.page.styl index ca626adbc..9492e71ea 100644 --- a/packages/ui-default/pages/problem_edit.page.styl +++ b/packages/ui-default/pages/problem_edit.page.styl @@ -1,4 +1,4 @@ -.page--problem_edit +.page--problem_edit,.page--problem_create .section__tab-header-item border-top: 3px solid transparent; transition: border-color 0.2s, background 0.2s, color 0.2s; @@ -10,4 +10,29 @@ background: #f8f8f8; &.tab--active - border-color: #ed5f82; \ No newline at end of file + border-color: #ed5f82; + + .section--problem-sidebar-tags + &.collapsed .collapsed--hidden, + &.expanded .expanded--hidden + display: none + + &.collapsed .problem-sidebar-tags__detail + display: none + + .section--problem-sidebar-additional_file + .problem-sidebar-additional_file__detail + height: 270px + overflow-y: auto + + &.collapsed .collapsed--hidden, + &.expanded .expanded--hidden + display: none + + &.collapsed .problem-sidebar-additional_file__detail + display: none + + .col--size + width: 80px + .col--operation + width: 40px \ No newline at end of file diff --git a/packages/ui-default/templates/partials/problem_sidebar_additional_file.html b/packages/ui-default/templates/partials/problem_sidebar_additional_file.html new file mode 100644 index 000000000..1c651dd19 --- /dev/null +++ b/packages/ui-default/templates/partials/problem_sidebar_additional_file.html @@ -0,0 +1,57 @@ +{% import "components/nothing.html" as nothing with context %} +{% import "components/problem.html" as problem with context %} +
+
+

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

+
+ +
+ {% if additional_file.length %} + + + + + + + + + + + + + + + {%- for file in additional_file -%} + + + + + + {%- endfor -%} + +
{{ _('Filename') }}{{ _('Size') }}
+ {{ file.name }} + {{ size(file.size) }}
+ {% else %} + {{ nothing.render('No additional file at current.',compact = true) }} + {% endif %} +
+
\ No newline at end of file diff --git a/packages/ui-default/templates/partials/problem_sidebar_normal.html b/packages/ui-default/templates/partials/problem_sidebar_normal.html index 3aa8a43c1..89c960cb6 100644 --- a/packages/ui-default/templates/partials/problem_sidebar_normal.html +++ b/packages/ui-default/templates/partials/problem_sidebar_normal.html @@ -83,6 +83,9 @@ + {% endif %} {% if page_name == 'problem_detail' %} @@ -100,14 +103,14 @@
-{% if page_name != 'problem_detail' and page_name != 'homework_detail_problem' and page_name != 'contest_detail_problem' %} +{% if page_name not in ['problem_config', 'problem_detail', 'homework_detail_problem', 'contest_detail_problem'] %}

{{ _('Information') }}

{% include "partials/problem-sidebar-information.html" %}
-{% elif page_name != 'contest_detail_problem' %} +{% elif page_name != 'contest_detail_problem' and page_name != 'problem_config' %}

{{ _('Statistics') }}

diff --git a/packages/ui-default/templates/partials/problem_sidebar_testdata.html b/packages/ui-default/templates/partials/problem_sidebar_testdata.html new file mode 100644 index 000000000..a63838e3d --- /dev/null +++ b/packages/ui-default/templates/partials/problem_sidebar_testdata.html @@ -0,0 +1,58 @@ +{% import "components/nothing.html" as nothing with context %} +{% import "components/problem.html" as problem with context %} + \ No newline at end of file diff --git a/packages/ui-default/templates/problem_config.html b/packages/ui-default/templates/problem_config.html new file mode 100644 index 000000000..5a2753cbd --- /dev/null +++ b/packages/ui-default/templates/problem_config.html @@ -0,0 +1,21 @@ +{% extends "layout/basic.html" %} +{% block content %} +
+
+
+
+

{{_('Judge Config')}}

+
+
+ {{ noscript_note.render() }} +
+
+
+
+
+ {% include "partials/problem_sidebar_testdata.html" %} + {% set owner_udoc = udoc %} + {% include "partials/problem_sidebar.html" %} +
+
+{% endblock %} \ No newline at end of file diff --git a/packages/ui-default/templates/problem_edit.html b/packages/ui-default/templates/problem_edit.html index f1e2bd4fc..afebdafaf 100644 --- a/packages/ui-default/templates/problem_edit.html +++ b/packages/ui-default/templates/problem_edit.html @@ -92,14 +92,23 @@
-
+

{{ _('Categories') }} ({{ _('click to add') }})

+
-
+
{% include "partials/category.html" %}
+ {% include "partials/problem_sidebar_additional_file.html" %} {% if page_name == 'problem_edit' %} {% set owner_udoc = udoc %} {% include "partials/problem_sidebar.html" %} diff --git a/packages/utils/lib/cases.ts b/packages/utils/lib/cases.ts index a9e841bda..b0494dc53 100644 --- a/packages/utils/lib/cases.ts +++ b/packages/utils/lib/cases.ts @@ -1,5 +1,44 @@ import { findFileSync, parseMemoryMB, parseTimeMS } from './utils'; +interface Re0 { + reg: RegExp, + output: ((a: RegExpExecArray) => string)[], + id: (a: RegExpExecArray) => number, +} + +interface Re1 extends Re0 { + subtask: (a: RegExpExecArray) => number, +} + +const RE0: Re0[] = [ + { + reg: /^([^\d]*)(\d+).(in|txt)$/, + output: [ + (a) => `${a[1] + a[2]}.out`, + (a) => `${a[1] + a[2]}.ans`, + (a) => `${a[1] + a[2]}.out`.replace(/input/g, 'output'), + (a) => (a[1].includes('input') ? `${a[1] + a[2]}.txt`.replace(/input/g, 'output') : null), + ], + id: (a) => +a[2], + }, + { + reg: /^([^\d]*)\.in(\d+)$/, + output: [ + (a) => `${a[1]}.ou${a[2]}`, + (a) => `${a[1]}.ou${a[2]}`.replace(/input/g, 'output'), + ], + id: (a) => +a[2], + }, +]; +const RE1: Re1[] = [ + { + reg: /^([^\d]*)([0-9]+)([-_])([0-9]+).in$/, + output: [(a) => `${a[1] + a[2]}${a[3]}${a[4]}.out`], + subtask: (a) => +a[2], + id: (a) => +a[4], + }, +]; + // eslint-disable-next-line @typescript-eslint/no-unused-vars export default async function readYamlCases(cfg: Record = {}, checkFile = (s: string, errMsg: string) => s) { const config: any = { @@ -98,3 +137,111 @@ export function convertIniConfig(ini: string) { } return res; } + +export async function readCasesFromFiles(files: string[], checkFile, cfg) { + const cases = []; + for (const file of files) { + for (const REG of RE0) { + if (REG.reg.test(file)) { + const data = REG.reg.exec(file); + const c = { input: file, output: '', id: REG.id(data) }; + for (const func of REG.output) { + if (cfg.noOutputFile) c.output = '/dev/null'; + else c.output = func(data); + if (c.output && (c.output === '/dev/null' || checkFile(c.output))) { + cases.push(c); + break; + } + } + } + } + } + cases.sort((a, b) => (a.id - b.id)); + const extra = cases.length - (100 % cases.length); + const config = { + count: 0, + subtasks: [{ + time: parseTimeMS(cfg.time || '1s'), + memory: parseMemoryMB(cfg.memory || '256m'), + type: 'sum', + cases: [], + score: Math.floor(100 / cases.length), + }], + }; + for (let i = 0; i < extra; i++) { + config.count++; + config.subtasks[0].cases.push({ + id: config.count, + input: checkFile(cases[i].input), + output: checkFile(cases[i].output), + }); + } + if (extra < cases.length) { + config.subtasks.push({ + time: parseTimeMS(cfg.time || '1s'), + memory: parseMemoryMB(cfg.memory || '256m'), + type: 'sum', + cases: [], + score: Math.floor(100 / cases.length) + 1, + }); + for (let i = extra; i < cases.length; i++) { + config.count++; + config.subtasks[1].cases.push({ + id: config.count, + input: checkFile(cases[i].input), + output: checkFile(cases[i].output), + }); + } + } + return config; +} + +export async function readSubtasksFromFiles(files: string[], checkFile, cfg, rst) { + const subtask = {}; + for (const s of rst.subtasks) if (s.id) subtask[s.id] = s; + const subtasks = []; + for (const file of files) { + for (const REG of RE1) { + if (REG.reg.test(file)) { + const data = REG.reg.exec(file); + const sid = REG.subtask(data); + const c = { input: file, output: '', id: REG.id(data) }; + for (const func of REG.output) { + if (cfg.noOutputFile) c.output = '/dev/null'; + else c.output = func(data); + if (c.output === '/dev/null' || checkFile(c.output)) { + if (!subtask[sid]) { + subtask[sid] = { + time: parseTimeMS(cfg.time || '1s'), + memory: parseMemoryMB(cfg.memory || '256m'), + type: 'min', + cases: [c], + }; + } else if (!subtask[sid].cases) subtask[sid].cases = [c]; + else subtask[sid].cases.push(c); + break; + } + } + } + } + } + for (const i in subtask) { + subtask[i].cases.sort((a, b) => (a.id - b.id)); + subtasks.push(subtask[i]); + } + const base = Math.floor(100 / subtasks.length); + const extra = subtasks.length - (100 % subtasks.length); + const config = { count: 0, subtasks }; + const keys = Object.keys(subtask); + for (let i = 0; i < keys.length; i++) { + if (i >= extra) subtask[keys[i]].score = base + 1; + else subtask[keys[i]].score = base; + for (const j of subtask[keys[i]].cases) { + config.count++; + j.input = checkFile(j.input); + j.output = checkFile(j.output); + j.id = config.count; + } + } + return config; +} From 4ccb838a06b663e74222c6c4fba83f8ac31ebf3a Mon Sep 17 00:00:00 2001 From: panda Date: Mon, 11 Apr 2022 04:23:56 +0800 Subject: [PATCH 14/44] 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'; From 3f37b02b7915745a0e4b7a9da21209bf25b9dd87 Mon Sep 17 00:00:00 2001 From: panda Date: Fri, 22 Apr 2022 02:53:19 +0800 Subject: [PATCH 15/44] ui: add configYamlFormat & fix bugs --- .../autocomplete/components/AutoComplete.tsx | 4 +- .../problemconfig/ProblemConfigEditor.tsx | 59 ++++++- .../problemconfig/ProblemConfigForm.tsx | 155 ++++++++---------- .../problemconfig/reducer/config.ts | 49 +++--- packages/ui-default/locales/zh.yaml | 14 ++ 5 files changed, 167 insertions(+), 114 deletions(-) diff --git a/packages/ui-default/components/autocomplete/components/AutoComplete.tsx b/packages/ui-default/components/autocomplete/components/AutoComplete.tsx index 5b79bc4cc..b6739046d 100644 --- a/packages/ui-default/components/autocomplete/components/AutoComplete.tsx +++ b/packages/ui-default/components/autocomplete/components/AutoComplete.tsx @@ -128,8 +128,10 @@ const AutoComplete = forwardRef(function Impl(props: AutoCompleteProps, re else onChange(selectedKeys.join(',')); }; + let first = !multi; useEffect(() => { - dispatchChange(); + if (first) first = false; + else dispatchChange(); const ids = []; for (const key of selectedKeys) if (!valueCache[key]) ids.push(key); if (!ids.length) return; diff --git a/packages/ui-default/components/problemconfig/ProblemConfigEditor.tsx b/packages/ui-default/components/problemconfig/ProblemConfigEditor.tsx index 0028f8c1b..e3debd546 100644 --- a/packages/ui-default/components/problemconfig/ProblemConfigEditor.tsx +++ b/packages/ui-default/components/problemconfig/ProblemConfigEditor.tsx @@ -4,6 +4,7 @@ import { connect } from 'react-redux'; import { load } from 'vj/components/monaco/loader'; import Editor from 'vj/components/editor'; import yaml from 'js-yaml'; +import type { ProblemConfigFile, TestCaseConfig } from 'hydrooj/src/interface'; const mapStateToProps = (state) => ({ config: state.config, @@ -22,10 +23,62 @@ interface Props { handleUpdateCode: Function; } +const configKey = [ + 'type', 'subType', 'target', 'score', 'time', + 'memory', 'filename', 'checker_type', 'checker', 'interactor', + 'user_extra_files', 'judge_extra_files', 'detail', 'outputs', 'redirect', + 'cases', 'subtasks', 'langs', +]; + +const subtasksKey = [ + 'time', 'memory', 'score', 'if', 'id', + 'type', 'cases', +]; + +const casesKey = ['time', 'memory', 'input', 'output']; + +function configYamlFormat(config: ProblemConfigFile) { + const formatConfig: ProblemConfigFile = {}; + configKey.forEach((key) => { + if (config[key] !== undefined) { + if (key === 'checker_type' && config.type !== 'default') return; + if (key === 'checker' + && (['default', 'strict'].includes(formatConfig.checker_type) || formatConfig.checker_type === undefined)) return; + if (key === 'interactor' && config.type !== 'interactive') return; + if (key === 'subtasks') { + formatConfig[key] = []; + config[key].forEach((subtask) => { + const formatSubtask: object = {}; + subtasksKey.forEach((subtaskKey) => { + if (subtask[subtaskKey] !== undefined) { + formatSubtask[subtaskKey] = subtask[subtaskKey]; + } + }); + formatConfig[key].push(formatSubtask); + }); + } else if (key === 'cases') { + formatConfig[key] = []; + config[key].forEach((caseItem) => { + const formatCase: TestCaseConfig = { + time: 1000, memory: 256, input: '', output: '', + }; + casesKey.forEach((caseKey) => { + if (caseItem[caseKey] !== undefined) formatCase[caseKey] = caseItem[caseKey]; + else delete formatCase[caseKey]; + }); + formatConfig[key].push(formatCase); + }); + } else formatConfig[key] = config[key]; + } + }); + return formatConfig; +} + export default connect(mapStateToProps, mapDispatchToProps)(class MonacoEditor extends React.PureComponent { disposable = []; containerElement: HTMLElement; private __preventUpdate = false; + private __preventFormat = false; editor: editor.IStandaloneCodeEditor; model: editor.ITextModel; @@ -40,7 +93,7 @@ export default connect(mapStateToProps, mapDispatchToProps)(class MonacoEditor e model: this.model, onChange: (value: string) => { this.__preventUpdate = true; - this.props.handleUpdateCode(value); + if (!this.__preventFormat) this.props.handleUpdateCode(value); this.__preventUpdate = false; }, }) as Editor; @@ -50,14 +103,16 @@ export default connect(mapStateToProps, mapDispatchToProps)(class MonacoEditor e componentDidUpdate(prevProps) { if (this.__preventUpdate) return; if (yaml.dump(prevProps.config) !== yaml.dump(this.props.config)) { + this.__preventFormat = true; this.model?.pushEditOperations( [], [{ range: this.model.getFullModelRange(), - text: yaml.dump(this.props.config), + text: yaml.dump(configYamlFormat(this.props.config)), }], undefined, ); + this.__preventFormat = false; } } diff --git a/packages/ui-default/components/problemconfig/ProblemConfigForm.tsx b/packages/ui-default/components/problemconfig/ProblemConfigForm.tsx index e2e1cd929..f70d4b410 100644 --- a/packages/ui-default/components/problemconfig/ProblemConfigForm.tsx +++ b/packages/ui-default/components/problemconfig/ProblemConfigForm.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useState } from 'react'; +import React, { useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import i18n from 'vj/utils/i18n'; import { isEqual } from 'lodash'; @@ -13,8 +13,14 @@ const SelectValue = { task_type: ['min', 'max', 'sum'], }; -const eq = (a: SubtaskConfig[], b: SubtaskConfig[]) => isEqual(a, b); -const eq1 = (a: TestCaseConfig, b: TestCaseConfig) => isEqual(a, b); +const eq = (a: TestCaseConfig, b: TestCaseConfig) => isEqual(a, b); +const eqId = (a: SubtaskConfig[], b: SubtaskConfig[]) => { + if (a.length !== b.length) return true; + for (let i = 0; i < a.length; i++) { + if (a[i].id !== b[i].id) return true; + } + return false; +}; function FormItem({ columns, label, children, helpText = '', disableLabel = false, ...props @@ -120,7 +126,7 @@ function BasicInfo() { ? ( { dispatch({ type: 'CONFIG_FORM_UPDATE', key: 'checker_type', value: checkerType === 'strict' ? 'default' : 'strict' }); @@ -153,12 +159,12 @@ function ExtraFilesConfig() { const dispatch = useDispatch(); return ( - setshowTab(!showTab)} /> + setshowTab(!showTab)} />
(index === -1 - ? state.config.cases[subindex] : state.config.subtasks[index].cases[subindex]), eq1); + ? state.config.cases[subindex] : state.config.subtasks[index].cases[subindex]), eq); const Files = useSelector((state: RootState) => state.testdata); const dispatch = useDispatch(); const dispatcher = (casesKey: string): React.ChangeEventHandler => (ev) => { @@ -262,14 +268,7 @@ function CasesSubCasesTable({ index, 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); - const Files = useSelector((state: RootState) => state.testdata); - const inputRef = useRef(null); - const outputRef = useRef(null); + const casesLength = useSelector((state: RootState) => (index === -1 ? state.config.cases?.length : state.config.subtasks[index].cases?.length)); const dispatch = useDispatch(); return (
{{ file.name }} {{ size(file.size) }}
@@ -279,73 +278,67 @@ function CasesTable({ index }) { index === -1 && ( <> ) } - {cases && cases.map((k, v) => )} + {casesLength && [...Array(casesLength).keys()].map((i) => )}
- {i18n('Time')}
- setTime(ev.currentTarget.value)} - className="textbox" - /> + {i18n('Time')}
- {i18n('Memory')}
- setMemory(ev.currentTarget.value)} - className="textbox" - /> + {i18n('Memory')}
- {i18n('Input')}
- + {i18n('Input')}
- {i18n('Output')}
- + {i18n('Output')}
-
{ - setTime(''); - setMemory(''); - inputRef?.current.setQuery(''); - outputRef?.current.setQuery(''); - dispatch({ - type: index === -1 ? 'CONFIG_CASES_UPDATE' : 'CONFIG_SUBTASK_UPDATE', - id: index, - key: 'cases-add', - value: index === -1 ? { - time, memory, input, output, - } : { input, output }, - }); - }} + onClick={() => dispatch({ + type: index === -1 ? 'CONFIG_CASES_UPDATE' : 'CONFIG_SUBTASK_UPDATE', + id: index, + key: 'cases-add', + value: index === -1 ? { + time: 1000, memory: 256, input: '', output: '', + } : { input: '', output: '' }, + })} >
); } -function SubtasksTable({ data, index }) { - const subtasks = useSelector((state: RootState) => state.config.subtasks, eq); +function SubtasksIds({ index }) { + const subtasks = useSelector((state: RootState) => state.config.subtasks, eqId); + const subtaskIf = useSelector((state: RootState) => state.config.subtasks[index].if); + const dispatch = useDispatch(); + const subtasksIds = subtasks.map((i) => i.id).filter((i) => i !== undefined); + return ( + + if + + `${item}`} + onChange={(val) => dispatch({ + type: 'CONFIG_SUBTASK_UPDATE', id: index, key: 'if', value: val.split(','), + })} + multi + /> + + + ); +} + +function SubtasksTable({ index }) { + const subtask = (key: string) => useSelector((state: RootState) => state.config.subtasks[index][key]); const dispatch = useDispatch(); const dispatcher = (key: string): React.ChangeEventHandler => (ev) => { dispatch({ @@ -353,7 +346,7 @@ function SubtasksTable({ data, index }) { }); }; return ( - + ); } function TaskConfig({ onAutoLoad }) { - const subtasks = useSelector((state: RootState) => state.config.subtasks, eq); - const cases = useSelector((state: RootState) => state.config.cases, eq); + const subtasksLength = useSelector((state: RootState) => state.config.subtasks?.length); + const casesLength = useSelector((state: RootState) => state.config.cases?.length); const dispatch = useDispatch(); return ( @@ -449,11 +430,11 @@ function TaskConfig({ onAutoLoad }) { - {cases && dispatch({ type: 'CONFIG_SUBTASKS_SWITCH', value: true })} />} + {casesLength && dispatch({ type: 'CONFIG_SUBTASKS_SWITCH', value: true })} />} { - subtasks || cases - ? subtasks &&
    {subtasks.map((k, v) => )}
- || cases && () + subtasksLength || casesLength + ? subtasksLength && [...Array(subtasksLength).keys()].map((i) => ) + || casesLength && () : (
onAutoLoad()}> {i18n('Auto Read Tasks')} @@ -480,7 +461,7 @@ function LangConfig() { }, [JSON.stringify(selectedKeys)]); return ( - setshowTab(!showTab)} /> + setshowTab(!showTab)} /> ({ + id: v, time: k.time, memory: k.memory, cases: [{ input: k.input, output: k.output }], + })); if (!next.subtasks.length) next.subtasks.push({ id: 0 }); delete next.cases; return next; } case 'CONFIG_CASES_UPDATE': { + if (['time', 'memory'].includes(action.casesKey) && action.value !== '') action.value = +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') { + if (action.key === 'cases-delete') { return { ...state, cases: state.cases.filter((k, v) => v !== action.value) }; } - return state; + const cases = cloneDeep(state.cases); + cases[action.casesId][action.casesKey] = action.value; + if (action.value === '') delete cases[action.casesId][action.casesKey]; + return { ...state, cases }; } case 'CONFIG_SUBTASK_UPDATE': { + const subtasks = cloneDeep(state.subtasks); + const subsubtasks = cloneDeep(state.subtasks[action.id]); + if (action.value !== '' + && (['time', 'memory'].includes(action.casesKey) || ['time', 'memory', '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') { - 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); + subsubtasks.cases = subsubtasks.cases.filter((k, v) => v !== action.value); } - subtasks[action.id] = subsubtasks; - return { ...state, subtasks }; + } else if (action.key === 'add') subtasks.push({ time: 1000, memory: 256, cases: [] }); + else if (action.key === 'delete') delete subtasks[action.key]; + else { + if (action.value === '' || (action.key === 'if' && action.value.join('') === '')) delete subsubtasks[action.key]; + else subsubtasks[action.key] = action.value; } - 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 next.subtasks[action.id][action.key] = action.value; - return next; + if (action.key !== 'delete') subtasks[action.id] = subsubtasks; + return { ...state, subtasks }; } default: return state; diff --git a/packages/ui-default/locales/zh.yaml b/packages/ui-default/locales/zh.yaml index 65ca01f98..73a82ef28 100644 --- a/packages/ui-default/locales/zh.yaml +++ b/packages/ui-default/locales/zh.yaml @@ -86,6 +86,7 @@ Attend Contest: 参加比赛 Attend contests: 参加比赛 Attended: 已参加 author: 作者 +Auto Read Tasks: 自动识别任务 Be Copied: 被复制 Begin at: 开始于 Begin Date: 开始日期 @@ -117,6 +118,8 @@ Change Email: 修改电子邮件 Change Password: 修改密码 Change Theme: 切换主题 Check In: 签到 +CheckerType: 比较器类型 +Checker: 比较器 Chinese: 中文 Choose the background image in your profile page.: 选择您资料页面的背景图片。 Claim homework: 认领作业 @@ -267,6 +270,7 @@ domain_user: 管理用户 domain: 域 Domain: 域 Don't have an account?: 还没有账户? +Don't ignore space and enter.: 不忽略行末回车与空格。 Done: 已结束 Download All: 下载全部 Download Dataset: 下载数据集 @@ -319,6 +323,7 @@ Export as CSV: 导出为 CSV Export as HTML: 导出为 HTML Extension (days): 最长延期 (日) Extension Score Penalty: 延期递交扣分规则 +Extra Files Config: 额外文件设置 Failed to join the domain. You are already a member.: 加入域失败,您已是该域的成员。 Feedback: 反馈 Field {0} or {1} validation failed.: 字段 {0} 或 {1} 验证失败。 @@ -401,6 +406,7 @@ Information: 信息 Initialize: 初始化 Inline codes: 行内代码 Inline formulas: 行内公式 +Input: 输入 Insert Code: 插入代码 Insert Image: 插入图像 Installed Modules: 已安装的模块 @@ -418,11 +424,14 @@ Join: 加入 Judge Config: 评测设置 Judge Status: 评测状态 judge_playground: 评测游乐场 +judge_extra_files: 评测额外文件 Judged At: 评测时间 Judged By: 评测机 Judging Queue: 评测队列 Keep current expiration: 保持当前过期设置 +langs: 语言 Language: 语言 +Langs Config: 语言设置 last active at: 最后活动于 last login at: 最后登录于 Last Submit At: 最后递交于 @@ -533,6 +542,7 @@ Or, with automatically filled invitation code: 或者,这是可以自动填写 Ordered List: 有序列表 Original Score: 原始分数 OS Info: 系统信息 +Output: 输出 Over {0} problems: 超过 {0} 道题目 Owner: 所有者 page.problem_detail.sidebar.show_category: 点击显示 @@ -585,6 +595,7 @@ Problem Solution: 题解列表 Problem Tags Visibility: 题目标签可见性 Problem Tags: 题目标签 problem_create: 创建题目 +problem_config: 评测设置 problem_edit: 编辑题目 problem_import: 导入题目 problem_main: 题库 @@ -758,6 +769,7 @@ Support: 支持 Switch account: 切换账户 Tags: 标签 tasks_list: 任务列表 +Task Settings: 任务设置 Technical Information: 技术信息 Temporary session: 临时会话 Terms of Service: 服务条款 @@ -832,12 +844,14 @@ Usage exceeded.: 用量超限。 Use admin specificed: 由管理员指定 Use algorithm calculated: 使用算法计算 Use average of above: 使用上面的平均值 +Use Subtasks Mode: 使用子任务模式 User {0} already exists.: 用户 {0} 已存在。 User {0} not found.: 用户 {0} 不存在。 User can join this domain by visiting the following URL: 用户可以访问此链接来加入此域 User ID: 用户 ID User will no longer be allowed to join the domain after expiration.: 过期后,用户将不能加入此域。 user_detail: 用户 +user_extra_files: 编译额外文件 user_login: 登录 user_logout: 登出 user_lostpass: 忘记密码 From 310189bc4ddb62e8b236778dd567107f32be03b2 Mon Sep 17 00:00:00 2001 From: undefined Date: Tue, 26 Apr 2022 23:49:24 +0800 Subject: [PATCH 16/44] ui: UI&UX enhance, bug fixes Co-authored-by: panda --- package.json | 5 +- packages/hydrojudge/src/cases.ts | 19 +- packages/hydrooj/src/entry/common.ts | 1 + packages/hydrooj/src/interface.ts | 9 +- packages/ui-default/.eslintrc.js | 3 +- .../autocomplete/autocomplete.page.styl | 8 +- .../components/CustomSelectAutoComplete.tsx | 2 +- .../components/FileSelectAutoComplete.tsx | 51 ++ .../components/hotkey/hotkey.page.js | 18 +- .../messagepad/reducers/activeId.js | 18 +- .../messagepad/reducers/dialogues.js | 94 ++-- .../components/messagepad/reducers/inputs.js | 66 +-- .../messagepad/reducers/isPosting.js | 84 ++-- .../components/monaco/languages/yaml.ts | 7 +- .../components/problemconfig/BasicForm.tsx | 69 +++ .../problemconfig/ProblemConfigEditor.tsx | 40 +- .../problemconfig/ProblemConfigForm.tsx | 444 ++---------------- .../components/problemconfig/ProblemType.tsx | 129 +++++ .../components/problemconfig/SubtaskTable.tsx | 139 ++++++ .../problemconfig/TestCasesTable.tsx | 123 +++++ .../problemconfig/reducer/config.ts | 133 +++--- .../problemconfig/reducer/testdata.ts | 24 +- .../components/scratchpad/reducers/records.ts | 46 +- .../components/scratchpad/reducers/ui.ts | 174 +++---- packages/ui-default/locales/zh.yaml | 12 +- packages/ui-default/package.json | 3 +- packages/ui-default/pages/home_files.page.js | 8 +- .../ui-default/pages/homework_main.page.js | 20 +- .../ui-default/pages/problem_config.page.styl | 52 +- .../ui-default/pages/problem_config.page.tsx | 61 +-- .../ui-default/pages/problem_edit.page.js | 8 +- .../ui-default/pages/problem_files.page.js | 8 +- .../ui-default/templates/contest_detail.html | 14 +- .../ui-default/templates/problem_detail.html | 20 +- packages/ui-default/theme/dark.styl | 2 +- packages/ui-default/utils/i18n.js | 1 + packages/utils/lib/common.ts | 137 ++---- 37 files changed, 1104 insertions(+), 948 deletions(-) create mode 100644 packages/ui-default/components/autocomplete/components/FileSelectAutoComplete.tsx create mode 100644 packages/ui-default/components/problemconfig/BasicForm.tsx create mode 100644 packages/ui-default/components/problemconfig/ProblemType.tsx create mode 100644 packages/ui-default/components/problemconfig/SubtaskTable.tsx create mode 100644 packages/ui-default/components/problemconfig/TestCasesTable.tsx diff --git a/package.json b/package.json index d00626301..c42cf827a 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "debug": "node --trace-warnings --async-stack-traces --trace-deprecation node_modules/hydrooj/bin/hydrooj --debug --template", "start:all": "node build/start", "start": "node_modules/.bin/hydrooj", - "postinstall": "node build/hack && node build/prepare.js && rm -rf node_modules/esbuild-loader/node_modules/esbuild" + "postinstall": "node build/hack && node build/prepare.js" }, "version": "1.0.0", "license": "AGPL-3.0-only", @@ -64,5 +64,8 @@ "supertest": "^6.2.2", "typedoc": "^0.22.13", "typescript": "4.6.2" + }, + "resolutions": { + "esbuild": "0.14.3" } } diff --git a/packages/hydrojudge/src/cases.ts b/packages/hydrojudge/src/cases.ts index 6edf22aa3..d81ab2e17 100644 --- a/packages/hydrojudge/src/cases.ts +++ b/packages/hydrojudge/src/cases.ts @@ -3,7 +3,7 @@ import fs from 'fs-extra'; import yaml from 'js-yaml'; import { max, sum } from 'lodash'; import readYamlCases, { convertIniConfig } from '@hydrooj/utils/lib/cases'; -import { readCasesFromFiles, readSubtasksFromFiles } from '@hydrooj/utils/lib/common'; +import { readSubtasksFromFiles } from '@hydrooj/utils/lib/common'; import { changeErrorType } from '@hydrooj/utils/lib/utils'; import { getConfig } from './config'; import { FormatError, SystemError } from './error'; @@ -28,8 +28,7 @@ async function readAutoCases(folder: string, { next }, cfg, rst) { const outputs = await fs.readdir(path.resolve(folder, 'output')); files.push(...outputs.map((i) => `output/${i}`)); } - let result = await readCasesFromFiles(files, checkFile, cfg); - if (!result.count) result = await readSubtasksFromFiles(files, checkFile, cfg, rst); + const result = readSubtasksFromFiles(files, checkFile, cfg, rst); Object.assign(config, result); if (cfg.isSelfSubmission) next({ message: { message: 'Found {0} testcases.', params: [config.count] } }); } catch (e) { @@ -42,11 +41,11 @@ function isValidConfig(config) { if (config.count > (getConfig('testcases_max') || 100)) { throw new FormatError('Too many testcases. Cancelled.'); } - const time = sum(config.subtasks.map((subtask) => subtask.time * subtask.cases.length)); + const time = sum(config.subtasks.map((subtask) => sum(subtask.cases.map((c) => c.time)))); if (time > (getConfig('total_time_limit') || 60) * 1000) { throw new FormatError('Total time limit longer than {0}s. Cancelled.', [+getConfig('total_time_limit') || 60]); } - const memMax = max(config.subtasks.map((subtask) => subtask.memory)); + const memMax = max(config.subtasks.map((subtask) => max(subtask.cases.map((c) => c.memory)))); if (memMax > parseMemoryMB(getConfig('memoryMax'))) throw new FormatError('Memory limit larger than memory_max'); if (!['default', 'strict'].includes(config.checker_type || 'default') && !config.checker) { throw new FormatError('You did not specify a checker.'); @@ -57,18 +56,18 @@ export default async function readCases(folder: string, cfg: Record const iniConfig = path.resolve(folder, 'config.ini'); const yamlConfig = path.resolve(folder, 'config.yaml'); const ymlConfig = path.resolve(folder, 'config.yml'); - let config; + const config = { ...cfg }; if (fs.existsSync(yamlConfig)) { - config = { ...cfg, ...yaml.load(fs.readFileSync(yamlConfig).toString()) as object }; + Object.assign(config, yaml.load(fs.readFileSync(yamlConfig).toString())); } else if (fs.existsSync(ymlConfig)) { - config = { ...cfg, ...yaml.load(fs.readFileSync(ymlConfig).toString()) as object }; + Object.assign(config, yaml.load(fs.readFileSync(ymlConfig).toString())); } else if (fs.existsSync(iniConfig)) { try { - config = { ...cfg, ...convertIniConfig(fs.readFileSync(iniConfig).toString()) }; + Object.assign(config, convertIniConfig(fs.readFileSync(iniConfig).toString())); } catch (e) { throw changeErrorType(e, FormatError); } - } else config = cfg; + } let result; try { result = await readYamlCases(config, ensureFile(folder)); diff --git a/packages/hydrooj/src/entry/common.ts b/packages/hydrooj/src/entry/common.ts index 5a15bb04a..4cfb71851 100644 --- a/packages/hydrooj/src/entry/common.ts +++ b/packages/hydrooj/src/entry/common.ts @@ -178,6 +178,7 @@ export async function service(pending: string[], fail: string[]) { } } for (const key in global.Hydro.service) { + if (key === 'server') continue; const srv = global.Hydro.service[key]; if (!srv.started && srv.start) await srv.start(); } diff --git a/packages/hydrooj/src/interface.ts b/packages/hydrooj/src/interface.ts index f6ea50295..f2772bf57 100644 --- a/packages/hydrooj/src/interface.ts +++ b/packages/hydrooj/src/interface.ts @@ -127,10 +127,11 @@ export interface FileInfo { } export interface TestCaseConfig { - input: string, - output: string, - time?: number, - memory?: number, + input: string; + output: string; + time?: number; + memory?: number; + score?: number; } export enum ProblemType { diff --git a/packages/ui-default/.eslintrc.js b/packages/ui-default/.eslintrc.js index 09bf98b73..20983ca61 100644 --- a/packages/ui-default/.eslintrc.js +++ b/packages/ui-default/.eslintrc.js @@ -81,7 +81,7 @@ module.exports = { indent: [ 'error', 2, - { SwitchCase: 0 }, + { SwitchCase: 1 }, ], 'max-len': ['error', 150], quotes: 'warn', @@ -115,6 +115,7 @@ module.exports = { 'no-underscore-dangle': 'off', 'no-await-in-loop': 'off', 'no-lonely-if': 'off', + 'no-nested-ternary': 'off', 'no-param-reassign': 'off', 'no-script-url': 'off', 'no-bitwise': 'off', diff --git a/packages/ui-default/components/autocomplete/autocomplete.page.styl b/packages/ui-default/components/autocomplete/autocomplete.page.styl index dd4fc3e28..ff150f4fe 100644 --- a/packages/ui-default/components/autocomplete/autocomplete.page.styl +++ b/packages/ui-default/components/autocomplete/autocomplete.page.styl @@ -53,7 +53,7 @@ white-space: nowrap text-overflow: ellipsis - &>span.bp3-icon>svg + &>span.bp4-icon>svg font-size: 12px cursor: pointer padding: 4px @@ -78,19 +78,19 @@ &>div flex-grow: 1 - &>span.bp3-icon>svg + &>span.bp4-icon>svg color: transparent &[aria-selected='true'] background-color: #fafafa font-weight: 600 - &>span.bp3-icon>svg + &>span.bp4-icon>svg color: #1890ff &[data-focus='true'] background-color: #e6f7ff cursor: pointer - &>span.bp3-icon>svg + &>span.bp4-icon>svg color: currentColor diff --git a/packages/ui-default/components/autocomplete/components/CustomSelectAutoComplete.tsx b/packages/ui-default/components/autocomplete/components/CustomSelectAutoComplete.tsx index 70d185a00..f8cd5acd8 100644 --- a/packages/ui-default/components/autocomplete/components/CustomSelectAutoComplete.tsx +++ b/packages/ui-default/components/autocomplete/components/CustomSelectAutoComplete.tsx @@ -33,7 +33,7 @@ CustomSelectAutoComplete.propTypes = { allowEmptyQuery: PropTypes.bool, freeSolo: PropTypes.bool, freeSoloConverter: PropTypes.func, - data: PropTypes.any, + data: PropTypes.arrayOf(PropTypes.any), }; CustomSelectAutoComplete.displayName = 'CustomSelectAutoComplete'; diff --git a/packages/ui-default/components/autocomplete/components/FileSelectAutoComplete.tsx b/packages/ui-default/components/autocomplete/components/FileSelectAutoComplete.tsx new file mode 100644 index 000000000..4ff950c0c --- /dev/null +++ b/packages/ui-default/components/autocomplete/components/FileSelectAutoComplete.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import AutoComplete from './AutoComplete'; + +type DefaultProps = { + _id: string; + name: string; +} & string; + +const FileSelectAutoComplete = React.forwardRef((props, ref) => ( + + ref={ref as any} + fetchItems={(keys) => props.data.filter((i) => (i._id ? keys.includes(i._id) : keys.includes(i)))} + queryItems={(query) => props.data.filter((i) => (i.name + ? i.name.toLowerCase().match(query.toLowerCase()) + : i.toString().toLowerCase().match(query.toLowerCase()) + ))} + itemText={(item) => `${item.name || item}`} + itemKey={(item) => `${item._id?.toString() || item.name || item}`} + renderItem={(item) => item.name || item._id || item} // TODO icon + allowEmptyQuery + {...props} + /> +)); + +FileSelectAutoComplete.propTypes = { + width: PropTypes.string, + height: PropTypes.string, + listStyle: PropTypes.object, + onChange: PropTypes.func.isRequired, + multi: PropTypes.bool, + selectedKeys: PropTypes.arrayOf(PropTypes.string), + allowEmptyQuery: PropTypes.bool, + freeSolo: PropTypes.bool, + freeSoloConverter: PropTypes.func, + data: PropTypes.arrayOf(PropTypes.any), +}; + +FileSelectAutoComplete.displayName = 'FileSelectAutoComplete'; + +FileSelectAutoComplete.defaultProps = { + width: '100%', + height: 'auto', + listStyle: {}, + multi: false, + selectedKeys: [], + freeSolo: false, + freeSoloConverter: (input) => input, +}; + +export default FileSelectAutoComplete; diff --git a/packages/ui-default/components/hotkey/hotkey.page.js b/packages/ui-default/components/hotkey/hotkey.page.js index ab174d564..e4537a6df 100644 --- a/packages/ui-default/components/hotkey/hotkey.page.js +++ b/packages/ui-default/components/hotkey/hotkey.page.js @@ -38,15 +38,15 @@ function testElementHotkey(hotkey, $element, attr) { const [defStr, trigger] = singleDef.split(':'); if (isHotkeyMatch(hotkey, defStr)) { switch (trigger) { - case 'submit': - $element.closest('form').trigger('submit'); - break; - case undefined: - $element.trigger('click'); - break; - default: - $element.trigger(trigger); - break; + case 'submit': + $element.closest('form').trigger('submit'); + break; + case undefined: + $element.trigger('click'); + break; + default: + $element.trigger(trigger); + break; } } }); diff --git a/packages/ui-default/components/messagepad/reducers/activeId.js b/packages/ui-default/components/messagepad/reducers/activeId.js index 036642c14..8af2782c8 100644 --- a/packages/ui-default/components/messagepad/reducers/activeId.js +++ b/packages/ui-default/components/messagepad/reducers/activeId.js @@ -21,14 +21,14 @@ function scrollToViewport() { export default function reducer(state = null, action) { switch (action.type) { - case 'DIALOGUES_SWITCH_TO': { - scrollToViewport(); - return action.payload; - } - case 'DIALOGUES_POST_SEND_FULFILLED': { - return action.meta.dialogueId; - } - default: - return state; + case 'DIALOGUES_SWITCH_TO': { + scrollToViewport(); + return action.payload; + } + case 'DIALOGUES_POST_SEND_FULFILLED': { + return action.meta.dialogueId; + } + default: + return state; } } diff --git a/packages/ui-default/components/messagepad/reducers/dialogues.js b/packages/ui-default/components/messagepad/reducers/dialogues.js index e56009a5a..d6e55d7bf 100644 --- a/packages/ui-default/components/messagepad/reducers/dialogues.js +++ b/packages/ui-default/components/messagepad/reducers/dialogues.js @@ -2,52 +2,52 @@ import _ from 'lodash'; export default function reducer(state = {}, action) { switch (action.type) { - case 'DIALOGUES_LOAD_DIALOGUES_FULFILLED': { - const { messages } = action.payload; - return { ...state, ..._.keyBy(messages, 'udoc._id') }; - } - case 'DIALOGUES_CREATE': { - const { user } = action.payload; - if (state[user._id]) return state; - return { - ...state, - [user._id]: { - _id: user._id, - udoc: { ...user }, - messages: [], - }, - }; - } - case 'DIALOGUES_POST_SEND_FULFILLED': { - const id = action.meta.dialogueId; - const { mdoc } = action.payload; - return { - ...state, - [id]: { - ...state[id], - messages: [ - ...state[id].messages, - mdoc, - ], - }, - }; - } - case 'DIALOGUES_MESSAGE_PUSH': { - const { mdoc, udoc } = action.payload; - const to = mdoc.from === UserContext._id ? mdoc.to : mdoc.from; - return { - ...state, - [to]: { - ...state[to] || {}, - udoc, - messages: [ - ...state[to]?.messages || [], - mdoc, - ], - }, - }; - } - default: - return state; + case 'DIALOGUES_LOAD_DIALOGUES_FULFILLED': { + const { messages } = action.payload; + return { ...state, ..._.keyBy(messages, 'udoc._id') }; + } + case 'DIALOGUES_CREATE': { + const { user } = action.payload; + if (state[user._id]) return state; + return { + ...state, + [user._id]: { + _id: user._id, + udoc: { ...user }, + messages: [], + }, + }; + } + case 'DIALOGUES_POST_SEND_FULFILLED': { + const id = action.meta.dialogueId; + const { mdoc } = action.payload; + return { + ...state, + [id]: { + ...state[id], + messages: [ + ...state[id].messages, + mdoc, + ], + }, + }; + } + case 'DIALOGUES_MESSAGE_PUSH': { + const { mdoc, udoc } = action.payload; + const to = mdoc.from === UserContext._id ? mdoc.to : mdoc.from; + return { + ...state, + [to]: { + ...state[to] || {}, + udoc, + messages: [ + ...state[to]?.messages || [], + mdoc, + ], + }, + }; + } + default: + return state; } } diff --git a/packages/ui-default/components/messagepad/reducers/inputs.js b/packages/ui-default/components/messagepad/reducers/inputs.js index a0856a741..a188073e6 100644 --- a/packages/ui-default/components/messagepad/reducers/inputs.js +++ b/packages/ui-default/components/messagepad/reducers/inputs.js @@ -2,42 +2,42 @@ import _ from 'lodash'; export default function reducer(state = {}, action) { switch (action.type) { - case 'DIALOGUES_LOAD_DIALOGUES_FULFILLED': { - const dialogues = action.payload.messages; - return { ...state, ..._.fromPairs(_.map(dialogues, (d) => [d._id, ''])) }; - } - case 'DIALOGUES_CREATE': { - const { user } = action.payload; - return { - ...state, - [user._id]: '', - }; - } - case 'DIALOGUES_INPUT_CHANGED': { - const id = action.meta.dialogueId; - return { - ...state, - [id]: action.payload, - }; - } - case 'DIALOGUES_POST_SEND_FULFILLED': { - const id = action.meta.dialogueId; - return { - ...state, - [id]: '', - }; - } - case 'DIALOGUES_MESSAGE_PUSH': { - const { udoc } = action.payload; - if (!state[udoc._id]) { + case 'DIALOGUES_LOAD_DIALOGUES_FULFILLED': { + const dialogues = action.payload.messages; + return { ...state, ..._.fromPairs(_.map(dialogues, (d) => [d._id, ''])) }; + } + case 'DIALOGUES_CREATE': { + const { user } = action.payload; return { ...state, - [udoc._id]: '', + [user._id]: '', }; } - return state; - } - default: - return state; + case 'DIALOGUES_INPUT_CHANGED': { + const id = action.meta.dialogueId; + return { + ...state, + [id]: action.payload, + }; + } + case 'DIALOGUES_POST_SEND_FULFILLED': { + const id = action.meta.dialogueId; + return { + ...state, + [id]: '', + }; + } + case 'DIALOGUES_MESSAGE_PUSH': { + const { udoc } = action.payload; + if (!state[udoc._id]) { + return { + ...state, + [udoc._id]: '', + }; + } + return state; + } + default: + return state; } } diff --git a/packages/ui-default/components/messagepad/reducers/isPosting.js b/packages/ui-default/components/messagepad/reducers/isPosting.js index 4bb841122..ecd04cbff 100644 --- a/packages/ui-default/components/messagepad/reducers/isPosting.js +++ b/packages/ui-default/components/messagepad/reducers/isPosting.js @@ -3,47 +3,47 @@ import Notification from 'vj/components/notification'; export default function reducer(state = {}, action) { switch (action.type) { - case 'DIALOGUES_LOAD_DIALOGUES_FULFILLED': { - const dialogues = action.payload.messages; - return { ...state, ..._.fromPairs(_.map(dialogues, (d) => [d._id, false])) }; - } - case 'DIALOGUES_CREATE': { - const { user } = action.payload; - return { - ...state, - [user._id]: false, - }; - } - case 'DIALOGUES_POST_SEND_PENDING': { - const id = action.meta.dialogueId; - return { - ...state, - [id]: true, - }; - } - case 'DIALOGUES_POST_SEND_REJECTED': { - Notification.error(action.payload.message); - const id = action.meta.dialogueId; - return { - ...state, - [id]: false, - }; - } - case 'DIALOGUES_POST_SEND_FULFILLED': { - const id = action.meta.dialogueId; - return { - ...state, - [id]: false, - }; - } - case 'DIALOGUES_MESSAGE_PUSH': { - const { udoc } = action.payload; - return { - ...state, - [udoc._id]: false, - }; - } - default: - return state; + case 'DIALOGUES_LOAD_DIALOGUES_FULFILLED': { + const dialogues = action.payload.messages; + return { ...state, ..._.fromPairs(_.map(dialogues, (d) => [d._id, false])) }; + } + case 'DIALOGUES_CREATE': { + const { user } = action.payload; + return { + ...state, + [user._id]: false, + }; + } + case 'DIALOGUES_POST_SEND_PENDING': { + const id = action.meta.dialogueId; + return { + ...state, + [id]: true, + }; + } + case 'DIALOGUES_POST_SEND_REJECTED': { + Notification.error(action.payload.message); + const id = action.meta.dialogueId; + return { + ...state, + [id]: false, + }; + } + case 'DIALOGUES_POST_SEND_FULFILLED': { + const id = action.meta.dialogueId; + return { + ...state, + [id]: false, + }; + } + case 'DIALOGUES_MESSAGE_PUSH': { + const { udoc } = action.payload; + return { + ...state, + [udoc._id]: false, + }; + } + default: + return state; } } diff --git a/packages/ui-default/components/monaco/languages/yaml.ts b/packages/ui-default/components/monaco/languages/yaml.ts index 544d0fd85..783524435 100644 --- a/packages/ui-default/components/monaco/languages/yaml.ts +++ b/packages/ui-default/components/monaco/languages/yaml.ts @@ -8,6 +8,9 @@ const problemConfigSchemaDef = { properties: { input: { type: 'string' }, output: { type: 'string' }, + time: { $ref: '#/def/time' }, + memory: { $ref: '#/def/memory' }, + score: { $ref: '#/def/score', description: 'score' }, }, required: ['input'], additionalProperties: false, @@ -28,7 +31,7 @@ const problemConfigSchemaDef = { additionalProperties: false, }, time: { type: 'string', pattern: '^([0-9]+(?:\\.[0-9]*)?)([mu]?)s?$' }, - memory: { type: 'string', pattern: '^([0-9]+(?:\\.[0-9]*)?)([kmg])b?$' }, + memory: { type: 'string', pattern: '^([0-9]+(?:\\.[0-9]*)?)([kKmMgG])[bB]?$' }, score: { type: 'integer', maximum: 100, minimum: 1 }, }; @@ -37,7 +40,7 @@ const problemConfigSchema = { type: 'object', def: problemConfigSchemaDef, properties: { - redirect: { type: 'string', pattern: '[0-9a-z_-]+\\/[0-9]+' }, + redirect: { type: 'string', pattern: '[0-9a-zA-Z_-]+\\/[0-9]+' }, key: { type: 'string', pattern: '[0-9a-f]{32}' }, type: { enum: ['default', 'interactive', 'submit_answer', 'objective', 'remote_judge'] }, subType: { type: 'string' }, diff --git a/packages/ui-default/components/problemconfig/BasicForm.tsx b/packages/ui-default/components/problemconfig/BasicForm.tsx new file mode 100644 index 000000000..0d34abcaf --- /dev/null +++ b/packages/ui-default/components/problemconfig/BasicForm.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import i18n from 'vj/utils/i18n'; +import type { RootState } from './reducer/index'; +import FileSelectAutoComplete from '../autocomplete/components/FileSelectAutoComplete'; + +export function FormItem({ + columns, label, children, helpText = '', disableLabel = false, ...props +}) { + return ( +
+ +
+ ); +} + +type KeyType = { + [Q in keyof K]: K[Q] extends T ? Q : never; +}[keyof K]; + +export function ManagedInput({ placeholder, formKey }: { placeholder?: string, formKey: KeyType }) { + const value = useSelector((state: RootState) => state.config[formKey]); + const dispatch = useDispatch(); + return ( + { + dispatch({ type: 'CONFIG_FORM_UPDATE', key: formKey, value: ev.currentTarget.value }); + }} + className="textbox" + /> + ); +} + +export function ManagedSelect({ options, formKey }: { options: string[], formKey: KeyType }) { + const value = useSelector((state: RootState) => state.config[formKey]); + const dispatch = useDispatch(); + return ( + + ); +} + +export function SingleFileSelect({ formKey }: { formKey: KeyType }) { + const value = useSelector((state: RootState) => state.config[formKey]); + const Files = useSelector((state: RootState) => state.testdata); + const dispatch = useDispatch(); + return ( + dispatch({ type: 'CONFIG_FORM_UPDATE', key: formKey, value: val })} + /> + ); +} diff --git a/packages/ui-default/components/problemconfig/ProblemConfigEditor.tsx b/packages/ui-default/components/problemconfig/ProblemConfigEditor.tsx index e3debd546..68774fc7c 100644 --- a/packages/ui-default/components/problemconfig/ProblemConfigEditor.tsx +++ b/packages/ui-default/components/problemconfig/ProblemConfigEditor.tsx @@ -3,6 +3,7 @@ import type { editor } from 'monaco-editor'; import { connect } from 'react-redux'; import { load } from 'vj/components/monaco/loader'; import Editor from 'vj/components/editor'; +import { diffLines } from 'diff'; import yaml from 'js-yaml'; import type { ProblemConfigFile, TestCaseConfig } from 'hydrooj/src/interface'; @@ -41,9 +42,10 @@ function configYamlFormat(config: ProblemConfigFile) { const formatConfig: ProblemConfigFile = {}; configKey.forEach((key) => { if (config[key] !== undefined) { + if (key === 'subType' && ['single', 'multi'].includes(config[key]) && config.type !== 'submit_answer') return; if (key === 'checker_type' && config.type !== 'default') return; if (key === 'checker' - && (['default', 'strict'].includes(formatConfig.checker_type) || formatConfig.checker_type === undefined)) return; + && (['default', 'strict'].includes(formatConfig.checker_type) || formatConfig.checker_type === undefined)) return; if (key === 'interactor' && config.type !== 'interactive') return; if (key === 'subtasks') { formatConfig[key] = []; @@ -71,6 +73,11 @@ function configYamlFormat(config: ProblemConfigFile) { } else formatConfig[key] = config[key]; } }); + if (formatConfig.type === 'objective') { + Object.keys(formatConfig).filter((i) => !['type', 'outputs'].includes(i)).forEach((i) => delete formatConfig[i]); + formatConfig.outputs = formatConfig.outputs || []; + } + Object.keys(formatConfig).filter((i) => i.startsWith('__')).forEach((i) => delete formatConfig[i]); return formatConfig; } @@ -87,7 +94,7 @@ export default connect(mapStateToProps, mapDispatchToProps)(class MonacoEditor e async componentDidMount() { const { monaco } = await load(['yaml']); const uri = monaco.Uri.parse('hydro://problem/file/config.yaml'); - this.model = monaco.editor.createModel(yaml.dump(this.props.config), 'yaml', uri); + this.model = monaco.editor.createModel(yaml.dump(configYamlFormat(this.props.config)), 'yaml', uri); this.vjEditor = Editor.getOrConstruct($(this.containerElement), { language: 'yaml', model: this.model, @@ -101,17 +108,28 @@ export default connect(mapStateToProps, mapDispatchToProps)(class MonacoEditor e } componentDidUpdate(prevProps) { - if (this.__preventUpdate) return; + if (this.__preventUpdate || !this.model) return; if (yaml.dump(prevProps.config) !== yaml.dump(this.props.config)) { this.__preventFormat = true; - this.model?.pushEditOperations( - [], - [{ - range: this.model.getFullModelRange(), - text: yaml.dump(configYamlFormat(this.props.config)), - }], - undefined, - ); + 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; } } diff --git a/packages/ui-default/components/problemconfig/ProblemConfigForm.tsx b/packages/ui-default/components/problemconfig/ProblemConfigForm.tsx index f70d4b410..afab7fac0 100644 --- a/packages/ui-default/components/problemconfig/ProblemConfigForm.tsx +++ b/packages/ui-default/components/problemconfig/ProblemConfigForm.tsx @@ -1,153 +1,36 @@ import React, { useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import i18n from 'vj/utils/i18n'; -import { isEqual } from 'lodash'; -import { Switch, Tab, Tabs } from '@blueprintjs/core'; -import type { SubtaskConfig, TestCaseConfig } from 'hydrooj/src/interface'; +import { + Card, Switch, Tab, Tabs, InputGroup, Tag, +} from '@blueprintjs/core'; import type { RootState } from './reducer/index'; import CustomSelectAutoComplete from '../autocomplete/components/CustomSelectAutoComplete'; +import FileSelectAutoComplete from '../autocomplete/components/FileSelectAutoComplete'; +import { FormItem } from './BasicForm'; +import ProblemType from './ProblemType'; +import { TaskConfig } from './SubtaskTable'; -const SelectValue = { - type: ['default', 'interactive', 'submit_answer', 'objective'], - checker_type: ['default', 'lemon', 'syzoj', 'hustoj', 'testlib', 'qduoj'], - task_type: ['min', 'max', 'sum'], -}; - -const eq = (a: TestCaseConfig, b: TestCaseConfig) => isEqual(a, b); -const eqId = (a: SubtaskConfig[], b: SubtaskConfig[]) => { - if (a.length !== b.length) return true; - for (let i = 0; i < a.length; i++) { - if (a[i].id !== b[i].id) return true; - } - return false; -}; - -function FormItem({ - columns, label, children, helpText = '', disableLabel = false, ...props -}) { - return ( -
- -
- ); -} - -type KeyType = { - [Q in keyof K]: K[Q] extends T ? Q : never; -}[keyof K]; - -function ManagedInput({ placeholder, formKey }: { placeholder: string, formKey: KeyType }) { - const value = useSelector((state: RootState) => state.config[formKey]); +function FileIOConfig() { + const filename = useSelector((state: RootState) => state.config.filename); const dispatch = useDispatch(); return ( - { - dispatch({ type: 'CONFIG_FORM_UPDATE', key: formKey, value: ev.currentTarget.value }); - }} - className="textbox" - /> - ); -} - -function ManagedSelect({ placeholder, formKey }: { placeholder: string, formKey: KeyType }) { - const value = useSelector((state: RootState) => state.config[formKey]); - const dispatch = useDispatch(); - return ( - - ); -} - -function SingleFileSelect({ formKey }: { formKey: KeyType }) { - const value = useSelector((state: RootState) => state.config[formKey]); - const Files = useSelector((state: RootState) => state.testdata); - const dispatch = useDispatch(); - return ( - dispatch({ type: 'CONFIG_FORM_UPDATE', key: formKey, value: val })} - /> - ); -} - -function BasicInfo() { - const Type = useSelector((state: RootState) => state.config.type); - const checkerType = useSelector((state: RootState) => state.config.checker_type); - const dispatch = useDispatch(); - return ( - <> - - - - - - - - { - dispatch({ type: 'CONFIG_FORM_UPDATE', key: 'checker_type', value }); - }} - renderActiveTabPanelOnly - > - {i18n('CheckerType')} - - { - SelectValue.checker_type.map((i) => ( - - {['default', 'strict'].includes(i) - ? ( - { - dispatch({ type: 'CONFIG_FORM_UPDATE', key: 'checker_type', value: checkerType === 'strict' ? 'default' : 'strict' }); - } - } - /> - ) - : ( - - )} - - )} - /> - )) - } - -
- - - - + + +
+ + .in/.out} + value={filename || ''} + onChange={(ev) => { + dispatch({ type: 'CONFIG_FORM_UPDATE', key: 'filename', value: ev.currentTarget.value }); + }} + fill + /> + +
+
+
); } @@ -166,7 +49,7 @@ function ExtraFilesConfig() { id="user_extra_files" title={i18n('user_extra_files')} panel={( - dispatch({ type: 'CONFIG_FORM_UPDATE', key: 'user_extra_files', value: val.split(',') })} @@ -178,7 +61,7 @@ function ExtraFilesConfig() { id="judge_extra_files" title={i18n('judge_extra_files')} panel={( - dispatch({ type: 'CONFIG_FORM_UPDATE', key: 'judge_extra_files', value: val.split(',') })} @@ -192,261 +75,6 @@ function ExtraFilesConfig() { ); } -function CasesSubCasesTable({ index, subindex }) { - const subcases = useSelector((state: RootState) => (index === -1 - ? state.config.cases[subindex] : state.config.subtasks[index].cases[subindex]), eq); - 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 casesLength = useSelector((state: RootState) => (index === -1 ? state.config.cases?.length : state.config.subtasks[index].cases?.length)); - const dispatch = useDispatch(); - return ( - - - - { - index === -1 && ( - <> - - - - ) - } - - - - - - - {casesLength && [...Array(casesLength).keys()].map((i) => )} - -
- {i18n('Time')} - - {i18n('Memory')} - - {i18n('Input')} - - {i18n('Output')} - - dispatch({ - type: index === -1 ? 'CONFIG_CASES_UPDATE' : 'CONFIG_SUBTASK_UPDATE', - id: index, - key: 'cases-add', - value: index === -1 ? { - time: 1000, memory: 256, input: '', output: '', - } : { input: '', output: '' }, - })} - > - -
- ); -} - -function SubtasksIds({ index }) { - const subtasks = useSelector((state: RootState) => state.config.subtasks, eqId); - const subtaskIf = useSelector((state: RootState) => state.config.subtasks[index].if); - const dispatch = useDispatch(); - const subtasksIds = subtasks.map((i) => i.id).filter((i) => i !== undefined); - return ( - - if - - `${item}`} - onChange={(val) => dispatch({ - type: 'CONFIG_SUBTASK_UPDATE', id: index, key: 'if', value: val.split(','), - })} - multi - /> - - - ); -} - -function SubtasksTable({ index }) { - const subtask = (key: string) => useSelector((state: RootState) => state.config.subtasks[index][key]); - const dispatch = useDispatch(); - const dispatcher = (key: string): React.ChangeEventHandler => (ev) => { - dispatch({ - type: 'CONFIG_SUBTASK_UPDATE', id: index, key, value: ev.currentTarget.value, - }); - }; - return ( - <> - Subtasks #{index + 1} - { dispatch({ type: 'CONFIG_SUBTASK_UPDATE', id: index, key: 'add' }); }}> - { dispatch({ type: 'CONFIG_SUBTASK_UPDATE', id: index, key: 'delete' }); }} - > - - - - - - - - - - - - - - - - - - - - - -
#ScoreTypeTimeMemory
- - - - - - - - - -
- - - ); -} - -function TaskConfig({ onAutoLoad }) { - const subtasksLength = useSelector((state: RootState) => state.config.subtasks?.length); - const casesLength = useSelector((state: RootState) => state.config.cases?.length); - const dispatch = useDispatch(); - return ( - -
- - - - - - - - - - - {casesLength && dispatch({ type: 'CONFIG_SUBTASKS_SWITCH', value: true })} />} - { - subtasksLength || casesLength - ? subtasksLength && [...Array(subtasksLength).keys()].map((i) => ) - || casesLength && () - : ( - onAutoLoad()}> - {i18n('Auto Read Tasks')} - - ) - } - -
-
- ); -} - function LangConfig() { const [showTab, setshowTab] = useState(false); const langs = useSelector((state: RootState) => state.config.langs) || []; @@ -479,13 +107,19 @@ function LangConfig() { ); } -export default function ProblemConfigForm({ onAutoLoad }) { +export default function ProblemConfigForm() { + const Type = useSelector((state: RootState) => state.config.type); return ( -
- - - - +
+ + {Type === 'default' && } + {!['submit_answer', 'objective'].includes(Type) && ( + <> + + + + + )}
); } diff --git a/packages/ui-default/components/problemconfig/ProblemType.tsx b/packages/ui-default/components/problemconfig/ProblemType.tsx new file mode 100644 index 000000000..f81c88fe1 --- /dev/null +++ b/packages/ui-default/components/problemconfig/ProblemType.tsx @@ -0,0 +1,129 @@ +import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import i18n from 'vj/utils/i18n'; +import { + Card, Switch, Tab, Tabs, +} from '@blueprintjs/core'; +import type { RootState } from './reducer/index'; +import { FormItem, ManagedSelect, SingleFileSelect } from './BasicForm'; + +export default function ProblemType() { + const Type = useSelector((state: RootState) => state.config.type); + const checkerType = useSelector((state: RootState) => state.config.checker_type); + const filename = useSelector((state: RootState) => state.config.filename); + const subType = useSelector((state: RootState) => state.config.subType); + const dispatch = useDispatch(); + const dispatcher = (base) => (value) => dispatch({ ...base, value }); + return ( + + + + {i18n('Problem Type')} + + + {i18n('CheckerType')} + + + + { + dispatch({ type: 'CONFIG_FORM_UPDATE', key: 'checker_type', value: checkerType === 'strict' ? 'default' : 'strict' }); + }} + /> + +
+ )} + /> + {['testlib', 'other'].map((i) => ( + + {i === 'other' && ( + + + + )} + + + +
+ )} + /> + ))} + + )} + /> + + + + +
+ )} + /> + + + { + dispatch({ type: 'CONFIG_FORM_UPDATE', key: 'subType', value: subType === 'multi' ? 'single' : 'multi' }); + }} + /> + + + dispatch(({ type: 'CONFIG_FORM_UPDATE', key: 'filename', value: ev.currentTarget.value }))} + className="textbox" + /> + +
+ )} + /> + 暂不支持快捷配置此类题目。请参照文档操作。

)} + /> + + + + ); +} diff --git a/packages/ui-default/components/problemconfig/SubtaskTable.tsx b/packages/ui-default/components/problemconfig/SubtaskTable.tsx new file mode 100644 index 000000000..0f3e68148 --- /dev/null +++ b/packages/ui-default/components/problemconfig/SubtaskTable.tsx @@ -0,0 +1,139 @@ +import { Card, NumericInput, Tag } from '@blueprintjs/core'; +import { pick, isEqual } from 'lodash'; +import React from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import { SubtaskConfig } from 'hydrooj/src/interface'; +import { parseTimeMS, parseMemoryMB } from '@hydrooj/utils/lib/common'; +import CustomSelectAutoComplete from '../autocomplete/components/CustomSelectAutoComplete'; +import { FormItem, ManagedInput } from './BasicForm'; +import { RootState } from './reducer'; +import { CasesTable } from './TestCasesTable'; + +const eq = (a: SubtaskConfig, b: SubtaskConfig) => isEqual(a, b); + +export function SubtasksIds({ index }) { + const ids = useSelector((state: RootState) => state.config.subtasks.map((i) => i.id).filter((i) => i !== undefined).join(',')); + const subtaskIf = useSelector((state: RootState) => state.config.subtasks[index].if); + const dispatch = useDispatch(); + return ( + + if + + i.length)} + selectedKeys={subtaskIf?.map(String) || []} + onChange={(val) => dispatch({ + type: 'CONFIG_SUBTASK_UPDATE', id: index, key: 'if', value: val.split(','), + })} + multi + /> + + + ); +} + +export function SubtasksTable({ index }) { + const keys = ['id', 'type', 'score', 'time', 'memory']; + const subtask = useSelector((state: RootState) => pick(state.config.subtasks[index], keys), eq); + const defaultTime = useSelector((state: RootState) => state.config.time); + const defaultMemory = useSelector((state: RootState) => state.config.memory); + const dispatch = useDispatch(); + const dispatcher = (key: string, suffix = '') => (ev: React.ChangeEvent | number) => { + let value = typeof ev !== 'object' ? ev : ev.currentTarget?.value; + if (value === 0) value = ''; + if (value && suffix) value += suffix; + dispatch({ + type: 'CONFIG_SUBTASK_UPDATE', id: index, key, value, + }); + }; + return ( + + Subtasks #{index + 1} + dispatch({ type: 'CONFIG_SUBTASK_UPDATE', id: index, key: 'add' })}> + dispatch({ type: 'CONFIG_SUBTASK_UPDATE', id: index, key: 'delete' })} + > + + + + + + + + + + + + + + + {['id', 'score'].map((key) => ( + + ))} + + + + + + +
#ScoreTypeTimeMemory
+ + + + + ms} + value={subtask.time ? parseTimeMS(subtask.time).toString() : ''} + placeholder={parseTimeMS(defaultTime || '1000ms').toString()} + onValueChange={dispatcher('time', 'ms')} + buttonPosition="none" + fill + /> + + MB} + value={subtask.memory ? parseMemoryMB(subtask.memory).toString() : ''} + placeholder={parseMemoryMB(defaultMemory || '256m').toString()} + onValueChange={dispatcher('memory', 'MB')} + buttonPosition="none" + fill + /> +
+ +
+ ); +} + +export function TaskConfig() { + const len = useSelector((state: RootState) => state.config.subtasks?.length); + return ( + +
+ + + + + + + + + + + {len && [...Array(len).keys()].map((i) => )} + +
+
+ ); +} diff --git a/packages/ui-default/components/problemconfig/TestCasesTable.tsx b/packages/ui-default/components/problemconfig/TestCasesTable.tsx new file mode 100644 index 000000000..23c2ff287 --- /dev/null +++ b/packages/ui-default/components/problemconfig/TestCasesTable.tsx @@ -0,0 +1,123 @@ +import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import i18n from 'vj/utils/i18n'; +import { isEqual } from 'lodash'; +import { Tag, NumericInput } from '@blueprintjs/core'; +import { parseTimeMS, parseMemoryMB } from '@hydrooj/utils/lib/common'; +import type { SubtaskConfig } from 'hydrooj/src/interface'; +import type { RootState } from './reducer/index'; +import FileSelectAutoComplete from '../autocomplete/components/FileSelectAutoComplete'; + +const eq = (a: SubtaskConfig, b: SubtaskConfig) => isEqual(a, b); +const eqArr = (a: any[], b: any[]) => isEqual(a, b); + +export function TestCaseEntry({ index, subindex }) { + const testcase = useSelector((state: RootState) => state.config.subtasks[index].cases[subindex], eq); + const Files = useSelector((state: RootState) => state.testdata, eqArr); + const defaultTime = useSelector((state: RootState) => state.config.subtasks[index].time || state.config.time); + const defaultMemory = useSelector((state: RootState) => state.config.subtasks[index].memory || state.config.memory); + const dispatch = useDispatch(); + const dispatcher = (casesKey: string, valueSuffix = '') => (ev: React.ChangeEvent | number) => { + let value = typeof ev !== 'object' ? ev : ev.currentTarget.value; + if (value === 0) value = ''; + if (valueSuffix && value) value += valueSuffix; + dispatch({ + type: 'CONFIG_SUBTASK_UPDATE', + id: index, + key: 'cases-edit', + casesId: subindex, + casesKey, + value, + }); + }; + return ( + + + ms} + value={testcase.time ? parseTimeMS(testcase.time).toString() : ''} + placeholder={parseTimeMS(defaultTime || '1000ms').toString()} + onValueChange={dispatcher('time', 'ms')} + buttonPosition="none" + fill + /> + + + MB} + value={testcase.memory ? parseMemoryMB(testcase.memory).toString() : ''} + placeholder={parseMemoryMB(defaultMemory || '256m').toString()} + onValueChange={dispatcher('memory', 'MB')} + buttonPosition="none" + fill + /> + + {['input', 'output'].map((t) => ( + + + + ))} + + dispatch({ + type: 'CONFIG_SUBTASK_UPDATE', id: index, key: 'cases-delete', value: subindex, + })} + > + + + + ); +} + +export function CasesTable({ index }) { + const len = useSelector((state: RootState) => state.config.subtasks[index].cases?.length); + const dispatch = useDispatch(); + return ( + + + + + + + + + + + + + + + + + + + {[...Array(len).keys()].map((i) => )} + +
{i18n('Time')}{i18n('Memory')}{i18n('Input')}{i18n('Output')} + dispatch({ + type: 'CONFIG_SUBTASK_UPDATE', + id: index, + key: 'cases-add', + value: { input: '', output: '' }, + })} + > + +
{i18n('Time')}{i18n('Memory')}{i18n('Input')}{i18n('Output')} + dispatch({ + type: 'CONFIG_SUBTASK_UPDATE', + id: index, + key: 'cases-add', + value: { input: '', output: '' }, + })} + > + +
+ ); +} diff --git a/packages/ui-default/components/problemconfig/reducer/config.ts b/packages/ui-default/components/problemconfig/reducer/config.ts index 011bd0ff4..eec223d6a 100644 --- a/packages/ui-default/components/problemconfig/reducer/config.ts +++ b/packages/ui-default/components/problemconfig/reducer/config.ts @@ -2,84 +2,69 @@ import yaml from 'js-yaml'; import type { ProblemConfigFile } from 'hydrooj/src/interface'; import { cloneDeep } from 'lodash'; -export default function reducer(state = { type: 'default' } as ProblemConfigFile, action): ProblemConfigFile { +type State = ProblemConfigFile & { __loaded: boolean }; + +export default function reducer(state = { type: 'default', __loaded: false } as State, action): State { switch (action.type) { - case 'CONFIG_LOAD_FULFILLED': { - // TODO set yaml schema - return { ...state, ...yaml.load(action.payload.config) as object }; - } - case 'CONFIG_FORM_UPDATE': { - const next = { ...state, [action.key]: action.value }; - if (action.key === 'score' && action.value) next.score = +next.score; - if (!action.value || (typeof action.value === 'object' && !action.value.join(''))) delete next[action.key]; - return next; - } - case 'CONFIG_CODE_UPDATE': { - try { - return { ...state, ...yaml.load(action.payload) as object }; - } catch { - return state; + case 'CONFIG_LOAD_FULFILLED': { + return { ...state, ...yaml.load(action.payload.config) as object, __loaded: true }; } - } - case 'CONFIG_AUTOCASES_UPDATE': { - const next = { ...state }; - 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({ - time: autocases.subtasks[0].time, memory: autocases.subtasks[0].memory, input: i.input, output: i.output, - })); - } else { - next.subtasks = autocases.subtasks.map((subtask) => ( - { ...subtask, ...{ cases: subtask.cases.map((i) => ({ input: i.input, output: i.output })) } })); + case 'CONFIG_FORM_UPDATE': { + const next = { ...state, [action.key]: action.value }; + if (action.key === 'score' && action.value) next.score = +next.score; + if (action.key === 'checker_type' && action.value === 'other') next.checker_type = 'syzoj'; + if (!action.value || (typeof action.value === 'object' && !action.value.join(''))) delete next[action.key]; + return next; } - return next; - } - case 'CONFIG_SUBTASKS_SWITCH': { - const next = { ...state }; - next.subtasks = next.cases.map((k, v) => ({ - id: v, time: k.time, memory: k.memory, cases: [{ input: k.input, output: k.output }], - })); - if (!next.subtasks.length) next.subtasks.push({ id: 0 }); - delete next.cases; - return next; - } - case 'CONFIG_CASES_UPDATE': { - if (['time', 'memory'].includes(action.casesKey) && action.value !== '') action.value = +action.value; - if (action.key === 'cases-add') return { ...state, cases: [...state.cases, action.value] }; - if (action.key === 'cases-delete') { - return { ...state, cases: state.cases.filter((k, v) => v !== action.value) }; - } - const cases = cloneDeep(state.cases); - cases[action.casesId][action.casesKey] = action.value; - if (action.value === '') delete cases[action.casesId][action.casesKey]; - return { ...state, cases }; - } - case 'CONFIG_SUBTASK_UPDATE': { - const subtasks = cloneDeep(state.subtasks); - const subsubtasks = cloneDeep(state.subtasks[action.id]); - if (action.value !== '' - && (['time', 'memory'].includes(action.casesKey) || ['time', 'memory', 'score', 'id'].includes(action.key)) - ) action.value = +action.value; - if (action.key === 'if' && action.value.join('') !== '') action.value = action.value.map((i) => +i); - if (action.key.split('-')[0] === 'cases') { - if (action.key === 'cases-add') subsubtasks.cases.push(action.value); - else if (action.key === 'cases-edit') subsubtasks.cases[action.casesId][action.casesKey] = action.value; - else if (action.key === 'cases-delete') { - subsubtasks.cases = subsubtasks.cases.filter((k, v) => v !== action.value); + case 'CONFIG_CODE_UPDATE': { + try { + return { ...state, ...yaml.load(action.payload) as object }; + } catch { + return state; } - } else if (action.key === 'add') subtasks.push({ time: 1000, memory: 256, cases: [] }); - else if (action.key === 'delete') delete subtasks[action.key]; - else { - if (action.value === '' || (action.key === 'if' && action.value.join('') === '')) delete subsubtasks[action.key]; - else subsubtasks[action.key] = action.value; } - if (action.key !== 'delete') subtasks[action.id] = subsubtasks; - return { ...state, subtasks }; - } - default: - return state; + case 'CONFIG_AUTOCASES_UPDATE': { + const next = { ...state }; + const autocases = action.value; + if (autocases.subtasks.length === 0) next.subtasks = []; + else { + next.subtasks = autocases.subtasks.map((subtask) => ( + { ...subtask, ...{ cases: subtask.cases.map((i) => ({ input: i.input, output: i.output })) } })); + } + return next; + } + case 'CONFIG_SUBTASKS_SWITCH': { + const next = { ...state }; + next.subtasks = next.cases.map((k, v) => ({ + id: v, time: k.time, memory: k.memory, cases: [{ input: k.input, output: k.output }], + })); + if (!next.subtasks.length) next.subtasks.push({ id: 0 }); + delete next.cases; + return next; + } + case 'CONFIG_SUBTASK_UPDATE': { + const subtasks = cloneDeep(state.subtasks); + const subsubtasks = cloneDeep(state.subtasks[action.id]); + if (action.value !== '' && ['score', 'id'].includes(action.key)) action.value = +action.value; + if (action.key === 'if' && action.value.join('') !== '') action.value = action.value.map((i) => +i); + if (action.key.split('-')[0] === 'cases') { + if (action.key === 'cases-add') subsubtasks.cases.push(action.value); + else if (action.key === 'cases-edit') { + if (action.value === '') delete subsubtasks.cases[action.casesId][action.casesKey]; + else subsubtasks.cases[action.casesId][action.casesKey] = action.value; + } else if (action.key === 'cases-delete') { + subsubtasks.cases = subsubtasks.cases.filter((k, v) => v !== action.value); + } + } else if (action.key === 'add') subtasks.push({ cases: [] }); + else if (action.key === 'delete') return { ...state, subtasks: subtasks.filter((k, v) => v !== action.id) }; + else { + if (action.value === '' || (action.key === 'if' && action.value.join('') === '')) delete subsubtasks[action.key]; + else subsubtasks[action.key] = action.value; + } + subtasks[action.id] = subsubtasks; + return { ...state, subtasks }; + } + default: + return state; } } diff --git a/packages/ui-default/components/problemconfig/reducer/testdata.ts b/packages/ui-default/components/problemconfig/reducer/testdata.ts index 924766e41..34fb0fb88 100644 --- a/packages/ui-default/components/problemconfig/reducer/testdata.ts +++ b/packages/ui-default/components/problemconfig/reducer/testdata.ts @@ -1,16 +1,16 @@ export default function reducer(state = [], action) { switch (action.type) { - case 'CONFIG_LOAD_FULFILLED': { - return state.concat(action.payload.testdata); - } - case 'CONFIG_DELETE_TESTDATA': { - const next = state.filter((i) => i.name !== action.value[0]); - return next; - } - case 'CONFIG_ADD_TESTDATA': { - return state.concat([action.value]); - } - default: - return state; + case 'CONFIG_LOAD_FULFILLED': { + return state.concat(action.payload.testdata); + } + case 'CONFIG_DELETE_TESTDATA': { + const next = state.filter((i) => i.name !== action.value[0]); + return next; + } + case 'CONFIG_ADD_TESTDATA': { + return state.concat([action.value]); + } + default: + return state; } } diff --git a/packages/ui-default/components/scratchpad/reducers/records.ts b/packages/ui-default/components/scratchpad/reducers/records.ts index 4c44b09e6..3b8b0d655 100644 --- a/packages/ui-default/components/scratchpad/reducers/records.ts +++ b/packages/ui-default/components/scratchpad/reducers/records.ts @@ -5,36 +5,36 @@ export default function reducer(state = { items: {}, }, action) { switch (action.type) { - case 'SCRATCHPAD_RECORDS_LOAD_SUBMISSIONS_FULFILLED': { - const { rdocs } = action.payload; - return { - ...state, - rows: _.map(rdocs, '_id'), - items: _.keyBy(rdocs, '_id'), - }; - } - case 'SCRATCHPAD_RECORDS_PUSH': { - const { rdoc } = action.payload; - const rows = [...state.rows]; - if (!rows.includes(rdoc._id)) { + case 'SCRATCHPAD_RECORDS_LOAD_SUBMISSIONS_FULFILLED': { + const { rdocs } = action.payload; + return { + ...state, + rows: _.map(rdocs, '_id'), + items: _.keyBy(rdocs, '_id'), + }; + } + case 'SCRATCHPAD_RECORDS_PUSH': { + const { rdoc } = action.payload; + const rows = [...state.rows]; + if (!rows.includes(rdoc._id)) { + return { + ...state, + rows: [rdoc._id, ...state.rows], + items: { + ...state.items, + [rdoc._id]: rdoc, + }, + }; + } return { ...state, - rows: [rdoc._id, ...state.rows], items: { ...state.items, [rdoc._id]: rdoc, }, }; } - return { - ...state, - items: { - ...state.items, - [rdoc._id]: rdoc, - }, - }; - } - default: - return state; + default: + return state; } } diff --git a/packages/ui-default/components/scratchpad/reducers/ui.ts b/packages/ui-default/components/scratchpad/reducers/ui.ts index 257868f14..ca374c480 100644 --- a/packages/ui-default/components/scratchpad/reducers/ui.ts +++ b/packages/ui-default/components/scratchpad/reducers/ui.ts @@ -19,93 +19,93 @@ export default function reducer(state = { isPosting: false, }, action) { switch (action.type) { - case 'SCRATCHPAD_UI_CHANGE_SIZE': { - const { uiElement, size } = action.payload; - return { - ...state, - [uiElement]: { - ...state[uiElement], - size, - }, - }; - } - case 'SCRATCHPAD_UI_SET_VISIBILITY': { - const { uiElement, visibility } = action.payload; - localStorage.setItem(`scratchpad/${uiElement}`, visibility.toString()); - return { - ...state, - [uiElement]: { - ...state[uiElement], - visible: visibility, - }, - }; - } - case 'SCRATCHPAD_UI_TOGGLE_VISIBILITY': { - const { uiElement } = action.payload; - localStorage.setItem(`scratchpad/${uiElement}`, (!state[uiElement].visible).toString()); - return { - ...state, - [uiElement]: { - ...state[uiElement], - visible: !state[uiElement].visible, - }, - }; - } - case 'SCRATCHPAD_POST_PRETEST_PENDING': - case 'SCRATCHPAD_POST_SUBMIT_PENDING': { - return { - ...state, - isPosting: true, - }; - } - case 'SCRATCHPAD_POST_PRETEST_FULFILLED': - case 'SCRATCHPAD_POST_SUBMIT_FULFILLED': { - Notification.success(i18n('Submitted.')); - if (action.type === 'SCRATCHPAD_POST_SUBMIT_FULFILLED' && UiContext.canViewRecord) { - state.records.visible = true; + case 'SCRATCHPAD_UI_CHANGE_SIZE': { + const { uiElement, size } = action.payload; + return { + ...state, + [uiElement]: { + ...state[uiElement], + size, + }, + }; } - return { - ...state, - isPosting: false, - }; - } - case 'SCRATCHPAD_POST_PRETEST_REJECTED': - case 'SCRATCHPAD_POST_SUBMIT_REJECTED': { - Notification.error(action.payload.message); - return { - ...state, - isPosting: false, - }; - } - case 'SCRATCHPAD_RECORDS_LOAD_SUBMISSIONS_PENDING': { - return { - ...state, - records: { - ...state.records, - isLoading: true, - }, - }; - } - case 'SCRATCHPAD_RECORDS_LOAD_SUBMISSIONS_REJECTED': { - Notification.error(action.payload.message); - return { - ...state, - records: { - ...state.records, - isLoading: false, - }, - }; - } - case 'SCRATCHPAD_RECORDS_LOAD_SUBMISSIONS_FULFILLED': { - return { - ...state, - records: { - ...state.records, - isLoading: false, - }, - }; - } - default: - return state; + case 'SCRATCHPAD_UI_SET_VISIBILITY': { + const { uiElement, visibility } = action.payload; + localStorage.setItem(`scratchpad/${uiElement}`, visibility.toString()); + return { + ...state, + [uiElement]: { + ...state[uiElement], + visible: visibility, + }, + }; + } + case 'SCRATCHPAD_UI_TOGGLE_VISIBILITY': { + const { uiElement } = action.payload; + localStorage.setItem(`scratchpad/${uiElement}`, (!state[uiElement].visible).toString()); + return { + ...state, + [uiElement]: { + ...state[uiElement], + visible: !state[uiElement].visible, + }, + }; + } + case 'SCRATCHPAD_POST_PRETEST_PENDING': + case 'SCRATCHPAD_POST_SUBMIT_PENDING': { + return { + ...state, + isPosting: true, + }; + } + case 'SCRATCHPAD_POST_PRETEST_FULFILLED': + case 'SCRATCHPAD_POST_SUBMIT_FULFILLED': { + Notification.success(i18n('Submitted.')); + if (action.type === 'SCRATCHPAD_POST_SUBMIT_FULFILLED' && UiContext.canViewRecord) { + state.records.visible = true; + } + return { + ...state, + isPosting: false, + }; + } + case 'SCRATCHPAD_POST_PRETEST_REJECTED': + case 'SCRATCHPAD_POST_SUBMIT_REJECTED': { + Notification.error(action.payload.message); + return { + ...state, + isPosting: false, + }; + } + case 'SCRATCHPAD_RECORDS_LOAD_SUBMISSIONS_PENDING': { + return { + ...state, + records: { + ...state.records, + isLoading: true, + }, + }; + } + case 'SCRATCHPAD_RECORDS_LOAD_SUBMISSIONS_REJECTED': { + Notification.error(action.payload.message); + return { + ...state, + records: { + ...state.records, + isLoading: false, + }, + }; + } + case 'SCRATCHPAD_RECORDS_LOAD_SUBMISSIONS_FULFILLED': { + return { + ...state, + records: { + ...state.records, + isLoading: false, + }, + }; + } + default: + return state; } } diff --git a/packages/ui-default/locales/zh.yaml b/packages/ui-default/locales/zh.yaml index 73a82ef28..7aaa46934 100644 --- a/packages/ui-default/locales/zh.yaml +++ b/packages/ui-default/locales/zh.yaml @@ -118,8 +118,8 @@ Change Email: 修改电子邮件 Change Password: 修改密码 Change Theme: 切换主题 Check In: 签到 -CheckerType: 比较器类型 Checker: 比较器 +CheckerType: 比较器类型 Chinese: 中文 Choose the background image in your profile page.: 选择您资料页面的背景图片。 Claim homework: 认领作业 @@ -270,7 +270,6 @@ domain_user: 管理用户 domain: 域 Domain: 域 Don't have an account?: 还没有账户? -Don't ignore space and enter.: 不忽略行末回车与空格。 Done: 已结束 Download All: 下载全部 Download Dataset: 下载数据集 @@ -388,6 +387,7 @@ Host: 主持人 hour(s): 小时 If enabled, source code will be emailed to you after the submission is accepted.: 如果启用,在您通过题目后,源代码会以 Email 的形式发送给您。 If left blank, the built-in template of the corresponding language will be used.: 若留空,则将使用对应语言的内置代码模板。 +Ignore trailing space and enter.: 忽略行末空格与文件尾回车。 Images With Size: 指定大小的图片 Images: 图片 Import from: 导入自 @@ -423,15 +423,15 @@ Italic: 斜体 Join: 加入 Judge Config: 评测设置 Judge Status: 评测状态 -judge_playground: 评测游乐场 judge_extra_files: 评测额外文件 +judge_playground: 评测游乐场 Judged At: 评测时间 Judged By: 评测机 Judging Queue: 评测队列 Keep current expiration: 保持当前过期设置 +Langs Config: 语言设置 langs: 语言 Language: 语言 -Langs Config: 语言设置 last active at: 最后活动于 last login at: 最后登录于 Last Submit At: 最后递交于 @@ -594,8 +594,8 @@ Problem Solution List: 题解列表 Problem Solution: 题解列表 Problem Tags Visibility: 题目标签可见性 Problem Tags: 题目标签 -problem_create: 创建题目 problem_config: 评测设置 +problem_create: 创建题目 problem_edit: 编辑题目 problem_import: 导入题目 problem_main: 题库 @@ -768,8 +768,8 @@ SuperUser: 超级管理员 Support: 支持 Switch account: 切换账户 Tags: 标签 -tasks_list: 任务列表 Task Settings: 任务设置 +tasks_list: 任务列表 Technical Information: 技术信息 Temporary session: 临时会话 Terms of Service: 服务条款 diff --git a/packages/ui-default/package.json b/packages/ui-default/package.json index 5a3abccdd..9df5f5240 100644 --- a/packages/ui-default/package.json +++ b/packages/ui-default/package.json @@ -10,7 +10,7 @@ "lint": "eslint ." }, "devDependencies": { - "@blueprintjs/core": "^3.54.0", + "@blueprintjs/core": "^4.2.1", "@fontsource/dm-mono": "^4.5.7", "@fontsource/fira-code": "^4.5.8", "@fontsource/inconsolata": "^4.5.4", @@ -37,6 +37,7 @@ "clipboard": "^2.0.10", "copy-webpack-plugin": "^6.4.1", "css-loader": "^4.3.0", + "diff": "^5.0.0", "diff-dom": "^4.2.3", "echarts": "^5.3.2", "emojis-keywords": "2.0.0", diff --git a/packages/ui-default/pages/home_files.page.js b/packages/ui-default/pages/home_files.page.js index 1aca098df..e2f3ee0c2 100644 --- a/packages/ui-default/pages/home_files.page.js +++ b/packages/ui-default/pages/home_files.page.js @@ -40,12 +40,12 @@ async function handleClickUpload(files) { const dialog = new Dialog({ $body: `
-
-
+
+
-
-
+
+
`, }); try { diff --git a/packages/ui-default/pages/homework_main.page.js b/packages/ui-default/pages/homework_main.page.js index 4dc9a6d58..842e223b2 100644 --- a/packages/ui-default/pages/homework_main.page.js +++ b/packages/ui-default/pages/homework_main.page.js @@ -19,16 +19,16 @@ const page = new NamedPage('homework_main', () => { calendar.getDom().appendTo('[name="calendar_entry"]'); $('[name="homework_display"]').change((ev) => { switch (ev.currentTarget.value) { - case 'calendar': - $('.homework__list').hide(); - $('[name="calendar_entry"]').show(); - break; - case 'list': - $('.homework__list').show(); - $('[name="calendar_entry"]').hide(); - break; - default: - throw new Error('Unexpected display parameter'); + case 'calendar': + $('.homework__list').hide(); + $('[name="calendar_entry"]').show(); + break; + case 'list': + $('.homework__list').show(); + $('[name="calendar_entry"]').hide(); + break; + default: + throw new Error('Unexpected display parameter'); } }); } diff --git a/packages/ui-default/pages/problem_config.page.styl b/packages/ui-default/pages/problem_config.page.styl index 304845f7e..0166572f8 100644 --- a/packages/ui-default/pages/problem_config.page.styl +++ b/packages/ui-default/pages/problem_config.page.styl @@ -15,9 +15,57 @@ width: 80px .col--operation width: 40px + text-align: center .textbox,.select margin: 0 - .bp3-tab-panel - margin-top: 5px \ No newline at end of file + .bp4-tab-panel + margin-top: 5px + + .problem-config-form + font-family: var(--code-font-family) + + input::-webkit-outer-spin-button, input::-webkit-inner-spin-button + -webkit-appearance: none !important + input[type="number"] + -moz-appearance: textfield + + .bp4-tag.bp4-minimal + background: transparent + .bp4-input-action + pointer-events: none + .bp4-input-group + input + appearance: none + display: block + width: 100% + font-size: 0.875rem + height: 2.1875rem + line-height: 1.2 + padding: 0.3125rem; + border: 1px solid #ddd + color: inherit + border-radius: 0 + background-color: transparent + outline: 2px solid transparent + transition: outline-color 0.2s, border-color 0.2s + transition-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1) + box-shadow: none + input::placeholder + color: inherit + + .data-table + tr + border-top: 0 + border-bottom: 0 + height: auto + td + padding: 0 + .thead td + text-align: center + td input, td div + background: transparent + + .hover-row.status-bar + display: none \ No newline at end of file diff --git a/packages/ui-default/pages/problem_config.page.tsx b/packages/ui-default/pages/problem_config.page.tsx index 51334c9f8..06eeeef47 100644 --- a/packages/ui-default/pages/problem_config.page.tsx +++ b/packages/ui-default/pages/problem_config.page.tsx @@ -7,7 +7,9 @@ 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 { + size, readSubtasksFromFiles, parseTimeMS, parseMemoryMB, +} from '@hydrooj/utils/lib/common'; import tpl from 'vj/utils/tpl'; async function handleSection(ev: JQuery.ClickEvent, type: string) { @@ -29,10 +31,6 @@ function onBeforeUnload(e) { e.returnValue = ''; } -function ensureFile(testdata) { - return (file: string) => testdata.filter((i) => i === file)[0]; -} - const page = new NamedPage('problem_config', () => { let reduxStore; @@ -46,12 +44,12 @@ const page = new NamedPage('problem_config', () => { const dialog = new Dialog({ $body: `
-
-
+
+
-
-
+
+
`, }); try { @@ -147,7 +145,7 @@ const page = new NamedPage('problem_config', () => { await download(`${pdoc.docId} ${pdoc.title}.zip`, targets); } - async function uploadConfig(config:object) { + async function uploadConfig(config: object) { const configYaml = yaml.dump(config); Notification.info(i18n('Saving file...')); const data = new FormData(); @@ -161,9 +159,11 @@ const page = new NamedPage('problem_config', () => { } async function mountComponent() { - const { default: ProblemConfigEditor } = await import('vj/components/problemconfig/ProblemConfigEditor'); - const { default: ProblemConfigForm } = await import('vj/components/problemconfig/ProblemConfigForm'); - const { default: ProblemConfigReducer } = await import('vj/components/problemconfig/reducer'); + const [{ default: ProblemConfigEditor }, { default: ProblemConfigForm }, { default: ProblemConfigReducer }] = await Promise.all([ + import('vj/components/problemconfig/ProblemConfigEditor'), + import('vj/components/problemconfig/ProblemConfigForm'), + import('vj/components/problemconfig/reducer'), + ]); const { React, render, Provider, store, @@ -175,6 +175,26 @@ const page = new NamedPage('problem_config', () => { type: 'CONFIG_LOAD', payload: request.get(), }); + const unsubscribe = store.subscribe(() => { + // TODO set yaml schema + const state = store.getState(); + if (!state.config.__loaded) return; + if (state.config.subtasks || state.config.cases) return; + const testdata = (state.testdata || []).map((i) => i.name); + const checkFile = (file: string) => (testdata.includes(file) ? file : null); + unsubscribe(); + const value = readSubtasksFromFiles(testdata, checkFile, state.config, {}); + for (const subtask of value.subtasks) { + if (subtask.time === parseTimeMS(state.config.time || '1s')) delete subtask.time; + if (subtask.memory === parseMemoryMB(state.config.memory || '256m')) delete subtask.memory; + if (subtask.time) subtask.time += 'ms'; + if (subtask.memory) subtask.memory += 'MB'; + } + store.dispatch({ + type: 'CONFIG_AUTOCASES_UPDATE', + value, + }); + }); render(
@@ -182,20 +202,7 @@ 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, - }); - }} - /> +
diff --git a/packages/ui-default/pages/problem_edit.page.js b/packages/ui-default/pages/problem_edit.page.js index 3e5c4f8e0..759ccec00 100644 --- a/packages/ui-default/pages/problem_edit.page.js +++ b/packages/ui-default/pages/problem_edit.page.js @@ -202,12 +202,12 @@ export default new NamedPage(['problem_create', 'problem_edit'], (pagename) => { const dialog = new Dialog({ $body: `
-
-
+
+
-
-
+
+
`, }); try { diff --git a/packages/ui-default/pages/problem_files.page.js b/packages/ui-default/pages/problem_files.page.js index 1b4e08311..1f633665f 100644 --- a/packages/ui-default/pages/problem_files.page.js +++ b/packages/ui-default/pages/problem_files.page.js @@ -51,12 +51,12 @@ const page = new NamedPage('problem_files', () => { const dialog = new Dialog({ $body: `
-
-
+
+
-
-
+
+
`, }); try { diff --git a/packages/ui-default/templates/contest_detail.html b/packages/ui-default/templates/contest_detail.html index f22c726fa..64b521654 100644 --- a/packages/ui-default/templates/contest_detail.html +++ b/packages/ui-default/templates/contest_detail.html @@ -11,14 +11,14 @@

{{ tdoc.title }}

- {{ _(model.contest.statusText(tdoc)) }} - {{ _(model.contest.RULES[tdoc.rule].TEXT) }} - {{ _('Start at') }}: {{ contest.render_time(tdoc.beginAt) }} - {{ contest.render_duration(tdoc) }} {{ _('hour(s)') }} - {{ _('Host') }}: {{ user.render_inline(udict[tdoc.owner], badge=false) }} - {{ tdoc['attend']|default(0) }} + {{ _(model.contest.statusText(tdoc)) }} + {{ _(model.contest.RULES[tdoc.rule].TEXT) }} + {{ _('Start at') }}: {{ contest.render_time(tdoc.beginAt) }} + {{ contest.render_duration(tdoc) }} {{ _('hour(s)') }} + {{ _('Host') }}: {{ user.render_inline(udict[tdoc.owner], badge=false) }} + {{ tdoc['attend']|default(0) }} {% if tsdoc.attend %} - {{ _('Attended') }} + {{ _('Attended') }} {% endif %}
diff --git a/packages/ui-default/templates/problem_detail.html b/packages/ui-default/templates/problem_detail.html index 69b0fdb26..5929c7849 100644 --- a/packages/ui-default/templates/problem_detail.html +++ b/packages/ui-default/templates/problem_detail.html @@ -49,22 +49,22 @@
    - {{ _('ID') }}: {{ pdoc.docId }} - {{ _('problem_type.' + pdoc.config.type).format(pdoc.config.subType) }} + {{ _('ID') }}: {{ pdoc.docId }} + {{ _('problem_type.' + pdoc.config.type).format(pdoc.config.subType) }} {% if pdoc.config.type != 'objective' and pdoc.config.type != 'submit_answer' %} - {{ pdoc.config.timeMax if pdoc.config.timeMax == pdoc.config.timeMin else pdoc.config.timeMin+'~'+pdoc.config.timeMax }}ms - {{ pdoc.config.memoryMax if pdoc.config.memoryMax == pdoc.config.memoryMin else pdoc.config.memoryMin+'~'+pdoc.config.memoryMax }}MiB + {{ pdoc.config.timeMax if pdoc.config.timeMax == pdoc.config.timeMin else pdoc.config.timeMin+'~'+pdoc.config.timeMax }}ms + {{ pdoc.config.memoryMax if pdoc.config.memoryMax == pdoc.config.memoryMin else pdoc.config.memoryMin+'~'+pdoc.config.memoryMax }}MiB {% endif %} {% if not tdoc %} - {{ _('Tried') }}: {{ pdoc.nSubmit }} - {{ _('Accepted') }}: {{ pdoc.nAccept if ((not tdoc) or showAccept) else '?' }} - {{ _('Difficulty') }}: {{ pdoc['difficulty'] or lib.difficulty(pdoc.nSubmit, pdoc.nAccept) or _('(None)') }} + {{ _('Tried') }}: {{ pdoc.nSubmit }} + {{ _('Accepted') }}: {{ pdoc.nAccept if ((not tdoc) or showAccept) else '?' }} + {{ _('Difficulty') }}: {{ pdoc['difficulty'] or lib.difficulty(pdoc.nSubmit, pdoc.nAccept) or _('(None)') }} {% endif %} - {{ _('Uploaded By') }}: {{ user.render_inline(udoc, badge=false) }} + {{ _('Uploaded By') }}: {{ user.render_inline(udoc, badge=false) }} {% if pdoc.tag.length %} - {{ _('Tags') + '>' }} + {{ _('Tags') + '>' }} {%- for tag in pdoc.tag -%} - {{ tag }} + {{ tag }} {%- endfor -%} {% endif %}
diff --git a/packages/ui-default/theme/dark.styl b/packages/ui-default/theme/dark.styl index 93f58cdf1..f0f03f2b9 100644 --- a/packages/ui-default/theme/dark.styl +++ b/packages/ui-default/theme/dark.styl @@ -212,7 +212,7 @@ $hover-background-color = #424242 .menu__link.highlight background: rgb(40, 11, 20) - .bp3-tag.bp3-minimal:not([class*="bp3-intent-"]) + .bp4-tag.bp4-minimal:not([class*="bp4-intent-"]) background-color: rgba(87, 100, 117, .2) color: #d9dfe7 diff --git a/packages/ui-default/utils/i18n.js b/packages/ui-default/utils/i18n.js index bc5b6464d..7cb5aab10 100644 --- a/packages/ui-default/utils/i18n.js +++ b/packages/ui-default/utils/i18n.js @@ -1,6 +1,7 @@ import substitute from './substitute'; export default function i18n(str, ...params) { + if (!str) return ''; return substitute(window.LOCALES?.[str] || str, params); } diff --git a/packages/utils/lib/common.ts b/packages/utils/lib/common.ts index 9469bb70b..1edd95a88 100644 --- a/packages/utils/lib/common.ts +++ b/packages/utils/lib/common.ts @@ -137,14 +137,14 @@ 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; + if (typeof str === 'number' || Number.isSafeInteger(+str)) 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; + if (typeof str === 'number' || Number.isSafeInteger(+str)) 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()]); @@ -161,19 +161,17 @@ export function size(s: number, base = 1) { return `${Math.round(s * unit)} ${unitNames[unitNames.length - 1]}`; } -interface Re0 { - reg: RegExp, - output: ((a: RegExpExecArray) => string)[], - id: (a: RegExpExecArray) => number, +interface MatchRule { + regex: RegExp; + output: ((a: RegExpExecArray) => string)[]; + id: (a: RegExpExecArray) => number; + subtask: (a: RegExpExecArray) => number; + preferredScorerType: 'min' | 'max' | 'sum'; } -interface Re1 extends Re0 { - subtask: (a: RegExpExecArray) => number, -} - -const RE0: Re0[] = [ +const SubtaskMatcher: MatchRule[] = [ { - reg: /^([^\d]*)(\d+).(in|txt)$/, + regex: /^([^\d]*)(\d+).(in|txt)$/, output: [ (a) => `${a[1] + a[2]}.out`, (a) => `${a[1] + a[2]}.ans`, @@ -181,108 +179,52 @@ const RE0: Re0[] = [ (a) => (a[1].includes('input') ? `${a[1] + a[2]}.txt`.replace(/input/g, 'output') : null), ], id: (a) => +a[2], + subtask: () => 0, + preferredScorerType: 'sum', }, { - reg: /^([^\d]*)\.in(\d+)$/, + regex: /^([^\d]*)\.in(\d+)$/, output: [ (a) => `${a[1]}.ou${a[2]}`, (a) => `${a[1]}.ou${a[2]}`.replace(/input/g, 'output'), ], id: (a) => +a[2], + subtask: () => 0, + preferredScorerType: 'sum', }, -]; -const RE1: Re1[] = [ { - reg: /^([^\d]*)([0-9]+)([-_])([0-9]+).in$/, + regex: /^([^\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], + subtask: (a) => +a[2], + preferredScorerType: 'min', }, ]; -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) { +export function readSubtasksFromFiles(files: string[], checkFile, cfg, rst) { const subtask = {}; - for (const s of rst.subtasks) if (s.id) subtask[s.id] = s; + 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 rule of SubtaskMatcher) { + const data = rule.regex.exec(file); + if (!data) continue; + const sid = rule.subtask(data); + const c = { input: file, output: '', id: rule.id(data) }; + for (const func of rule.output) { + if (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: rule.preferredScorerType, + cases: [c], + }; + } else if (!subtask[sid].cases) subtask[sid].cases = [c]; + else subtask[sid].cases.push(c); + break; } } } @@ -291,6 +233,7 @@ export async function readSubtasksFromFiles(files: string[], checkFile, cfg, rst subtask[i].cases.sort((a, b) => (a.id - b.id)); subtasks.push(subtask[i]); } + subtasks.sort((a, b) => (a.id - b.id)); const base = Math.floor(100 / subtasks.length); const extra = subtasks.length - (100 % subtasks.length); const config = { count: 0, subtasks }; From 539a1c44ce42c407edc8acf494d130ae1e6507bb Mon Sep 17 00:00:00 2001 From: undefined Date: Thu, 5 May 2022 21:51:12 +0800 Subject: [PATCH 17/44] judge: use case.score & normalizeSubtask API --- packages/hydrojudge/src/cases.ts | 89 ++++++-------- packages/hydrojudge/src/judge/default.ts | 2 +- packages/hydrojudge/src/judge/interactive.ts | 2 +- packages/hydrojudge/src/judge/interface.ts | 1 + .../hydrojudge/src/judge/submit_answer.ts | 2 +- packages/hydrooj/src/interface.ts | 8 +- packages/hydrooj/src/lib/testdataConfig.ts | 31 ++--- .../problemconfig/ProblemConfigEditor.tsx | 6 +- .../components/problemconfig/SubtaskTable.tsx | 52 ++++++-- .../problemconfig/reducer/config.ts | 22 ++-- .../ui-default/pages/problem_config.page.styl | 8 +- .../ui-default/pages/problem_config.page.tsx | 26 ++-- packages/utils/lib/cases.ts | 68 ++--------- packages/utils/lib/common.ts | 115 ++++++++++++++---- 14 files changed, 231 insertions(+), 201 deletions(-) diff --git a/packages/hydrojudge/src/cases.ts b/packages/hydrojudge/src/cases.ts index d81ab2e17..ca824114b 100644 --- a/packages/hydrojudge/src/cases.ts +++ b/packages/hydrojudge/src/cases.ts @@ -3,40 +3,12 @@ import fs from 'fs-extra'; import yaml from 'js-yaml'; import { max, sum } from 'lodash'; import readYamlCases, { convertIniConfig } from '@hydrooj/utils/lib/cases'; -import { readSubtasksFromFiles } from '@hydrooj/utils/lib/common'; +import { normalizeSubtasks, readSubtasksFromFiles } from '@hydrooj/utils/lib/common'; import { changeErrorType } from '@hydrooj/utils/lib/utils'; import { getConfig } from './config'; import { FormatError, SystemError } from './error'; import { ensureFile, parseMemoryMB } from './utils'; -async function readAutoCases(folder: string, { next }, cfg, rst) { - const config = { - checker_type: 'default', - count: 0, - subtasks: [], - judge_extra_files: [], - user_extra_files: [], - }; - const checkFile = ensureFile(folder); - try { - const files = await fs.readdir(folder); - if (await fs.pathExists(path.resolve(folder, 'input'))) { - const inputs = await fs.readdir(path.resolve(folder, 'input')); - files.push(...inputs.map((i) => `input/${i}`)); - } - if (await fs.pathExists(path.resolve(folder, 'output'))) { - const outputs = await fs.readdir(path.resolve(folder, 'output')); - files.push(...outputs.map((i) => `output/${i}`)); - } - const result = readSubtasksFromFiles(files, checkFile, cfg, rst); - Object.assign(config, result); - if (cfg.isSelfSubmission) next({ message: { message: 'Found {0} testcases.', params: [config.count] } }); - } catch (e) { - throw new SystemError('Cannot parse testdata.', [e.message, ...e.params]); - } - return config; -} - function isValidConfig(config) { if (config.count > (getConfig('testcases_max') || 100)) { throw new FormatError('Too many testcases. Cancelled.'); @@ -52,35 +24,54 @@ function isValidConfig(config) { } } +async function collectFiles(folder: string) { + const files = await fs.readdir(folder); + await Promise.all(['input', 'output'].map(async (t) => { + if (await fs.pathExists(path.resolve(folder, t))) { + const f = await fs.readdir(path.resolve(folder, t)); + files.push(...f.map((i) => `${t}/${i}`)); + } + })); + return files; +} + export default async function readCases(folder: string, cfg: Record = {}, args) { const iniConfig = path.resolve(folder, 'config.ini'); const yamlConfig = path.resolve(folder, 'config.yaml'); const ymlConfig = path.resolve(folder, 'config.yml'); - const config = { ...cfg }; - if (fs.existsSync(yamlConfig)) { - Object.assign(config, yaml.load(fs.readFileSync(yamlConfig).toString())); - } else if (fs.existsSync(ymlConfig)) { - Object.assign(config, yaml.load(fs.readFileSync(ymlConfig).toString())); - } else if (fs.existsSync(iniConfig)) { - try { - Object.assign(config, convertIniConfig(fs.readFileSync(iniConfig).toString())); - } catch (e) { - throw changeErrorType(e, FormatError); - } - } - let result; + const config: Record = { + checker_type: 'default', + count: 0, + subtasks: [], + judge_extra_files: [], + user_extra_files: [], + ...cfg, + }; try { - result = await readYamlCases(config, ensureFile(folder)); + if (fs.existsSync(yamlConfig)) { + Object.assign(config, yaml.load(fs.readFileSync(yamlConfig).toString())); + } else if (fs.existsSync(ymlConfig)) { + Object.assign(config, yaml.load(fs.readFileSync(ymlConfig).toString())); + } else if (fs.existsSync(iniConfig)) { + Object.assign(config, convertIniConfig(fs.readFileSync(iniConfig).toString())); + } } catch (e) { throw changeErrorType(e, FormatError); } - let cases = result.outputs?.length || 0; - cases += Math.sum((result.subtasks || []).map((subtask) => subtask.cases.length)); - if (!cases) { - const c = await readAutoCases(folder, args, config, result); - result.subtasks = c.subtasks; - result.count = c.count; + const checkFile = ensureFile(folder); + const result = await readYamlCases(config, checkFile) + .catch((e) => { throw changeErrorType(e, FormatError); }); + result.count = result.outputs?.length || Math.sum((result.subtasks || []).map((s) => s.cases.length)); + if (!result.count) { + try { + result.subtasks = readSubtasksFromFiles(await collectFiles(folder), checkFile, cfg); + result.count = Math.sum(result.subtasks.map((i) => i.cases.length)); + if (cfg.isSelfSubmission) args.next?.({ message: { message: 'Found {0} testcases.', params: [result.count] } }); + } catch (e) { + throw new SystemError('Cannot parse testdata.', [e.message, ...e.params]); + } } + result.subtasks = normalizeSubtasks(result.subtasks || [], checkFile, config.time, config.memory); if (result.key && args.key !== result.key) throw new FormatError('Incorrect secret key'); if (!result.key) isValidConfig(result); return result; diff --git a/packages/hydrojudge/src/judge/default.ts b/packages/hydrojudge/src/judge/default.ts index 14a589783..976cb5867 100644 --- a/packages/hydrojudge/src/judge/default.ts +++ b/packages/hydrojudge/src/judge/default.ts @@ -76,7 +76,7 @@ function judgeCase(c: Case, sid: string) { user_stdout: stdout, user_stderr: stderr, checker_type: ctx.config.checker_type, - score: ctxSubtask.subtask.score, + score: c.score, detail: ctx.config.detail ?? true, env: { ...ctx.env, HYDRO_TESTCASE: c.id.toString() }, }); diff --git a/packages/hydrojudge/src/judge/interactive.ts b/packages/hydrojudge/src/judge/interactive.ts index e99b33713..cc7d492a4 100644 --- a/packages/hydrojudge/src/judge/interactive.ts +++ b/packages/hydrojudge/src/judge/interactive.ts @@ -50,7 +50,7 @@ function judgeCase(c: Case) { if (code < 32) message = signals[code]; else message = { message: 'Your program returned {0}.', params: [code] }; } else { - const result = parse(resInteractor.stderr, ctxSubtask.subtask.score); + const result = parse(resInteractor.stderr, c.score); status = result.status; score = result.score; message = result.message; diff --git a/packages/hydrojudge/src/judge/interface.ts b/packages/hydrojudge/src/judge/interface.ts index b5f5f45d5..448ae7cef 100644 --- a/packages/hydrojudge/src/judge/interface.ts +++ b/packages/hydrojudge/src/judge/interface.ts @@ -105,4 +105,5 @@ export interface Case { id: number; input: string; output: string; + score: number; } diff --git a/packages/hydrojudge/src/judge/submit_answer.ts b/packages/hydrojudge/src/judge/submit_answer.ts index 67096be5c..d0623a795 100644 --- a/packages/hydrojudge/src/judge/submit_answer.ts +++ b/packages/hydrojudge/src/judge/submit_answer.ts @@ -74,7 +74,7 @@ function judgeCase(c: Case, sid: string) { user_stdout: file, user_stderr: { content: '' }, checker_type: ctx.config.checker_type, - score: ctxSubtask.subtask.score, + score: c.score, detail: ctx.config.detail ?? true, env: { ...ctx.env, HYDRO_TESTCASE: c.id.toString() }, }); diff --git a/packages/hydrooj/src/interface.ts b/packages/hydrooj/src/interface.ts index f2772bf57..dc33df041 100644 --- a/packages/hydrooj/src/interface.ts +++ b/packages/hydrooj/src/interface.ts @@ -129,8 +129,8 @@ export interface FileInfo { export interface TestCaseConfig { input: string; output: string; - time?: number; - memory?: number; + time?: string; + memory?: string; score?: number; } @@ -148,8 +148,8 @@ export enum SubtaskType { } export interface SubtaskConfig { - time?: number; - memory?: number; + time?: string; + memory?: string; score?: number; if?: number[]; id?: number; diff --git a/packages/hydrooj/src/lib/testdataConfig.ts b/packages/hydrooj/src/lib/testdataConfig.ts index 3607b5d06..c6a1fde45 100644 --- a/packages/hydrooj/src/lib/testdataConfig.ts +++ b/packages/hydrooj/src/lib/testdataConfig.ts @@ -1,13 +1,12 @@ import { load } from 'js-yaml'; import readYamlCases from '@hydrooj/utils/lib/cases'; import type { ProblemConfig, ProblemConfigFile } from '../interface'; +import { normalizeSubtasks, parseMemoryMB, parseTimeMS } from '../utils'; export async function parseConfig(config: string | ProblemConfigFile = {}) { - let cfg: ProblemConfigFile = {}; - if (typeof config === 'string') { - // TODO should validate here? - cfg = await readYamlCases(load(config) as Record); - } else if (typeof config === 'object') cfg = await readYamlCases(config); + const cfg: ProblemConfigFile = typeof config === 'string' + ? await readYamlCases(load(config) as Record) + : await readYamlCases(config); const result: ProblemConfig = { count: 0, memoryMin: Number.MAX_SAFE_INTEGER, @@ -18,23 +17,19 @@ export async function parseConfig(config: string | ProblemConfigFile = {}) { }; if (cfg.subType) result.subType = cfg.subType; if (cfg.target) result.target = cfg.target; - if (cfg.subtasks.length) { - for (const subtask of cfg.subtasks) { - result.memoryMax = Math.max(result.memoryMax, subtask.memory); - result.memoryMin = Math.min(result.memoryMin, subtask.memory); - result.timeMax = Math.max(result.timeMax, subtask.time); - result.timeMin = Math.min(result.timeMin, subtask.time); + if (cfg.subtasks?.length) { + for (const subtask of normalizeSubtasks(cfg.subtasks as any || [], (i) => i, cfg.time, cfg.memory)) { + result.memoryMax = Math.max(result.memoryMax, ...subtask.cases.map((i) => parseMemoryMB(i.memory))); + result.memoryMin = Math.min(result.memoryMin, ...subtask.cases.map((i) => parseMemoryMB(i.memory))); + result.timeMax = Math.max(result.timeMax, ...subtask.cases.map((i) => parseTimeMS(i.time))); + result.timeMin = Math.min(result.timeMin, ...subtask.cases.map((i) => parseTimeMS(i.time))); } - } else if (cfg.time || cfg.memory) { + } else { if (cfg.time) result.timeMax = result.timeMin = cfg.time as unknown as number; if (cfg.memory) result.memoryMax = result.memoryMin = cfg.memory as unknown as number; } - if (result.memoryMax < result.memoryMin) { - result.memoryMax = result.memoryMin = 256; - } - if (result.timeMax < result.timeMin) { - result.timeMax = result.timeMin = 1000; - } + if (result.memoryMax < result.memoryMin) result.memoryMax = result.memoryMin = 256; + if (result.timeMax < result.timeMin) result.timeMax = result.timeMin = 1000; if (cfg.langs) result.langs = cfg.langs; if (cfg.redirect) result.redirect = cfg.redirect.split('/', 2) as any; if (cfg.filename && result.type === 'default') { diff --git a/packages/ui-default/components/problemconfig/ProblemConfigEditor.tsx b/packages/ui-default/components/problemconfig/ProblemConfigEditor.tsx index 68774fc7c..6da3b1baf 100644 --- a/packages/ui-default/components/problemconfig/ProblemConfigEditor.tsx +++ b/packages/ui-default/components/problemconfig/ProblemConfigEditor.tsx @@ -38,14 +38,14 @@ const subtasksKey = [ const casesKey = ['time', 'memory', 'input', 'output']; -function configYamlFormat(config: ProblemConfigFile) { +export function configYamlFormat(config: ProblemConfigFile) { const formatConfig: ProblemConfigFile = {}; configKey.forEach((key) => { if (config[key] !== undefined) { if (key === 'subType' && ['single', 'multi'].includes(config[key]) && config.type !== 'submit_answer') return; if (key === 'checker_type' && config.type !== 'default') return; if (key === 'checker' - && (['default', 'strict'].includes(formatConfig.checker_type) || formatConfig.checker_type === undefined)) return; + && (['default', 'strict'].includes(formatConfig.checker_type) || !formatConfig.checker_type)) return; if (key === 'interactor' && config.type !== 'interactive') return; if (key === 'subtasks') { formatConfig[key] = []; @@ -62,7 +62,7 @@ function configYamlFormat(config: ProblemConfigFile) { formatConfig[key] = []; config[key].forEach((caseItem) => { const formatCase: TestCaseConfig = { - time: 1000, memory: 256, input: '', output: '', + time: '1000ms', memory: '256MB', input: '', output: '', }; casesKey.forEach((caseKey) => { if (caseItem[caseKey] !== undefined) formatCase[caseKey] = caseItem[caseKey]; diff --git a/packages/ui-default/components/problemconfig/SubtaskTable.tsx b/packages/ui-default/components/problemconfig/SubtaskTable.tsx index 0f3e68148..145b23e7f 100644 --- a/packages/ui-default/components/problemconfig/SubtaskTable.tsx +++ b/packages/ui-default/components/problemconfig/SubtaskTable.tsx @@ -5,7 +5,7 @@ import { useSelector, useDispatch } from 'react-redux'; import { SubtaskConfig } from 'hydrooj/src/interface'; import { parseTimeMS, parseMemoryMB } from '@hydrooj/utils/lib/common'; import CustomSelectAutoComplete from '../autocomplete/components/CustomSelectAutoComplete'; -import { FormItem, ManagedInput } from './BasicForm'; +import { FormItem } from './BasicForm'; import { RootState } from './reducer'; import { CasesTable } from './TestCasesTable'; @@ -71,7 +71,7 @@ export function SubtasksTable({ index }) { {['id', 'score'].map((key) => ( + {% include "partials/problem_stat.html" %} , - $('#ProblemConfig').get(0), ); } diff --git a/packages/ui-default/pages/problem_detail.page.jsx b/packages/ui-default/pages/problem_detail.page.jsx index 4d2890a8d..49fc75686 100644 --- a/packages/ui-default/pages/problem_detail.page.jsx +++ b/packages/ui-default/pages/problem_detail.page.jsx @@ -1,4 +1,6 @@ import yaml from 'js-yaml'; +import ReactDOM, { createRoot } from 'react-dom/client'; +import React from 'react'; import { getScoreColor } from '@hydrooj/utils/lib/status'; import { NamedPage } from 'vj/misc/Page'; import { downloadProblemSet } from 'vj/components/zipDownloader'; @@ -132,9 +134,7 @@ const page = new NamedPage(['problem_detail', 'contest_detail_problem', 'homewor const { default: WebSocket } = await import('../components/socket'); const { default: ScratchpadApp } = await import('../components/scratchpad'); const { default: ScratchpadReducer } = await import('../components/scratchpad/reducers'); - const { - React, render, unmountComponentAtNode, Provider, store, - } = await loadReactRedux(ScratchpadReducer); + const { Provider, store } = await loadReactRedux(ScratchpadReducer); const sock = new WebSocket(UiContext.pretestConnUrl); sock.onmessage = (message) => { @@ -145,17 +145,15 @@ const page = new NamedPage(['problem_detail', 'contest_detail_problem', 'homewor }); }; + const root = createRoot($('#scratchpad').get(0)); renderReact = () => { - render( + root.render( , - $('#scratchpad').get(0), ); }; - unmountReact = () => { - unmountComponentAtNode($('#scratchpad').get(0)); - }; + unmountReact = () => root.unmount(); reactLoaded = true; $('.loader-container').hide(); } @@ -192,17 +190,12 @@ const page = new NamedPage(['problem_detail', 'contest_detail_problem', 'homewor const props = yaml.load(s); if (!(props instanceof Array)) return; $('.outer-loader-container').show(); - const [{ default: Objective }, React, ReactDOM] = await Promise.all([ - import('vj/components/objective-question/index'), - import('react'), - import('react-dom'), - ]); + const { default: Objective } = await import('vj/components/objective-question/index'); - ReactDOM.render( + ReactDOM.createRoot($('.problem-content').get(0)).render(
, - $('.problem-content').get(0), ); $('.outer-loader-container').hide(); } catch (e) { } diff --git a/packages/ui-default/utils/loadReactRedux.ts b/packages/ui-default/utils/loadReactRedux.ts index 468b9f85e..467234576 100644 --- a/packages/ui-default/utils/loadReactRedux.ts +++ b/packages/ui-default/utils/loadReactRedux.ts @@ -2,7 +2,7 @@ import { Action, AnyAction, Reducer } from 'redux'; export default async function loadReactRedux(storeReducer: Reducer) { const React = await import('react'); - const { render, unmountComponentAtNode } = await import('react-dom'); + const { createRoot } = await import('react-dom/client'); const { Provider } = await import('react-redux'); const { createStore, applyMiddleware } = await import('redux'); const { default: reduxThunk } = await import('redux-thunk'); @@ -24,8 +24,7 @@ export default async function loadReactRedux(st return { React, - render, - unmountComponentAtNode, + createRoot, Provider, store, }; diff --git a/packages/ui-default/utils/yamlCompact.js b/packages/ui-default/utils/yamlCompact.js deleted file mode 100644 index 2d68eab7c..000000000 --- a/packages/ui-default/utils/yamlCompact.js +++ /dev/null @@ -1,8 +0,0 @@ -// eslint-disable-next-line import/no-unresolved -import yaml, { Schema } from 'real-js-yaml'; - -Schema.create = (arg1, arg2) => new Schema(arg1, arg2); - -// eslint-disable-next-line import/no-unresolved -export * from 'real-js-yaml'; -export default yaml; diff --git a/packages/utils/package.json b/packages/utils/package.json index 9ebc0bb7b..72e45a397 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -1,6 +1,6 @@ { "name": "@hydrooj/utils", - "version": "1.2.1", + "version": "1.2.2", "description": "hydrooj utils", "main": "package.json", "repository": "https://github.com/hydro-dev/Hydro.git", @@ -8,14 +8,13 @@ "license": "AGPL-3.0-or-later", "preferUnplugged": true, "dependencies": { - "fs-extra": "^10.0.1", + "fs-extra": "^10.1.0", "js-yaml": "^4.1.0", - "moment": "^2.29.1", + "moment-timezone": "^0.5.34", "mongodb": "^3.7.3", - "systeminformation": "^5.11.9" + "systeminformation": "^5.11.14" }, "devDependencies": { - "@types/fs-extra": "^9.0.13", - "moment-timezone": "^0.5.34" + "@types/fs-extra": "^9.0.13" } } diff --git a/packages/vjudge/package.json b/packages/vjudge/package.json index 654118a1a..4f27f158b 100644 --- a/packages/vjudge/package.json +++ b/packages/vjudge/package.json @@ -12,16 +12,16 @@ "chrome-finder": "^1.0.7", "jsdom": "^19.0.0", "lodash": "^4.17.21", - "puppeteer-core": "^13.5.2", + "puppeteer-core": "^14.0.0", "puppeteer-extra": "^3.2.3", "puppeteer-extra-plugin-portal": "^3.0.1", "puppeteer-extra-plugin-stealth": "^2.9.0", - "superagent": "^7.1.2", + "superagent": "^7.1.3", "superagent-proxy": "^3.0.0" }, "devDependencies": { "@types/jsdom": "^16.2.14", - "@types/lodash": "^4.14.181", + "@types/lodash": "^4.14.182", "@types/superagent": "^4.1.15", "@types/superagent-proxy": "^3.0.0" } From 1e286b069f657e0e33ff71a1e871a8f247b3853d Mon Sep 17 00:00:00 2001 From: undefined Date: Thu, 12 May 2022 00:32:35 +0800 Subject: [PATCH 27/44] core: fix formidable types --- packages/fps-importer/handler.ts | 6 +++--- packages/fps-importer/package.json | 2 +- packages/hydrooj/src/handler/import.ts | 2 +- packages/hydrooj/src/handler/misc.ts | 9 +++++---- packages/hydrooj/src/handler/problem.ts | 16 ++++++++-------- packages/import-qduoj/handler.ts | 4 ++-- packages/import-qduoj/package.json | 2 +- 7 files changed, 21 insertions(+), 20 deletions(-) diff --git a/packages/fps-importer/handler.ts b/packages/fps-importer/handler.ts index 3278192de..f6569802e 100644 --- a/packages/fps-importer/handler.ts +++ b/packages/fps-importer/handler.ts @@ -104,15 +104,15 @@ class FpsProblemImportHandler extends Handler { if (!this.request.files.file) throw new ValidationError('file'); const tasks = []; try { - const file = await fs.stat(this.request.files.file.path); + const file = await fs.stat(this.request.files.file.filepath); if (file.size > 64 * 1024 * 1024) throw new FileTooLargeError('64m'); - const content = fs.readFileSync(this.request.files.file.path, 'utf-8'); + const content = fs.readFileSync(this.request.files.file.filepath, 'utf-8'); const result = await xml2js.parseStringPromise(content); tasks.push(result); } catch (e) { if (e instanceof FileTooLargeError) throw e; console.log(e); - const zip = new AdmZip(this.request.files.file.path); + const zip = new AdmZip(this.request.files.file.filepath); for (const entry of zip.getEntries()) { try { const buf = entry.getData(); diff --git a/packages/fps-importer/package.json b/packages/fps-importer/package.json index 88220fb23..96cf8473d 100644 --- a/packages/fps-importer/package.json +++ b/packages/fps-importer/package.json @@ -1,6 +1,6 @@ { "name": "@hydrooj/fps-importer", - "version": "1.3.3", + "version": "1.3.4", "description": "Import FPS problems", "main": "package.json", "repository": "https://github.com/hydro-dev/Hydro.git", diff --git a/packages/hydrooj/src/handler/import.ts b/packages/hydrooj/src/handler/import.ts index 73b4d3cf1..b59d092ab 100644 --- a/packages/hydrooj/src/handler/import.ts +++ b/packages/hydrooj/src/handler/import.ts @@ -37,7 +37,7 @@ class ProblemImportHydroHandler extends Handler { if (keepUser) this.checkPriv(PRIV.PRIV_EDIT_SYSTEM); if (!this.request.files.file) throw new ValidationError('file'); const tmpdir = path.join(os.tmpdir(), 'hydro', `${Math.random()}.import`); - const zip = new AdmZip(this.request.files.file.path); + const zip = new AdmZip(this.request.files.file.filepath); await new Promise((resolve, reject) => { zip.extractAllToAsync(tmpdir, true, (err) => { if (err) reject(err); diff --git a/packages/hydrooj/src/handler/misc.ts b/packages/hydrooj/src/handler/misc.ts index 5c1a98d00..7f09ad5fd 100644 --- a/packages/hydrooj/src/handler/misc.ts +++ b/packages/hydrooj/src/handler/misc.ts @@ -49,16 +49,17 @@ export class FilesHandler extends Handler { if ((this.user._files?.length || 0) >= system.get('limit.user_files')) { throw new ForbiddenError('File limit exceeded.'); } - if (!this.request.files.file) throw new ValidationError('file'); - const f = statSync(this.request.files.file.path); + const file = this.request.files?.file; + if (!file) throw new ValidationError('file'); + const f = statSync(file.filepath); const size = Math.sum((this.user._files || []).map((i) => i.size)) + f.size; if (size >= system.get('limit.user_files_size')) { throw new ForbiddenError('File size limit exceeded.'); } - if (!filename) filename = this.request.files.file.name || String.random(16); + if (!filename) filename = file.originalFilename || String.random(16); if (filename.includes('/') || filename.includes('..')) throw new ValidationError('filename', null, 'Bad filename'); if (this.user._files.filter((i) => i.name === filename).length) throw new BadRequestError('file exists'); - await storage.put(`user/${this.user._id}/${filename}`, this.request.files.file.path, this.user._id); + await storage.put(`user/${this.user._id}/${filename}`, file.filepath, this.user._id); const meta = await storage.getMeta(`user/${this.user._id}/${filename}`); const payload = { name: filename, ...pick(meta, ['size', 'lastModified', 'etag']) }; if (!meta) throw new Error('Upload failed'); diff --git a/packages/hydrooj/src/handler/problem.ts b/packages/hydrooj/src/handler/problem.ts index 6110cef7c..dffa7dded 100644 --- a/packages/hydrooj/src/handler/problem.ts +++ b/packages/hydrooj/src/handler/problem.ts @@ -493,13 +493,13 @@ export class ProblemSubmitHandler extends ProblemDetailHandler { if (typeof config !== 'object' || config === null) throw new BadRequestError('Incorrect problem config'); const sizeLimit = config.type === 'submit_answer' ? 128 * 1024 * 1024 : 65535; if (file.size > sizeLimit) throw new ValidationError('file'); - if (file.size > 65535 || await isBinaryFile(file.path, file.size)) { + if (file.size > 65535 || await isBinaryFile(file.filepath, file.size)) { const id = nanoid(); - await storage.put(`submission/${this.user._id}/${id}`, file.path, this.user._id); - code = `@@hydro_submission_file@@${this.user._id}/${id}#${file.name}`; + await storage.put(`submission/${this.user._id}/${id}`, file.filepath, this.user._id); + code = `@@hydro_submission_file@@${this.user._id}/${id}#${file.originalFilename}`; } else { // TODO auto detect & convert encoding - code = await readFile(file.path, 'utf-8'); + code = await readFile(file.filepath, 'utf-8'); } } else if (code.startsWith('@@hydro_submission_file@@')) throw new ValidationError('code'); await this.limitRate('add_record', 60, system.get('limit.submission')); @@ -633,12 +633,12 @@ export class ProblemFilesHandler extends ProblemDetailHandler { async postUploadFile(domainId: string, filename: string, type = 'testdata') { if (this.pdoc.reference) throw new ForbiddenError('Cannot edit files of a referenced problem.'); if (!this.request.files.file) throw new ValidationError('file'); - if (!filename) filename = this.request.files.file.name || String.random(16); + if (!filename) filename = this.request.files.file.originalFilename || String.random(16); if (filename.includes('/') || filename.includes('..')) throw new ValidationError('filename', null, 'Bad filename'); if (!this.user.own(this.pdoc, PERM.PERM_EDIT_PROBLEM_SELF)) this.checkPerm(PERM.PERM_EDIT_PROBLEM); const files = []; if (filename.endsWith('.zip')) { - const zip = new AdmZip(this.request.files.file.path); + const zip = new AdmZip(this.request.files.file.filepath); const entries = zip.getEntries(); for (const entry of entries) { if (!entry.name) continue; @@ -653,8 +653,8 @@ export class ProblemFilesHandler extends ProblemDetailHandler { files.push({ type, name: filename, - size: statSync(this.request.files.file.path).size, - data: () => this.request.files.file.path, + size: statSync(this.request.files.file.filepath).size, + data: () => this.request.files.file.filepath, }); } if (!this.user.hasPriv(PRIV.PRIV_EDIT_SYSTEM)) { diff --git a/packages/import-qduoj/handler.ts b/packages/import-qduoj/handler.ts index d34319add..44758be22 100644 --- a/packages/import-qduoj/handler.ts +++ b/packages/import-qduoj/handler.ts @@ -119,9 +119,9 @@ class ImportQduojHandler extends Handler { async post({ domainId }) { if (!this.request.files.file) throw new ValidationError('file'); - const stat = await fs.stat(this.request.files.file.path); + const stat = await fs.stat(this.request.files.file.filepath); if (stat.size > 128 * 1024 * 1024) throw new ValidationError('file', 'File too large'); - await this.fromFile(domainId, this.request.files.file.path); + await this.fromFile(domainId, this.request.files.file.filepath); this.response.redirect = this.url('problem_main'); } } diff --git a/packages/import-qduoj/package.json b/packages/import-qduoj/package.json index cfaca9770..35ab4d7cb 100644 --- a/packages/import-qduoj/package.json +++ b/packages/import-qduoj/package.json @@ -1,6 +1,6 @@ { "name": "@hydrooj/import-qduoj", - "version": "1.3.3", + "version": "1.3.4", "description": "Import QDUOJ problem export", "main": "package.json", "repository": "https://github.com/hydro-dev/Hydro.git", From 8e68670de2bb73afb354a692c3da805f83b997fc Mon Sep 17 00:00:00 2001 From: undefined Date: Thu, 12 May 2022 00:45:49 +0800 Subject: [PATCH 28/44] ui: fix workspace lint --- package.json | 4 ++-- packages/ui-default/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 45ee09b48..8c569b6de 100644 --- a/package.json +++ b/package.json @@ -18,8 +18,8 @@ "benchmark": "cross-env BENCHMARK=true mocha", "lint": "eslint packages --ext ts --fix", "lint:ci": "eslint packages --ext ts", - "lint:ui": "yarn workspace @hydrooj/ui-default eslint --ext .js,.ts,.jsx,.tsx . --fix", - "lint:ui:ci": "yarn workspace @hydrooj/ui-default eslint --ext .js,.ts,.jsx,.tsx .", + "lint:ui": "yarn workspace @hydrooj/ui-default lint --ext .js,.ts,.jsx,.tsx . --fix", + "lint:ui:ci": "yarn workspace @hydrooj/ui-default lint --ext .js,.ts,.jsx,.tsx .", "typedoc": "cross-env NODE_OPTIONS=--max_old_space_size=8192 typedoc", "debug:all": "node --trace-warnings --async-stack-traces --trace-deprecation build/start --debug --template", "debug": "node --trace-warnings --async-stack-traces --trace-deprecation node_modules/hydrooj/bin/hydrooj --debug --template", diff --git a/packages/ui-default/package.json b/packages/ui-default/package.json index fba90f57a..a3a4d343f 100644 --- a/packages/ui-default/package.json +++ b/packages/ui-default/package.json @@ -7,7 +7,7 @@ "repository": "https://github.com/hydro-dev/Hydro.git", "preferUnplugged": true, "scripts": { - "lint": "eslint ." + "lint": "eslint" }, "devDependencies": { "@blueprintjs/core": "^4.3.0", From ba3c6bb235a616851acaf9afe576b80d674a608c Mon Sep 17 00:00:00 2001 From: undefined Date: Thu, 12 May 2022 10:28:09 +0800 Subject: [PATCH 29/44] judge: fix checkFIle impl --- packages/hydrojudge/package.json | 2 +- packages/hydrojudge/src/cases.ts | 2 +- packages/ui-default/package.json | 2 +- packages/ui-default/pages/problem_config.page.tsx | 3 +-- packages/utils/lib/common.ts | 4 ++-- packages/utils/package.json | 2 +- 6 files changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/hydrojudge/package.json b/packages/hydrojudge/package.json index 1977f1c36..9f9b0ba29 100644 --- a/packages/hydrojudge/package.json +++ b/packages/hydrojudge/package.json @@ -1,7 +1,7 @@ { "name": "@hydrooj/hydrojudge", "bin": "bin/hydrojudge.js", - "version": "2.19.2", + "version": "2.19.3", "main": "package.json", "author": "undefined ", "repository": "https://github.com/hydro-dev/Hydro.git", diff --git a/packages/hydrojudge/src/cases.ts b/packages/hydrojudge/src/cases.ts index ca824114b..c0b256a31 100644 --- a/packages/hydrojudge/src/cases.ts +++ b/packages/hydrojudge/src/cases.ts @@ -64,7 +64,7 @@ export default async function readCases(folder: string, cfg: Record result.count = result.outputs?.length || Math.sum((result.subtasks || []).map((s) => s.cases.length)); if (!result.count) { try { - result.subtasks = readSubtasksFromFiles(await collectFiles(folder), checkFile, cfg); + result.subtasks = readSubtasksFromFiles(await collectFiles(folder), cfg); result.count = Math.sum(result.subtasks.map((i) => i.cases.length)); if (cfg.isSelfSubmission) args.next?.({ message: { message: 'Found {0} testcases.', params: [result.count] } }); } catch (e) { diff --git a/packages/ui-default/package.json b/packages/ui-default/package.json index a3a4d343f..76e89b92e 100644 --- a/packages/ui-default/package.json +++ b/packages/ui-default/package.json @@ -1,6 +1,6 @@ { "name": "@hydrooj/ui-default", - "version": "4.37.0", + "version": "4.37.1", "author": "undefined ", "license": "AGPL-3.0", "main": "hydro.js", diff --git a/packages/ui-default/pages/problem_config.page.tsx b/packages/ui-default/pages/problem_config.page.tsx index 7b6a40a53..f73dbad86 100644 --- a/packages/ui-default/pages/problem_config.page.tsx +++ b/packages/ui-default/pages/problem_config.page.tsx @@ -171,9 +171,8 @@ const page = new NamedPage('problem_config', () => { } if (state.config.subtasks) return; const testdata = (state.testdata || []).map((i) => i.name); - const checkFile = (file: string) => (testdata.includes(file) ? file : null); unsubscribe(); - const subtasks = readSubtasksFromFiles(testdata, checkFile, state.config); + const subtasks = readSubtasksFromFiles(testdata, state.config); store.dispatch({ type: 'CONFIG_AUTOCASES_UPDATE', subtasks: normalizeSubtasks(subtasks, (i) => i, state.config.time, state.config.memory, true), diff --git a/packages/utils/lib/common.ts b/packages/utils/lib/common.ts index f1eb71d69..9b0b834af 100644 --- a/packages/utils/lib/common.ts +++ b/packages/utils/lib/common.ts @@ -230,7 +230,7 @@ interface ParsedSubtask { if?: number[]; } -export function readSubtasksFromFiles(files: string[], checkFile, config) { +export function readSubtasksFromFiles(files: string[], config) { const subtask: Record = {}; for (const s of config.subtasks || []) if (s.id) subtask[s.id] = s; for (const file of files) { @@ -242,7 +242,7 @@ export function readSubtasksFromFiles(files: string[], checkFile, config) { for (const func of rule.output) { if (config.noOutputFile) c.output = '/dev/null'; else c.output = func(data); - if (c.output === '/dev/null' || checkFile(c.output)) { + if (c.output === '/dev/null' || files.includes(c.output)) { if (!subtask[sid]) { subtask[sid] = { time: config.time, diff --git a/packages/utils/package.json b/packages/utils/package.json index 72e45a397..be1e54208 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -1,6 +1,6 @@ { "name": "@hydrooj/utils", - "version": "1.2.2", + "version": "1.2.3", "description": "hydrooj utils", "main": "package.json", "repository": "https://github.com/hydro-dev/Hydro.git", From 69c6a34c34b49741714d451a237ec16141531beb Mon Sep 17 00:00:00 2001 From: undefined Date: Thu, 12 May 2022 10:50:01 +0800 Subject: [PATCH 30/44] judge: optional error params --- packages/hydrojudge/package.json | 2 +- packages/hydrojudge/src/cases.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/hydrojudge/package.json b/packages/hydrojudge/package.json index 9f9b0ba29..6f261fb4b 100644 --- a/packages/hydrojudge/package.json +++ b/packages/hydrojudge/package.json @@ -1,7 +1,7 @@ { "name": "@hydrooj/hydrojudge", "bin": "bin/hydrojudge.js", - "version": "2.19.3", + "version": "2.19.4", "main": "package.json", "author": "undefined ", "repository": "https://github.com/hydro-dev/Hydro.git", diff --git a/packages/hydrojudge/src/cases.ts b/packages/hydrojudge/src/cases.ts index c0b256a31..2eacf86c9 100644 --- a/packages/hydrojudge/src/cases.ts +++ b/packages/hydrojudge/src/cases.ts @@ -68,7 +68,7 @@ export default async function readCases(folder: string, cfg: Record result.count = Math.sum(result.subtasks.map((i) => i.cases.length)); if (cfg.isSelfSubmission) args.next?.({ message: { message: 'Found {0} testcases.', params: [result.count] } }); } catch (e) { - throw new SystemError('Cannot parse testdata.', [e.message, ...e.params]); + throw new SystemError('Cannot parse testdata.', [e.message, ...(e.params || [])]); } } result.subtasks = normalizeSubtasks(result.subtasks || [], checkFile, config.time, config.memory); From fdcf06c3736d15c7643fe2d1ed8786bafbb02ed5 Mon Sep 17 00:00:00 2001 From: undefined Date: Thu, 12 May 2022 11:28:35 +0800 Subject: [PATCH 31/44] core: fic category selection --- packages/hydrooj/package.json | 2 +- packages/hydrooj/src/handler/problem.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/hydrooj/package.json b/packages/hydrooj/package.json index 3fe7846ee..ce577180e 100644 --- a/packages/hydrooj/package.json +++ b/packages/hydrooj/package.json @@ -1,6 +1,6 @@ { "name": "hydrooj", - "version": "3.13.3", + "version": "3.13.4", "bin": "bin/hydrooj.js", "main": "src/loader", "module": "src/loader", diff --git a/packages/hydrooj/src/handler/problem.ts b/packages/hydrooj/src/handler/problem.ts index dffa7dded..5dffb71b2 100644 --- a/packages/hydrooj/src/handler/problem.ts +++ b/packages/hydrooj/src/handler/problem.ts @@ -149,7 +149,7 @@ export class ProblemMainHandler extends ProblemHandler { let pcountRelation = 'eq'; const category = flattenDeep(decodeURIComponent(q).split(' ') .filter((i) => i.startsWith('category:')) - .map((i) => i.split(':')[1]?.split(','))); + .map((i) => i.split('category:')[1]?.split(','))); const text = q.split(' ').filter((i) => !i.startsWith('category:')).join(' '); if (category.length) query.$and = category.map((tag) => ({ tag })); if (text) category.push(text); @@ -288,7 +288,7 @@ export class ProblemRandomHandler extends ProblemHandler { async get(domainId: string, qs = '') { const category = flattenDeep(decodeURIComponent(qs).split(' ') .filter((i) => i.startsWith('category:')) - .map((i) => i.split(':')[1]?.split(','))); + .map((i) => i.split('category:')[1]?.split(','))); console.log(qs, category); const q = buildQuery(this.user); if (category.length) q.$and = category.map((tag) => ({ tag })); From 60aa0e1611030f03d2c649f5b9f99f4974c7b545 Mon Sep 17 00:00:00 2001 From: undefined Date: Thu, 12 May 2022 22:59:47 +0800 Subject: [PATCH 32/44] ui: fix connection close --- packages/ui-default/components/socket/index.js | 4 +--- packages/ui-default/package.json | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/ui-default/components/socket/index.js b/packages/ui-default/components/socket/index.js index 52208e113..6bb9b0e3b 100644 --- a/packages/ui-default/components/socket/index.js +++ b/packages/ui-default/components/socket/index.js @@ -5,7 +5,6 @@ export default class Sock { const i = new URL(url, window.location.href); i.protocol = i.protocol.replace('http', 'ws'); this.url = i.toString(); - this.closed = false; this.sock = new ReconnectingWebSocket(this.url); this.sock.onopen = () => { console.log('Connected'); @@ -13,7 +12,7 @@ export default class Sock { }; this.sock.onclose = ({ code, reason }) => { console.warn('Connection closed, ', code, reason); - if (code >= 4000) this.closed = true; + if (code >= 4000) this.close(); this.onclose?.(code, reason); }; this.sock.onmessage = (message) => { @@ -29,7 +28,6 @@ export default class Sock { } close() { - this.closed = true; this.sock?.close?.(); } } diff --git a/packages/ui-default/package.json b/packages/ui-default/package.json index 76e89b92e..f79bb29ed 100644 --- a/packages/ui-default/package.json +++ b/packages/ui-default/package.json @@ -1,6 +1,6 @@ { "name": "@hydrooj/ui-default", - "version": "4.37.1", + "version": "4.37.2", "author": "undefined ", "license": "AGPL-3.0", "main": "hydro.js", From a585c13a7292741204fe24bbbef6af4395e55649 Mon Sep 17 00:00:00 2001 From: undefined Date: Fri, 13 May 2022 00:00:55 +0800 Subject: [PATCH 33/44] judge: provide score & subtaskId --- packages/hydrojudge/package.json | 2 +- packages/hydrojudge/src/compile.ts | 2 +- packages/hydrojudge/src/hosts/hydro.ts | 44 +++++----------- packages/hydrojudge/src/judge/default.ts | 29 +++++------ packages/hydrojudge/src/judge/interactive.ts | 19 +++---- packages/hydrojudge/src/judge/interface.ts | 52 ++++--------------- packages/hydrojudge/src/judge/objective.ts | 4 +- packages/hydrojudge/src/judge/run.ts | 16 +++--- .../hydrojudge/src/judge/submit_answer.ts | 24 ++++----- packages/hydrojudge/src/service.ts | 35 ++++--------- packages/hydrooj/package.json | 2 +- packages/hydrooj/src/handler/judge.ts | 12 ++--- packages/hydrooj/src/handler/problem.ts | 1 - packages/hydrooj/src/handler/record.ts | 2 +- packages/hydrooj/src/interface.ts | 39 +++++--------- packages/hydrooj/src/service/server.ts | 2 +- packages/migrate/scripts/vijos.ts | 6 ++- packages/utils/lib/common.ts | 11 ++-- packages/utils/package.json | 2 +- 19 files changed, 118 insertions(+), 186 deletions(-) diff --git a/packages/hydrojudge/package.json b/packages/hydrojudge/package.json index 6f261fb4b..f129c1976 100644 --- a/packages/hydrojudge/package.json +++ b/packages/hydrojudge/package.json @@ -1,7 +1,7 @@ { "name": "@hydrooj/hydrojudge", "bin": "bin/hydrojudge.js", - "version": "2.19.4", + "version": "2.20.0", "main": "package.json", "author": "undefined ", "repository": "https://github.com/hydro-dev/Hydro.git", diff --git a/packages/hydrojudge/src/compile.ts b/packages/hydrojudge/src/compile.ts index eb767152b..76c170c5c 100644 --- a/packages/hydrojudge/src/compile.ts +++ b/packages/hydrojudge/src/compile.ts @@ -25,7 +25,7 @@ export = async function compile( ); if (status !== STATUS.STATUS_ACCEPTED) throw new CompileError({ status, stdout, stderr }); if (!fileIds[target]) throw new CompileError({ stderr: 'Executable file not found.' }); - if (next) next({ compiler_text: compilerText(stdout, stderr) }); + if (next) next({ compilerText: compilerText(stdout, stderr) }); return { execute, copyIn: { ...copyIn, [target]: { fileId: fileIds[target] } }, diff --git a/packages/hydrojudge/src/hosts/hydro.ts b/packages/hydrojudge/src/hosts/hydro.ts index 950975099..072e000c1 100644 --- a/packages/hydrojudge/src/hosts/hydro.ts +++ b/packages/hydrojudge/src/hosts/hydro.ts @@ -9,12 +9,13 @@ import PQueue from 'p-queue'; import WebSocket from 'ws'; import { LangConfig } from '@hydrooj/utils/lib/lang'; import { STATUS } from '@hydrooj/utils/lib/status'; +import type { JudgeResultBody } from 'hydrooj'; import readCases from '../cases'; import { getConfig } from '../config'; import { CompileError, FormatError, SystemError } from '../error'; import judge from '../judge'; import log from '../log'; -import { CopyInFile } from '../sandbox/interface'; +import type { CopyInFile } from '../sandbox/interface'; import * as sysinfo from '../sysinfo'; import * as tmpfs from '../tmpfs'; import { @@ -82,21 +83,21 @@ class JudgeTask { await this.doSubmission(startPromise); } catch (e) { if (e instanceof CompileError) { - this.next({ compiler_text: compilerText(e.stdout, e.stderr) }); + this.next({ compilerText: compilerText(e.stdout, e.stderr) }); this.end({ - status: STATUS.STATUS_COMPILE_ERROR, score: 0, time_ms: 0, memory_kb: 0, + status: STATUS.STATUS_COMPILE_ERROR, score: 0, time: 0, memory: 0, }); } else if (e instanceof FormatError) { - this.next({ judge_text: 'Testdata configuration incorrect.' }); - this.next({ judge_text: { message: e.message, params: e.params } }); + this.next({ message: 'Testdata configuration incorrect.' }); + this.next({ message: { message: e.message, params: e.params } }); this.end({ - status: STATUS.STATUS_FORMAT_ERROR, score: 0, time_ms: 0, memory_kb: 0, + status: STATUS.STATUS_FORMAT_ERROR, score: 0, time: 0, memory: 0, }); } else { log.error(e); this.next({ message: { message: e.message, params: e.params || [], ...argv.options.debug ? { stack: e.stack } : {} } }); this.end({ - status: STATUS.STATUS_SYSTEM_ERROR, score: 0, time_ms: 0, memory_kb: 0, + status: STATUS.STATUS_SYSTEM_ERROR, score: 0, time: 0, memory: 0, }); } } finally { @@ -132,38 +133,21 @@ class JudgeTask { await judge[type].judge(this, startPromise); } - next(data, id?: number) { + next(data: Partial, id?: number) { log.debug('Next: %d %o', id, data); data.key = 'next'; data.rid = new ObjectID(this.rid); - if (data.time_ms) data.time = data.time_ms; - if (data.memory_kb) data.memory = data.memory_kb; - if (data.compiler_text) data.compilerText = data.compiler_text; - if (data.judge_text) data.message = data.judge_text; - delete data.time_ms; - delete data.memory_kb; - delete data.compiler_text; - delete data.judge_text; if (data.case) { - data.case = { - id, - status: data.case.status, - time: data.case.time_ms || data.case.time, - memory: data.case.memory_kb || data.case.memory, - message: data.case.message || data.case.judgeText || data.case.judge_text || '', - }; + data.case.id ||= id; + data.case.message ||= ''; } this.ws.send(JSON.stringify(data)); } - end(data) { + end(data: Partial) { log.info('End: %o', data); data.key = 'end'; - data.rid = new ObjectID(this.request.rid); - data.time = data.time_ms ?? data.time; - delete data.time_ms; - data.memory = data.memory_kb ?? data.memory; - delete data.memory_kb; + data.rid = this.request.rid; this.ws.send(JSON.stringify(data)); } } @@ -221,7 +205,7 @@ export default class Hydro { } if (filenames.length) { log.info(`Getting problem data: ${this.config.host}/${source}`); - if (next) next({ judge_text: 'Syncing testdata, please wait...' }); + if (next) next({ message: 'Syncing testdata, please wait...' }); await this.ensureLogin(); const res = await this.axios.post(`/d/${domainId}/judge/files`, { pid: +pid, diff --git a/packages/hydrojudge/src/judge/default.ts b/packages/hydrojudge/src/judge/default.ts index 976cb5867..a1efc69e4 100644 --- a/packages/hydrojudge/src/judge/default.ts +++ b/packages/hydrojudge/src/judge/default.ts @@ -8,10 +8,8 @@ import { Logger } from '../log'; import { del, run } from '../sandbox'; import { CmdFile } from '../sandbox/interface'; import signals from '../signals'; -import { parseFilename } from '../utils'; -import { - Case, Context, ContextSubTask, SubTask, -} from './interface'; +import { NormalizedCase, NormalizedSubtask, parseFilename } from '../utils'; +import { Context, ContextSubTask } from './interface'; const Score = { sum: (a: number, b: number) => (a + b), @@ -21,18 +19,18 @@ const Score = { const logger = new Logger('judge/default'); -function judgeCase(c: Case, sid: string) { +function judgeCase(c: NormalizedCase, sid: string) { return async (ctx: Context, ctxSubtask: ContextSubTask, runner?: Function) => { if (ctx.errored || (ctx.failed[sid] && ctxSubtask.subtask.type === 'min') || (ctxSubtask.subtask.type === 'max' && ctxSubtask.score === ctxSubtask.subtask.score) - || ((ctxSubtask.subtask.if || []).filter((i: string) => ctx.failed[i]).length) + || ((ctxSubtask.subtask.if || []).filter((i) => ctx.failed[i]).length) ) { ctx.next({ case: { status: STATUS.STATUS_CANCELED, score: 0, - time_ms: 0, - memory_kb: 0, + time: 0, + memory: 0, message: '', }, addProgress: 100 / ctx.config.count, @@ -100,10 +98,11 @@ function judgeCase(c: Case, sid: string) { ctx.total_memory_usage_kb = Math.max(ctx.total_memory_usage_kb, memory_usage_kb); ctx.next({ case: { + subtaskId: ctxSubtask.subtask.id, status, - score: 0, - time_ms: time_usage_ms, - memory_kb: memory_usage_kb, + score, + time: time_usage_ms, + memory: memory_usage_kb, message, }, addProgress: 100 / ctx.config.count, @@ -126,7 +125,7 @@ function judgeCase(c: Case, sid: string) { env: ctx.env, }); const out = r.stdout.toString(); - if (out.length) ctx.next({ compiler_text: out.substring(0, 1024) }); + if (out.length) ctx.next({ compilerText: out.substring(0, 1024) }); if (process.env.DEV) console.log(r); } catch (e) { logger.info('Failed to run analysis'); @@ -137,7 +136,7 @@ function judgeCase(c: Case, sid: string) { }; } -function judgeSubtask(subtask: SubTask, sid: string) { +function judgeSubtask(subtask: NormalizedSubtask, sid: string) { return async (ctx: Context) => { subtask.type = subtask.type || 'min'; const ctxSubtask = { @@ -219,7 +218,7 @@ export const judge = async (ctx: Context, startPromise = Promise.resolve()) => { ctx.end({ status: ctx.total_status, score: ctx.total_score, - time_ms: Math.floor(ctx.total_time_usage_ms * 1000000) / 1000000, - memory_kb: ctx.total_memory_usage_kb, + time: Math.floor(ctx.total_time_usage_ms * 1000000) / 1000000, + memory: ctx.total_memory_usage_kb, }); }; diff --git a/packages/hydrojudge/src/judge/interactive.ts b/packages/hydrojudge/src/judge/interactive.ts index cc7d492a4..2e556ad07 100644 --- a/packages/hydrojudge/src/judge/interactive.ts +++ b/packages/hydrojudge/src/judge/interactive.ts @@ -5,10 +5,10 @@ import { getConfig } from '../config'; import { runPiped } from '../sandbox'; import signals from '../signals'; import { parse } from '../testlib'; -import { findFileSync, parseFilename } from '../utils'; import { - Case, Context, ContextSubTask, SubTask, -} from './interface'; + findFileSync, NormalizedCase, NormalizedSubtask, parseFilename, +} from '../utils'; +import { Context, ContextSubTask } from './interface'; const testlibSrc = findFileSync('@hydrooj/hydrojudge/vendor/testlib/testlib.h'); const Score = { @@ -17,7 +17,7 @@ const Score = { min: Math.min, }; -function judgeCase(c: Case) { +function judgeCase(c: NormalizedCase) { return async (ctx: Context, ctxSubtask: ContextSubTask) => { ctx.executeInteractor.copyIn.in = c.input ? { src: c.input } : { content: '' }; ctx.executeInteractor.copyIn.out = c.output ? { src: c.output } : { content: '' }; @@ -63,10 +63,11 @@ function judgeCase(c: Case) { ctx.next({ status: STATUS.STATUS_JUDGING, case: { + subtaskId: ctxSubtask.subtask.id, status, score, - time_ms: time_usage_ms, - memory_kb: memory_usage_kb, + time: time_usage_ms, + memory: memory_usage_kb, message, }, progress: Math.floor((c.id * 100) / ctx.config.count), @@ -74,7 +75,7 @@ function judgeCase(c: Case) { }; } -function judgeSubtask(subtask: SubTask) { +function judgeSubtask(subtask: NormalizedSubtask) { return async (ctx: Context) => { subtask.type = subtask.type || 'min'; const ctxSubtask = { @@ -134,7 +135,7 @@ export const judge = async (ctx: Context, startPromise = Promise.resolve()) => { ctx.end({ status: ctx.total_status, score: ctx.total_score, - time_ms: ctx.total_time_usage_ms, - memory_kb: ctx.total_memory_usage_kb, + time: ctx.total_time_usage_ms, + memory: ctx.total_memory_usage_kb, }); }; diff --git a/packages/hydrojudge/src/judge/interface.ts b/packages/hydrojudge/src/judge/interface.ts index 448ae7cef..bfad1f22d 100644 --- a/packages/hydrojudge/src/judge/interface.ts +++ b/packages/hydrojudge/src/judge/interface.ts @@ -1,31 +1,15 @@ import PQueue from 'p-queue'; -import { LangConfig } from '@hydrooj/utils/lib/lang'; +import type { NormalizedSubtask } from '@hydrooj/utils/lib/common'; +import type { LangConfig } from '@hydrooj/utils/lib/lang'; +import type { JudgeResultBody } from 'hydrooj'; import { Execute } from '../interface'; import { CopyInFile } from '../sandbox/interface'; export type Context = JudgeTaskInterface & RuntimeContext; export interface JudgeTaskInterface { - next(arg0: { - compiler_text?: string; - status?: number; - message?: string; - progress?: number; - addProgress?: number; - case?: { - status: number; - score?: number; - time_ms: number; - memory_kb: number; - message: string; - }; - }, arg1?: number): void; - end(arg0: { - status: number; - score: number; - time_ms: number; - memory_kb: number; - }): void; + next(data: Partial, id?: number): void; + end(data: Partial): void; getLang: (name: string) => LangConfig; stat: Record; @@ -56,22 +40,20 @@ export interface RuntimeContext { executeUser?: Execute; } -type ExtraFile = string[]; - export interface Config { type: string; subType?: string; time: string; memory: string; - subtasks?: SubTask[]; + subtasks?: NormalizedSubtask[]; count?: number; checker_type?: string; detail?: boolean; filename?: string; - judge_extra_files?: ExtraFile; - user_extra_files?: ExtraFile; + judge_extra_files?: string[]; + user_extra_files?: string[]; template?: Record; checker?: string; @@ -86,24 +68,8 @@ export interface Config { }[]; } -export interface SubTask { - time: number; - memory: number; - type: 'sum' | 'max' | 'min'; - score: number; - cases: Record; - if: string[]; -} - export interface ContextSubTask { - subtask: SubTask; + subtask: NormalizedSubtask; score: number; status: number; } - -export interface Case { - id: number; - input: string; - output: string; - score: number; -} diff --git a/packages/hydrojudge/src/judge/objective.ts b/packages/hydrojudge/src/judge/objective.ts index 278c1b538..0d6a374fb 100644 --- a/packages/hydrojudge/src/judge/objective.ts +++ b/packages/hydrojudge/src/judge/objective.ts @@ -29,11 +29,11 @@ export async function judge({ status: totalStatus, progress: (100 * (+i + 1)) / config.outputs.length, case: { - status, score, time_ms: 0, memory_kb: 0, message: '', + status, score, time: 0, memory: 0, message: '', }, }, +i + 1); } return end({ - status: totalStatus, score: totalScore, time_ms: 0, memory_kb: 0, + status: totalStatus, score: totalScore, time: 0, memory: 0, }); } diff --git a/packages/hydrojudge/src/judge/run.ts b/packages/hydrojudge/src/judge/run.ts index c746ea2d8..c87503ec8 100644 --- a/packages/hydrojudge/src/judge/run.ts +++ b/packages/hydrojudge/src/judge/run.ts @@ -12,8 +12,8 @@ import { Context } from './interface'; const failure = (status: number, message?: string) => ({ status, score: 0, - time_ms: 0, - memory_kb: 0, + time: 0, + memory: 0, message, }); @@ -88,9 +88,11 @@ export const judge = async (ctx: Context) => { ctx.next({ status, case: { + subtaskId: 0, status, - time_ms: time_usage_ms, - memory_kb: memory_usage_kb, + score: 100, + time: time_usage_ms, + memory: memory_usage_kb, message: message.join('\n').substring(0, 102400), }, }); @@ -115,7 +117,7 @@ export const judge = async (ctx: Context) => { memory: 256, }); const out = r.stdout.toString(); - if (out.length) ctx.next({ compiler_text: out.substring(0, 1024) }); + if (out.length) ctx.next({ compilerText: out.substring(0, 1024) }); if (process.env.DEV) console.log(r); } catch (e) { logger.info('Failed to run analysis'); @@ -128,7 +130,7 @@ export const judge = async (ctx: Context) => { ctx.end({ status, score: status === STATUS.STATUS_ACCEPTED ? 100 : 0, - time_ms: Math.floor(time_usage_ms * 1000000) / 1000000, - memory_kb: memory_usage_kb, + time: Math.floor(time_usage_ms * 1000000) / 1000000, + memory: memory_usage_kb, }); }; diff --git a/packages/hydrojudge/src/judge/submit_answer.ts b/packages/hydrojudge/src/judge/submit_answer.ts index d0623a795..00c3fa9d8 100644 --- a/packages/hydrojudge/src/judge/submit_answer.ts +++ b/packages/hydrojudge/src/judge/submit_answer.ts @@ -5,10 +5,8 @@ import { check, compileChecker } from '../check'; import { getConfig } from '../config'; import { FormatError } from '../error'; import { del, run } from '../sandbox'; -import { parseFilename } from '../utils'; -import { - Case, Context, ContextSubTask, SubTask, -} from './interface'; +import { NormalizedCase, NormalizedSubtask, parseFilename } from '../utils'; +import { Context, ContextSubTask } from './interface'; const Score = { sum: (a: number, b: number) => (a + b), @@ -16,18 +14,18 @@ const Score = { min: Math.min, }; -function judgeCase(c: Case, sid: string) { +function judgeCase(c: NormalizedCase, sid: string) { return async (ctx: Context, ctxSubtask: ContextSubTask) => { if (ctx.errored || (ctx.failed[sid] && ctxSubtask.subtask.type === 'min') || (ctxSubtask.subtask.type === 'max' && ctxSubtask.score === ctxSubtask.subtask.score) - || ((ctxSubtask.subtask.if || []).filter((i: string) => ctx.failed[i]).length) + || ((ctxSubtask.subtask.if || []).filter((i) => ctx.failed[i]).length) ) { ctx.next({ case: { status: STATUS.STATUS_CANCELED, score: 0, - time_ms: 0, - memory_kb: 0, + time: 0, + memory: 0, message: '', }, addProgress: 100 / ctx.config.count, @@ -87,8 +85,8 @@ function judgeCase(c: Case, sid: string) { case: { status, score: 0, - time_ms: 0, - memory_kb: 0, + time: 0, + memory: 0, message, }, addProgress: 100 / ctx.config.count, @@ -96,7 +94,7 @@ function judgeCase(c: Case, sid: string) { }; } -function judgeSubtask(subtask: SubTask, sid: string) { +function judgeSubtask(subtask: NormalizedSubtask, sid: string) { return async (ctx: Context) => { subtask.type = subtask.type || 'min'; const ctxSubtask = { @@ -160,7 +158,7 @@ export const judge = async (ctx: Context, startPromise = Promise.resolve()) => { ctx.end({ status: ctx.total_status, score: ctx.total_score, - time_ms: 0, - memory_kb: 0, + time: 0, + memory: 0, }); }; diff --git a/packages/hydrojudge/src/service.ts b/packages/hydrojudge/src/service.ts index cace7bd8f..bec7390e5 100644 --- a/packages/hydrojudge/src/service.ts +++ b/packages/hydrojudge/src/service.ts @@ -7,6 +7,7 @@ import fs from 'fs-extra'; import { noop } from 'lodash'; import { ObjectID } from 'mongodb'; import { STATUS } from '@hydrooj/utils/lib/status'; +import type { JudgeResultBody } from 'hydrooj'; import { Logger } from 'hydrooj/src/logger'; import * as monitor from 'hydrooj/src/service/monitor'; import readCases from './cases'; @@ -108,34 +109,18 @@ async function postInit() { } function getNext(that) { - return (data, id = 0) => { + return (data: Partial, id = 0) => { logger.debug('Next: %d %o', id, data); data.rid = new ObjectID(that.rid); - if (data.time_ms) data.time = data.time_ms; - if (data.memory_kb) data.memory = data.memory_kb; - if (data.compiler_text) data.compilerText = data.compiler_text; - delete data.time_ms; - delete data.memory_kb; - delete data.compiler_text; - if (data.case) { - data.case = { - id, - status: data.case.status, - time: data.case.time_ms || data.case.time, - memory: data.case.memory_kb || data.case.memory, - message: data.case.message || data.case.judgeText || '', - }; - } + if (data.case) data.case.id ||= id; _judge.next(data); }; } function getEnd(rid: string) { - return (data) => { + return (data: Partial) => { data.key = 'end'; data.rid = new ObjectID(rid); - data.time = data.time_ms ?? data.time; - data.memory = data.memory_kb ?? data.memory; logger.info('End: status=%d score=%d time=%dms memory=%dkb', data.status, data.score, data.time, data.memory); _judge.end(data); }; @@ -160,8 +145,8 @@ async function postInit() { config: any; env: any; input?: string; - next: (data: any, id?: number) => void; - end: (data: any) => void; + next: ReturnType; + end: ReturnType; tmpdir: string; clean: (() => Promise)[]; folder: string; @@ -204,20 +189,20 @@ async function postInit() { await this.submission(); } catch (e) { if (e instanceof CompileError) { - this.next({ compiler_text: compilerText(e.stdout, e.stderr) }); + this.next({ compilerText: compilerText(e.stdout, e.stderr) }); this.end({ - status: STATUS.STATUS_COMPILE_ERROR, score: 0, time_ms: 0, memory_kb: 0, + status: STATUS.STATUS_COMPILE_ERROR, score: 0, time: 0, memory: 0, }); } else if (e instanceof FormatError) { this.next({ message: { message: e.message, params: e.params } }); this.end({ - status: STATUS.STATUS_FORMAT_ERROR, score: 0, time_ms: 0, memory_kb: 0, + status: STATUS.STATUS_FORMAT_ERROR, score: 0, time: 0, memory: 0, }); } else { logger.error(e); this.next({ message: { message: e.message, params: e.params, ...process.env.DEV ? { stack: e.stack } : {} } }); this.end({ - status: STATUS.STATUS_SYSTEM_ERROR, score: 0, time_ms: 0, memory_kb: 0, + status: STATUS.STATUS_SYSTEM_ERROR, score: 0, time: 0, memory: 0, }); } } diff --git a/packages/hydrooj/package.json b/packages/hydrooj/package.json index ce577180e..01e9eb2e4 100644 --- a/packages/hydrooj/package.json +++ b/packages/hydrooj/package.json @@ -1,6 +1,6 @@ { "name": "hydrooj", - "version": "3.13.4", + "version": "3.13.5", "bin": "bin/hydrooj.js", "main": "src/loader", "module": "src/loader", diff --git a/packages/hydrooj/src/handler/judge.ts b/packages/hydrooj/src/handler/judge.ts index e1fb46ee9..283289c52 100644 --- a/packages/hydrooj/src/handler/judge.ts +++ b/packages/hydrooj/src/handler/judge.ts @@ -41,19 +41,19 @@ export async function postJudge(rdoc: RecordDoc) { await bus.serial('record/judge', rdoc, updated); } -export async function next(body: JudgeResultBody) { +export async function next(body: Partial) { body.rid = new ObjectID(body.rid); let rdoc = await record.get(body.rid); if (!rdoc) return; const $set: Partial = {}; const $push: any = {}; if (body.case) { - const c: TestCase = { + const c: Required = { + ...body.case, id: body.case.id || 0, - memory: body.case.memory, - time: body.case.time, + subtaskId: body.case.subtaskId || 0, + score: body.case.score || 0, message: body.case.message || '', - status: body.case.status, }; rdoc.testCases.push(c); $push.testCases = c; @@ -75,7 +75,7 @@ export async function next(body: JudgeResultBody) { bus.broadcast('record/change', rdoc!, $set, $push); } -export async function end(body: JudgeResultBody) { +export async function end(body: Partial) { if (body.rid) body.rid = new ObjectID(body.rid); let rdoc = await record.get(body.rid); if (!rdoc) return; diff --git a/packages/hydrooj/src/handler/problem.ts b/packages/hydrooj/src/handler/problem.ts index 5dffb71b2..00760623b 100644 --- a/packages/hydrooj/src/handler/problem.ts +++ b/packages/hydrooj/src/handler/problem.ts @@ -289,7 +289,6 @@ export class ProblemRandomHandler extends ProblemHandler { const category = flattenDeep(decodeURIComponent(qs).split(' ') .filter((i) => i.startsWith('category:')) .map((i) => i.split('category:')[1]?.split(','))); - console.log(qs, category); const q = buildQuery(this.user); if (category.length) q.$and = category.map((tag) => ({ tag })); await bus.serial('problem/list', q, this); diff --git a/packages/hydrooj/src/handler/record.ts b/packages/hydrooj/src/handler/record.ts index 6a90f5411..b21152259 100644 --- a/packages/hydrooj/src/handler/record.ts +++ b/packages/hydrooj/src/handler/record.ts @@ -183,7 +183,7 @@ class RecordDetailHandler extends Handler { time: 0, memory: 0, testCases: [{ - status: 9, score: 0, time: 0, memory: 0, message: 'score canceled', + id: 0, subtaskId: 0, status: 9, score: 0, time: 0, memory: 0, message: 'score canceled', }], }; const [latest] = await Promise.all([ diff --git a/packages/hydrooj/src/interface.ts b/packages/hydrooj/src/interface.ts index dc33df041..34a7d902a 100644 --- a/packages/hydrooj/src/interface.ts +++ b/packages/hydrooj/src/interface.ts @@ -2,7 +2,6 @@ import type fs from 'fs'; import type { Dictionary, NumericDictionary } from 'lodash'; import type { ItemBucketMetadata } from 'minio'; import type { Cursor, ObjectID } from 'mongodb'; -import type { Readable, Writable } from 'stream'; import type { ProblemDoc } from './model/problem'; type document = typeof import('./model/document'); @@ -208,6 +207,7 @@ export interface SampleContentNode { sectionTitle: string, payload: [string, string], } +// TODO drop contentNode support export type ContentNode = PlainContentNode | TextContentNode | SampleContentNode; export type Content = string | ContentNode[] | Record; @@ -272,6 +272,8 @@ export interface ProblemStatusDoc extends StatusDoc { export interface TestCase { id?: number; + subtaskId?: number; + score?: number; time: number; memory: number; status: number; @@ -288,9 +290,9 @@ export interface RecordDoc { score: number; memory: number; time: number; - judgeTexts: string[]; + judgeTexts: (string | JudgeMessage)[]; compilerTexts: string[]; - testCases: TestCase[]; + testCases: Required[]; rejudged: boolean; source?: string; /** judge uid */ @@ -497,24 +499,25 @@ export interface Script { validate: any, } +export interface JudgeMessage { + message: string; + params?: string[]; + stack?: string; +} + export interface JudgeResultBody { + key: string; domainId: string; rid: ObjectID; judger?: number; progress?: number; addProgress?: number; - case?: { - id?: number; - status: number; - time: number; - memory: number; - message?: string; - }, + case?: TestCase, status?: number; score?: number; time?: number; memory?: number; - message?: string; + message?: string | JudgeMessage; compilerText?: string, } @@ -527,19 +530,6 @@ export interface Task { [key: string]: any; } -export interface UploadStream extends Writable { - id: ObjectID; -} - -export interface HydroFileSystem { - openUploadStream: (filename: string) => UploadStream - openDownloadStream: (_id: ObjectID) => Readable - // TODO - find: (...args: any[]) => any -} - -export type PathComponent = [string, string, Dictionary?, boolean?]; - export interface BaseService { started: boolean; error?: Error | string; @@ -637,7 +627,6 @@ export interface Model { export interface Service { bus: typeof import('./service/bus'), db: typeof import('./service/db'), - fs: HydroFileSystem, monitor: typeof import('./service/monitor'), server: typeof import('./service/server'), storage: typeof import('./service/storage'), diff --git a/packages/hydrooj/src/service/server.ts b/packages/hydrooj/src/service/server.ts index 13719f8cf..417ed376c 100644 --- a/packages/hydrooj/src/service/server.ts +++ b/packages/hydrooj/src/service/server.ts @@ -95,7 +95,7 @@ app.on('error', (error) => { } }); wsServer.on('error', (error) => { - console.log(123, error); + console.log('Websocket server error:', error); }); const ignoredLimit = `,${argv.options.ignoredLimit},`; diff --git a/packages/migrate/scripts/vijos.ts b/packages/migrate/scripts/vijos.ts index 0aa6ffea7..4af1515a6 100644 --- a/packages/migrate/scripts/vijos.ts +++ b/packages/migrate/scripts/vijos.ts @@ -200,9 +200,13 @@ const tasks = { }, record: async (doc) => { if (doc.hidden) return null; - const testCases: TestCase[] = []; + const testCases: Required[] = []; + let i = 1; for (const c of doc.cases || []) { testCases.push({ + id: i++, + subtaskId: 0, + score: 0, status: c.status, time: c.time_ms || c.time, memory: c.memory_kb || c.memory, diff --git a/packages/utils/lib/common.ts b/packages/utils/lib/common.ts index 9b0b834af..1d1d24240 100644 --- a/packages/utils/lib/common.ts +++ b/packages/utils/lib/common.ts @@ -173,7 +173,7 @@ interface MatchRule { const SubtaskMatcher: MatchRule[] = [ { - regex: /^([^\d]*)(\d+).(in|txt)$/, + regex: /^([^\d]*(?:\d+[a-zA-Z]+)*)(\d+).(in|txt)$/, output: [ (a) => `${a[1] + a[2]}.out`, (a) => `${a[1] + a[2]}.ans`, @@ -260,9 +260,14 @@ export function readSubtasksFromFiles(files: string[], config) { return Object.values(subtask); } -type NormalizedCase = Required; -interface NormalizedSubtask extends Required { +export interface NormalizedCase extends Required { + time: number; + memory: number; +} +export interface NormalizedSubtask extends Required { cases: NormalizedCase[]; + time: number; + memory: number; } export function normalizeSubtasks( diff --git a/packages/utils/package.json b/packages/utils/package.json index be1e54208..d12a23eba 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -1,6 +1,6 @@ { "name": "@hydrooj/utils", - "version": "1.2.3", + "version": "1.2.4", "description": "hydrooj utils", "main": "package.json", "repository": "https://github.com/hydro-dev/Hydro.git", From 842322d15c838264d65d4061521809199fcc63be Mon Sep 17 00:00:00 2001 From: panda Date: Fri, 13 May 2022 12:24:30 +0800 Subject: [PATCH 34/44] utils: fix normalizeSubtasks score (#356) --- packages/hydrojudge/package.json | 2 +- packages/hydrooj/package.json | 2 +- packages/ui-default/package.json | 2 +- packages/utils/lib/common.ts | 6 +++--- packages/utils/package.json | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/hydrojudge/package.json b/packages/hydrojudge/package.json index f129c1976..2db6ae3de 100644 --- a/packages/hydrojudge/package.json +++ b/packages/hydrojudge/package.json @@ -1,7 +1,7 @@ { "name": "@hydrooj/hydrojudge", "bin": "bin/hydrojudge.js", - "version": "2.20.0", + "version": "2.20.1", "main": "package.json", "author": "undefined ", "repository": "https://github.com/hydro-dev/Hydro.git", diff --git a/packages/hydrooj/package.json b/packages/hydrooj/package.json index 01e9eb2e4..3094cfae0 100644 --- a/packages/hydrooj/package.json +++ b/packages/hydrooj/package.json @@ -1,6 +1,6 @@ { "name": "hydrooj", - "version": "3.13.5", + "version": "3.13.6", "bin": "bin/hydrooj.js", "main": "src/loader", "module": "src/loader", diff --git a/packages/ui-default/package.json b/packages/ui-default/package.json index f79bb29ed..14a75e6d8 100644 --- a/packages/ui-default/package.json +++ b/packages/ui-default/package.json @@ -1,6 +1,6 @@ { "name": "@hydrooj/ui-default", - "version": "4.37.2", + "version": "4.37.3", "author": "undefined ", "license": "AGPL-3.0", "main": "hydro.js", diff --git a/packages/utils/lib/common.ts b/packages/utils/lib/common.ts index 1d1d24240..10b4c49cf 100644 --- a/packages/utils/lib/common.ts +++ b/packages/utils/lib/common.ts @@ -284,25 +284,25 @@ export function normalizeSubtasks( return subtasks.map((s) => { id++; s.cases.sort((a, b) => (a.id - b.id)); - const score = subtaskScore.next().value as number; + const score = s.score || subtaskScore.next().value as number; const caseScore = getScore( Math.max(score - Math.sum(s.cases.map((i) => i.score || 0)), 0), s.cases.filter((i) => !i.score).length, ); return { id, - score, type: 'min', if: [], ...s, + score, time: parseTimeMS(s.time || time, !ignoreParseError), memory: parseMemoryMB(s.memory || memory, !ignoreParseError), cases: s.cases.map((c) => { count++; return { id: count, - score: s.type === 'sum' ? caseScore.next().value as number : score, ...c, + score: c.score || (s.type === 'sum' ? caseScore.next().value as number : score), time: parseTimeMS(c.time || s.time || time, !ignoreParseError), memory: parseMemoryMB(c.memory || s.memory || memory, !ignoreParseError), input: c.input ? checkFile(c.input, 'Cannot find input file {0}.') : '/dev/null', diff --git a/packages/utils/package.json b/packages/utils/package.json index d12a23eba..038dd0ee9 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -1,6 +1,6 @@ { "name": "@hydrooj/utils", - "version": "1.2.4", + "version": "1.2.5", "description": "hydrooj utils", "main": "package.json", "repository": "https://github.com/hydro-dev/Hydro.git", From 7457786c5c1fee66383fed33a8fd38e1801501cf Mon Sep 17 00:00:00 2001 From: undefined Date: Fri, 13 May 2022 13:14:44 +0800 Subject: [PATCH 35/44] elastic: add skip limit --- packages/elastic/package.json | 2 +- packages/elastic/script.ts | 3 ++- packages/elastic/setting.yaml | 2 ++ 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/elastic/package.json b/packages/elastic/package.json index 2cf9545e4..b30d7de51 100644 --- a/packages/elastic/package.json +++ b/packages/elastic/package.json @@ -1,6 +1,6 @@ { "name": "@hydrooj/elastic-search", - "version": "1.0.3", + "version": "1.0.4", "main": "package.json", "repository": "https://github.com/hydro-dev/Hydro", "author": "undefined ", diff --git a/packages/elastic/script.ts b/packages/elastic/script.ts index 0e269e476..e65401222 100644 --- a/packages/elastic/script.ts +++ b/packages/elastic/script.ts @@ -32,8 +32,9 @@ bus.on('problem/del', async (domainId, docId) => { }); global.Hydro.lib.problemSearch = async (domainId, q, opts) => { + const allowedSize = system.get('elasic.indexSize'); const size = opts?.limit || system.get('pagination.problem'); - const from = opts?.skip || 0; + const from = Math.min(allowedSize - size, opts?.skip || 0); const union = await DomainModel.getUnion(domainId); const domainIds = [domainId, ...(union?.union || [])]; const res = await client.search({ diff --git a/packages/elastic/setting.yaml b/packages/elastic/setting.yaml index aea785885..f40c9cc15 100644 --- a/packages/elastic/setting.yaml +++ b/packages/elastic/setting.yaml @@ -1,2 +1,4 @@ url: value: http://127.0.0.1:9200 +indexSize: + value: 10000 \ No newline at end of file From 6f7154a83a88a6246cef66beceef876d867b600c Mon Sep 17 00:00:00 2001 From: undefined Date: Fri, 13 May 2022 13:17:53 +0800 Subject: [PATCH 36/44] ui: set page number to 1 when updating search string --- packages/ui-default/package.json | 2 +- packages/ui-default/pages/problem_main.page.js | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/ui-default/package.json b/packages/ui-default/package.json index 14a75e6d8..4976daeea 100644 --- a/packages/ui-default/package.json +++ b/packages/ui-default/package.json @@ -1,6 +1,6 @@ { "name": "@hydrooj/ui-default", - "version": "4.37.3", + "version": "4.37.4", "author": "undefined ", "license": "AGPL-3.0", "main": "hydro.js", diff --git a/packages/ui-default/pages/problem_main.page.js b/packages/ui-default/pages/problem_main.page.js index df9823b28..0620ec4f2 100644 --- a/packages/ui-default/pages/problem_main.page.js +++ b/packages/ui-default/pages/problem_main.page.js @@ -56,6 +56,7 @@ async function updateSelection(sendRequest = true) { const url = new URL(window.location.href); if (!q) url.searchParams.delete('q'); else url.searchParams.set('q', q); + url.searchParams.delete('page'); pjax.request({ url: url.toString() }); $('[name="q"]').val(q); } From d11070ff4fbab491cb63dc6b94c6585f1f6e6471 Mon Sep 17 00:00:00 2001 From: undefined Date: Fri, 13 May 2022 15:36:25 +0800 Subject: [PATCH 37/44] elastic: set default size --- packages/elastic/package.json | 2 +- packages/elastic/script.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/elastic/package.json b/packages/elastic/package.json index b30d7de51..6c6507332 100644 --- a/packages/elastic/package.json +++ b/packages/elastic/package.json @@ -1,6 +1,6 @@ { "name": "@hydrooj/elastic-search", - "version": "1.0.4", + "version": "1.0.5", "main": "package.json", "repository": "https://github.com/hydro-dev/Hydro", "author": "undefined ", diff --git a/packages/elastic/script.ts b/packages/elastic/script.ts index e65401222..0c4412913 100644 --- a/packages/elastic/script.ts +++ b/packages/elastic/script.ts @@ -32,7 +32,7 @@ bus.on('problem/del', async (domainId, docId) => { }); global.Hydro.lib.problemSearch = async (domainId, q, opts) => { - const allowedSize = system.get('elasic.indexSize'); + const allowedSize = system.get('elasic-search.indexSize') || 10000; const size = opts?.limit || system.get('pagination.problem'); const from = Math.min(allowedSize - size, opts?.skip || 0); const union = await DomainModel.getUnion(domainId); From 080040579fee71ddf9d996ec5c083f78a36078d7 Mon Sep 17 00:00:00 2001 From: undefined Date: Fri, 13 May 2022 21:43:10 +0800 Subject: [PATCH 38/44] core: fix mention link --- packages/hydrooj/package.json | 2 +- packages/hydrooj/src/handler/discussion.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/hydrooj/package.json b/packages/hydrooj/package.json index 3094cfae0..408f014c2 100644 --- a/packages/hydrooj/package.json +++ b/packages/hydrooj/package.json @@ -1,6 +1,6 @@ { "name": "hydrooj", - "version": "3.13.6", + "version": "3.13.7", "bin": "bin/hydrooj.js", "main": "src/loader", "module": "src/loader", diff --git a/packages/hydrooj/src/handler/discussion.ts b/packages/hydrooj/src/handler/discussion.ts index 98b39e9f4..7b4743445 100644 --- a/packages/hydrooj/src/handler/discussion.ts +++ b/packages/hydrooj/src/handler/discussion.ts @@ -240,7 +240,7 @@ class DiscussionDetailHandler extends DiscussionHandler { const uids = Object.keys(await user.getList(domainId, Array.from(targets))).map((i) => +i); const msg = JSON.stringify({ message: 'User {0} mentioned you in {1:link}', - params: [this.user.uname, this.request.path], + params: [this.user.uname, `/d/${domainId}${this.request.path}`], }); for (const uid of uids) { message.send(1, uid, msg, message.FLAG_RICHTEXT | message.FLAG_UNREAD); @@ -259,7 +259,7 @@ class DiscussionDetailHandler extends DiscussionHandler { const uids = Object.keys(await user.getList(domainId, Array.from(targets))).map((i) => +i); const msg = JSON.stringify({ message: 'User {0} mentioned you in {1:link}', - params: [this.user.uname, this.request.path], + params: [this.user.uname, `/d/${domainId}${this.request.path}`], }); for (const uid of uids) { message.send(1, uid, msg, message.FLAG_RICHTEXT | message.FLAG_UNREAD); From ff7db46e2db49d558378521cf6552972bbb7f8d0 Mon Sep 17 00:00:00 2001 From: undefined Date: Sat, 14 May 2022 10:12:25 +0800 Subject: [PATCH 39/44] ui: fix pagination qs --- packages/ui-default/package.json | 2 +- packages/ui-default/templates/partials/problem_list.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui-default/package.json b/packages/ui-default/package.json index 4976daeea..6f3eee62d 100644 --- a/packages/ui-default/package.json +++ b/packages/ui-default/package.json @@ -1,6 +1,6 @@ { "name": "@hydrooj/ui-default", - "version": "4.37.4", + "version": "4.37.5", "author": "undefined ", "license": "AGPL-3.0", "main": "hydro.js", diff --git a/packages/ui-default/templates/partials/problem_list.html b/packages/ui-default/templates/partials/problem_list.html index 5796bd017..b0fadb319 100644 --- a/packages/ui-default/templates/partials/problem_list.html +++ b/packages/ui-default/templates/partials/problem_list.html @@ -73,6 +73,6 @@ {%- endfor -%} - {{ paginator.render(page, ppcount, add_qs=qs) }} + {{ paginator.render(page, ppcount, add_qs=('q=' + qs if qs else '')) }} {% endif %}
From e4b916037ce192083d5812991376d830bc646859 Mon Sep 17 00:00:00 2001 From: undefined Date: Sat, 14 May 2022 10:21:27 +0800 Subject: [PATCH 40/44] ui: fix another paginator --- packages/ui-default/package.json | 2 +- packages/ui-default/templates/partials/problem_list.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui-default/package.json b/packages/ui-default/package.json index 6f3eee62d..9fbf1cde3 100644 --- a/packages/ui-default/package.json +++ b/packages/ui-default/package.json @@ -1,6 +1,6 @@ { "name": "@hydrooj/ui-default", - "version": "4.37.5", + "version": "4.37.6", "author": "undefined ", "license": "AGPL-3.0", "main": "hydro.js", diff --git a/packages/ui-default/templates/partials/problem_list.html b/packages/ui-default/templates/partials/problem_list.html index b0fadb319..ad6c49d33 100644 --- a/packages/ui-default/templates/partials/problem_list.html +++ b/packages/ui-default/templates/partials/problem_list.html @@ -6,7 +6,7 @@ {% if not pdocs.length %} {{ nothing.render('Sorry, there is no problem in the problem set') }} {% else %} - {{ paginator.render(page, ppcount, position='top', add_qs=qs) }} + {{ paginator.render(page, ppcount, position='top', add_qs=('q=' + qs if qs else '')) }} From b74f6b64f8b04bd1717e3e1d166c32b88cc07f0f Mon Sep 17 00:00:00 2001 From: undefined Date: Sun, 15 May 2022 23:12:18 +0800 Subject: [PATCH 41/44] judge: merge processTestdata --- packages/hydrojudge/package.json | 2 +- packages/hydrojudge/setting.yaml | 5 --- packages/hydrojudge/src/cases.ts | 27 +++++++++++++++ packages/hydrojudge/src/check.ts | 3 ++ packages/hydrojudge/src/config.ts | 10 +++--- packages/hydrojudge/src/daemon.ts | 24 ++++---------- packages/hydrojudge/src/error.ts | 33 ++++--------------- packages/hydrojudge/src/hosts/hydro.ts | 32 ++---------------- packages/hydrojudge/src/judge/default.ts | 28 +++++++--------- packages/hydrojudge/src/judge/interface.ts | 4 +-- .../hydrojudge/src/judge/submit_answer.ts | 3 -- packages/hydrojudge/src/log.ts | 22 +------------ packages/hydrojudge/src/sandbox.ts | 7 ++++ packages/hydrojudge/src/service.ts | 27 ++------------- packages/hydrojudge/src/sysinfo.ts | 3 ++ 15 files changed, 78 insertions(+), 152 deletions(-) diff --git a/packages/hydrojudge/package.json b/packages/hydrojudge/package.json index 2db6ae3de..b57564e63 100644 --- a/packages/hydrojudge/package.json +++ b/packages/hydrojudge/package.json @@ -1,7 +1,7 @@ { "name": "@hydrooj/hydrojudge", "bin": "bin/hydrojudge.js", - "version": "2.20.1", + "version": "2.20.2", "main": "package.json", "author": "undefined ", "repository": "https://github.com/hydro-dev/Hydro.git", diff --git a/packages/hydrojudge/setting.yaml b/packages/hydrojudge/setting.yaml index d9533b83a..9fa0f23f7 100644 --- a/packages/hydrojudge/setting.yaml +++ b/packages/hydrojudge/setting.yaml @@ -37,11 +37,6 @@ memoryMax: type: text name: memoryMax default: 512m -retry_delay_sec: - type: number - name: retry_delay_sec - desc: Retry delay sec - default: 15 cache_dir: type: text name: cache_dir diff --git a/packages/hydrojudge/src/cases.ts b/packages/hydrojudge/src/cases.ts index 2eacf86c9..baf38cd5d 100644 --- a/packages/hydrojudge/src/cases.ts +++ b/packages/hydrojudge/src/cases.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-await-in-loop */ import path from 'path'; import fs from 'fs-extra'; import yaml from 'js-yaml'; @@ -35,6 +36,32 @@ async function collectFiles(folder: string) { return files; } +export async function processTestdata(folder: string) { + let files = await fs.readdir(folder); + if (files.length <= 2) { + if (files.length === 2) files.splice(files.indexOf('version'), 1); + if (fs.statSync(path.resolve(folder, files[0])).isDirectory()) { + folder = path.resolve(folder, files[0]); + files = await fs.readdir(folder); + } + } + const ini = files.filter((i) => i.toLowerCase() === 'config.ini')[0]; + if (!ini) return; + const t = fs.readFileSync(path.resolve(folder, ini), 'utf8'); + await fs.writeFile(path.resolve(folder, 'config.ini'), t.toLowerCase()); + for (const i of files) { + if (i.toLowerCase() === 'input') await fs.rename(`${folder}/${i}`, `${folder}/input`); + if (i.toLowerCase() === 'output') await fs.rename(`${folder}/${i}`, `${folder}/output`); + } + await Promise.all(['input', 'output'].flatMap(async (f) => { + const dir = path.resolve(folder, f); + const sf = await fs.readdir(dir); + return sf + .filter((i) => i !== i.toLowerCase()) + .map((i) => fs.rename(`${dir}/${i}`, `${dir}/${i.toLowerCase()}`)); + })); +} + export default async function readCases(folder: string, cfg: Record = {}, args) { const iniConfig = path.resolve(folder, 'config.ini'); const yamlConfig = path.resolve(folder, 'config.yaml'); diff --git a/packages/hydrojudge/src/check.ts b/packages/hydrojudge/src/check.ts index 8155c878c..1223c6dab 100644 --- a/packages/hydrojudge/src/check.ts +++ b/packages/hydrojudge/src/check.ts @@ -16,6 +16,9 @@ export async function check(config: CheckConfig): Promise<[number, number, strin } export async function compileChecker(getLang: Function, checkerType: string, checker: string, copyIn: any): Promise { + if (['default', 'strict'].includes(checkerType)) { + return { execute: '', copyIn: {}, clean: () => Promise.resolve(null) }; + } if (!checkers[checkerType]) throw new SystemError('Unknown checker type {0}.', [checkerType]); if (checkerType === 'testlib') copyIn['testlib.h'] = { src: testlibSrc }; const s = checker.replace('@', '.').split('.'); diff --git a/packages/hydrojudge/src/config.ts b/packages/hydrojudge/src/config.ts index 51cdbab56..b20e597ea 100644 --- a/packages/hydrojudge/src/config.ts +++ b/packages/hydrojudge/src/config.ts @@ -1,4 +1,3 @@ -/* eslint-disable prefer-const */ import os from 'os'; import path from 'path'; import cac from 'cac'; @@ -9,19 +8,19 @@ const argv = cac().parse(); // eslint-disable-next-line @typescript-eslint/naming-convention let CONFIG_FILE = path.resolve(os.homedir(), '.config', 'hydro', 'judge.yaml'); -let config = { +const config = { cache_dir: path.resolve(os.homedir(), '.cache', 'hydro', 'judge'), tmp_dir: path.resolve(os.tmpdir(), 'hydro', 'judge'), tmpfs_size: '256m', stdio_size: '32m', memoryMax: '512m', strict_memory: false, - retry_delay_sec: 15, sandbox_host: 'http://localhost:5050', testcases_max: 100, total_time_limit: 60, processLimit: 32, parallelism: 2, + singleTaskParallelism: 2, rerun: 0, rate: 1, config: null, @@ -29,10 +28,11 @@ let config = { env: `\ PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin HOME=/w`, + hosts: {}, secret: String.random(32), }; -export function getConfig(key: string) { +export function getConfig(key: keyof typeof config) { return global.Hydro ? global.Hydro.model.system.get(`hydrojudge.${key}`) : config[key]; } @@ -61,5 +61,5 @@ if (!global.Hydro) { config.sandbox_host = path.resolve(process.env.EXECUTION_HOST || argv.options.sandbox); } const configFile = fs.readFileSync(CONFIG_FILE).toString(); - config = { ...config, ...yaml.load(configFile) as any }; + Object.assign(config, yaml.load(configFile) as any); } diff --git a/packages/hydrojudge/src/daemon.ts b/packages/hydrojudge/src/daemon.ts index 858f12fec..02cfe91ad 100644 --- a/packages/hydrojudge/src/daemon.ts +++ b/packages/hydrojudge/src/daemon.ts @@ -18,7 +18,7 @@ import './utils'; import { getConfig } from './config'; import * as Session from './hosts/index'; import log from './log'; -import { Queue, sleep } from './utils'; +import { Queue } from './utils'; declare global { namespace NodeJS { @@ -28,8 +28,8 @@ declare global { } } } -if (!global.onDestroy) global.onDestroy = []; -if (!global.hosts) global.hosts = []; +global.onDestroy ||= []; +global.hosts ||= []; let exit = false; const terminate = async () => { @@ -60,28 +60,16 @@ async function worker(queue: Queue) { async function daemon() { const _hosts = getConfig('hosts'); - const delay = getConfig('retry_delay_sec'); const hosts = {}; const queue = new Queue(); + worker(queue).catch((e) => log.error(e)); for (const i in _hosts) { _hosts[i].host = _hosts[i].host || i; - hosts[i] = new Session[_hosts[i].type || 'vj4'](_hosts[i]); + hosts[i] = new Session[_hosts[i].type || 'hydro'](_hosts[i]); await hosts[i].init(); } + for (const i in hosts) hosts[i].consume(queue); global.hosts = hosts; - worker(queue); - for (const i in hosts) { - while ('Orz twd2') { - try { - await hosts[i].consume(queue); - break; - } catch (e) { - log.error(e, e.stack); - log.info(`在 ${delay} 秒后重试`); - await sleep(delay * 1000); - } - } - } } if (require.main === module) daemon(); diff --git a/packages/hydrojudge/src/error.ts b/packages/hydrojudge/src/error.ts index 29419e5ab..ff79b9a0b 100644 --- a/packages/hydrojudge/src/error.ts +++ b/packages/hydrojudge/src/error.ts @@ -5,7 +5,7 @@ export class CompileError extends Error { stdout: string; stderr: string; status: string; - type: string; + type = 'CompileError'; constructor(obj: string | CompileErrorInfo) { super('Compile Error'); @@ -17,48 +17,29 @@ export class CompileError extends Error { this.stderr = obj.stderr || ''; this.status = obj.status ? STATUS_TEXTS[obj.status] || '' : ''; } - this.type = 'CompileError'; } } export class FormatError extends Error { - type: string; - params: any[]; + type = 'FormatError'; - constructor(message: string, params = []) { + constructor(message: string, public params = []) { super(message); - this.type = 'FormatError'; - this.params = params; } } export class RuntimeError extends Error { - type: string; - detail: any; + type = 'RuntimeError'; - constructor(detail: string, message: string) { + constructor(public detail: string, message: string) { super(message); - this.type = 'RuntimeError'; - this.detail = detail; } } export class SystemError extends Error { - type: string; - params: any[]; + type = 'SystemError'; - constructor(message: string, params = []) { + constructor(message: string, public params = []) { super(message); - this.type = 'SystemError'; - this.params = params; - } -} - -export class TooFrequentError extends Error { - type: string; - - constructor(message: string) { - super(message); - this.type = 'TooFrequentError'; } } diff --git a/packages/hydrojudge/src/hosts/hydro.ts b/packages/hydrojudge/src/hosts/hydro.ts index 072e000c1..6824b5af0 100644 --- a/packages/hydrojudge/src/hosts/hydro.ts +++ b/packages/hydrojudge/src/hosts/hydro.ts @@ -10,7 +10,7 @@ import WebSocket from 'ws'; import { LangConfig } from '@hydrooj/utils/lib/lang'; import { STATUS } from '@hydrooj/utils/lib/status'; import type { JudgeResultBody } from 'hydrooj'; -import readCases from '../cases'; +import readCases, { processTestdata } from '../cases'; import { getConfig } from '../config'; import { CompileError, FormatError, SystemError } from '../error'; import judge from '../judge'; @@ -232,7 +232,7 @@ export default class Hydro { queue.start(); await Promise.all(tasks); fs.writeFileSync(path.join(filePath, 'etags'), JSON.stringify(version)); - await this.processData(filePath).catch(noop); + await processTestdata(filePath); } fs.writeFileSync(path.join(filePath, 'lastUsage'), new Date().getTime().toString()); return filePath; @@ -332,34 +332,6 @@ export default class Hydro { } } - async processData(folder: string) { // eslint-disable-line class-methods-use-this - let files = await fs.readdir(folder); - let ini = false; - if (files.length <= 2) { - if (files.length === 2) files.splice(files.indexOf('version'), 1); - const s = fs.statSync(path.resolve(folder, files[0])); - if (s.isDirectory()) folder = path.resolve(folder, files[0]); - } - files = await fs.readdir(folder); - for (const i of files) { - if (i.toLowerCase() === 'config.ini') { - ini = true; - await fs.rename(`${folder}/${i}`, `${folder}/config.ini`); - break; - } - } - if (ini) { - for (const i of files) { - if (i.toLowerCase() === 'input') await fs.rename(`${folder}/${i}`, `${folder}/input`); - else if (i.toLowerCase() === 'output') await fs.rename(`${folder}/${i}`, `${folder}/output`); - } - files = await fs.readdir(`${folder}/input`); - for (const i of files) await fs.rename(`${folder}/input/${i}`, `${folder}/input/${i.toLowerCase()}`); - files = await fs.readdir(`${folder}/output`); - for (const i of files) await fs.rename(`${folder}/output/${i}`, `${folder}/output/${i.toLowerCase()}`); - } - } - async retry(queue: Queue) { this.consume(queue).catch(() => { setTimeout(() => this.retry(queue), 30000); diff --git a/packages/hydrojudge/src/judge/default.ts b/packages/hydrojudge/src/judge/default.ts index a1efc69e4..e9a9aab0a 100644 --- a/packages/hydrojudge/src/judge/default.ts +++ b/packages/hydrojudge/src/judge/default.ts @@ -177,21 +177,17 @@ export const judge = async (ctx: Context, startPromise = Promise.resolve()) => { ), ctx.next, ), - (() => { - if (['default', 'strict'].includes(ctx.config.checker_type || 'default')) { - return { execute: '', copyIn: {}, clean: () => Promise.resolve(null) }; - } - const copyIn = { user_code: ctx.code }; - for (const file of ctx.config.judge_extra_files) { - copyIn[parseFilename(file)] = { src: file }; - } - return compileChecker( - ctx.getLang, - ctx.config.checker_type, - ctx.config.checker, - copyIn, - ); - })(), + compileChecker( + ctx.getLang, + ctx.config.checker_type, + ctx.config.checker, + { + user_code: ctx.code, + ...Object.fromEntries( + (ctx.config.judge_extra_files || []).map((i) => [parseFilename(i), { src: i }]), + ), + }, + ), ]); ctx.clean.push(ctx.execute.clean, ctx.checker.clean); await startPromise; @@ -202,7 +198,7 @@ export const judge = async (ctx: Context, startPromise = Promise.resolve()) => { ctx.total_memory_usage_kb = 0; ctx.total_time_usage_ms = 0; ctx.rerun = getConfig('rerun') || 0; - ctx.queue = new Queue({ concurrency: getConfig('parallelism') }); + ctx.queue = new Queue({ concurrency: getConfig('singleTaskParallelism') }); ctx.failed = {}; for (const sid in ctx.config.subtasks) tasks.push(judgeSubtask(ctx.config.subtasks[sid], sid)(ctx)); const scores = await Promise.all(tasks); diff --git a/packages/hydrojudge/src/judge/interface.ts b/packages/hydrojudge/src/judge/interface.ts index bfad1f22d..1ebb1f78b 100644 --- a/packages/hydrojudge/src/judge/interface.ts +++ b/packages/hydrojudge/src/judge/interface.ts @@ -52,8 +52,8 @@ export interface Config { detail?: boolean; filename?: string; - judge_extra_files?: string[]; - user_extra_files?: string[]; + judge_extra_files: string[]; + user_extra_files: string[]; template?: Record; checker?: string; diff --git a/packages/hydrojudge/src/judge/submit_answer.ts b/packages/hydrojudge/src/judge/submit_answer.ts index 00c3fa9d8..699f58cdd 100644 --- a/packages/hydrojudge/src/judge/submit_answer.ts +++ b/packages/hydrojudge/src/judge/submit_answer.ts @@ -122,9 +122,6 @@ export const judge = async (ctx: Context, startPromise = Promise.resolve()) => { if (!ctx.config.subtasks.length) throw new FormatError('Problem data not found.'); startPromise.then(() => ctx.next({ status: STATUS.STATUS_COMPILING })); ctx.checker = await (() => { - if (['default', 'strict'].includes(ctx.config.checker_type || 'default')) { - return { execute: '', copyIn: {}, clean: () => Promise.resolve(null) }; - } const copyIn = { user_code: ctx.code }; for (const file of ctx.config.judge_extra_files) { copyIn[parseFilename(file)] = { src: file }; diff --git a/packages/hydrojudge/src/log.ts b/packages/hydrojudge/src/log.ts index 611e5da29..b67edf409 100644 --- a/packages/hydrojudge/src/log.ts +++ b/packages/hydrojudge/src/log.ts @@ -1,29 +1,9 @@ import { format, inspect, InspectOptions } from 'util'; import cac from 'cac'; +import { Time } from '@hydrooj/utils/lib/utils'; const argv = cac().parse(); -namespace Time { - export const second = 1000; - export const minute = second * 60; - export const hour = minute * 60; - export const day = hour * 24; - export const week = day * 7; - export function formatTimeShort(ms: number) { - const abs = Math.abs(ms); - if (abs >= day - hour / 2) { - return `${Math.round(ms / day)}d`; - } if (abs >= hour - minute / 2) { - return `${Math.round(ms / hour)}h`; - } if (abs >= minute - second / 2) { - return `${Math.round(ms / minute)}m`; - } if (abs >= second) { - return `${Math.round(ms / second)}s`; - } - return `${ms}ms`; - } -} - const colors = [ 20, 21, 26, 27, 32, 33, 38, 39, 40, 41, 42, 43, 44, 45, 56, 57, 62, 63, 68, 69, 74, 75, 76, 77, 78, 79, 80, 81, 92, 93, 98, 99, 112, 113, diff --git a/packages/hydrojudge/src/sandbox.ts b/packages/hydrojudge/src/sandbox.ts index 86d7f1f3f..feb6a5a0c 100644 --- a/packages/hydrojudge/src/sandbox.ts +++ b/packages/hydrojudge/src/sandbox.ts @@ -1,5 +1,6 @@ import cac from 'cac'; import fs from 'fs-extra'; +import PQueue from 'p-queue'; import { ParseEntry } from 'shell-quote'; import { STATUS } from '@hydrooj/utils/lib/status'; import { getConfig } from './config'; @@ -191,3 +192,9 @@ export async function run(execute: string, params?: Parameter): Promise run(execute, params), { priority }); +} diff --git a/packages/hydrojudge/src/service.ts b/packages/hydrojudge/src/service.ts index bec7390e5..a8ac4f3c0 100644 --- a/packages/hydrojudge/src/service.ts +++ b/packages/hydrojudge/src/service.ts @@ -10,7 +10,7 @@ import { STATUS } from '@hydrooj/utils/lib/status'; import type { JudgeResultBody } from 'hydrooj'; import { Logger } from 'hydrooj/src/logger'; import * as monitor from 'hydrooj/src/service/monitor'; -import readCases from './cases'; +import readCases, { processTestdata } from './cases'; import { getConfig } from './config'; import { CompileError, FormatError, SystemError } from './error'; import { CopyInFile } from './sandbox/interface'; @@ -23,7 +23,6 @@ declare module 'hydrooj/src/interface' { 'hydrojudge.cache_dir': string; 'hydrojudge.tmp_dir': string; 'hydrojudge.tmpfs_size': string; - 'hydrojudge.retry_delay_sec': number; 'hydrojudge.sandbox_host': string; 'hydrojudge.memoryMax': string; 'hydrojudge.testcases_max': number; @@ -54,28 +53,6 @@ async function postInit() { monitor.updateJudge({ mid, ...inf }); }, 1200000); - async function processData(folder: string) { - let files = await fs.readdir(folder); - let ini = false; - for (const i of files) { - if (i.toLowerCase() === 'config.ini') { - ini = true; - await fs.rename(`${folder}/${i}`, `${folder}/config.ini`); - break; - } - } - if (ini) { - for (const i of files) { - if (i.toLowerCase() === 'input') await fs.rename(`${folder}/${i}`, `${folder}/input`); - else if (i.toLowerCase() === 'output') await fs.rename(`${folder}/${i}`, `${folder}/output`); - } - files = await fs.readdir(`${folder}/input`); - for (const i of files) await fs.rename(`${folder}/input/${i}`, `${folder}/input/${i.toLowerCase()}`); - files = await fs.readdir(`${folder}/output`); - for (const i of files) await fs.rename(`${folder}/output/${i}`, `${folder}/output/${i.toLowerCase()}`); - } - } - async function fetchCodeFile(name: string) { const target = path.join('/tmp/hydro/judge', name.replace(/\//g, '_')); await storage.get(`submission/${name}`, target); @@ -104,7 +81,7 @@ async function postInit() { } fs.writeFileSync(path.join(filePath, 'etags'), JSON.stringify(version)); fs.writeFileSync(path.join(filePath, 'lastUsage'), new Date().getTime().toString()); - await processData(filePath).catch(noop); + await processTestdata(filePath); return filePath; } diff --git a/packages/hydrojudge/src/sysinfo.ts b/packages/hydrojudge/src/sysinfo.ts index 1c4650a57..ac2c0f63d 100644 --- a/packages/hydrojudge/src/sysinfo.ts +++ b/packages/hydrojudge/src/sysinfo.ts @@ -1,3 +1,4 @@ +// TODO this should be moved into web import path from 'path'; import fs from 'fs-extra'; import { noop } from 'lodash'; @@ -30,6 +31,8 @@ int main(){ type: 'default', time: '3s', memory: '256m', + user_extra_files: [], + judge_extra_files: [], }, stat: {}, clean: [], From a6c41ec39ddaed32ae4fa5a12019247ea9176218 Mon Sep 17 00:00:00 2001 From: undefined Date: Mon, 16 May 2022 08:52:32 +0800 Subject: [PATCH 42/44] judge: default setting --- packages/hydrojudge/package.json | 2 +- packages/hydrojudge/setting.yaml | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/hydrojudge/package.json b/packages/hydrojudge/package.json index b57564e63..ddaaca7f1 100644 --- a/packages/hydrojudge/package.json +++ b/packages/hydrojudge/package.json @@ -1,7 +1,7 @@ { "name": "@hydrooj/hydrojudge", "bin": "bin/hydrojudge.js", - "version": "2.20.2", + "version": "2.20.3", "main": "package.json", "author": "undefined ", "repository": "https://github.com/hydro-dev/Hydro.git", diff --git a/packages/hydrojudge/setting.yaml b/packages/hydrojudge/setting.yaml index 9fa0f23f7..a3af6198d 100644 --- a/packages/hydrojudge/setting.yaml +++ b/packages/hydrojudge/setting.yaml @@ -47,6 +47,10 @@ parallelism: name: parallelism desc: Runner parallelism default: 2 +singleTaskParallelism: + type: number + name: singleTaskParallelism + default: 2 rate: type: float name: rate From e3d61924effd7ea03731a69bbcb4b13b6bcbc575 Mon Sep 17 00:00:00 2001 From: undefined Date: Mon, 16 May 2022 09:00:13 +0800 Subject: [PATCH 43/44] judge: use config[key] --- packages/hydrojudge/package.json | 2 +- packages/hydrojudge/src/config.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/hydrojudge/package.json b/packages/hydrojudge/package.json index ddaaca7f1..ce698f16e 100644 --- a/packages/hydrojudge/package.json +++ b/packages/hydrojudge/package.json @@ -1,7 +1,7 @@ { "name": "@hydrooj/hydrojudge", "bin": "bin/hydrojudge.js", - "version": "2.20.3", + "version": "2.20.4", "main": "package.json", "author": "undefined ", "repository": "https://github.com/hydro-dev/Hydro.git", diff --git a/packages/hydrojudge/src/config.ts b/packages/hydrojudge/src/config.ts index b20e597ea..564728377 100644 --- a/packages/hydrojudge/src/config.ts +++ b/packages/hydrojudge/src/config.ts @@ -33,7 +33,7 @@ HOME=/w`, }; export function getConfig(key: keyof typeof config) { - return global.Hydro ? global.Hydro.model.system.get(`hydrojudge.${key}`) : config[key]; + return global.Hydro ? global.Hydro.model.system.get(`hydrojudge.${key}`) ?? config[key] : config[key]; } if (fs.existsSync(path.resolve(process.cwd(), '.env'))) { From f6cc2a72ff9cbaa11c419fc6c0ec3c1dcedbc227 Mon Sep 17 00:00:00 2001 From: undefined Date: Mon, 16 May 2022 14:07:34 +0800 Subject: [PATCH 44/44] core: update download file perm control --- packages/hydrooj/package.json | 2 +- packages/hydrooj/src/handler/misc.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/hydrooj/package.json b/packages/hydrooj/package.json index 408f014c2..4cc64ebed 100644 --- a/packages/hydrooj/package.json +++ b/packages/hydrooj/package.json @@ -1,6 +1,6 @@ { "name": "hydrooj", - "version": "3.13.7", + "version": "3.13.8", "bin": "bin/hydrooj.js", "main": "src/loader", "module": "src/loader", diff --git a/packages/hydrooj/src/handler/misc.ts b/packages/hydrooj/src/handler/misc.ts index 7f09ad5fd..f2776ca0f 100644 --- a/packages/hydrooj/src/handler/misc.ts +++ b/packages/hydrooj/src/handler/misc.ts @@ -91,7 +91,7 @@ export class FSDownloadHandler extends Handler { @param('noDisposition', Types.Boolean) async get(domainId: string, uid: number, filename: string, noDisposition = false) { const targetUser = await user.getById('system', uid); - if (!targetUser.hasPriv(PRIV.PRIV_CREATE_FILE)) throw new ForbiddenError('Access denied'); + if (this.user._id !== uid && !targetUser.hasPriv(PRIV.PRIV_CREATE_FILE)) throw new ForbiddenError('Access denied'); this.response.addHeader('Cache-Control', 'public'); const target = `user/${uid}/${filename}`; const file = await storage.getMeta(target);