diff --git a/packages/hydrooj/package.json b/packages/hydrooj/package.json index acfacec05..c61594be2 100644 --- a/packages/hydrooj/package.json +++ b/packages/hydrooj/package.json @@ -1,6 +1,6 @@ { "name": "hydrooj", - "version": "3.5.5", + "version": "3.5.6", "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 025609c7f..9fa4eed50 100644 --- a/packages/hydrooj/src/handler/problem.ts +++ b/packages/hydrooj/src/handler/problem.ts @@ -790,8 +790,20 @@ export class ProblemCreateHandler extends Handler { domainId: string, title: string, content: string, pid: string, hidden = false, difficulty = 0, tag: string[] = [], assign: string[] = [], ) { - if (pid && await problem.get(domainId, pid)) throw new BadRequestError('invalid pid'); + if (pid && await problem.get(domainId, pid)) throw new BadRequestError('ProblemID already exists'); const docId = await problem.add(domainId, pid, title, content, this.user._id, tag ?? [], hidden, assign); + const files = new Set(Array.from(content.matchAll(/file:\/\/([a-zA-Z0-9_-]+\.[a-zA-Z0-9]+)/g)).map((i) => i[1])); + const tasks = []; + for (const file of files) { + if (this.user._files.find((i) => i.name === file)) { + tasks.push( + storage.rename(`user/${this.user._id}/${file}`, `problem/${domainId}/${docId}/additional_file/${file}`) + .then(() => problem.addAdditionalFile(domainId, docId, file, '', true)), + user.setById(this.user._id, { _files: this.user._files.filter((i) => i.name !== file) }), + ); + } + } + await Promise.all(tasks); if (difficulty) await problem.edit(domainId, docId, { difficulty }); this.response.body = { pid: pid || docId }; this.response.redirect = this.url('problem_files', { pid: pid || docId }); diff --git a/packages/hydrooj/src/model/problem.ts b/packages/hydrooj/src/model/problem.ts index aed0b64a5..0568ab6ba 100644 --- a/packages/hydrooj/src/model/problem.ts +++ b/packages/hydrooj/src/model/problem.ts @@ -264,10 +264,10 @@ export class ProblemModel { await bus.emit('problem/delTestdata', domainId, pid, names); } - static async addAdditionalFile(domainId: string, pid: number, name: string, f: Readable | Buffer | string) { + static async addAdditionalFile(domainId: string, pid: number, name: string, f: Readable | Buffer | string, skipUpload = false) { const [[, fileinfo]] = await Promise.all([ document.getSub(domainId, document.TYPE_PROBLEM, pid, 'additional_file', name), - storage.put(`problem/${domainId}/${pid}/additional_file/${name}`, f), + skipUpload || storage.put(`problem/${domainId}/${pid}/additional_file/${name}`, f), ]); const meta = await storage.getMeta(`problem/${domainId}/${pid}/additional_file/${name}`); const payload = { name, ...pick(meta, ['size', 'lastModified', 'etag']) }; diff --git a/packages/hydrooj/src/service/storage.ts b/packages/hydrooj/src/service/storage.ts index 45065684a..787b385cb 100644 --- a/packages/hydrooj/src/service/storage.ts +++ b/packages/hydrooj/src/service/storage.ts @@ -108,8 +108,13 @@ class StorageService { accessKey: this.opts.accessKey, secretKey: this.opts.secretKey, }); - const exists = await this.client.bucketExists(this.opts.bucket); - if (!exists) await this.client.makeBucket(this.opts.bucket, this.opts.region); + try { + const exists = await this.client.bucketExists(this.opts.bucket); + if (!exists) await this.client.makeBucket(this.opts.bucket, this.opts.region); + } catch (e) { + // Some platform doesn't support bucketExists & makeBucket API. + // Ignore this error. + } this.replaceWithAlternativeUrlFor = { user: parseAlternativeEndpointUrl(this.opts.endPointForUser), judge: parseAlternativeEndpointUrl(this.opts.endPointForJudge), diff --git a/packages/hydrooj/src/upgrade.ts b/packages/hydrooj/src/upgrade.ts index d8f6cc006..fd8aff4ae 100644 --- a/packages/hydrooj/src/upgrade.ts +++ b/packages/hydrooj/src/upgrade.ts @@ -686,6 +686,7 @@ const scripts: UpgradeScript[] = [ async function _59_60() { const langs = await system.get('hydrooj.langs'); await system.set('hydrooj.langs', langs.replace(/\$\{dir\}/g, '/w').replace(/\$\{name\}/g, 'foo')); + return true; }, ]; diff --git a/packages/ui-default/backendlib/template.js b/packages/ui-default/backendlib/template.js index 120c57dec..3b93861f8 100644 --- a/packages/ui-default/backendlib/template.js +++ b/packages/ui-default/backendlib/template.js @@ -140,9 +140,7 @@ env.addGlobal('set', (obj, key, val) => { else Object.assign(obj, key); return ''; }); -env.addGlobal('findSubModule', (prefix) => { - filter(Object.keys(global.Hydro.ui.template), (n) => n.startsWith(prefix)); -}); +env.addGlobal('findSubModule', (prefix) => Object.keys(global.Hydro.ui.template).filter((n) => n.startsWith(prefix))); async function render(name, state) { // eslint-disable-next-line no-return-await diff --git a/packages/ui-default/components/monaco/index.ts b/packages/ui-default/components/monaco/index.ts index f057ce401..541e0c321 100644 --- a/packages/ui-default/components/monaco/index.ts +++ b/packages/ui-default/components/monaco/index.ts @@ -2,7 +2,9 @@ import * as monaco from 'monaco-editor/esm/vs/editor/editor.api'; import { EditorAction, registerEditorAction } from 'monaco-editor/esm/vs/editor/browser/editorExtensions'; import { IQuickInputService } from 'monaco-editor/esm/vs/platform/quickinput/common/quickInput'; import list from 'monaco-themes/themes/themelist.json'; +import { nanoid } from 'nanoid'; import i18n from 'vj/utils/i18n'; +import request from 'vj/utils/request'; import './monaco.styl'; export default monaco; @@ -59,6 +61,72 @@ class ChangeThemeAction extends EditorAction { registerEditorAction(ChangeThemeAction); +const pagename = document.documentElement.getAttribute('data-page'); +const isProblemPage = ['problem_create', 'problem_edit'].includes(pagename); +const isProblemEdit = pagename === 'problem_edit'; +function handlePasteEvent(editor: monaco.editor.IStandaloneCodeEditor) { + window.addEventListener('paste', (ev: ClipboardEvent) => { + if (!editor.hasTextFocus()) return; + const selection = editor.getSelection(); + const { items } = ev.clipboardData; + let wrapper = ['', '']; + let ext; + const matches = items[0].type.match(/^image\/(png|jpg|jpeg|gif)$/i); + if (matches) { + wrapper = ['![image](', ')']; + [, ext] = matches; + } else if (items[0].type === 'application/x-zip-compressed') { + wrapper = ['[file](', ')']; + ext = 'zip'; + } + if (!ext) return; + const filename = `${nanoid()}.${ext}`; + const data = new FormData(); + data.append('filename', filename); + data.append('file', items[0].getAsFile()); + data.append('operation', 'upload_file'); + if (isProblemEdit) data.append('type', 'additional_file'); + let range: monaco.Range = null; + editor.executeEdits('', [{ + range: new monaco.Range(selection.endLineNumber, selection.endColumn, selection.endLineNumber, selection.endColumn), + text: `![image](${i18n('Preparing Upload...')}) `, + }], (inverseOp) => { range = inverseOp[0].range; return null; }); + editor.setPosition(editor.getSelection().getEndPosition()); + + function updateText(text: string) { + const pos = editor.getSelection(); + const rangeBefore = range; + editor.executeEdits('', [{ range, text: `${wrapper[0]}${text}${wrapper[1]} ` }], (inverseOp) => { + range = inverseOp[0].range; + if (pos.endLineNumber !== pos.startLineNumber || pos.endLineNumber !== range.endLineNumber) return null; + const delta = rangeBefore.endColumn - range.endColumn; + editor.setPosition(new monaco.Position(pos.endLineNumber, pos.endColumn - delta)); + return null; + }); + } + let progress = 0; + request.postFile(isProblemEdit ? './file' : '/file', data, { + xhr() { + const xhr = new XMLHttpRequest(); + xhr.upload.addEventListener('loadstart', () => updateText(i18n('Uploading...'))); + xhr.upload.addEventListener('progress', (e) => { + if (!e.lengthComputable) return; + const percentComplete = Math.round((e.loaded / e.total) * 100); + if (percentComplete === progress) return; + progress = percentComplete; + updateText(`${i18n('Uploading...')} ${percentComplete}%`); + }, false); + return xhr; + }, + }) + .then(() => updateText(`${isProblemPage ? 'file://' : `/file/${UserContext._id}/`}${filename}`)) + .catch((e) => { + console.error(e); + updateText(`${i18n('Upload Failed')}: ${e.message}`); + }); + }); +} + export function registerAction( editor: monaco.editor.IStandaloneCodeEditor, model: monaco.editor.IModel, @@ -92,5 +160,6 @@ export function registerAction( if (model.getLanguageId() === 'markdown') { const suggestWidget = (editor.getContribution('editor.contrib.suggestController') as any).widget?.value; if (suggestWidget?._setDetailsVisible) suggestWidget._setDetailsVisible(true); + handlePasteEvent(editor); } } diff --git a/packages/ui-default/components/tab/Tab.js b/packages/ui-default/components/tab/Tab.js index 611fb8890..b6c9a79d6 100644 --- a/packages/ui-default/components/tab/Tab.js +++ b/packages/ui-default/components/tab/Tab.js @@ -92,6 +92,7 @@ export default class Tab extends DOMAttachedObject { .removeClass('active'); this.$content .css('x', '0'); + $newTab.trigger('vjContentShow'); // 4. Finalize this.currentIndex = idx; diff --git a/packages/ui-default/locales/zh.yaml b/packages/ui-default/locales/zh.yaml index 609392dd6..88e16daae 100644 --- a/packages/ui-default/locales/zh.yaml +++ b/packages/ui-default/locales/zh.yaml @@ -52,6 +52,8 @@ About test data: 关于测试数据 about: 关于 About: 关于 AC Message: 通过消息 +Accept: 通过 +Accepted Problem: 通过的题目 Accepted Ratio: 通过率 Accepted: 已通过 Access Control: 访问控制 @@ -574,6 +576,7 @@ Problem Settings: 题目设置 Problem Solution List: 题解列表 Problem Solution: 题解列表 Problem Tags Visibility: 题目标签可见性 +Problem Tags: 题目标签 problem_create: 创建题目 problem_edit: 编辑题目 problem_import: 导入题目 @@ -825,8 +828,12 @@ User ID: 用户 ID User will no longer be allowed to join the domain after expiration.: 过期后,用户将不能加入此域。 user_detail: 用户 user_login: 登录 +Uploading...: 正在上传... +Upload Failed: 上传失败 +Preparing Upload...: 准备上传... user_logout: 登出 user_lostpass: 忘记密码 +user_register_with_code: 注册 user_register: 注册 User-defined role: 用户定义的角色 User: 用户 diff --git a/packages/ui-default/package.json b/packages/ui-default/package.json index 0022fc823..3572957c6 100644 --- a/packages/ui-default/package.json +++ b/packages/ui-default/package.json @@ -1,6 +1,6 @@ { "name": "@hydrooj/ui-default", - "version": "4.32.10", + "version": "4.32.11", "author": "undefined ", "license": "AGPL-3.0", "main": "hydro.js", diff --git a/packages/ui-default/templates/user_detail.html b/packages/ui-default/templates/user_detail.html index 9bb2b71aa..9e97b5a8b 100644 --- a/packages/ui-default/templates/user_detail.html +++ b/packages/ui-default/templates/user_detail.html @@ -99,35 +99,34 @@ {% endif %} + {% if pdocs.length %} +
  • +

    {{ _('Accepted Problems') }}

    +
    +
    + {% set _mark = Math.ceil(pdocs.length/4) %} + {% macro col(pdocs) %} +
      + {% for pdoc in pdocs %} +
    • {{ problem.render_problem_title(pdoc, show_tags=false, small=true) }}
    • + {% endfor %} +
    + {% endmacro %} +
    + {{ col(pdocs.slice(0, _mark)) }} + {{ col(pdocs.slice(_mark, _mark*2)) }} + {{ col(pdocs.slice(_mark*2, _mark*3)) }} + {{ col(pdocs.slice(_mark*3, pdocs.length - 1)) }} +
    +
    +
    +
  • + {% endif %} + {% for page in findSubModule('user_detail/') %}{% include page %}{% endfor %} - {% if pdocs.length %} -
    -
    -

    {{ _('Accepted Problems') }}

    -
    -
    - {% set _mark = Math.ceil(pdocs.length/4) %} - {% macro col(pdocs) %} -
    -
      - {% for pdoc in pdocs %} -
    • {{ problem.render_problem_title(pdoc, show_tags=false, small=true) }}
    • - {% endfor %} -
    -
    - {% endmacro %} -
    - {{ col(pdocs.slice(0, _mark)) }} - {{ col(pdocs.slice(_mark, _mark*2)) }} - {{ col(pdocs.slice(_mark*2, _mark*3)) }} - {{ col(pdocs.slice(_mark*3, pdocs.length - 1)) }} -
    -
    -
    - {% endif %}
    {% if isSelfProfile %} diff --git a/packages/ui-default/utils/request.js b/packages/ui-default/utils/request.js index 05e645148..0bb63e501 100644 --- a/packages/ui-default/utils/request.js +++ b/packages/ui-default/utils/request.js @@ -21,8 +21,6 @@ const request = { reject(err); } else if (jqXHR.readyState === 0) { reject(new Error(i18n('Network error'))); - } else if (errorThrown instanceof Error) { - reject(errorThrown); } else if (typeof jqXHR.responseJSON === 'object' && jqXHR.responseJSON.error) { const { error } = jqXHR.responseJSON; if (error.params) { @@ -32,6 +30,8 @@ const request = { err.params = error.params; reject(err); } else reject(new Error(jqXHR.responseJSON.error.message)); + } else if (errorThrown instanceof Error) { + reject(errorThrown); } else { reject(new Error(textStatus)); } diff --git a/packages/utils/lib/utils.ts b/packages/utils/lib/utils.ts index 90ef5c686..05ee8bc25 100644 --- a/packages/utils/lib/utils.ts +++ b/packages/utils/lib/utils.ts @@ -28,7 +28,7 @@ declare global { interface SetConstructor { isSuperset: (set: Set, subset: Set) => boolean; intersection: (setA: Set | Array, setB: Set | Array) => Set; - union: (setA: Set, setB: Set) => Set; + union: (setA: Set | Array, setB: Set | Array) => Set; } } @@ -124,7 +124,7 @@ Set.isSuperset = function isSuperset(set, subset) { return true; }; -Set.union = function Union(setA: Set, setB: Set) { +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;