core&ui: contest: acm: lock scoreboard

This commit is contained in:
undefined committed 2022-02-02 00:53:06 +08:00
1 parent cb02b8dafe
commit e67ff511bb
17 files changed
+224 -196

No files matched your search

+6 -6
View File
@@ -28,27 +28,27 @@
"license": "AGPL-3.0-only",
"devDependencies": {
"@types/cross-spawn": "^6.0.2",
"@types/node": "^16.11.19",
"@types/node": "^16.11.21",
"@types/semver": "^7.3.9",
"@typescript-eslint/eslint-plugin": "5.9.0",
"@typescript-eslint/parser": "5.9.0",
"@typescript-eslint/eslint-plugin": "5.10.0",
"@typescript-eslint/parser": "5.10.0",
"cac": "^6.7.12",
"cross-env": "^7.0.3",
"cross-spawn": "^7.0.3",
"esbuild": "0.14.3",
"eslint": "^8.6.0",
"eslint": "^8.7.0",
"eslint-config-airbnb-typescript": "12.3.1",
"eslint-import-resolver-typescript": "2.5.0",
"eslint-plugin-import": "2.25.4",
"eslint-plugin-simple-import-sort": "7.0.0",
"fs-extra": "^10.0.0",
"globby": "11.0.4",
"globby": "11.1.0",
"latest-version": "^6.0.0",
"mongodb": "^3.7.3",
"nmls": "^3.0.1",
"ora": "5.4.1",
"semver": "^7.3.5",
"typedoc": "^0.22.10",
"typedoc": "^0.22.11",
"typescript": "4.4.3"
}
}
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "hydrooj",
"version": "3.3.20",
"version": "3.4.0",
"bin": "bin/hydrooj.js",
"main": "src/loader",
"module": "src/loader",
@@ -22,7 +22,7 @@
"detect-browser": "^5.3.0",
"esbuild": "0.14.3",
"fs-extra": "^10.0.0",
"graphql": "^16.2.0",
"graphql": "^16.3.0",
"graphql-scalars": "1.14.1",
"js-yaml": "^4.1.0",
"koa": "^2.13.4",
@@ -40,7 +40,7 @@
"nanoid": "^3.2.0",
"nodemailer": "^6.7.2",
"notp": "^2.0.3",
"p-queue": "^7.1.0",
"p-queue": "^7.2.0",
"reflect-metadata": "^0.1.13",
"require-resolve-hook": "^1.1.0",
"semver": "^7.3.5",
@@ -62,7 +62,7 @@
"@types/lodash": "^4.14.178",
"@types/lru-cache": "^5.1.1",
"@types/mime-types": "^2.1.1",
"@types/minio": "^7.0.11",
"@types/minio": "^7.0.12",
"@types/mongodb": "^3.6.20",
"@types/nodemailer": "^6.4.4",
"@types/notp": "^2.0.1",
@@ -70,7 +70,7 @@
"@types/serialize-javascript": "^5.0.2",
"@types/sockjs": "^0.3.33",
"@types/source-map-support": "^0.5.4",
"@types/superagent": "^4.1.14",
"@types/superagent": "^4.1.15",
"moment": "^2.29.1",
"pm2": "^5.1.2"
},
+41 -13
View File
@@ -47,13 +47,18 @@ registerResolver(
'Get a contest by ID',
);
TaskModel.Worker.addHandler('contest.problemHide', async (doc) => {
TaskModel.Worker.addHandler('contest', async (doc) => {
const tdoc = await contest.get(doc.domainId, doc.tid);
if (!tdoc) return;
for (const pid of tdoc.pids) {
// eslint-disable-next-line no-await-in-loop
await problem.edit(doc.domainId, pid, { hidden: false });
const tasks = [];
for (const op of doc.operation) {
if (op === 'unhide') {
for (const pid of tdoc.pids) {
tasks.push(problem.edit(doc.domainId, pid, { hidden: false }));
}
} else if (op === 'unlock') tasks.push(contest.recalcStatus(doc.domainId, doc.tid));
}
await Promise.all(tasks);
});
export class ContestListHandler extends Handler {
@@ -129,7 +134,7 @@ export class ContestDetailHandler extends Handler {
if (!this.user.own(tdoc)) this.checkPerm(PERM.PERM_EDIT_CONTEST);
await contest.del(domainId, tid);
await TaskModel.deleteMany({
type: 'schedule', subType: 'contest.problemHide', domainId, tid,
type: 'schedule', subType: 'contest', domainId, tid,
});
this.response.redirect = this.url('contest_main');
}
@@ -167,8 +172,13 @@ export class ContestBroadcastHandler extends Handler {
export class ContestScoreboardHandler extends Handler {
@param('tid', Types.ObjectID)
@param('page', Types.PositiveInt, true)
async get(domainId: string, tid: ObjectID, page = 1) {
const [tdoc, rows, udict, , nPages] = await contest.getScoreboard.call(this, domainId, tid, false, page);
@param('ignoreLock', Types.Boolean, true)
async get(domainId: string, tid: ObjectID, page = 1, ignoreLock = false) {
const tdoc = await contest.get(domainId, tid);
if (ignoreLock && !this.user.own(tdoc)) {
this.checkPerm(PERM.PERM_VIEW_CONTEST_HIDDEN_SCOREBOARD);
}
const [, rows, udict, , nPages] = await contest.getScoreboard.call(this, domainId, tid, false, page, ignoreLock);
const path = [
['Hydro', 'homepage'],
['contest_main', 'contest_main'],
@@ -185,13 +195,18 @@ export class ContestScoreboardHandler extends Handler {
export class ContestScoreboardDownloadHandler extends Handler {
@param('tid', Types.ObjectID)
@param('ext', Types.Range(['csv', 'html']))
async get(domainId: string, tid: ObjectID, ext: string) {
@param('ignoreLock', Types.Boolean, true)
async get(domainId: string, tid: ObjectID, ext: string, ignoreLock = false) {
await this.limitRate('scoreboard_download', 120, 3);
const getContent = {
csv: async (rows) => `\uFEFF${rows.map((c) => (c.map((i) => i.value).join(','))).join('\n')}`,
html: (rows, tdoc) => this.renderHTML('contest_scoreboard_download_html.html', { rows, tdoc }),
};
const [tdoc, rows] = await contest.getScoreboard.call(this, domainId, tid, true, 0);
const tdoc = await contest.get(domainId, tid);
if (ignoreLock && !this.user.own(tdoc)) {
this.checkPerm(PERM.PERM_VIEW_CONTEST_HIDDEN_SCOREBOARD);
}
const [, rows] = await contest.getScoreboard.call(this, domainId, tid, true, 0, ignoreLock);
this.binary(await getContent[ext](rows, tdoc), `${tdoc.title}.${ext}`);
}
}
@@ -240,10 +255,11 @@ export class ContestEditHandler extends Handler {
@param('code', Types.String, true)
@param('autoHide', Types.String, true)
@param('assign', Types.CommaSeperatedArray, true)
@param('lock', Types.UnsignedInt, true)
async post(
domainId: string, tid: ObjectID, beginAtDate: string, beginAtTime: string, duration: number,
title: string, content: string, rule: string, _pids: string, rated = false,
_code = '', autoHide = false, assign: string[] = null,
_code = '', autoHide = false, assign: string[] = null, lock: number = null,
) {
if (autoHide) this.checkPerm(PERM.PERM_EDIT_PROBLEM);
const pids = _pids.replace(/,/g, ',').split(',').map((i) => +i).filter((i) => i);
@@ -252,20 +268,22 @@ export class ContestEditHandler extends Handler {
const endAt = beginAtMoment.clone().add(duration, 'hours').toDate();
if (beginAtMoment.isSameOrAfter(endAt)) throw new ValidationError('duration');
const beginAt = beginAtMoment.toDate();
const lockAt = lock ? moment(endAt).add(-lock, 'minutes').toDate() : null;
await problem.getList(domainId, pids, this.user.hasPerm(PERM.PERM_VIEW_PROBLEM_HIDDEN) || this.user._id, this.user.group, true);
if (tid) {
await contest.edit(domainId, tid, {
title, content, rule, beginAt, endAt, pids, rated,
});
if (this.tdoc.beginAt !== beginAt || this.tdoc.endAt !== endAt
|| Array.isDiff(this.tdoc.pids, pids) || this.tdoc.rule !== rule) {
|| Array.isDiff(this.tdoc.pids, pids) || this.tdoc.rule !== rule
|| lockAt !== this.tdoc.lockAt) {
await contest.recalcStatus(domainId, this.tdoc.docId);
}
} else {
tid = await contest.add(domainId, title, content, this.user._id, rule, beginAt, endAt, pids, rated);
}
const task = {
type: 'schedule', subType: 'contest.problemHide', domainId, tid,
type: 'schedule', subType: 'contest', domainId, tid,
};
await TaskModel.deleteMany(task);
if (Date.now() <= endAt.getTime() && autoHide) {
@@ -273,10 +291,20 @@ export class ContestEditHandler extends Handler {
await Promise.all(pids.map((pid) => problem.edit(domainId, pid, { hidden: true })));
await TaskModel.add({
...task,
operation: ['unhide'],
executeAfter: endAt,
});
}
await contest.edit(domainId, tid, { assign, _code, autoHide });
if (lock && lockAt <= endAt) {
await TaskModel.add({
...task,
operation: ['unlock'],
executeAfter: endAt,
});
}
await contest.edit(domainId, tid, {
assign, _code, autoHide, lockAt,
});
this.response.body = { tid };
this.response.redirect = this.url('contest_detail', { tid });
}
+10 -5
View File
@@ -348,6 +348,9 @@ export interface Tdoc<docType = document['TYPE_CONTEST'] | document['TYPE_TRAINI
_code?: string;
assign?: string[];
// For contest
lockAt?: Date;
// For homework
penaltySince?: Date;
penaltyRules?: PenaltyRules;
@@ -478,19 +481,21 @@ export interface ContestStat extends Record<string, any> {
detail: any,
}
export interface ContestRule {
export interface ContestRule<T = any> {
_originalRule?: Partial<ContestRule<T>>;
TEXT: string;
check: (args: any) => any;
statusSort: any;
submitAfterAccept: boolean;
showScoreboard: (tdoc: Tdoc<30>, now: Date) => boolean;
showSelfRecord: (tdoc: Tdoc<30>, now: Date) => boolean;
showRecord: (tdoc: Tdoc<30>, now: Date) => boolean;
stat: (tdoc: Tdoc<30>, journal: any[]) => ContestStat;
stat: (this: ContestRule<T>, tdoc: Tdoc<30>, journal: any[], ignoreLock?: boolean) => ContestStat & T;
scoreboard: (
isExport: boolean, _: (s: string) => string,
tdoc: Tdoc<30>, pdict: ProblemDict, cursor: Cursor<any>, page: number,
this: ContestRule<T>, isExport: boolean, _: (s: string) => string,
tdoc: Tdoc<30>, pdict: ProblemDict, cursor: Cursor<ContestStat & T>, page: number,
) => Promise<[board: ScoreboardRow[], udict: Udict, nPages: number]>;
ranked: (tdoc: Tdoc<30>, cursor: Cursor<any>) => Promise<any[]>;
ranked: (tdoc: Tdoc<30>, cursor: Cursor<ContestStat & T>) => Promise<[Array<[number, ContestStat & T]>, number]>;
}
export type ContestRules = Dictionary<ContestRule>;
+76 -47
View File
@@ -1,4 +1,5 @@
import { FilterQuery, ObjectID } from 'mongodb';
import { Counter } from '@hydrooj/utils/lib/utils';
import {
ContestAlreadyAttendedError, ContestNotAttendedError, ContestNotFoundError,
ContestScoreboardHiddenError, ValidationError,
@@ -11,6 +12,7 @@ import {
import * as misc from '../lib/misc';
import ranked from '../lib/rank';
import * as bus from '../service/bus';
import type { Handler } from '../service/server';
import { PERM, STATUS } from './builtin';
import * as document from './document';
import problem from './problem';
@@ -29,41 +31,51 @@ interface AcmDetail extends AcmJournal {
real: number;
}
const acm: ContestRule = {
function buildContestRule<T>(def: ContestRule<T>): ContestRule<T> {
def._originalRule = { scoreboard: def.scoreboard, stat: def.stat };
def.scoreboard = (def._originalRule?.scoreboard || def.scoreboard).bind(def);
def.stat = (def._originalRule?.stat || def.stat).bind(def);
return def;
}
function filterEffective<T extends AcmJournal>(tdoc: Tdoc, journal: T[], ignoreLock = false): T[] {
// eslint-disable-next-line @typescript-eslint/no-use-before-define
if (isLocked(tdoc) && !ignoreLock) journal = journal.filter((i) => i.rid.generationTime * 1000 < tdoc.lockAt.getTime());
return journal.filter((i) => tdoc.pids.includes(i.pid));
}
const acm = buildContestRule({
TEXT: 'ACM/ICPC',
check: () => { },
statusSort: { accept: -1, time: 1 },
submitAfterAccept: false,
showScoreboard: () => true,
showSelfRecord: () => true,
showRecord: (tdoc, now) => now > tdoc.endAt,
stat: (tdoc, journal: AcmJournal[]) => {
const naccept: Record<number, number> = {};
stat(tdoc, journal: AcmJournal[], ignoreLock = false) {
const naccept = Counter<number>();
const effective: Record<number, AcmJournal> = {};
const detail: AcmDetail[] = [];
let accept = 0;
let time = 0;
for (const j of journal) {
if (tdoc.pids.includes(j.pid)
&& effective[j.pid]?.status !== STATUS.STATUS_ACCEPTED) {
effective[j.pid] = j;
if (![STATUS.STATUS_ACCEPTED, STATUS.STATUS_COMPILE_ERROR].includes(j.status)) {
naccept[j.pid] = (naccept[j.pid] || 0) + 1;
}
for (const j of filterEffective(tdoc, journal, ignoreLock)) {
if (!this.submitAfterAccept && effective[j.pid]?.status === STATUS.STATUS_ACCEPTED) continue;
effective[j.pid] = j;
if (![STATUS.STATUS_ACCEPTED, STATUS.STATUS_COMPILE_ERROR].includes(j.status)) {
naccept[j.pid]++;
}
}
for (const key in effective) {
const j = effective[key];
for (const pid in effective) {
const j = effective[pid];
const real = j.rid.generationTime - Math.floor(tdoc.beginAt.getTime() / 1000);
const penalty = 20 * 60 * (naccept[j.pid] || 0);
const penalty = 20 * 60 * naccept[j.pid];
detail.push({
...j, naccept: naccept[j.pid] || 0, time: real + penalty, real, penalty,
...j, naccept: naccept[j.pid], time: real + penalty, real, penalty,
});
}
for (const d of detail) {
if (d.status === STATUS.STATUS_ACCEPTED) {
accept += 1;
time += d.time;
}
for (const d of detail.filter((i) => i.status === STATUS.STATUS_ACCEPTED)) {
accept++;
time += d.time;
}
return { accept, time, detail };
},
@@ -80,7 +92,6 @@ const acm: ContestRule = {
columns.push(
{ type: 'total_time', value: _('Penalty') },
{ type: 'total_time', value: _('Total Time (Seconds)') },
{ type: 'total_time_str', value: _('Total Time') },
);
}
for (let i = 1; i <= tdoc.pids.length; i++) {
@@ -108,41 +119,43 @@ const acm: ContestRule = {
});
}
}
// Find first accept
const first = {};
for (const pid of tdoc.pids) first[pid] = new ObjectID().generationTime;
for (const [, tsdoc] of rankedTsdocs) {
const tsddict = {};
for (const item of tsdoc.journal || []) tsddict[item.pid] = item;
for (const item of tsdoc.detail || []) tsddict[item.pid] = item;
for (const pid of tdoc.pids) {
if (tsddict[pid]?.status === STATUS.STATUS_ACCEPTED && tsddict[pid].rid.generationTime < first[pid]) {
first[pid] = tsddict[pid].rid.generationTime;
}
}
}
const rows: ScoreboardRow[] = [columns];
for (const [rank, tsdoc] of rankedTsdocs) {
const tsddict = {};
for (const item of tsdoc.detail || []) tsddict[item.pid] = item;
const tsddict: Record<number, AcmDetail> = {};
for (const item of tsdoc.detail) tsddict[item.pid] = item;
const row: ScoreboardRow = [
{ type: 'string', value: rank.toString() },
{ type: 'user', value: udict[tsdoc.uid].uname, raw: tsdoc.uid },
{ type: 'string', value: tsdoc.accept || 0 },
];
if (isExport) {
const penalty = Math.sum(tdoc.pids.map((i) => (tsddict[i]?.naccept || 0))) * 20 * 60;
const penalty = Math.sum(tdoc.pids.map((i) => tsddict[i]?.naccept || 0)) * 20 * 60;
row.push(
{ type: 'string', value: penalty.toString() },
{ type: 'string', value: tsdoc.time || 0.0 },
{ type: 'string', value: tsdoc.time || 0.0 },
);
}
for (const pid of tdoc.pids) {
const doc = tsddict[pid] || {};
const doc = tsddict[pid] || {} as Partial<AcmDetail>;
const accept = doc.status === STATUS.STATUS_ACCEPTED;
const rid = accept ? doc.rid : null;
const colAccepted = `${(accept && isExport) ? `${_('Accepted')} ` : ''}${doc.naccept ? ` (-${doc.naccept})` : ''}`;
const colTime = accept ? doc.time : '-';
const colTimeStr = accept ? misc.formatSeconds(colTime) : '-';
const colTime = accept ? doc.time.toString() : '-';
const colTimeStr = accept ? misc.formatSeconds(doc.time) : '-';
if (isExport) {
row.push(
{ type: 'string', value: colAccepted },
@@ -161,7 +174,6 @@ const acm: ContestRule = {
});
}
}
row.raw = tsdoc;
rows.push(row);
}
return [rows, udict, nPages];
@@ -169,17 +181,18 @@ const acm: ContestRule = {
async ranked(tdoc, cursor) {
return await ranked.all(cursor, (a, b) => a.score === b.score);
},
};
});
const oi: ContestRule = {
const oi = buildContestRule({
TEXT: 'OI',
check: () => { },
submitAfterAccept: true,
statusSort: { score: -1 },
stat: (tdoc, journal) => {
stat(tdoc, journal) {
const detail = {};
let score = 0;
for (const j of journal) {
if (tdoc.pids.includes(j.pid)) detail[j.pid] = j;
for (const j of journal.filter((i) => tdoc.pids.includes(i.pid))) {
if (detail[j.pid]?.status === STATUS.STATUS_ACCEPTED && !this.submitAfterAccept) detail[j.pid] = j;
}
for (const i in detail) score += detail[i].score;
return { score, detail };
@@ -278,19 +291,21 @@ const oi: ContestRule = {
async ranked(tdoc, cursor) {
return await ranked.all(cursor, (a, b) => a.score === b.score);
},
};
});
const ioi: ContestRule = {
const ioi = buildContestRule({
...oi,
TEXT: 'IOI',
submitAfterAccept: false,
showRecord: (tdoc, now) => now > tdoc.endAt,
showSelfRecord: () => true,
showScoreboard: () => true,
};
});
const homework: ContestRule = {
const homework = buildContestRule({
TEXT: 'Assignment',
check: () => { },
submitAfterAccept: false,
statusSort: { penaltyScore: -1, time: 1 },
stat: (tdoc, journal) => {
const effective = {};
@@ -434,7 +449,7 @@ const homework: ContestRule = {
async ranked(tdoc, cursor) {
return await ranked.all(cursor, (a, b) => a.score === b.score);
},
};
});
export const RULES: ContestRules = {
acm, oi, homework, ioi,
@@ -449,12 +464,12 @@ export async function add(
rule: string, beginAt = new Date(), endAt = new Date(), pids: number[] = [],
rated = false, data: Partial<Tdoc> = {},
) {
if (!this.RULES[rule]) throw new ValidationError('rule');
if (!RULES[rule]) throw new ValidationError('rule');
if (beginAt >= endAt) throw new ValidationError('beginAt', 'endAt');
Object.assign(data, {
content, owner, title, rule, beginAt, endAt, pids, attend: 0,
});
this.RULES[rule].check(data);
RULES[rule].check(data);
await bus.serial('contest/before-add', data);
const res = await document.add(domainId, content, owner, document.TYPE_CONTEST, null, null, null, {
...data, title, rule, beginAt, endAt, pids, attend: 0, rated,
@@ -464,10 +479,10 @@ export async function add(
}
export async function edit(domainId: string, tid: ObjectID, $set: any) {
if ($set.rule && !this.RULES[$set.rule]) throw new ValidationError('rule');
if ($set.rule && !RULES[$set.rule]) throw new ValidationError('rule');
const tdoc = await document.get(domainId, document.TYPE_CONTEST, tid);
if (!tdoc) throw new ContestNotFoundError(domainId, tid);
this.RULES[$set.rule || tdoc.rule].check(Object.assign(tdoc, $set));
RULES[$set.rule || tdoc.rule].check(Object.assign(tdoc, $set));
return await document.set(domainId, document.TYPE_CONTEST, tid, $set);
}
@@ -489,8 +504,14 @@ export async function getRelated(domainId: string, pid: number) {
return await document.getMulti(domainId, document.TYPE_CONTEST, { pids: pid }).toArray();
}
export function getStatus(domainId: string, tid: ObjectID, uid: number) {
return document.getStatus(domainId, document.TYPE_CONTEST, tid, uid);
export async function getStatus(domainId: string, tid: ObjectID, uid: number) {
const [tdoc, status] = await Promise.all([
get(domainId, tid),
document.getStatus(domainId, document.TYPE_CONTEST, tid, uid),
]);
// eslint-disable-next-line @typescript-eslint/no-use-before-define
if (isLocked(tdoc)) Object.assign(status, RULES[tdoc.rule].stat(tdoc, status.journal || [], true));
return status;
}
export async function updateStatus(
@@ -556,6 +577,12 @@ export function isDone(tdoc: Tdoc) {
return tdoc.endAt <= new Date();
}
export function isLocked(tdoc: Tdoc) {
if (!tdoc.lockAt) return false;
const now = new Date();
return (tdoc.lockAt < now && now < tdoc.endAt);
}
export function isExtended(tdoc: Tdoc) {
const now = new Date().getTime();
return tdoc.penaltySince.getTime() <= now && now < tdoc.endAt.getTime();
@@ -627,11 +654,12 @@ export function canShowScoreboard(tdoc: Tdoc<30>, allowPermOverride = true) {
}
export async function getScoreboard(
domainId: string, tid: ObjectID,
isExport = false, page: number,
this: Handler, domainId: string, tid: ObjectID,
isExport = false, page: number, ignoreLock = false,
): Promise<[Tdoc<30 | 60>, ScoreboardRow[], Udict, ProblemDict, number]> {
const tdoc = await get(domainId, tid);
if (!canShowScoreboard.call(this, tdoc)) throw new ContestScoreboardHiddenError(tid);
if (ignoreLock) delete tdoc.lockAt;
const tsdocsCursor = getMultiStatus(domainId, { docId: tid }).sort(RULES[tdoc.rule].statusSort);
const pdict = await problem.getList(domainId, tdoc.pids, true);
const [rows, udict, nPages] = await RULES[tdoc.rule].scoreboard(
@@ -684,6 +712,7 @@ global.Hydro.model.contest = {
isNotStarted,
isOngoing,
isDone,
isLocked,
isExtended,
statusText,
getStatusText,
+1
View File
@@ -286,6 +286,7 @@ export class ProblemModel {
group: string[] = [], doThrow = true, projection = ProblemModel.PROJECTION_PUBLIC,
indexByDocIdOnly = false,
): Promise<ProblemDict> {
if (!pids?.length) return [];
const r: Record<number, ProblemDoc> = {};
const l: Record<string, ProblemDoc> = {};
const q: any = { docId: { $in: pids } };
+4 -1
View File
@@ -70,7 +70,10 @@ class WorkerService implements BaseService {
try {
logger.debug('Worker task: %o', doc);
const start = Date.now();
await this.handlers[doc.subType](doc);
await Promise.race([
this.handlers[doc.subType](doc),
new Promise((resolve) => setTimeout(resolve, 300000)),
]);
const spent = Date.now() - start;
if (spent > 500) logger.warn('Slow worker task (%d ms): %s', spent, doc);
} catch (e) {
+9
View File
@@ -21,6 +21,7 @@ import problem from './model/problem';
import RecordModel from './model/record';
import StorageModel from './model/storage';
import * as system from './model/system';
import TaskModel from './model/task';
import user from './model/user';
import {
iterateAllDomain, iterateAllProblem, iterateAllPsdoc, iterateAllUser,
@@ -673,6 +674,14 @@ const scripts: UpgradeScript[] = [
);
return true;
},
async function _58_59() {
const _FRESH_INSTALL_IGNORE = 1;
const tasks = await db.collection('task').find({ type: 'schedule', subType: 'contest.problemHide' }).toArray();
for (const task of tasks) {
await TaskModel.add({ ...task, subType: 'contest', operation: ['unhide'] });
}
return true;
},
];
export default scripts;
@@ -27,7 +27,7 @@ class ChangeThemeAction extends EditorAction {
super({
id: 'hydro.changeEditorTheme',
label: i18n('Change Theme'),
alias: i18n('Change Theme'),
alias: [i18n('Change Theme'), 'Change Theme'],
});
}
+1
View File
@@ -663,6 +663,7 @@ Saving file...: 正在保存文件...
score canceled: 成绩取消
Score: 分数
Scoreboard (Hidden): 成绩表 (隐藏)
Scoreboard locked at {0}: 比赛已于 {0} 封榜。
Scoreboard: 成绩表
Scripts: 脚本
Search: 搜索
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@hydrooj/ui-default",
"version": "4.30.9",
"version": "4.30.10",
"author": "undefined <i@undefined.moe>",
"license": "AGPL-3.0",
"main": "hydro.js",
@@ -25,15 +25,15 @@
"chalk": "^4.1.2",
"classnames": "^2.3.1",
"clean-webpack-plugin": "^4.0.0",
"clipboard": "^2.0.8",
"clipboard": "^2.0.9",
"copy-webpack-plugin": "^6.4.1",
"css-loader": "^4.3.0",
"diff-dom": "^4.2.3",
"echarts": "^5.2.2",
"echarts": "^5.3.0",
"emojis-keywords": "2.0.0",
"emojis-list": "2.1.0",
"esbuild-loader": "^2.18.0",
"eslint": "^8.7.0",
"eslint": "^8.8.0",
"eslint-config-airbnb": "^18.2.1",
"eslint-import-resolver-webpack": "^0.13.2",
"eslint-plugin-import": "^2.25.4",
@@ -92,7 +92,7 @@
"through2": "^4.0.2",
"timeago-react": "^3.0.4",
"timeago.js": "^4.0.2",
"vditor": "^3.8.10",
"vditor": "^3.8.11",
"vinyl-buffer": "^1.0.1",
"web-streams-polyfill": "^3.2.0",
"webpack": "^4.46.0",
@@ -116,7 +116,7 @@
"markdown-it-table-of-contents": "^0.6.0",
"mongodb": "^3.7.3",
"nunjucks": "^3.2.3",
"react-query": "^3.34.8",
"react-query": "^3.34.12",
"streamsaver": "^2.0.5",
"xss": "^1.0.10"
}
@@ -1,65 +1,16 @@
import { NamedPage } from 'vj/misc/Page';
import code from 'vj/components/highlighter/code-example';
import i18n from 'vj/utils/i18n';
import tpl from 'vj/utils/tpl';
const page = new NamedPage('home_preference', async () => {
async function mountComponent() {
const [{ default: prismjs }, React, { render }] = await Promise.all([
import('vj/components/highlighter/prismjs'),
import('react'),
import('react-dom'),
]);
const page = new NamedPage('home_preference', () => {
function setOptions($el, options) {
$el.empty();
$.each(options, (key, value) => {
$el.append($('<option></option>').attr('value', key).text(value));
});
}
class AstylePreview extends React.PureComponent {
constructor(props) {
super(props);
this.state = { highlight: prismjs.highlight(this.preview, prismjs.Prism.languages.cpp, 'C++') };
}
render() {
return (
<>
<div className="medium-4 columns form__item end">
<label htmlFor="astyleOptions_input">
{i18n('astyleOptions')}
<div id="astyleOptions_input" name="form_item_astyleOptions" className="textbox-container">
<textarea
type="textarea"
spellCheck={false}
name="astyleOptions"
value={this.state.value}
style={{ height: '400px' }}
onChange={(ev) => {
ev.stopPropagation();
const highlight = prismjs.highlight(code, prismjs.Prism.languages.cpp, 'C++');
this.setState({ value: ev.target.value, highlight });
}}
className="textbox"
/>
</div>
</label>
<p className="help-text">{i18n('astyle formatting options')}</p>
</div>
<div className="medium-8 columns form__item end">
<label htmlFor="astylePreview">
{i18n('Preview') /* eslint-disable-next-line */}
<pre id="astylePreview" style={{ height: '400px' }}><code dangerouslySetInnerHTML={{ __html: this.state.highlight }}></code></pre>
</label>
</div>
</>
);
}
}
function setOptions($el, options) {
$el.empty();
$.each(options, (key, value) => {
$el.append($('<option></option>').attr('value', key).text(value));
});
}
const $el = $(tpl`\
const $el = $(tpl`\
<div class="row">
<div class="medium-5 columns form__item end">
<label>
@@ -79,50 +30,42 @@ const page = new NamedPage('home_preference', async () => {
</div>
</div>
`);
$('[name="codeLang"]')
.parent().parent().parent()
.parent()
.hide()
.after($el);
$('[name="codeLang"]')
.parent().parent().parent()
.parent()
.hide()
.after($el);
function onChangeMain(update = true) {
const options = {};
for (const key in window.LANGS) {
if (key.startsWith(`${this.value}.`) && key !== this.value) options[key] = window.LANGS[key].display;
}
if (Object.keys(options).length > 1) {
setOptions($('#codelang-sub-select'), options);
$('#codelang-sub-container').show();
if (update) $('[name="codeLang"]').val($('#codelang-sub-select').val());
} else {
$('#codelang-sub-container').hide();
if (update) $('[name="codeLang"]').val(this.value);
}
}
const main = {};
function onChangeMain(update = true) {
const options = {};
for (const key in window.LANGS) {
if (!key.includes('.')) main[key] = window.LANGS[key].display;
if (key.startsWith(`${this.value}.`) && key !== this.value) options[key] = window.LANGS[key].display;
}
if (Object.keys(options).length > 1) {
setOptions($('#codelang-sub-select'), options);
$('#codelang-sub-container').show();
if (update) $('[name="codeLang"]').val($('#codelang-sub-select').val());
} else {
$('#codelang-sub-container').hide();
if (update) $('[name="codeLang"]').val(this.value);
}
setOptions($('#codelang-main-select'), main);
const current = $('[name="codeLang"]').val();
if (current.includes('.')) {
const [m] = current.split('.');
$('#codelang-main-select').val(m);
onChangeMain.call({ value: m }, false);
$('#codelang-sub-select').val(current);
} else $('#codelang-main-select').val(current);
$('#codelang-main-select').on('change', onChangeMain);
$('#codelang-sub-select').on('change', function () {
$('[name="codeLang"]').val(this.value);
});
render(
<AstylePreview></AstylePreview>,
$('[name="form_item_astyleOptions"]').get(0).parentNode.parentNode.parentNode,
);
}
mountComponent();
const main = {};
for (const key in window.LANGS) {
if (!key.includes('.')) main[key] = window.LANGS[key].display;
}
setOptions($('#codelang-main-select'), main);
const current = $('[name="codeLang"]').val();
if (current.includes('.')) {
const [m] = current.split('.');
$('#codelang-main-select').val(m);
onChangeMain.call({ value: m }, false);
$('#codelang-sub-select').val(current);
} else $('#codelang-main-select').val(current);
$('#codelang-main-select').on('change', onChangeMain);
$('#codelang-sub-select').on('change', function () {
$('[name="codeLang"]').val(this.value);
});
});
export default page;
-10
View File
@@ -40,16 +40,6 @@ formatCode:
desc: Auto format code
category: preference
value: true
astyleOptions:
type: text
family: highlight
desc: astyle formatting options
category: preference
value: >
style=java attach-namespaces attach-classes attach-inlines attach-extern-c attach-closing-while
indent-col1-comments break-blocks pad-oper pad-comma pad-header unpad-paren align-pointer=name
break-one-line-headers attach-return-type attach-return-type-decl convert-tabs close-templates
max-code-length=110 break-after-logical
about:
type: markdown
name: about
@@ -92,6 +92,9 @@
disabled:not handler.user.hasPerm(perm.PERM_EDIT_PROBLEM),
row:false
}) }}
{% if tdoc.lockAt %}
<input type="hidden" name="lock" value="{{ ((tdoc.endAt - tdoc.lockAt)/60000)|int }}">
{% endif %}
</div>
{{ form.form_textarea({
columns:null,
@@ -12,6 +12,13 @@
<span class="icon icon-download"></span> {{ _('Export as CSV') }}
</a>
</div>
{% if model.contest.isLocked(tdoc) %}
<div class="section__body no-padding">
<blockquote class="note">
{{ _('Scoreboard locked at {0}').format(datetimeSpan(tdoc.lockAt))|safe }}
</blockquote>
</div>
{% endif %}
<div class="section__body no-padding overflow-hidden-horizontal">
<table class="data-table">
<colgroup>
+12 -3
View File
@@ -305,7 +305,7 @@ function _digit2(number: number) {
return number < 10 ? `0${number}` : number.toString();
}
export function formatSeconds(_seconds = '0') {
export function formatSeconds(_seconds: string | number = '0') {
const seconds = +_seconds;
return '{0}:{1}:{2}'.format(
_digit2(Math.floor(seconds / 3600)),
@@ -431,11 +431,20 @@ export function sortFiles(files: { _id: string }[] | string[]) {
return isString ? result.map((x) => x.name) : result;
}
export const htmlEncode = (str) => str.replace(/[&<>'"]/g,
(tag) => ({
export const htmlEncode = (str: string) => str.replace(/[&<>'"]/g,
(tag: string) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
"'": '&#39;',
'"': '&quot;',
}[tag]));
export function Counter<T extends (string | number) = string>() {
return new Proxy({}, {
get: (target, prop) => {
if (target[prop] === undefined) return 0;
return target[prop];
},
}) as Record<T, number>;
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@hydrooj/utils",
"version": "1.1.11",
"version": "1.1.13",
"description": "hydrooj utils",
"main": "package.json",
"repository": "https://github.com/hydro-dev/Hydro.git",
@@ -12,7 +12,7 @@
"js-yaml": "^4.1.0",
"moment": "^2.29.1",
"mongodb": "^3.7.3",
"systeminformation": "^5.9.17"
"systeminformation": "^5.11.0"
},
"devDependencies": {
"@types/fs-extra": "^9.0.13",