diff --git a/.gitignore b/.gitignore index 40ab8ec8e..69b50713b 100644 --- a/.gitignore +++ b/.gitignore @@ -59,4 +59,6 @@ __* *.local # Monokle -.monokle \ No newline at end of file +.monokle + +packages/*/public/assets diff --git a/packages/hydrooj/package.json b/packages/hydrooj/package.json index bb30e5a52..a1ddc5c60 100644 --- a/packages/hydrooj/package.json +++ b/packages/hydrooj/package.json @@ -25,6 +25,7 @@ "adm-zip": "0.5.14", "cac": "^6.7.14", "cordis": "3.18.1", + "csv-stringify": "^6.5.1", "emoji-regex": "^10.4.0", "emojis-list": "2.1.0", "fs-extra": "^11.2.0", diff --git a/packages/hydrooj/src/handler/contest.ts b/packages/hydrooj/src/handler/contest.ts index efd8a3954..c1fc95a48 100644 --- a/packages/hydrooj/src/handler/contest.ts +++ b/packages/hydrooj/src/handler/contest.ts @@ -1,4 +1,5 @@ import AdmZip from 'adm-zip'; +import { stringify as toCSV } from 'csv-stringify/sync'; import { statSync } from 'fs-extra'; import { pick } from 'lodash'; import moment from 'moment-timezone'; @@ -6,11 +7,11 @@ import { ObjectId } from 'mongodb'; import { Counter, sortFiles, streamToBuffer, Time, yaml, } from '@hydrooj/utils/lib/utils'; -import type { Context } from '../context'; +import { Context, Service } from '../context'; import { BadRequestError, ContestNotAttendedError, ContestNotEndedError, ContestNotFoundError, ContestNotLiveError, ContestScoreboardHiddenError, FileLimitExceededError, FileUploadError, - InvalidTokenError, NotAssignedError, PermissionError, ValidationError, + InvalidTokenError, NotAssignedError, NotFoundError, PermissionError, ValidationError, } from '../error'; import { ScoreboardConfig, Tdoc } from '../interface'; import { PERM, PRIV, STATUS } from '../model/builtin'; @@ -26,7 +27,7 @@ import storage from '../model/storage'; import * as system from '../model/system'; import user from '../model/user'; import { - Handler, param, post, Types, + Handler, param, post, Type, Types, } from '../service/server'; export class ContestListHandler extends Handler { @@ -238,115 +239,6 @@ export class ContestProblemListHandler extends ContestDetailBaseHandler { } } -export class ContestScoreboardHandler extends ContestDetailBaseHandler { - @param('tid', Types.ObjectId) - @param('ext', Types.Range(['csv', 'html', 'ghost']), true) - @param('realtime', Types.Boolean) - async get(domainId: string, tid: ObjectId, ext = '', realtime) { - if (!contest.canShowScoreboard.call(this, this.tdoc, true)) throw new ContestScoreboardHiddenError(tid); - if (contest.isNotStarted(this.tdoc)) throw new ContestNotLiveError(domainId, tid); - if (realtime && !this.user.own(this.tdoc)) { - this.checkPerm(PERM.PERM_VIEW_CONTEST_HIDDEN_SCOREBOARD); - } - if (ext) { - await this.exportScoreboard(domainId, tid, ext); - return; - } - const config: ScoreboardConfig = { isExport: false, showDisplayName: this.user.hasPerm(PERM.PERM_VIEW_DISPLAYNAME) }; - if (!realtime && this.tdoc.lockAt && !this.tdoc.unlocked) { - config.lockAt = this.tdoc.lockAt; - } - const allGroups = (this.user.hasPerm(PERM.PERM_EDIT_CONTEST_SELF) && this.user.own(this.tdoc)) || this.user.hasPerm(PERM.PERM_EDIT_CONTEST); - const [[, rows, udict, pdict], groups] = await Promise.all([ - contest.getScoreboard.call(this, domainId, tid, config), - user.listGroup(domainId, allGroups ? undefined : this.user._id), - ]); - // eslint-disable-next-line @typescript-eslint/naming-convention - const page_name = this.tdoc.rule === 'homework' - ? 'homework_scoreboard' - : 'contest_scoreboard'; - this.response.body = { - tdoc: this.tdoc, tsdoc: this.tsdocAsPublic(), rows, udict, pdict, page_name, groups, - }; - this.response.pjax = 'partials/scoreboard.html'; - this.response.template = 'contest_scoreboard.html'; - } - - async exportGhost(domainId: string, tid: ObjectId) { - const tdoc = this.tdoc; - const [pdict, teams] = await Promise.all([ - problem.getList(domainId, tdoc.pids, true, false, problem.PROJECTION_LIST, true), - contest.getMultiStatus(domainId, { docId: tid }).toArray(), - ]); - const udict = await user.getList(domainId, teams.map((i) => i.uid)); - const teamIds: Record = {}; - for (let i = 1; i <= teams.length; i++) teamIds[teams[i - 1].uid] = i; - const time = (t: ObjectId) => Math.floor((t.getTimestamp().getTime() - tdoc.beginAt.getTime()) / Time.second); - const pid = (i: number) => String.fromCharCode(65 + i); - const escape = (i: string) => i.replace(/[",]/g, ''); - const unknownSchool = this.translate('Unknown School'); - const statusMap = { - [STATUS.STATUS_ACCEPTED]: 'AC', - [STATUS.STATUS_WRONG_ANSWER]: 'WA', - [STATUS.STATUS_COMPILE_ERROR]: 'CE', - [STATUS.STATUS_TIME_LIMIT_EXCEEDED]: 'TL', - [STATUS.STATUS_RUNTIME_ERROR]: 'RT', - }; - const submissions = teams.flatMap((i, idx) => { - if (!i.journal) return []; - const journal = i.journal.filter((s) => tdoc.pids.includes(s.pid)); - const c = Counter(); - return journal.map((s) => { - const id = pid(tdoc.pids.indexOf(s.pid)); - c[id]++; - return `@s ${idx + 1},${id},${c[id]},${time(s.rid)},${statusMap[s.status] || 'RJ'}`; - }); - }); - const res = [ - `@contest "${escape(tdoc.title)}"`, - `@contlen ${Math.floor((tdoc.endAt.getTime() - tdoc.beginAt.getTime()) / Time.minute)}`, - `@problems ${tdoc.pids.length}`, - `@teams ${tdoc.attend}`, - `@submissions ${submissions.length}`, - ].concat( - tdoc.pids.map((i, idx) => `@p ${pid(idx)},${escape(pdict[i]?.title || 'Unknown Problem')},20,0`), - teams.map((i, idx) => { - const teamName = `${i.rank ? '*' : ''}${escape(udict[i.uid].school || unknownSchool)}-${escape(udict[i.uid].uname)}`; - return `@t ${idx + 1},0,1,${teamName}`; - }), - submissions, - ); - this.binary(res.join('\n'), `${this.tdoc.title}.ghost`); - } - - async exportScoreboard(domainId: string, tid: ObjectId, ext: string) { - await this.limitRate('scoreboard_download', 60, 3); - if (ext === 'ghost') { - if (contest.isLocked(this.tdoc) && !this.user.own(this.tdoc)) { - this.checkPerm(PERM.PERM_VIEW_CONTEST_HIDDEN_SCOREBOARD); - } - await this.exportGhost(domainId, tid); - return; - } - const getContent = { - csv: async (rows) => `\uFEFF${rows.map((c) => (c.map((i) => i.value?.toString().replace(/\n/g, ' ')).join(','))).join('\n')}`, - html: (rows, tdoc) => this.renderHTML('contest_scoreboard_download_html.html', { rows, tdoc }), - }; - const [, rows] = await contest.getScoreboard.call(this, domainId, tid, { - isExport: true, lockAt: this.tdoc.lockAt, showDisplayName: this.user.hasPerm(PERM.PERM_VIEW_DISPLAYNAME), - }); - this.binary(await getContent[ext](rows, this.tdoc), `${this.tdoc.title}.${ext}`); - } - - @param('tid', Types.ObjectId) - async postUnlock(domainId: string, tid: ObjectId) { - if (!this.user.own(this.tdoc)) this.checkPerm(PERM.PERM_EDIT_CONTEST); - if (!contest.isDone(this.tdoc)) throw new ContestNotEndedError(domainId, tid); - await contest.unlockScoreboard(domainId, tid); - this.back(); - } -} - export class ContestEditHandler extends Handler { tdoc: Tdoc; @@ -721,14 +613,115 @@ export class ContestBalloonHandler extends ContestManagementBaseHandler { } } +type BuiltinInput = { + tdoc: Tdoc; + groups: any[]; +}; +type AnyFunction = (...args: any) => any; +type ParseArgs }> = { + [key in keyof T]: T[key] extends keyof BuiltinInput ? BuiltinInput[T[key]] : T[key] extends AnyFunction ? ReturnType : any +}; +export interface ScoreboardView }> { + id: string; + name: string; + supportedRules: string[]; + cacheTime?: number; // in seconds + args: T; + display: (this: ContestScoreboardHandler, args: ParseArgs) => Promise; +} + +export class ContestScoreboardHandler extends ContestDetailBaseHandler { + @param('tid', Types.ObjectId) + @param('view', Types.String, true) + async get(domainId: string, tid: ObjectId, viewId = 'default') { + if (!contest.canShowScoreboard.call(this, this.tdoc, true)) throw new ContestScoreboardHiddenError(tid); + if (contest.isNotStarted(this.tdoc)) throw new ContestNotLiveError(domainId, tid); + const view = this.ctx.scoreboard.getView(viewId); + if (!view) throw new NotFoundError(`View ${viewId} not found`); + const args = {}; + const fetcher = { + tdoc: () => this.tdoc, + groups: async () => { + const allGroups = (this.user.hasPerm(PERM.PERM_EDIT_CONTEST_SELF) && this.user.own(this.tdoc)) + || this.user.hasPerm(PERM.PERM_EDIT_CONTEST); + return await user.listGroup(domainId, allGroups ? undefined : this.user._id); + }, + }; + for (const key in view.args) { + if (typeof view.args[key] === 'function') { + try { + args[key] = view.args[key](this.args[key]); + } catch (e) { + throw new ValidationError(key); + } + } else if (view.args[key] instanceof Array) { + if (this.args[key] === undefined && view.args[key].find((i) => i === true)) continue; + if (view.args[key][1] && !view.args[key][1](this.args[key])) throw new ValidationError(key); + args[key] = view.args[key][0](this.args[key]); + } else if (fetcher[view.args[key]]) { + args[key] = await fetcher[view.args[key]](); // eslint-disable-line no-await-in-loop + } + } + await view.display.call(this, args); + } + + @param('tid', Types.ObjectId) + async postUnlock(domainId: string, tid: ObjectId) { + if (!this.user.own(this.tdoc)) this.checkPerm(PERM.PERM_EDIT_CONTEST); + if (!contest.isDone(this.tdoc)) throw new ContestNotEndedError(domainId, tid); + await contest.unlockScoreboard(domainId, tid); + this.back(); + } +} + +class ScoreboardService extends Service { + views: Record> = {}; + constructor(ctx: Context) { + super(ctx, 'scoreboard', true); + ctx.set('scoreboard', this); + } + + addView }>( + id: string, name: string, args: T, + { display, supportedRules, cacheTime }: { + display: (this: ContestScoreboardHandler, args: ParseArgs) => Promise, + supportedRules: string[], + cacheTime?: number, + }, + ) { + if (this.views[id]) throw new Error(`View ${id} already exists`); + this.ctx.effect(() => { + this.views[id] = { + id, name, args, display, supportedRules, cacheTime, + }; + return () => { + delete this.views[id]; + }; + }); + } + + getAvailableViews(rule: string) { + return Object.fromEntries(Object.values(this.views).filter((i) => i.supportedRules.includes(rule) || i.supportedRules.includes('*')) + .map((i) => [i.id, i.name])); + } + + getView(id: string) { + return this.views[id]; + } +} + +declare module '../context' { + interface Context { + scoreboard: ScoreboardService; + } +} + export async function apply(ctx: Context) { ctx.Route('contest_create', '/contest/create', ContestEditHandler); ctx.Route('contest_main', '/contest', ContestListHandler, PERM.PERM_VIEW_CONTEST); ctx.Route('contest_detail', '/contest/:tid', ContestDetailHandler, PERM.PERM_VIEW_CONTEST); ctx.Route('contest_problemlist', '/contest/:tid/problems', ContestProblemListHandler, PERM.PERM_VIEW_CONTEST); ctx.Route('contest_edit', '/contest/:tid/edit', ContestEditHandler, PERM.PERM_VIEW_CONTEST); - ctx.Route('contest_scoreboard', '/contest/:tid/scoreboard', ContestScoreboardHandler, PERM.PERM_VIEW_CONTEST_SCOREBOARD); - ctx.Route('contest_scoreboard_download', '/contest/:tid/export/:ext', ContestScoreboardHandler, PERM.PERM_VIEW_CONTEST_SCOREBOARD); ctx.Route('contest_manage', '/contest/:tid/management', ContestManagementHandler); ctx.Route('contest_code', '/contest/:tid/code', ContestCodeHandler, PERM.PERM_VIEW_CONTEST); ctx.Route('contest_file_download', '/contest/:tid/file/:filename', ContestFileDownloadHandler, PERM.PERM_VIEW_CONTEST); @@ -774,4 +767,103 @@ export async function apply(ctx: Context) { 'Get a contest by ID', ); }); + ctx.plugin(ScoreboardService); + ctx.inject(['scoreboard'], ({ Route, scoreboard }) => { + Route('contest_scoreboard', '/contest/:tid/scoreboard', ContestScoreboardHandler, PERM.PERM_VIEW_CONTEST_SCOREBOARD); + Route('contest_scoreboard_view', '/contest/:tid/scoreboard/:view', ContestScoreboardHandler, PERM.PERM_VIEW_CONTEST_SCOREBOARD); + scoreboard.addView('default', 'Default', { tdoc: 'tdoc', groups: 'groups', realtime: Types.Boolean }, { + async display({ realtime, tdoc, groups }) { + if (realtime && !this.user.own(tdoc)) { + this.checkPerm(PERM.PERM_VIEW_CONTEST_HIDDEN_SCOREBOARD); + } + const config: ScoreboardConfig = { isExport: false, showDisplayName: this.user.hasPerm(PERM.PERM_VIEW_DISPLAYNAME) }; + if (!realtime && this.tdoc.lockAt && !this.tdoc.unlocked) { + config.lockAt = this.tdoc.lockAt; + } + const [, rows, udict, pdict] = await contest.getScoreboard.call(this, tdoc.domainId, tdoc._id, config); + // eslint-disable-next-line @typescript-eslint/naming-convention + const page_name = tdoc.rule === 'homework' + ? 'homework_scoreboard' + : 'contest_scoreboard'; + const availableViews = scoreboard.getAvailableViews(tdoc.rule); + this.response.body = { + tdoc: this.tdoc, tsdoc: this.tsdocAsPublic(), rows, udict, pdict, page_name, groups, availableViews, + }; + this.response.pjax = 'partials/scoreboard.html'; + this.response.template = 'contest_scoreboard.html'; + }, + supportedRules: ['*'], + }); + scoreboard.addView('ghost', 'Ghost', { tdoc: 'tdoc' }, { + async display({ tdoc }) { + if (contest.isLocked(tdoc) && !this.user.own(tdoc)) { + this.checkPerm(PERM.PERM_VIEW_CONTEST_HIDDEN_SCOREBOARD); + } + const [pdict, teams] = await Promise.all([ + problem.getList(tdoc.domainId, tdoc.pids, true, false, problem.PROJECTION_LIST, true), + contest.getMultiStatus(tdoc.domainId, { docId: tdoc._id }).toArray(), + ]); + const udict = await user.getList(tdoc.domainId, teams.map((i) => i.uid)); + const teamIds: Record = {}; + for (let i = 1; i <= teams.length; i++) teamIds[teams[i - 1].uid] = i; + const time = (t: ObjectId) => Math.floor((t.getTimestamp().getTime() - tdoc.beginAt.getTime()) / Time.second); + const pid = (i: number) => String.fromCharCode(65 + i); + const escape = (i: string) => i.replace(/[",]/g, ''); + const unknownSchool = this.translate('Unknown School'); + const statusMap = { + [STATUS.STATUS_ACCEPTED]: 'AC', + [STATUS.STATUS_WRONG_ANSWER]: 'WA', + [STATUS.STATUS_COMPILE_ERROR]: 'CE', + [STATUS.STATUS_TIME_LIMIT_EXCEEDED]: 'TL', + [STATUS.STATUS_RUNTIME_ERROR]: 'RT', + }; + const submissions = teams.flatMap((i, idx) => { + if (!i.journal) return []; + const journal = i.journal.filter((s) => tdoc.pids.includes(s.pid)); + const c = Counter(); + return journal.map((s) => { + const id = pid(tdoc.pids.indexOf(s.pid)); + c[id]++; + return `@s ${idx + 1},${id},${c[id]},${time(s.rid)},${statusMap[s.status] || 'RJ'}`; + }); + }); + const res = [ + `@contest "${escape(tdoc.title)}"`, + `@contlen ${Math.floor((tdoc.endAt.getTime() - tdoc.beginAt.getTime()) / Time.minute)}`, + `@problems ${tdoc.pids.length}`, + `@teams ${tdoc.attend}`, + `@submissions ${submissions.length}`, + ].concat( + tdoc.pids.map((i, idx) => `@p ${pid(idx)},${escape(pdict[i]?.title || 'Unknown Problem')},20,0`), + teams.map((i, idx) => { + const teamName = `${i.rank ? '*' : ''}${escape(udict[i.uid].school || unknownSchool)}-${escape(udict[i.uid].uname)}`; + return `@t ${idx + 1},0,1,${teamName}`; + }), + submissions, + ); + this.binary(res.join('\n'), `${this.tdoc.title}.ghost`); + }, + supportedRules: ['*'], + }); + scoreboard.addView('html', 'HTML', { tdoc: 'tdoc' }, { + async display({ tdoc }) { + await this.limitRate('scoreboard_download', 60, 3); + const [, rows] = await contest.getScoreboard.call(this, tdoc.domainId, tdoc._id, { + isExport: true, lockAt: this.tdoc.lockAt, showDisplayName: this.user.hasPerm(PERM.PERM_VIEW_DISPLAYNAME), + }); + this.binary(await this.renderHTML('contest_scoreboard_download_html.html', { rows, tdoc }), `${this.tdoc.title}.html`); + }, + supportedRules: ['*'], + }); + scoreboard.addView('csv', 'CSV', { tdoc: 'tdoc' }, { + async display({ tdoc }) { + await this.limitRate('scoreboard_download', 60, 3); + const [, rows] = await contest.getScoreboard.call(this, tdoc.domainId, tdoc._id, { + isExport: true, lockAt: this.tdoc.lockAt, showDisplayName: this.user.hasPerm(PERM.PERM_VIEW_DISPLAYNAME), + }); + this.binary(toCSV(rows.map((r) => r.map((c) => c.value.toString()))), `${this.tdoc.title}.csv`); + }, + supportedRules: ['*'], + }); + }); } diff --git a/packages/hydrooj/src/handler/judge.ts b/packages/hydrooj/src/handler/judge.ts index c7c32b7ae..c474f7427 100644 --- a/packages/hydrooj/src/handler/judge.ts +++ b/packages/hydrooj/src/handler/judge.ts @@ -86,12 +86,8 @@ export async function postJudge(rdoc: RecordDoc) { if (rdoc.contest?.toString().startsWith('0'.repeat(23))) return; const accept = rdoc.status === builtin.STATUS.STATUS_ACCEPTED; const updated = await problem.updateStatus(rdoc.domainId, rdoc.pid, rdoc.uid, rdoc._id, rdoc.status, rdoc.score); - if (rdoc.contest) { - await contest.updateStatus( - rdoc.domainId, rdoc.contest, rdoc.uid, rdoc._id, - rdoc.pid, rdoc.status, rdoc.score, rdoc.subtasks, - ); - } else if (accept && updated) await domain.incUserInDomain(rdoc.domainId, rdoc.uid, 'nAccept', 1); + if (rdoc.contest) await contest.updateStatus(rdoc.domainId, rdoc.contest, rdoc.uid, rdoc._id, rdoc.pid, rdoc); + else if (accept && updated) await domain.incUserInDomain(rdoc.domainId, rdoc.uid, 'nAccept', 1); const isNormalSubmission = ![ STATUS.STATUS_ETC, STATUS.STATUS_HACK_SUCCESSFUL, STATUS.STATUS_HACK_UNSUCCESSFUL, STATUS.STATUS_FORMAT_ERROR, STATUS.STATUS_SYSTEM_ERROR, STATUS.STATUS_CANCELED, diff --git a/packages/hydrooj/src/interface.ts b/packages/hydrooj/src/interface.ts index de0ecc5ce..9be64681e 100644 --- a/packages/hydrooj/src/interface.ts +++ b/packages/hydrooj/src/interface.ts @@ -390,7 +390,7 @@ export interface Tdoc extends Document { lockAt?: Date; unlocked?: boolean; autoHide?: boolean; - balloon?: Record; + balloon?: Record; score?: Record; /** diff --git a/packages/hydrooj/src/model/contest.ts b/packages/hydrooj/src/model/contest.ts index fa27815f2..4dd94dcf4 100644 --- a/packages/hydrooj/src/model/contest.ts +++ b/packages/hydrooj/src/model/contest.ts @@ -849,27 +849,25 @@ export async function getStatus(domainId: string, tid: ObjectId, uid: number) { return await document.getStatus(domainId, document.TYPE_CONTEST, tid, uid); } -async function _updateStatus( - tdoc: Tdoc, uid: number, rid: ObjectId, pid: number, status: STATUS, score: number, - subtasks: Record, +export async function updateStatus( + domainId: string, tid: ObjectId, uid: number, rid: ObjectId, pid: number, + { + status = STATUS.STATUS_WAITING, + score = 0, + subtasks, + lang, + }: { status?: STATUS, score?: number, subtasks?: Record, lang?: string } = {}, ) { + const tdoc = await get(domainId, tid); + if (tdoc.balloon && status === STATUS.STATUS_ACCEPTED) await addBalloon(domainId, tid, uid, rid, pid); const tsdoc = await document.revPushStatus(tdoc.domainId, document.TYPE_CONTEST, tdoc.docId, uid, 'journal', { - rid, pid, status, score, subtasks, + rid, pid, status, score, subtasks, lang, }, 'rid'); const journal = _getStatusJournal(tsdoc); const stats = RULES[tdoc.rule].stat(tdoc, journal); return await document.revSetStatus(tdoc.domainId, document.TYPE_CONTEST, tdoc.docId, uid, tsdoc.rev, { journal, ...stats }); } -export async function updateStatus( - domainId: string, tid: ObjectId, uid: number, rid: ObjectId, pid: number, - status = STATUS.STATUS_WRONG_ANSWER, score = 0, subtasks: Record = {}, -) { - const tdoc = await get(domainId, tid); - if (tdoc.balloon && status === STATUS.STATUS_ACCEPTED) await addBalloon(domainId, tid, uid, rid, pid); - return await _updateStatus(tdoc, uid, rid, pid, status, score, subtasks); -} - export async function getListStatus(domainId: string, uid: number, tids: ObjectId[]) { const r = {}; // eslint-disable-next-line no-await-in-loop diff --git a/packages/scoreboard-xcpcio/index.ts b/packages/scoreboard-xcpcio/index.ts new file mode 100644 index 000000000..5fe3d6974 --- /dev/null +++ b/packages/scoreboard-xcpcio/index.ts @@ -0,0 +1,138 @@ +import path from 'path'; +import { + ContestModel, Context, fs, Handler, ObjectId, Schema, STATUS, Types, UserModel, +} from 'hydrooj'; + +const file = fs.readFileSync(path.join(__dirname, 'public/assets/board.html'), 'utf8'); +const indexJs = file.match(/index-([A-Za-z0-9_-]+)\.js"/)?.[1]; +const indexCss = file.match(/index-([A-Za-z0-9_-]+)\.css"/)?.[1]; + +class XcpcioHandler extends Handler { + async get() { + this.response.body = { + js: indexJs, + css: indexCss, + }; + this.response.template = 'xcpcio_board.html'; + } +} + +const status = { + [STATUS.STATUS_WRONG_ANSWER]: 'WRONG_ANSWER', + [STATUS.STATUS_ACCEPTED]: 'CORRECT', + [STATUS.STATUS_COMPILING]: 'PENDING', + [STATUS.STATUS_WAITING]: 'PENDING', + [STATUS.STATUS_JUDGING]: 'PENDING', + [STATUS.STATUS_TIME_LIMIT_EXCEEDED]: 'TIME_LIMIT_EXCEEDED', + [STATUS.STATUS_MEMORY_LIMIT_EXCEEDED]: 'MEMORY_LIMIT_EXCEEDED', + [STATUS.STATUS_RUNTIME_ERROR]: 'RUNTIME_ERROR', + [STATUS.STATUS_SYSTEM_ERROR]: 'SYSTEM_ERROR', + [STATUS.STATUS_COMPILE_ERROR]: 'COMPILATION_ERROR', + [STATUS.STATUS_FETCHED]: 'PENDING', + [STATUS.STATUS_OUTPUT_LIMIT_EXCEEDED]: 'OUTPUT_LIMIT_EXCEEDED', + [STATUS.STATUS_ETC]: 'SYSTEM_ERROR', + [STATUS.STATUS_CANCELED]: 'CANCELED', +}; + +export async function apply(ctx: Context) { + ctx.Route('board_xcpcio', '/board', XcpcioHandler); + ctx.inject(['scoreboard'], ({ scoreboard }) => { + scoreboard.addView('xcpcio', 'XCPCIO', { + tdoc: 'tdoc', + groups: 'groups', + json: Types.Boolean, + gold: Schema.transform(Schema.union([Schema.string(), Schema.number().step(1).min(0)]), (v) => +v).default(0), + silver: Schema.transform(Schema.union([Schema.string(), Schema.number().step(1).min(0)]), (v) => +v).default(0), + bronze: Schema.transform(Schema.union([Schema.string(), Schema.number().step(1).min(0)]), (v) => +v).default(0), + }, { + async display({ + tdoc, groups, json, gold, silver, bronze, + }) { + if (json || this.request.json) { + const tsdocs = await ContestModel.getMultiStatus(tdoc.domainId, { docId: tdoc.docId }).toArray(); + const udict = await UserModel.getList(tdoc.domainId, tsdocs.map((i) => i.uid)); + const teams = tsdocs.map((i) => { + const udoc = udict[i.uid]; + return { + team_id: `${udoc._id}`, + name: udoc.uname, + organization: udoc.school, + members: [], + coach: '', + group: [ + ...(udoc.group || []), + i.unrank ? 'unofficial' : 'official', + ], + }; + }); + const relatedGroups = teams.flatMap((i) => i.group); + this.response.body = { + contest: { + contest_name: tdoc.title, + start_time: Math.floor(tdoc.beginAt.getTime() / 1000), + end_time: Math.floor(tdoc.endAt.getTime() / 1000), + frozen_time: tdoc.lockAt ? Math.floor((tdoc.endAt.getTime() - tdoc.lockAt.getTime()) / 1000) : 0, + penalty: 1200, + problem_quantity: tdoc.pids.length, + problem_id: tdoc.pids.map((i, idx) => String.fromCharCode(65 + idx)), + group: { + official: '正式队伍', + unofficial: '打星队伍', + ...Object.fromEntries(groups.filter((i) => relatedGroups.includes(i.name)).map((i) => [i.name, i.name])), + }, + organization: 'School', + status_time_display: { + 'correct': true, + 'incorrect': true, + 'pending': true, + }, + medal: { + official: { + gold, + silver, + bronze, + }, + }, + balloon_color: tdoc.balloon + ? tdoc.pids.map((i) => ({ + color: '#000', + background_color: typeof tdoc.balloon[i] === 'string' ? tdoc.balloon[i] : tdoc.balloon[i].color, + })) + : [], + logo: { + preset: 'ICPC', + }, + // banner: { + // url: 'assets/banner.png', + // }, + options: { + submission_timestamp_unit: 'millisecond', + }, + }, + submissions: tsdocs.flatMap((i) => (i.journal || []).map((j) => ({ + problem_id: tdoc.pids.indexOf(j.pid), + team_id: `${i.uid}`, + timestamp: Math.floor(new ObjectId(j.rid).getTimestamp().getTime() - tdoc.beginAt.getTime()), + status: status[j.status] || 'SYSTEM_ERROR', + language: j.lang || '', + submission_id: j.rid, + }))), + teams, + }; + } else { + this.response.template = 'xcpcio_board.html'; + let query = ''; + if (gold || silver || bronze) { + query = `&gold=${gold}&silver=${silver}&bronze=${bronze}`; + } + this.response.body = { + dataSource: `/d/${tdoc.domainId}/contest/${tdoc.docId}/scoreboard/xcpcio?json=true${query}#allInOne=true`, + js: indexJs, + css: indexCss, + }; + } + }, + supportedRules: ['acm'], + }); + }); +} diff --git a/packages/scoreboard-xcpcio/install.ts b/packages/scoreboard-xcpcio/install.ts new file mode 100644 index 000000000..a909d645a --- /dev/null +++ b/packages/scoreboard-xcpcio/install.ts @@ -0,0 +1,12 @@ +import fs from 'fs'; +import path from 'path'; + +const dist = path.join(path.dirname(require.resolve('@xcpcio/board-app/package.json')), 'dist'); +const target = path.join(__dirname, 'public'); +if (fs.existsSync(target)) { + fs.rmSync(target, { recursive: true }); +} +fs.mkdirSync(target, { recursive: true }); +fs.cpSync(path.join(dist, 'assets'), path.join(target, 'assets'), { recursive: true }); +fs.cpSync(path.join(dist, 'index.html'), path.join(target, 'assets/board.html')); +console.log('Copied board app to', target); diff --git a/packages/scoreboard-xcpcio/package.json b/packages/scoreboard-xcpcio/package.json new file mode 100644 index 000000000..8780b39dc --- /dev/null +++ b/packages/scoreboard-xcpcio/package.json @@ -0,0 +1,12 @@ +{ + "name": "@hydrooj/scoreboard-xcpcio", + "license": "AGPL-3.0-or-later", + "version": "0.0.1", + "main": "index.ts", + "scripts": { + "postinstall": "node -r @hydrooj/register install.ts" + }, + "dependencies": { + "@xcpcio/board-app": "^0.53.2" + } +} diff --git a/packages/scoreboard-xcpcio/templates/xcpcio_board.html b/packages/scoreboard-xcpcio/templates/xcpcio_board.html new file mode 100644 index 000000000..8a93649e8 --- /dev/null +++ b/packages/scoreboard-xcpcio/templates/xcpcio_board.html @@ -0,0 +1,35 @@ +{% set page_name = 'contest_scoreboard_xcpcio' %} +{% extends "layout/basic.html" %} +{% block content %} + + + + +
+ +{% endblock %} \ No newline at end of file diff --git a/packages/ui-default/templates/contest_scoreboard.html b/packages/ui-default/templates/contest_scoreboard.html index 0753b12a1..f75321be0 100644 --- a/packages/ui-default/templates/contest_scoreboard.html +++ b/packages/ui-default/templates/contest_scoreboard.html @@ -8,11 +8,16 @@
- {%- for ext in ['HTML','CSV','Ghost'] -%} - + {%- for ext in ['HTML', 'CSV', 'Ghost'] -%} + {{ _('Export as {0}').format(ext) }} {%- endfor -%} + {% for id, name in availableViews %}{% if not ['html', 'csv', 'default', 'ghost'].includes(id) %} + + {{ _(name) }} + + {% endif %}{% endfor %} {% if model.contest.isDone(tdoc) and tdoc.lockAt and handler.user.own(tdoc) and not tdoc.unlocked %}