diff --git a/packages/hydrooj/package.json b/packages/hydrooj/package.json index 6c1f555fc..2d83433fc 100644 --- a/packages/hydrooj/package.json +++ b/packages/hydrooj/package.json @@ -1,6 +1,6 @@ { "name": "hydrooj", - "version": "4.1.1", + "version": "4.2.0", "bin": "bin/hydrooj.js", "main": "src/plugin-api", "module": "src/plugin-api", diff --git a/packages/hydrooj/src/handler/contest.ts b/packages/hydrooj/src/handler/contest.ts index e94bac777..6229662c3 100644 --- a/packages/hydrooj/src/handler/contest.ts +++ b/packages/hydrooj/src/handler/contest.ts @@ -127,7 +127,12 @@ export class ContestDetailBaseHandler extends Handler { }, { name: 'record_main', - args: { tid, prefix: 'record_main', query: contest.canShowRecord.call(this, this.tdoc, true) ? {} : { uidOrName: this.user._id } }, + args: { + prefix: 'record', + query: contest.canShowRecord.call(this, this.tdoc, true) + ? { tid } + : { tid, uidOrName: this.user._id }, + }, checker: () => contest.canShowSelfRecord.call(this, this.tdoc, true), }, { @@ -163,14 +168,14 @@ export class ContestDetailHandler extends ContestDetailBaseHandler { && !this.user.hasPerm(PERM.PERM_VIEW_CONTEST_HIDDEN_SCOREBOARD) ) return; const pdict = await problem.getList(domainId, this.tdoc.pids, true, undefined, undefined, problem.PROJECTION_CONTEST_LIST); - const psdict = {}; + let psdict = {}; let rdict = {}; if (this.tsdoc) { if (this.tsdoc.attend && !this.tsdoc.startAt && contest.isOngoing(this.tdoc)) { await contest.setStatus(domainId, tid, this.user._id, { startAt: new Date() }); this.tsdoc.startAt = new Date(); } - for (const pdetail of this.tsdoc.journal || []) psdict[pdetail.pid] = pdetail; + psdict = this.tsdoc.detail || {}; if (contest.canShowSelfRecord.call(this, this.tdoc)) { rdict = await record.getList(domainId, Object.values(psdict).map((i: any) => i.rid)); } else { diff --git a/packages/hydrooj/src/handler/problem.ts b/packages/hydrooj/src/handler/problem.ts index 457887b07..b303d4ece 100644 --- a/packages/hydrooj/src/handler/problem.ts +++ b/packages/hydrooj/src/handler/problem.ts @@ -377,8 +377,12 @@ export class ProblemDetailHandler extends ContestDetailBaseHandler { solutionCount: scnt, discussionCount: dcnt, tdoc: this.tdoc, - tsdoc: pick(this.tsdoc, ['attend', 'startAt']), }; + if (this.tdoc && this.tsdoc) { + const fields = ['attend', 'startAt']; + if (contest.canShowSelfRecord.call(this, this.tdoc, true)) fields.push('detail'); + this.response.body.tsdoc = pick(this.tsdoc, fields); + } this.response.template = 'problem_detail.html'; this.UiContext.extraTitleContent = this.pdoc.title; } diff --git a/packages/hydrooj/src/handler/record.ts b/packages/hydrooj/src/handler/record.ts index e53d3fcac..0adb85cfb 100644 --- a/packages/hydrooj/src/handler/record.ts +++ b/packages/hydrooj/src/handler/record.ts @@ -15,13 +15,14 @@ import * as system from '../model/system'; import TaskModel from '../model/task'; import user from '../model/user'; import * as bus from '../service/bus'; -import { - ConnectionHandler, Handler, param, Types, -} from '../service/server'; +import { ConnectionHandler, param, Types } from '../service/server'; import { buildProjection } from '../utils'; +import { ContestDetailBaseHandler } from './contest'; import { postJudge } from './judge'; -class RecordListHandler extends Handler { +class RecordListHandler extends ContestDetailBaseHandler { + tdoc?: Tdoc<30>; + @param('page', Types.PositiveInt, true) @param('pid', Types.Name, true) @param('tid', Types.ObjectID, true) @@ -42,6 +43,7 @@ class RecordListHandler extends Handler { if (full) uidOrName = this.user._id.toString(); if (tid) { tdoc = await contest.get(domainId, tid); + this.tdoc = tdoc; if (!tdoc) throw new ContestNotFoundError(domainId, pid); if (!contest.canShowScoreboard.call(this, tdoc, true)) throw new PermissionError(PERM.PERM_VIEW_CONTEST_HIDDEN_SCOREBOARD); if (!this.user.own(tdoc) && !(await contest.getStatus(domainId, tid, this.user._id))?.attend) { @@ -103,8 +105,10 @@ class RecordListHandler extends Handler { } } -class RecordDetailHandler extends Handler { +class RecordDetailHandler extends ContestDetailBaseHandler { rdoc: RecordDoc; + tdoc?: Tdoc<30>; + @param('rid', Types.ObjectID) async prepare(domainId: string, rid: ObjectID) { this.rdoc = await record.get(domainId, rid); @@ -130,15 +134,15 @@ class RecordDetailHandler extends Handler { // eslint-disable-next-line consistent-return async get(domainId: string, rid: ObjectID, download = false) { const rdoc = this.rdoc; - let tdoc: Tdoc<30>; if (rdoc.contest?.toString() === '000000000000000000000000') { if (rdoc.uid !== this.user._id) throw new PermissionError(PERM.PERM_READ_RECORD_CODE); } else if (rdoc.contest) { - tdoc = await contest.get(domainId, rdoc.contest); - let canView = this.user.own(tdoc); - canView ||= contest.canShowRecord.call(this, tdoc); - canView ||= contest.canShowSelfRecord.call(this, tdoc, true) && rdoc.uid === this.user._id; + this.tdoc = await contest.get(domainId, rdoc.contest); + let canView = this.user.own(this.tdoc); + canView ||= contest.canShowRecord.call(this, this.tdoc); + canView ||= contest.canShowSelfRecord.call(this, this.tdoc, true) && rdoc.uid === this.user._id; if (!canView) throw new PermissionError(rid); + this.args.tid = this.tdoc.docId; } // eslint-disable-next-line prefer-const @@ -152,8 +156,8 @@ class RecordDetailHandler extends Handler { canViewCode ||= this.user.hasPriv(PRIV.PRIV_READ_RECORD_CODE); canViewCode ||= this.user.hasPerm(PERM.PERM_READ_RECORD_CODE); canViewCode ||= this.user.hasPerm(PERM.PERM_READ_RECORD_CODE_ACCEPT) && self?.status === STATUS.STATUS_ACCEPTED; - if (tdoc && tdoc.allowViewCode && contest.isDone(tdoc)) { - const tsdoc = await contest.getStatus(domainId, tdoc.docId, this.user._id); + if (this.tdoc && this.tdoc.allowViewCode && contest.isDone(this.tdoc)) { + const tsdoc = await contest.getStatus(domainId, this.tdoc.docId, this.user._id); canViewCode ||= tsdoc?.attend; } if (!canViewCode) { @@ -167,7 +171,7 @@ class RecordDetailHandler extends Handler { this.response.template = 'record_detail.html'; this.response.body = { - udoc, rdoc, pdoc, tdoc, + udoc, rdoc, pdoc, tdoc: this.tdoc, }; } diff --git a/packages/hydrooj/src/interface.ts b/packages/hydrooj/src/interface.ts index a574289fb..376b3fc05 100644 --- a/packages/hydrooj/src/interface.ts +++ b/packages/hydrooj/src/interface.ts @@ -483,7 +483,7 @@ export interface OplogDoc extends Record { } export interface ContestStat extends Record { - detail: any, + detail: Record>, } export interface ContestRule { diff --git a/packages/hydrooj/src/model/contest.ts b/packages/hydrooj/src/model/contest.ts index 31885eba1..ed2b3196b 100644 --- a/packages/hydrooj/src/model/contest.ts +++ b/packages/hydrooj/src/model/contest.ts @@ -63,7 +63,7 @@ const acm = buildContestRule({ stat(tdoc, journal: AcmJournal[], ignoreLock = false) { const naccept = Counter(); const effective: Record = {}; - const detail: AcmDetail[] = []; + const detail: Record = {}; let accept = 0; let time = 0; for (const j of filterEffective(tdoc, journal, ignoreLock)) { @@ -77,11 +77,11 @@ const acm = buildContestRule({ const j = effective[pid]; const real = j.rid.generationTime - Math.floor(tdoc.beginAt.getTime() / 1000); const penalty = 20 * 60 * naccept[j.pid]; - detail.push({ + detail[pid] = { ...j, naccept: naccept[j.pid], time: real + penalty, real, penalty, - }); + }; } - for (const d of detail.filter((i) => i.status === STATUS.STATUS_ACCEPTED)) { + for (const d of Object.values(detail).filter((i) => i.status === STATUS.STATUS_ACCEPTED)) { accept++; time += d.time; } @@ -92,7 +92,12 @@ const acm = buildContestRule({ { type: 'rank', value: '#' }, { type: 'user', value: _('User') }, ]; - if (isExport) columns.push({ type: 'email', value: _('Email') }); + if (isExport) { + columns.push({ type: 'email', value: _('Email') }); + columns.push({ type: 'string', value: _('School') }); + columns.push({ type: 'string', value: _('Name') }); + columns.push({ type: 'string', value: _('Student ID') }); + } columns.push({ type: 'solved', value: `${_('Solved')}\n${_('Total Time')}` }); for (let i = 1; i <= tdoc.pids.length; i++) { const pid = tdoc.pids[i - 1]; @@ -118,13 +123,17 @@ const acm = buildContestRule({ return columns; }, async scoreboardRow(isExport, _, tdoc, pdict, udoc, rank, tsdoc, meta) { - const tsddict: Record = {}; - for (const item of tsdoc.detail || []) tsddict[item.pid] = item; + const tsddict = tsdoc.detail || {}; const row: ScoreboardRow = [ { type: 'rank', value: rank.toString() }, { type: 'user', value: udoc.uname, raw: tsdoc.uid }, ]; - if (isExport) row.push({ type: 'email', value: udoc.mail }); + if (isExport) { + row.push({ type: 'email', value: udoc.mail }); + row.push({ type: 'string', value: udoc.school }); + row.push({ type: 'string', value: udoc.displayName }); + row.push({ type: 'string', value: udoc.studentId }); + } row.push({ type: 'time', value: `${tsdoc.accept || 0}\n${formatSeconds(tsdoc.time || 0.0, false)}`, @@ -220,7 +229,12 @@ const oi = buildContestRule({ { type: 'rank', value: '#' }, { type: 'user', value: _('User') }, ]; - if (isExport) columns.push({ type: 'email', value: _('Email') }); + if (isExport) { + columns.push({ type: 'email', value: _('Email') }); + columns.push({ type: 'string', value: _('School') }); + columns.push({ type: 'string', value: _('Name') }); + columns.push({ type: 'string', value: _('Student ID') }); + } columns.push({ type: 'total_score', value: _('Total Score') }); for (let i = 1; i <= tdoc.pids.length; i++) { if (isExport) { @@ -244,7 +258,12 @@ const oi = buildContestRule({ { type: 'rank', value: rank.toString() }, { type: 'user', value: udoc.uname, raw: tsdoc.uid }, ]; - if (isExport) row.push({ type: 'email', value: udoc.mail }); + if (isExport) { + row.push({ type: 'email', value: udoc.mail }); + row.push({ type: 'string', value: udoc.school }); + row.push({ type: 'string', value: udoc.displayName }); + row.push({ type: 'string', value: udoc.studentId }); + } row.push({ type: 'total_score', value: tsdoc.score || 0 }); for (const pid of tdoc.pids) { const index = `${tsdoc.uid}/${tdoc.domainId}/${pid}`; @@ -366,7 +385,7 @@ const homework = buildContestRule({ score: sumBy(detail, 'score'), penaltyScore: sumBy(detail, 'penaltyScore'), time: Math.sum(detail.map((d) => d.time)), - detail, + detail: effective, }; }, showScoreboard: () => true, @@ -410,10 +429,7 @@ const homework = buildContestRule({ return columns; }, async scoreboardRow(isExport, _, tdoc, pdict, udoc, rank, tsdoc) { - const tsddict = {}; - for (const item of tsdoc.detail || []) { - if (!tsddict[item.pid] || tsddict[item.pid].score <= item.score) tsddict[item.pid] = item; - } + const tsddict = tsdoc.detail; const row: ScoreboardRow = [ { type: 'rank', value: rank.toString() }, { @@ -484,7 +500,7 @@ function _getStatusJournal(tsdoc) { export async function add( domainId: string, title: string, content: string, owner: number, rule: string, beginAt = new Date(), endAt = new Date(), pids: number[] = [], - rated = false, data: Partial = {}, + rated = false, data: Partial> = {}, ) { if (!RULES[rule]) throw new ValidationError('rule'); if (beginAt >= endAt) throw new ValidationError('beginAt', 'endAt'); @@ -682,7 +698,7 @@ export function canShowScoreboard(tdoc: Tdoc<30>, allowPermOverride = true) { export async function getScoreboard( this: Handler, domainId: string, tid: ObjectID, isExport = false, ignoreLock = false, -): Promise<[Tdoc<30 | 60>, ScoreboardRow[], Udict, ProblemDict]> { +): Promise<[Tdoc<30>, ScoreboardRow[], Udict, ProblemDict]> { const tdoc = await get(domainId, tid); if (!canShowScoreboard.call(this, tdoc)) throw new ContestScoreboardHiddenError(tid); if (ignoreLock) delete tdoc.lockAt; @@ -692,6 +708,7 @@ export async function getScoreboard( isExport, this.translate.bind(this), tdoc, pdict, tsdocsCursor, ); + await bus.parallel('contest/scoreboard', tdoc, rows, udict, pdict); return [tdoc, rows, udict, pdict]; } diff --git a/packages/hydrooj/src/service/bus.ts b/packages/hydrooj/src/service/bus.ts index 9194c7be4..d4e1224c3 100644 --- a/packages/hydrooj/src/service/bus.ts +++ b/packages/hydrooj/src/service/bus.ts @@ -7,8 +7,8 @@ import type { ProblemSolutionHandler } from '../handler/problem'; import type { UserRegisterHandler } from '../handler/user'; import type { DiscussionDoc, DomainDoc, FileInfo, - MessageDoc, ProblemDoc, RecordDoc, - Tdoc, TrainingDoc, User, + MessageDoc, ProblemDict, ProblemDoc, RecordDoc, + ScoreboardRow, Tdoc, TrainingDoc, Udict, User, } from '../interface'; import type { DocType } from '../model/document'; import type { ConnectionHandler, Handler } from './server'; @@ -86,8 +86,9 @@ export interface EventMap extends LifecycleEvents, HandlerEvents { 'problem/addAdditionalFile': (domainId: string, docId: number, name: string, payload: Omit) => VoidReturn 'problem/delAdditionalFile': (domainId: string, docId: number, name: string[]) => VoidReturn - 'contest/before-add': (payload: Partial) => VoidReturn - 'contest/add': (payload: Partial, id: ObjectID) => VoidReturn + 'contest/before-add': (payload: Partial>) => VoidReturn + 'contest/add': (payload: Partial>, id: ObjectID) => VoidReturn + 'contest/scoreboard': (tdoc: Tdoc<30>, rows: ScoreboardRow[], udict: Udict, pdict: ProblemDict) => VoidReturn 'oplog/log': (type: string, handler: Handler, args: any, data: any) => VoidReturn; diff --git a/packages/hydrooj/src/upgrade.ts b/packages/hydrooj/src/upgrade.ts index 63fcbacc4..9553c7fb4 100644 --- a/packages/hydrooj/src/upgrade.ts +++ b/packages/hydrooj/src/upgrade.ts @@ -20,6 +20,7 @@ import * as system from './model/system'; import TaskModel from './model/task'; import user from './model/user'; import { + iterateAllContest, iterateAllDomain, iterateAllProblem, iterateAllUser, } from './pipelineUtils'; import db from './service/db'; @@ -570,6 +571,14 @@ const scripts: UpgradeScript[] = [ await TaskModel.coll.deleteMany({}); return true; }, + async function _70_71() { + await iterateAllContest(async (tdoc) => { + if (['acm', 'homework'].includes(tdoc.rule)) { + await contest.recalcStatus(tdoc.domainId, tdoc.docId); + } + }); + return true; + }, ]; export default scripts; diff --git a/packages/ui-default/backendlib/template.js b/packages/ui-default/backendlib/template.js index 98183eff1..fed28e88d 100644 --- a/packages/ui-default/backendlib/template.js +++ b/packages/ui-default/backendlib/template.js @@ -142,6 +142,7 @@ env.addGlobal('static_url', (assetName) => { env.addGlobal('eval', eval); env.addGlobal('Date', Date); env.addGlobal('Object', Object); +env.addGlobal('String', String); env.addGlobal('Math', Math); env.addGlobal('process', process); env.addGlobal('global', global); diff --git a/packages/ui-default/components/contest/contest.page.styl b/packages/ui-default/components/contest/contest.page.styl index edea712a5..209109d00 100644 --- a/packages/ui-default/components/contest/contest.page.styl +++ b/packages/ui-default/components/contest/contest.page.styl @@ -40,3 +40,73 @@ for key, value in $contest-color &:hover background: $primary-color border-color: $primary-color + +.contest-timer + position: fixed; + left: 0; + top: 48px; + z-index: 10000; + +mobile() + visibility: hidden !important; + +.contest-problems + font-size: 0.9375rem; + font-weight: 400; + line-height: 1.375rem; + text-align: left; + box-sizing: border-box; + font-variant-ligatures: none!important; + display: grid; + grid-template-columns: repeat(auto-fit,minmax(1.5rem,1fr)); + justify-content: center; + row-gap: 0.75rem; + column-gap: 0.5rem; + padding-bottom: 0.5rem; + width: rem(180px); + + > a + text-decoration: none; + background-color: transparent; + cursor: pointer; + display: flex; + height: 1.5rem; + width: 1.5rem; + align-items: center; + justify-content: space-around; + border-radius: 0.375rem; + position: relative; + transition: opacity 0.2s; + color: grey; + &.pass + color: green; + &:before + background: green; + &.fail + color: red; + &:before + background: red; + + &:after + content: ''; + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + box-sizing: border-box; + border: 2px solid currentColor; + border-radius: 6px; + opacity: 0.3; + + &.active:after + border: 3px solid cyan; + + &:before + opacity: 0.5; + + &.pass, &.fail + &:not(:hover) .id + display: none; + + &:hover .icon + display: none; diff --git a/packages/ui-default/package.json b/packages/ui-default/package.json index 3221ca503..4394d997b 100644 --- a/packages/ui-default/package.json +++ b/packages/ui-default/package.json @@ -1,6 +1,6 @@ { "name": "@hydrooj/ui-default", - "version": "4.41.17", + "version": "4.41.18", "author": "undefined ", "license": "AGPL-3.0", "main": "index.ts", diff --git a/packages/ui-default/pages/contest.page.ts b/packages/ui-default/pages/contest.page.ts index d7eb47d75..954705d93 100644 --- a/packages/ui-default/pages/contest.page.ts +++ b/packages/ui-default/pages/contest.page.ts @@ -1,17 +1,28 @@ +import { formatSeconds } from '@hydrooj/utils/lib/common'; import NProgress from 'nprogress'; import { NamedPage } from 'vj/misc/Page'; +import tpl from 'vj/utils/tpl'; -export default new NamedPage(['contest_detail', 'contest_detail_problem'], () => { +const contestTimer = $(tpl``); +contestTimer.appendTo(document.body); + +export default new NamedPage(['contest_detail', 'contest_detail_problem', 'contest_scoreboard'], () => { const beginAt = new Date(UiContext.tdoc.beginAt).getTime(); const endAt = new Date(UiContext.tdoc.endAt).getTime(); NProgress.configure({ trickle: false, showSpinner: false, minimum: 0 }); function updateProgress() { const now = Date.now(); - if (beginAt <= now && now <= endAt) NProgress.set((now - beginAt) / (endAt - beginAt)); - else if (now > endAt) NProgress.set(1); - else NProgress.set(0); + if (beginAt <= now && now <= endAt) { + NProgress.set((now - beginAt) / (endAt - beginAt)); + contestTimer.show(); + contestTimer.text(formatSeconds(Math.floor((endAt - now) / 1000))); + } else { + if (now > endAt) NProgress.set(1); + else NProgress.set(0); + contestTimer.hide(); + } } NProgress.start(); updateProgress(); - setInterval(updateProgress, 5000); + setInterval(updateProgress, 1000); }); diff --git a/packages/ui-default/templates/contest_scoreboard.html b/packages/ui-default/templates/contest_scoreboard.html index c24ba3416..ea77baf1b 100644 --- a/packages/ui-default/templates/contest_scoreboard.html +++ b/packages/ui-default/templates/contest_scoreboard.html @@ -1,5 +1,6 @@ {% import "components/paginator.html" as paginator with context %} {% extends "layout/basic.html" %} +{{ set(UiContext, 'tdoc', tdoc) }} {% set type = 'homework' if tdoc.rule == 'homework' else 'contest' %} {% block content %}
diff --git a/packages/ui-default/templates/problem_detail.html b/packages/ui-default/templates/problem_detail.html index b4ba39bf8..1fac3df29 100644 --- a/packages/ui-default/templates/problem_detail.html +++ b/packages/ui-default/templates/problem_detail.html @@ -26,7 +26,7 @@
-
+
    - {{ _('ID') }}: {{ pdoc.docId }} + {% if not tdoc %} + {{ _('ID') }}: {{ pdoc.docId }} + {% endif %} {{ _('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 @@ -59,8 +76,8 @@ {{ _('Tried') }}: {{ pdoc.nSubmit }} {{ _('Accepted') }}: {{ pdoc.nAccept if ((not tdoc) or showAccept) else '?' }} {{ _('Difficulty') }}: {{ pdoc['difficulty'] or lib.difficulty(pdoc.nSubmit, pdoc.nAccept) or _('(None)') }} + {{ _('Uploaded By') }}: {{ user.render_inline(udoc, badge=false) }} {% endif %} - {{ _('Uploaded By') }}: {{ user.render_inline(udoc, badge=false) }} {% if pdoc.tag.length %} {{ _('Tags') + '>' }} {%- for tag in pdoc.tag -%} diff --git a/packages/utils/lib/common.ts b/packages/utils/lib/common.ts index 0e6c9dc04..eaa9ef4b7 100644 --- a/packages/utils/lib/common.ts +++ b/packages/utils/lib/common.ts @@ -149,6 +149,20 @@ export function parseMemoryMB(str: string | number, throwOnError = true) { return Math.ceil(parseFloat(match[1]) * MEMORY_UNITS[match[2].toLowerCase()]); } +function _digit2(number: number) { + return number < 10 ? `0${number}` : number.toString(); +} + +export function formatSeconds(_seconds: string | number = '0', showSeconds = true) { + const seconds = +_seconds; + let res = '{0}:{1}'.format( + showSeconds ? _digit2(Math.floor(seconds / 3600)) : Math.floor(seconds / 3600), + _digit2(Math.floor((seconds % 3600) / 60)), + ); + if (showSeconds) res += `:${_digit2(seconds % 60)}`; + return res; +} + export function size(s: number, base = 1) { s *= base; const unit = 1024; diff --git a/packages/utils/lib/utils.ts b/packages/utils/lib/utils.ts index 6f4c1cedd..83cd47d43 100644 --- a/packages/utils/lib/utils.ts +++ b/packages/utils/lib/utils.ts @@ -190,20 +190,6 @@ export function errorMessage(err: Error | string) { return err; } -function _digit2(number: number) { - return number < 10 ? `0${number}` : number.toString(); -} - -export function formatSeconds(_seconds: string | number = '0', showSeconds = true) { - const seconds = +_seconds; - let res = '{0}:{1}'.format( - showSeconds ? _digit2(Math.floor(seconds / 3600)) : Math.floor(seconds / 3600), - _digit2(Math.floor((seconds % 3600) / 60)), - ); - if (showSeconds) res += `:${_digit2(seconds % 60)}`; - return res; -} - export function changeErrorType(err: any, Err: any) { const e = new Err(err.message); e.stack = err.stack; diff --git a/packages/utils/package.json b/packages/utils/package.json index d19c59eb0..625bef026 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -1,6 +1,6 @@ { "name": "@hydrooj/utils", - "version": "1.4.1", + "version": "1.4.2", "description": "hydrooj utils", "main": "lib/utils.ts", "repository": "https://github.com/hydro-dev/Hydro.git",