From 4ed329ae46d76d62ad20ee7a5377384f517ac974 Mon Sep 17 00:00:00 2001 From: undefined Date: Sun, 18 Oct 2026 19:08:10 +0000 Subject: [PATCH 1/7] problem: statement list with per-file contentRenderer registry --- packages/hydrooj/src/handler/problem.ts | 52 ++- packages/hydrooj/src/interface.ts | 3 + packages/hydrooj/src/lib/content.ts | 99 ++++++ packages/hydrooj/src/model/problem.ts | 252 +++++++++++--- packages/hydrooj/src/plugin-api.ts | 3 +- packages/hydrooj/src/service/bus.ts | 1 + packages/ui-default/api.ts | 2 + .../ui-default/backendlib/contentRenderer.ts | 40 +++ packages/ui-default/backendlib/template.ts | 22 ++ .../components/form/textbox.page.styl | 7 + .../components/statement/editors.ts | 99 ++++++ packages/ui-default/components/upload.tsx | 2 + .../components/zipDownloader/index.ts | 36 +- packages/ui-default/index.ts | 4 + packages/ui-default/locales/en.yaml | 2 + packages/ui-default/locales/zh.yaml | 13 + .../ui-default/pages/problem_edit.page.jsx | 317 +++++++++++++++--- .../ui-default/pages/problem_edit.page.styl | 16 + .../partials/problem_description.html | 2 +- .../ui-default/templates/problem_detail.html | 25 +- .../ui-default/templates/problem_edit.html | 20 +- 21 files changed, 890 insertions(+), 127 deletions(-) create mode 100644 packages/ui-default/backendlib/contentRenderer.ts create mode 100644 packages/ui-default/components/statement/editors.ts create mode 100644 packages/ui-default/pages/problem_edit.page.styl diff --git a/packages/hydrooj/src/handler/problem.ts b/packages/hydrooj/src/handler/problem.ts index d5c4ccde3c..34250872c8 100644 --- a/packages/hydrooj/src/handler/problem.ts +++ b/packages/hydrooj/src/handler/problem.ts @@ -22,6 +22,7 @@ import { import { ProblemDoc, ProblemSearchOptions, ProblemStatusDoc, RecordDoc, User, } from '../interface'; +import { parseSubmittedStatements } from '../lib/content'; import { PERM, PRIV, STATUS } from '../model/builtin'; import * as contest from '../model/contest'; import * as discussion from '../model/discussion'; @@ -379,11 +380,13 @@ export class ProblemDetailHandler extends ContestDetailBaseHandler { @query('tid', Types.ObjectId, true) @query('pjax', Types.Boolean) async get(...args: any[]) { + const statements = await problem.getStatements(this.pdoc); // Navigate to current additional file download // e.g. ![img](file://a.jpg) will navigate to ![img](./pid/file/a.jpg) if (!this.request.json || args[2]) { - this.response.body.pdoc.content = this.response.body.pdoc.content - .replace(/file:\/\/([^ \n)\\"]+)/g, (str: string) => { + for (const s of statements) { + if (!s.content) continue; + s.content = s.content.replace(/file:\/\/([^ \n)\\"]+)/g, (str: string) => { const info = str.match(/file:\/\/([^ \n)\\"]+)/); const fileinfo = info[1]; let filename = fileinfo.split('?')[0]; // remove querystring @@ -394,14 +397,34 @@ export class ProblemDetailHandler extends ContestDetailBaseHandler { if (!args[1]) return `./${this.pdoc.docId}/file/${fileinfo}`; return `./${this.pdoc.docId}/file/${fileinfo}${fileinfo.includes('?') ? '&' : '?'}tid=${args[1]}`; }); + } + } + const stmtLangs = [...new Set(statements.map((s) => s.lang).filter(Boolean))]; + if (!stmtLangs.length) { + let content: any; + try { + content = JSON.parse(this.pdoc.content); + } catch (e) { } + if (content && typeof content === 'object') stmtLangs.push(...Object.keys(content)); } + const preferredLang = this.request.query.lang || this.user.viewLang || this.session.viewLang; + const language = preferredLang || stmtLangs[0]; + let candidates = statements.filter((s) => s.lang === language); + if (!candidates.length) candidates = statements.filter((s) => s.lang?.startsWith(language)); + const activeLang = (candidates[0] || statements[0])?.lang || language; + candidates = statements.filter((s) => s.lang === activeLang); + if (!candidates.length) candidates = statements.filter((s) => s.lang?.startsWith(activeLang)); + const stmtFiles = (candidates.length ? candidates : statements).map((s) => s.name); + const data = { + pdoc: this.pdoc, tdoc: this.tdoc, statements, stmtLangs, preferredLang, activeLang, stmtFiles, + }; + Object.assign(this.response.body, data); this.response.body.page_name = this.tdoc ? this.tdoc.rule === 'homework' ? 'homework_detail_problem' : 'contest_detail_problem' : 'problem_detail'; if (args[2]) { - const data = { pdoc: this.pdoc, tdoc: this.tdoc }; this.response.body = { title: this.renderTitle(this.response.body.page_name), fragments: [ @@ -621,6 +644,10 @@ export class ProblemManageHandler extends ProblemDetailHandler { export class ProblemEditHandler extends ProblemManageHandler { async get() { this.response.body.additional_file = sortFiles(this.pdoc.additional_file || []); + this.response.body.statements = await problem.getStatements(this.pdoc); + this.UiContext.statements = this.response.body.statements.map(({ + name, lang, content, file, + }) => ({ name, lang, content, file })); this.response.body.statementLangs = this.ctx.i18n.langs(false); this.response.template = 'problem_edit.html'; } @@ -638,8 +665,9 @@ export class ProblemEditHandler extends ProblemManageHandler { ) { if (typeof newPid !== 'string') newPid = `P${newPid}`; if (newPid !== this.pdoc.pid && await problem.get(domainId, newPid)) throw new ProblemAlreadyExistError(newPid); + parseSubmittedStatements(content, (this.pdoc.additional_file || []).map((i) => i.name)); const $update: Partial = { - title, content, pid: newPid, hidden, tag: tag ?? [], difficulty, html: false, + title, content, pid: newPid, hidden, tag: tag ?? [], difficulty, }; const pdoc = await problem.edit(domainId, this.pdoc.docId, $update); this.response.redirect = this.url('problem_detail', { pid: newPid || pdoc.docId }); @@ -996,12 +1024,13 @@ export class ProblemStatisticsHandler extends ProblemDetailHandler { export class ProblemCreateHandler extends Handler { async get() { - this.response.body.statementLangs = this.ctx.i18n.langs(false); - this.response.template = 'problem_edit.html'; this.response.body = { page_name: 'problem_create', additional_file: [], + statements: [], + statementLangs: this.ctx.i18n.langs(false), }; + this.response.template = 'problem_edit.html'; } @post('title', Types.Title) @@ -1016,8 +1045,10 @@ export class ProblemCreateHandler extends Handler { ) { if (typeof pid !== 'string') pid = `P${pid}`; if (pid && await problem.get(domainId, pid)) throw new ProblemAlreadyExistError(pid); - const docId = await problem.add(domainId, pid, title, content, this.user._id, tag ?? [], { hidden, difficulty }); + const entries = parseSubmittedStatements(content, this.user._files.map((i) => i.name)); + const docId = await problem.add(domainId, pid, title, '', this.user._id, tag ?? [], { hidden, difficulty }); const files = new Set(Array.from(content.matchAll(/file:\/\/([\w-]+\.[a-zA-Z0-9]+)/g)).map((i) => i[1])); + for (const entry of entries || []) if (entry.file) files.add(entry.file); const tasks = []; for (const file of files) { if (this.user._files.find((i) => i.name === file)) { @@ -1029,6 +1060,7 @@ export class ProblemCreateHandler extends Handler { } } await Promise.all(tasks); + await problem.edit(domainId, docId, { content }); this.response.body = { pid: pid || docId }; this.response.redirect = this.url('problem_files', { pid: pid || docId }); } @@ -1044,6 +1076,12 @@ export const ProblemApi = { const pdoc = await problem.get(args.domainId, args.id); if (!pdoc) return null; if (pdoc.hidden) ctx.checkPerm(PERM.PERM_VIEW_PROBLEM_HIDDEN); + (pdoc as any).statements = await problem.getStatements(pdoc); + if (pdoc.reference) { + const source = await problem.get(pdoc.reference.domainId, pdoc.reference.pid, ['additional_file']); + if (!source) throw new ProblemNotFoundError(pdoc.reference.domainId, pdoc.reference.pid); + pdoc.additional_file = source.additional_file; + } return pdoc; }, ), diff --git a/packages/hydrooj/src/interface.ts b/packages/hydrooj/src/interface.ts index 31e5c374d5..c478e1d654 100644 --- a/packages/hydrooj/src/interface.ts +++ b/packages/hydrooj/src/interface.ts @@ -182,6 +182,7 @@ declare module './model/problem' { data: FileInfo[]; additional_file: FileInfo[]; hidden?: boolean; + /** @deprecated 仅兼容读取旧题面,格式由题面文件扩展名决定。 */ html?: boolean; stats?: any; difficulty?: number; @@ -623,6 +624,7 @@ declare module './service/db' { 'record.stat': RecordStatDoc; 'record.history': RecordHistoryDoc; document: any; + 'problem.statement': import('./model/problem').ProblemStatementDoc; 'document.status': StatusDocBase & { [K in keyof DocStatusType]: { docType: K } & DocStatusType[K]; }[keyof DocStatusType]; @@ -700,6 +702,7 @@ export interface UI { export interface ModuleInterfaces { hash: (password: string, salt: string, user: User) => boolean | string | Promise; problemSearch: ProblemSearch; + contentRenderer: import('./lib/content').Renderer; } export interface HydroGlobal { diff --git a/packages/hydrooj/src/lib/content.ts b/packages/hydrooj/src/lib/content.ts index 230164d43e..9845d6bfe5 100644 --- a/packages/hydrooj/src/lib/content.ts +++ b/packages/hydrooj/src/lib/content.ts @@ -1,3 +1,102 @@ +import { ValidationError } from '../error'; +import type { ProblemDoc } from '../interface'; + +export interface StatementEntry { + name: string; + lang: string; + content?: string; + file?: string; + render?: any; +} + +export interface RenderCtx { + pdoc: ProblemDoc; + tid?: string; +} + +export interface CompileCtx extends RenderCtx { + readAdditionalFile: (name: string) => Promise; +} + +export interface Renderer { + render(entry: StatementEntry, ctx: RenderCtx): string; + compile?(entry: StatementEntry, ctx: CompileCtx): Promise; +} + +// statement name: safe filename with extension, no path separators +const NAME_RE = /^\w[\w .()-]*\.[A-Za-z0-9]{1,16}$/; + +export function extensionOf(name: string) { + const i = name.lastIndexOf('.'); + return i === -1 ? '' : name.slice(i + 1).toLowerCase(); +} + +/** + * Parse content field into statement entries. + * - New format `{"x.md": {lang, content|file}}` → entries as-is + * - Legacy `{lang: content}` → normalized entries (name `${lang}.${md|html}`) + * - Plain string / arrays / malformed → null (caller renders legacy) + */ +export function parseStatements(content: string, html = false): StatementEntry[] | null { + if (!content || typeof content !== 'string') return null; + let parsed: any; + try { + parsed = JSON.parse(content); + } catch { + return null; + } + if (typeof parsed !== 'object' || parsed instanceof Array || parsed === null) return null; + const entries: StatementEntry[] = []; + for (const [name, value] of Object.entries(parsed)) { + if (typeof value === 'string') { + entries.push({ name: `${name}.${html ? 'html' : 'md'}`, lang: name, content: value }); + } else if (typeof value === 'object' && value && typeof value.lang === 'string') { + entries.push({ name, ...value }); + } else return null; + } + return entries; +} + +export function normalizeStatements(content: string, lang: string, html = false): StatementEntry[] { + if (!content) return []; + return parseStatements(content, html) ?? [{ name: `problem_${lang}.${html ? 'html' : 'md'}`, lang, content }]; +} + +/** + * Parse user-submitted content (edit/create handlers). + * Plain strings are legacy submissions → null (caller decides whether to allow). + * A JSON object must be a valid new-format statement map; malformed entries throw + * instead of silently falling back to legacy rendering. + */ +export function parseSubmittedStatements(content: string, availableFiles: string[]): StatementEntry[] | null { + if (!content || typeof content !== 'string' || !content.trim().startsWith('{')) return null; + let parsed: any; + try { + parsed = JSON.parse(content); + } catch { + throw new ValidationError('statement.json'); + } + if (typeof parsed !== 'object' || parsed instanceof Array || parsed === null) return null; + const entries: StatementEntry[] = []; + const names = new Set(); + for (const [name, value] of Object.entries(parsed)) { + if (typeof value !== 'object' || value === null || typeof value.lang !== 'string') { + throw new ValidationError('statement.entry', null, name); + } + const entry = { name, ...value }; + if (!NAME_RE.test(entry.name)) throw new ValidationError('statement.name', null, entry.name); + if (names.has(entry.name)) throw new ValidationError('statement.duplicate', null, entry.name); + names.add(entry.name); + if (!entry.lang) throw new ValidationError('statement.lang', null, entry.name); + const hasContent = typeof entry.content === 'string' && entry.content.length > 0; + const hasFile = typeof entry.file === 'string' && entry.file.length > 0; + if (!hasContent && !hasFile) throw new ValidationError('statement.empty', null, entry.name); + if (hasFile && !availableFiles.includes(entry.file)) throw new ValidationError('statement.file', null, entry.file); + entries.push(entry); + } + return entries; +} + export interface ProblemSource { background?: string; description?: string; diff --git a/packages/hydrooj/src/model/problem.ts b/packages/hydrooj/src/model/problem.ts index 9ef42a82c3..6f45d9a2ef 100644 --- a/packages/hydrooj/src/model/problem.ts +++ b/packages/hydrooj/src/model/problem.ts @@ -17,6 +17,8 @@ import { FileUploadError, NotFoundError, ProblemNotFoundError, ValidationError } import type { Document, ProblemDict, ProblemStatusDoc, User, } from '../interface'; +import type { StatementEntry } from '../lib/content'; +import { extensionOf, normalizeStatements } from '../lib/content'; import { parseConfig } from '../lib/testdataConfig'; import bus from '../service/bus'; import db from '../service/db'; @@ -35,6 +37,15 @@ import SystemModel from './system'; export interface ProblemDoc extends Document { } export type Field = keyof ProblemDoc; +export interface ProblemStatementDoc extends StatementEntry { + _id: ObjectId; + domainId: string; + docType: number; + docId: number; +} + +const collStatement = db.collection('problem.statement'); + const logger = new Logger('problem'); function sortable(source: string, namespaces: Record) { const [namespace, pid] = source.includes('-') ? source.split('-') : ['default', source]; @@ -48,10 +59,10 @@ function findOverrideContent(dir: string, base: string) { if (files.includes(`${base}.md`)) return fs.readFileSync(path.join(dir, `${base}.md`), 'utf8'); if (files.includes(`${base}.pdf`)) return `@[PDF](file://${base}.pdf)`; const languages = {}; - files = files.filter((i) => new RegExp(`^${base}(?:_|.)([a-zA-Z_]+)\\.(md|pdf)$`).test(i)); + files = files.filter((i) => new RegExp(`^${base}(?:_|\\.)([a-zA-Z_-]+)\\.(md|pdf)$`).test(i)); if (!files.length) return null; for (const file of files) { - const match = file.match(`^${base}(?:_|.)([a-zA-Z_]+)\\.(md|pdf)$`); + const match = file.match(`^${base}(?:_|\\.)([a-zA-Z_-]+)\\.(md|pdf)$`); const lang = match[1]; const ext = match[2]; if (ext === 'pdf') languages[lang] = `@[PDF](file://${file})`; @@ -112,7 +123,6 @@ export class ProblemModel { owner: 1, title: '*', content: '', - html: false, nSubmit: 0, nAccept: 0, tag: [], @@ -133,7 +143,6 @@ export class ProblemModel { owner: 1, title: '*', content: 'Deleted Problem', - html: false, nSubmit: 0, nAccept: 0, tag: [], @@ -180,15 +189,83 @@ export class ProblemModel { if (meta.difficulty) args.difficulty = meta.difficulty; if (meta.reference) args.reference = meta.reference; await bus.parallel('problem/before-add', domainId, content, owner, docId, args); - const result = await document.add(domainId, content, owner, document.TYPE_PROBLEM, docId, null, null, args); - args.content = content; + const entries = normalizeStatements(args.content ?? content, SystemModel.get('server.language') || 'zh'); + args.content = ''; + delete args.html; + const result = await document.add(domainId, '', owner, document.TYPE_PROBLEM, docId, null, null, args); args.owner = owner; args.docType = document.TYPE_PROBLEM; args.domainId = domainId; - await bus.emit('problem/add', args, result); + args.docId = docId; + if (entries.length) await ProblemModel.setStatements(args as ProblemDoc, entries); + bus.emit('problem/add', args, result); return result; } + static async getStatements(pdoc: ProblemDoc, followRedirect = true): Promise { + const rows = await collStatement.find({ domainId: pdoc.domainId, docId: pdoc.docId }).sort({ _id: 1 }).toArray(); + if (rows.length) return rows; + const entries = normalizeStatements(pdoc.content || '', SystemModel.get('server.language') || 'zh', pdoc.html); + if (!entries.length && followRedirect && pdoc.reference) { + const source = await ProblemModel.get(pdoc.reference.domainId, pdoc.reference.pid); + if (!source) throw new ProblemNotFoundError(pdoc.reference.domainId, pdoc.reference.pid); + return ProblemModel.getStatements(source, false); + } + return entries.map((entry) => ({ + _id: new ObjectId(), + domainId: pdoc.domainId, + docType: document.TYPE_PROBLEM, + docId: pdoc.docId, + ...entry, + })); + } + + static async setStatements(pdoc: ProblemDoc, entries: StatementEntry[], forceCompile = false) { + const { domainId, docId } = pdoc; + const existing = await collStatement.find({ domainId, docId }).toArray(); + const prevByName = new Map(existing.map((r) => [r.name, r])); + const ops: Promise[] = []; + for (const entry of entries) { + const prev = prevByName.get(entry.name); + let render = forceCompile ? undefined : prev?.render; + const renderer = global.Hydro?.module?.contentRenderer?.[extensionOf(entry.name)]; + if (renderer?.compile && typeof entry.content === 'string' && (forceCompile || prev?.content !== entry.content)) { + try { + render = await renderer.compile(entry, { + pdoc, + readAdditionalFile: async (name) => streamToBuffer( + await storage.get( + `problem/${pdoc.reference?.domainId || domainId}/${pdoc.reference?.pid || docId}/additional_file/${name}`, + ), + ), + }); + } catch (e) { + // wasm/typst diagnostics may carry no message: keep the stringified form + render = { error: e?.message || String(e).slice(0, 500) }; + } + } + const doc: any = { + _id: prev?._id ?? new ObjectId(), + domainId, + docType: document.TYPE_PROBLEM, + docId, + name: entry.name, + lang: entry.lang, + }; + if (entry.content !== undefined) doc.content = entry.content; + if (entry.file) doc.file = entry.file; + if (render !== undefined) doc.render = render; + ops.push(collStatement.replaceOne({ domainId, docId, name: entry.name }, doc, { upsert: true })); + } + await Promise.all(ops); + const removed = existing.filter((r) => !entries.some((e) => e.name === r.name)); + if (removed.length) await collStatement.deleteMany({ domainId, docId, name: { $in: removed.map((r) => r.name) } }); + await document.set(domainId, document.TYPE_PROBLEM, docId, { content: '' }, { html: '' }); + pdoc.content = ''; + delete pdoc.html; + bus.emit('problem/setStatements', domainId, docId, entries); + } + static async get( domainId: string, pid: string | number, projection: Projection = ProblemModel.PROJECTION_PUBLIC, @@ -233,10 +310,10 @@ export class ProblemModel { return document.getMultiStatus(domainId, document.TYPE_PROBLEM, query); } - static async edit(domainId: string, _id: number, $set: Partial): Promise { + static async edit(domainId: string, _id: number, $set: Partial>): Promise { const delpid = $set.pid === ''; const ddoc = await DomainModel.get(domainId); - const $unset = delpid ? { pid: '' } : {}; + const $unset: Partial> = delpid ? { pid: '' } : {}; if (delpid) { delete $set.pid; $set.sort = sortable(`P${_id}`, ddoc.namespaces); @@ -244,8 +321,18 @@ export class ProblemModel { $set.sort = sortable($set.pid, ddoc.namespaces); } await bus.parallel('problem/before-edit', $set, $unset); - const result = await document.set(domainId, document.TYPE_PROBLEM, _id, $set, $unset); - await bus.emit('problem/edit', result); + const { content, ...metadata } = $set as Partial; + delete metadata.html; + delete $unset.content; + delete $unset.html; + const entries = typeof content === 'string' + ? normalizeStatements(content, SystemModel.get('server.language') || 'zh') + : null; + const result = Object.keys(metadata).length || Object.keys($unset).length + ? await document.set(domainId, document.TYPE_PROBLEM, _id, metadata, $unset) + : await document.get(domainId, document.TYPE_PROBLEM, _id); + if (entries) await ProblemModel.setStatements(result, entries); + bus.emit('problem/edit', result); return result; } @@ -258,7 +345,7 @@ export class ProblemModel { const $set = { hidden: hidden || original.hidden, reference: { domainId, pid: _id } } as any; if (typeof original.difficulty === 'number') $set.difficulty = original.difficulty; return await ProblemModel.add( - target, pid, original.title, original.content, + target, pid, original.title, '', original.owner, original.tag, $set, ); } @@ -284,6 +371,7 @@ export class ProblemModel { const res = await Promise.all([ document.deleteOne(domainId, document.TYPE_PROBLEM, docId), document.deleteMultiStatus(domainId, document.TYPE_PROBLEM, { docId }), + collStatement.deleteMany({ domainId, docId }), storage.list(`problem/${domainId}/${docId}/`) .then((items) => storage.del(items.map((item) => `problem/${domainId}/${docId}/${item.name}`))), bus.parallel('problem/delete', domainId, docId), @@ -551,9 +639,48 @@ export class ProblemModel { } if (!await isValidPid(pid)) pid = undefined; } - let overrideContent = findOverrideContent(path.join(tmpdir, i), 'problem'); - overrideContent ||= findOverrideContent(path.join(tmpdir, i, 'statement'), 'problem'); - overrideContent ||= findOverrideContent(path.join(tmpdir, i, 'problem_statement'), 'problem'); + const hasStatements = Object.hasOwn(pdoc, 'statements'); + const manifest = (pdoc as ProblemDoc & { statements?: unknown }).statements; + const statements: StatementEntry[] = []; + const sources = new Set(); + if (hasStatements) { + if (!Array.isArray(manifest)) throw new ValidationError('statements'); + const names = new Set(); + const additionalFiles = await getFiles('additional_file'); + for (const entry of manifest) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) throw new ValidationError('statement.entry'); + if (typeof entry.name !== 'string' || entry.name.trim() !== entry.name + || !/^\w[\w .()-]*\.[A-Za-z0-9]{1,16}$/.test(entry.name)) { + throw new ValidationError('statement.name'); + } + if (names.has(entry.name)) throw new ValidationError('statement.duplicate', null, entry.name); + names.add(entry.name); + if (typeof entry.lang !== 'string' || !entry.lang) throw new ValidationError('statement.lang', null, entry.name); + const isSource = Object.hasOwn(entry, 'source'); + if (isSource === Object.hasOwn(entry, 'file')) throw new ValidationError('statement.entry', null, entry.name); + const filename = isSource ? entry.source : entry.file; + if (typeof filename !== 'string' || !filename || filename.trim() !== filename + || /[/\\\0]/.test(filename) || ['.', '..'].includes(filename) + || path.isAbsolute(filename) || path.win32.isAbsolute(filename) + || (isSource && filename === 'problem.yaml')) { + throw new ValidationError('statement.file', null, entry.name); + } + if (!isSource && !additionalFiles.some(([f]) => f.name === filename && f.isFile())) { + throw new ValidationError('statement.file', null, filename); + } + const location = path.join(tmpdir, i, ...(isSource ? [] : ['additional_file']), filename); + const stat = await fs.lstat(location).catch(() => null); + if (!stat?.isFile()) throw new ValidationError('statement.file', null, filename); + if (isSource) sources.add(filename); + statements.push({ name: entry.name, lang: entry.lang, ...(!isSource ? { file: filename } : {}) }); + } + } + let overrideContent: string = null; + if (!hasStatements) { + overrideContent = findOverrideContent(path.join(tmpdir, i), 'problem'); + overrideContent ??= findOverrideContent(path.join(tmpdir, i, 'statement'), 'problem'); + overrideContent ??= findOverrideContent(path.join(tmpdir, i, 'problem_statement'), 'problem'); + } if (pdoc.difficulty && !Number.isSafeInteger(pdoc.difficulty)) delete pdoc.difficulty; const title = pdoc.title || (pdoc as any).name; if (typeof title !== 'string') throw new ValidationError('title', null, 'Invalid title'); @@ -562,9 +689,24 @@ export class ProblemModel { // The following is from https://icpc.io/problem-package-format/spec/2023-07-draft.html 'attachments', 'generators', 'include', 'data', 'statement', 'problem_statement', ); - const totalSize = allFiles.map((f) => fs.statSync(f[1]).size).reduce((a, b) => a + b, 0); - if (allFiles.length > SystemModel.get('limit.problem_files')) throw new ValidationError('files', null, 'Too many files'); + const sourceFiles = Array.from(sources, (name) => path.join(tmpdir, i, name)); + const totalSize = [...allFiles.map((f) => f[1]), ...sourceFiles] + .map((loc) => fs.statSync(loc).size).reduce((a, b) => a + b, 0); + if (allFiles.length + sources.size > SystemModel.get('limit.problem_files')) { + throw new ValidationError('files', null, 'Too many files'); + } if (totalSize > SystemModel.get('limit.problem_files_size')) throw new ValidationError('files', null, 'Files too large'); + if (hasStatements) { + for (const [index, entry] of (manifest as any[]).entries()) { + if (Object.hasOwn(entry, 'source')) { + statements[index].content = await fs.readFile(path.join(tmpdir, i, entry.source), 'utf8'); + } + } + } else { + statements.push(...normalizeStatements( + overrideContent ?? pdoc.content?.toString() ?? 'No content', SystemModel.get('server.language') || 'zh', pdoc.html, + )); + } const tag = (pdoc.tag || []).map((t) => t.toString()); let configChanged = false; let config: ProblemConfigFile = {}; @@ -593,18 +735,10 @@ export class ProblemModel { config.memory = (pdoc as any).limits.memory ? `${(pdoc as any).limits.memory}m` : config.memory || undefined; configChanged = true; } - const docId = overridePid - ? (await ProblemModel.edit(ddoc._id, overridePid, { - title: title.trim(), - content: overrideContent || pdoc.content?.toString() || 'No content', - tag, - difficulty: pdoc.difficulty, - ...(options.hidden ? { hidden: true } : {}), - })).docId - : await ProblemModel.add( - ddoc._id, pid, title.trim(), overrideContent || pdoc.content?.toString() || 'No content', - operator || pdoc.owner, tag, { hidden: options.hidden || pdoc.hidden, difficulty: pdoc.difficulty }, - ); + const docId = overridePid || await ProblemModel.add( + ddoc._id, pid, title.trim(), '', + operator || pdoc.owner, tag, { hidden: options.hidden || pdoc.hidden, difficulty: pdoc.difficulty }, + ); // TODO delete unused file when updating pdoc for (const [f, loc] of await getFiles('testdata', 'attachments', 'generators', 'include')) { if (f.isDirectory()) { @@ -616,7 +750,7 @@ export class ProblemModel { if (!f.isDirectory()) continue; const sub = await fs.readdir(loc); for (const file of sub) { - if (f.name === 'sample') await ProblemModel.addAdditionalFile(domainId, docId, file, path.join(loc, file)); + if (!hasStatements && f.name === 'sample') await ProblemModel.addAdditionalFile(domainId, docId, file, path.join(loc, file)); await ProblemModel.addTestdata(ddoc._id, docId, file, path.join(loc, file)); } } @@ -636,10 +770,19 @@ export class ProblemModel { configChanged = true; } } - for (const [f, loc] of await getFiles('additional_file', 'attachments', 'statement', 'problem_statement')) { + for (const [f, loc] of await getFiles(...(hasStatements + ? ['additional_file'] : ['additional_file', 'attachments', 'statement', 'problem_statement']))) { if (!f.isFile()) continue; await ProblemModel.addAdditionalFile(ddoc._id, docId, f.name, loc); } + const current = await ProblemModel.get(ddoc._id, docId); + await ProblemModel.setStatements(current, statements, true); + await ProblemModel.edit(ddoc._id, docId, { + title: title.trim(), + tag, + difficulty: pdoc.difficulty, + ...(options.hidden ? { hidden: true } : {}), + }); for (const [f, loc] of await getFiles('solution')) { if (!f.isFile()) continue; await SolutionModel.add(ddoc._id, docId, operator, await fs.readFile(loc, 'utf-8')); @@ -693,6 +836,23 @@ export class ProblemModel { const problemPath = path.join(tmpdir, `${pdoc.docId}`); await fs.mkdir(problemPath); const problemYaml = path.join(problemPath, 'problem.yaml'); + const statements = await ProblemModel.getStatements(pdoc); + let filePdoc = pdoc; + if (pdoc.reference) { + filePdoc = await ProblemModel.get(pdoc.reference.domainId, pdoc.reference.pid, ['domainId', 'docId', 'data', 'additional_file']); + if (!filePdoc) throw new ProblemNotFoundError(pdoc.reference.domainId, pdoc.reference.pid); + } + const manifest = statements.map((s) => { + if (/[/\\\0]/.test(s.name)) throw new ValidationError('statement.name', null, s.name); + if (s.file && !filePdoc.additional_file?.some((f) => f.name === s.file)) { + throw new ValidationError('statement.file', null, s.file); + } + return { + name: s.name, + lang: s.lang, + ...(s.file ? { file: s.file } : { source: `statement_${s.name}` }), + }; + }); const problemYamlContent = yaml.dump({ pid: pdoc.pid || `P${pdoc.docId}`, owner: pdoc.owner, @@ -701,33 +861,27 @@ export class ProblemModel { nSubmit: pdoc.nSubmit, nAccept: pdoc.nAccept, difficulty: pdoc.difficulty, + statements: manifest, }); await fs.writeFile(problemYaml, problemYamlContent); - try { - const c = JSON.parse(pdoc.content); - for (const key of Object.keys(c)) { - const problemContent = path.join(problemPath, `problem_${key}.md`); - await fs.writeFile(problemContent, typeof c[key] === 'string' ? c[key] : JSON.stringify(c[key])); - } - } catch (e) { - const problemContent = path.join(problemPath, 'problem.md'); - await fs.writeFile(problemContent, pdoc.content); + for (const s of statements) { + if (!s.file) await fs.writeFile(path.join(problemPath, `statement_${s.name}`), s.content ?? '', 'utf8'); } - if ((pdoc.data || []).length) { + if ((filePdoc.data || []).length) { const testdataPath = path.join(problemPath, 'testdata'); await fs.mkdir(testdataPath); - for (const file of pdoc.data) { - const stream = await storage.get(`problem/${domainId}/${pdoc.docId}/testdata/${file.name}`); + for (const file of filePdoc.data) { + const stream = await storage.get(`problem/${filePdoc.domainId}/${filePdoc.docId}/testdata/${file.name}`); const buf = await streamToBuffer(stream); const testdataFile = path.join(testdataPath, file.name); await fs.writeFile(testdataFile, buf); } } - if ((pdoc.additional_file || []).length) { + if ((filePdoc.additional_file || []).length) { const additionalPath = path.join(problemPath, 'additional_file'); await fs.mkdir(additionalPath); - for (const file of pdoc.additional_file) { - const stream = await storage.get(`problem/${domainId}/${pdoc.docId}/additional_file/${file.name}`); + for (const file of filePdoc.additional_file) { + const stream = await storage.get(`problem/${filePdoc.domainId}/${filePdoc.docId}/additional_file/${file.name}`); const buf = await streamToBuffer(stream); const additionalFile = path.join(additionalPath, file.name); await fs.writeFile(additionalFile, buf); @@ -743,7 +897,11 @@ export class ProblemModel { } } -export function apply(ctx: Context) { +export async function apply(ctx: Context) { + await db.ensureIndexes( + collStatement, + { key: { domainId: 1, docId: 1, name: 1 }, name: 'basic', unique: true }, + ); ctx.on('problem/addTestdata', async (domainId, docId, name) => { if (!['config.yaml', 'config.yml', 'Config.yaml', 'Config.yml'].includes(name)) return; const buf = await storage.get(`problem/${domainId}/${docId}/testdata/${name}`); diff --git a/packages/hydrooj/src/plugin-api.ts b/packages/hydrooj/src/plugin-api.ts index 004b973cbd..ff0e312627 100644 --- a/packages/hydrooj/src/plugin-api.ts +++ b/packages/hydrooj/src/plugin-api.ts @@ -43,7 +43,8 @@ export { JudgeResultCallbackContext, postJudge } from './handler/judge'; export { Collections } from './service/db'; export { ConnectionHandler, Handler, requireSudo } from './service/server'; export { Context, Fiber, FiberState, Service } from './context'; -export { buildContent } from './lib/content'; +export { buildContent, parseStatements, parseSubmittedStatements } from './lib/content'; +export type { CompileCtx, RenderCtx, Renderer, StatementEntry } from './lib/content'; export { default as mime } from './lib/mime'; export { default as difficultyAlgorithm } from './lib/difficulty'; export { default as rating } from './lib/rating'; diff --git a/packages/hydrooj/src/service/bus.ts b/packages/hydrooj/src/service/bus.ts index d9da8f5b7b..858bfdf122 100644 --- a/packages/hydrooj/src/service/bus.ts +++ b/packages/hydrooj/src/service/bus.ts @@ -75,6 +75,7 @@ export interface EventMap { 'problem/get': (doc: ProblemDoc, handler: any) => VoidReturn; 'problem/delete': (domainId: string, docId: number) => VoidReturn; 'problem/addTestdata': (domainId: string, docId: number, name: string, payload: Omit) => VoidReturn; + 'problem/setStatements': (domainId: string, docId: number, entries: import('../lib/content').StatementEntry[]) => VoidReturn; 'problem/renameTestdata': (domainId: string, docId: number, name: string, newName: string) => VoidReturn; 'problem/delTestdata': (domainId: string, docId: number, name: string[]) => VoidReturn; 'problem/addAdditionalFile': (domainId: string, docId: number, name: string, payload: Omit) => VoidReturn; diff --git a/packages/ui-default/api.ts b/packages/ui-default/api.ts index e6ef0d6af5..9c44fed759 100644 --- a/packages/ui-default/api.ts +++ b/packages/ui-default/api.ts @@ -1,10 +1,12 @@ /* eslint-disable import/first */ export * from './components/dialog'; +export { default as Editor } from './components/editor/index'; export { default as loadMonaco } from './components/monaco/loader'; export { default as Notification } from './components/notification'; export { default as Rotator } from './components/rotator'; export { default as selectUser } from './components/selectUser'; export { default as Socket } from './components/socket/index'; +export * from './components/statement/editors'; export { default as uploadFiles } from './components/upload'; export * from './components/zipDownloader'; export { default as download } from './components/zipDownloader'; diff --git a/packages/ui-default/backendlib/contentRenderer.ts b/packages/ui-default/backendlib/contentRenderer.ts new file mode 100644 index 0000000000..3b01f1bbbe --- /dev/null +++ b/packages/ui-default/backendlib/contentRenderer.ts @@ -0,0 +1,40 @@ +import type { Renderer, StatementEntry } from 'hydrooj'; +import { ValidationError } from 'hydrooj'; +import { escape } from 'lodash'; +import markdown from './markdown'; +import { ensureTag, xss } from './markdown-it-xss'; + +export function extensionOf(name: string) { + const i = name.lastIndexOf('.'); + return i === -1 ? '' : name.slice(i + 1).toLowerCase(); +} + +// Preset renderers; plugins register more (typst/latex/...) via ctx.provideModule('contentRenderer', ext, renderer) +const markdownRenderer: Renderer = { + render: (entry) => ensureTag(markdown.render(entry.content || '')), +}; + +const htmlRenderer: Renderer = { + render: (entry) => ensureTag(xss.process(entry.content || '')), +}; + +// Direct PDF embed, bypassing markdown entirely (see markdown-it-media.ts pdf branch) +const pdfRenderer: Renderer = { + render: (entry, ctx) => { + const name = entry.file || entry.name; + const src = `./${ctx.pdoc.docId}/file/${encodeURIComponent(name)}?noDisposition=1${ctx.tid ? `&tid=${ctx.tid}` : ''}`; + return ``; + }, +}; + +export const presetRenderers = { + md: markdownRenderer, + html: htmlRenderer, + pdf: pdfRenderer, +}; + +export function renderStatement(entry: StatementEntry, ctx: any) { + const renderer = global.Hydro?.module?.contentRenderer?.[extensionOf(entry.name)]; + if (!renderer) throw new ValidationError('statement.format', null, entry.name); + return renderer.render(entry, ctx); +} diff --git a/packages/ui-default/backendlib/template.ts b/packages/ui-default/backendlib/template.ts index cceddf5a47..aec6f22afb 100644 --- a/packages/ui-default/backendlib/template.ts +++ b/packages/ui-default/backendlib/template.ts @@ -8,6 +8,7 @@ import { cac } from 'cac'; import { convert } from 'html-to-text'; import jsesc from 'jsesc'; import nunjucks from 'nunjucks'; +import { renderStatement } from './contentRenderer'; import markdown from './markdown'; import { ensureTag, xss } from './markdown-it-xss'; import * as misc from './misc'; @@ -81,6 +82,12 @@ class Nunjucks extends nunjucks.Environment { this.addFilter('jsesc', (self) => jsesc(self, { isScriptContext: true })); this.addFilter('bitand', (self, val) => self & val); this.addFilter('toString', (self, s) => (typeof self === 'string' ? self : JSON.stringify(self, replacer(s)))); + this.addFilter('statementSubmitJson', (statements: any[]) => JSON.stringify( + Object.fromEntries((statements || []).map((s) => [ + s.name, + s.content !== undefined ? { lang: s.lang, content: s.content } : { lang: s.lang, file: s.file }, + ])), + )); this.addFilter('content', (content, language, html) => { let s: any = ''; try { @@ -97,6 +104,21 @@ class Nunjucks extends nunjucks.Environment { } return ensureTag(html ? xss.process(s) : markdown.render(s)); }); + this.addFilter('statementRender', (statements: any[], language: string, file: string, pdoc: any, tid?: string) => { + if (!statements?.length) { + // legacy problems without statement rows render the raw content field + if (!pdoc?.content) return ''; + return this.getFilter('content')(pdoc.content, language, pdoc.html); + } + let candidates = statements.filter((s) => s.lang === language); + if (!candidates.length) candidates = statements.filter((s) => s.lang?.startsWith(language)); + if (!candidates.length) candidates = statements; + const entry = (file && candidates.find((s) => s.name === file)) || candidates[0]; + return renderStatement(entry, { + pdoc, + tid, + }); + }); this.addFilter('problemPreview', (html) => { const res = convert(html, { selectors: [ diff --git a/packages/ui-default/components/form/textbox.page.styl b/packages/ui-default/components/form/textbox.page.styl index f154655e79..6750e5b83f 100644 --- a/packages/ui-default/components/form/textbox.page.styl +++ b/packages/ui-default/components/form/textbox.page.styl @@ -23,6 +23,13 @@ input.textbox, textarea.textbox, span.textbox textarea.textbox height: rem(200px) +input.textbox.compact + margin-bottom: 0 + height: rem($compact-control-height) + line-height: rem($compact-control-height - 2) + padding-top: 0 + padding-bottom: 0 + div.textbox min-height: rem(200px) margin-bottom: 1rem diff --git a/packages/ui-default/components/statement/editors.ts b/packages/ui-default/components/statement/editors.ts new file mode 100644 index 0000000000..76828eeea4 --- /dev/null +++ b/packages/ui-default/components/statement/editors.ts @@ -0,0 +1,99 @@ +import $ from 'jquery'; +import EditorComponent from 'vj/components/editor/index'; +import { i18n, tpl } from 'vj/utils'; + +// Statement editor registry — frontend counterpart of the server-side contentRenderer. +// Plugins register custom editors per extension (e.g. a typst editor with preview); +// unknown extensions fall back to the plain text editor. + +export interface StatementEntryLike { + name: string; + lang: string; + content?: string; + file?: string; + render?: any; +} + +export interface StatementEditorHandle { + onChange(callback: (value: string) => void): void; + destroy?(): void; +} + +export interface StatementEditorContext { + entry: StatementEntryLike; + $container: JQuery; +} + +export type StatementEditorFactory = (ctx: StatementEditorContext) => StatementEditorHandle; + +const registry: Record = {}; + +export function registerStatementEditor(extensions: string[], factory: StatementEditorFactory) { + for (const ext of extensions) registry[ext] = factory; +} + +export function resolveStatementEditor(name: string): StatementEditorFactory { + const ext = name.includes('.') ? name.split('.').pop().toLowerCase() : ''; + return registry[ext] || registry.__text; +} + +const textEditor: StatementEditorFactory = ({ entry, $container }) => { + // Initial value goes through the DOM: the Editor component initializes async, + // so calling its value() right after construction throws / is a no-op. + const $textarea = $(tpl``) + .val(entry.content ?? ''); + $container.append($textarea); + let cb: (value: string) => void = () => { }; + const editor = EditorComponent.getOrConstruct($textarea, { onChange: (value: string) => cb(value) }); + return { + onChange(callback: (value: string) => void) { + cb = callback; + }, + destroy() { + editor.destroy(); + }, + }; +}; + +const fileEditor: StatementEditorFactory = ({ entry, $container }) => { + const viewUrl = `./file/${encodeURIComponent(entry.file || entry.name)}`; + $container.append($(tpl` +
+

${entry.file || entry.name}

+

${i18n('This statement is a binary file stored in additional files.')}

+ ${i18n('View')} +
`)); + return { + onChange() { }, + }; +}; + +registerStatementEditor(['pdf'], fileEditor); + +registry.__text = textEditor; + +// ---- statement kinds (the "Add statement" buttons) ---- +// Text kinds create an empty entry with a default name; upload kinds pick a file +// (stored per the entry's `file` field) and reference it. Plugins register both +// a kind (add button) and an editor (edit area) — e.g. a typst plugin adds +// { id: 'typst', extension: 'typ', label: ... } plus its editor for 'typ'. + +export interface StatementKind { + id: string; + label: string; // i18n key + extension?: string; // text kinds: default entry name extension + accept?: string; // upload kinds: file input accept pattern +} + +const kinds: StatementKind[] = []; + +export function registerStatementKind(kind: StatementKind) { + kinds.push(kind); +} + +export function getStatementKinds(): StatementKind[] { + return kinds; +} + +registerStatementKind({ id: 'markdown', label: 'Add Markdown Statement', extension: 'md' }); +registerStatementKind({ id: 'pdf', label: 'Upload PDF Statement', accept: 'application/pdf,.pdf' }); diff --git a/packages/ui-default/components/upload.tsx b/packages/ui-default/components/upload.tsx index 34d2f67ec3..fd92497860 100644 --- a/packages/ui-default/components/upload.tsx +++ b/packages/ui-default/components/upload.tsx @@ -96,6 +96,8 @@ export default async function uploadFiles(endpoint = '', files: File[] | FileLis } catch (e) { console.error(e); Notification.error(i18n('File upload failed: {0}', e.toString())); + // rethrow so callers can skip their success path (e.g. adding a statement entry) + throw e; } finally { await delay(500); dialog.close(); diff --git a/packages/ui-default/components/zipDownloader/index.ts b/packages/ui-default/components/zipDownloader/index.ts index ddac700748..ce2fae50e2 100644 --- a/packages/ui-default/components/zipDownloader/index.ts +++ b/packages/ui-default/components/zipDownloader/index.ts @@ -99,7 +99,12 @@ export async function downloadProblemSet(pids, name = 'Export') { pid: 1, owner: 1, title: 1, - content: 1, + statements: { + name: 1, + lang: 1, + content: 1, + file: 1, + }, tag: 1, nSubmit: 1, nAccept: 1, @@ -110,6 +115,19 @@ export async function downloadProblemSet(pids, name = 'Export') { name: 1, }, }); + const statements: any[] = pdoc.statements || []; + const manifest = statements.map((entry) => { + if (/[/\\\0]/.test(entry.name)) throw new Error(`Invalid statement filename: ${entry.name}`); + if (entry.file) { + if (!pdoc.additional_file?.some((file) => file.name === entry.file)) { + throw new Error(`Missing statement file: ${entry.file}`); + } + return { name: entry.name, lang: entry.lang, file: entry.file }; + } + const source = `statement_${entry.name}`; + targets.push({ filename: `${pid}/${source}`, content: entry.content ?? '' }); + return { name: entry.name, lang: entry.lang, source }; + }); targets.push({ filename: `${pid}/problem.yaml`, content: dump({ @@ -119,23 +137,9 @@ export async function downloadProblemSet(pids, name = 'Export') { tag: pdoc.tag, nSubmit: pdoc.nSubmit, nAccept: pdoc.nAccept, + statements: manifest, }), }); - try { - const c = JSON.parse(pdoc.content); - if (c instanceof Array || typeof c === 'string') throw new Error('Invalid content'); - for (const key of Object.keys(c)) { - targets.push({ - filename: `${pid}/problem_${key}.md`, - content: typeof c[key] === 'string' ? c[key] : JSON.stringify(c[key]), - }); - } - } catch (e) { - targets.push({ - filename: `${pid}/problem.md`, - content: pdoc.content, - }); - } let { links } = await request.post( `/d/${UiContext.domainId}/p/${pid}/files`, { operation: 'get_links', files: (pdoc.data || []).map((i) => i.name), type: 'testdata' }, diff --git a/packages/ui-default/index.ts b/packages/ui-default/index.ts index 11f8c76769..adcf3cf3b9 100644 --- a/packages/ui-default/index.ts +++ b/packages/ui-default/index.ts @@ -5,6 +5,7 @@ import { SettingModel, SystemModel, Types, UserModel, yaml, } from 'hydrooj'; import convert from 'schemastery-jsonschema'; +import { presetRenderers } from './backendlib/contentRenderer'; import markdown from './backendlib/markdown'; import { TemplateService } from './backendlib/template'; @@ -172,6 +173,9 @@ export const Config = Schema.object({ }); export function apply(ctx: Context, config: ReturnType) { + for (const [ext, renderer] of Object.entries(presetRenderers)) { + ctx.provideModule('contentRenderer', ext, renderer); + } ctx.inject(['setting'], (c) => { c.setting.PreferenceSetting( SettingModel.Setting('setting_display', 'rounded', false, 'boolean', 'Rounded Corners'), diff --git a/packages/ui-default/locales/en.yaml b/packages/ui-default/locales/en.yaml index ea54563af0..e150db2b4c 100644 --- a/packages/ui-default/locales/en.yaml +++ b/packages/ui-default/locales/en.yaml @@ -69,3 +69,5 @@ user_lostpass: Lost Password user_register: Register wiki_help: Help You can also upload your avatar to Gravatar and it will be automatically updated here.: You can also upload your avatar to Gravatar and it will be automatically updated here. +'Invalid statement filename: {0}': 'Invalid statement filename: {0}' +No statement yet. Use the + button in the tab bar to add one. diff --git a/packages/ui-default/locales/zh.yaml b/packages/ui-default/locales/zh.yaml index 833e4bd68e..77429a3006 100644 --- a/packages/ui-default/locales/zh.yaml +++ b/packages/ui-default/locales/zh.yaml @@ -1139,3 +1139,16 @@ Your browser does not support WebAuthn or you are not in secure context.: 您的 Your Device: 你的设备 Your permission: 您的权限 Your role: 您的角色 +Add Markdown Statement: 添加 Markdown 题面 +Upload PDF Statement: 上传 PDF 题面 +Delete Statement: 删除题面 +Filename (with extension): 文件名(含扩展名) +Duplicate statement filename.: 题面文件名重复。 +'Invalid statement filename: {0}': 题面文件名不合法:{0} +No statement yet. Use the + button in the tab bar to add one.: 暂无题面,请使用上方标签栏的 + 按钮添加。 +This statement is a binary file stored in additional files.: 该题面为二进制文件,存储在附加文件中。 +Please rename it on the edit page after creating the problem.: 请在创建题目后在编辑页重命名。 +Confirm deleting this statement?: 确认删除这条题面? +Statement {0} is empty.: 题面 {0} 内容为空。 +File {0} referenced by statement {1} does not exist.: 题面 {1} 引用的文件 {0} 不存在。 +Unknown language: 未知语言 diff --git a/packages/ui-default/pages/problem_edit.page.jsx b/packages/ui-default/pages/problem_edit.page.jsx index 1546978812..7c0c39bffb 100644 --- a/packages/ui-default/pages/problem_edit.page.jsx +++ b/packages/ui-default/pages/problem_edit.page.jsx @@ -3,8 +3,9 @@ import _ from 'lodash'; import ReactDOM from 'react-dom/client'; import { confirm } 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 { getStatementKinds, resolveStatementEditor } from 'vj/components/statement/editors'; +import uploadFiles from 'vj/components/upload'; import { NamedPage } from 'vj/misc/Page'; import { i18n, request, tpl } from 'vj/utils'; @@ -157,48 +158,256 @@ export default new NamedPage(['problem_create', 'problem_edit'], () => { buildCategoryFilter(); parseCategorySelection(); - const $main = $('textarea[data-editor]'); - const $field = $('textarea[data-markdown-upload]'); - let content = $field.val(); - let isObject = false; - let activeTab = $('[data-lang]').first().attr('data-lang'); - try { - content = JSON.parse(content); - isObject = !(content instanceof Array); - if (!isObject) content = JSON.stringify(content); - } catch (e) { } - if (!isObject) content = { [activeTab]: content }; - function getContent(lang) { - let c = ''; - if (content[lang]) c = content[lang]; - else { - const list = Object.keys(content).filter((l) => l.startsWith(lang)); - if (list.length) c = content[list[0]]; - } - if (typeof c !== 'string') c = JSON.stringify(c); - return c; - } - $main.val(getContent(activeTab)); - function onChange(val) { + // ---------- statements ---------- + const UiCtx = window.UiContext || {}; + const langs = UiCtx.statementLangs || {}; + const isCreate = !!UiCtx.problemCreate; + const $field = $('textarea[name="content"]'); + const defaultLang = () => UserContext.viewLang || i18n('__id'); + + // Parse a submitted-shape JSON string into entries (null when not statement JSON). + // Used both for the server-rendered initial value and for restoring the draft + // the browser puts back into the textarea when returning from an error page. + function parseDraft(val) { try { - val = JSON.parse(val); - if (!(val instanceof Array)) val = JSON.stringify(val); - } catch { } - const empty = /^\s*$/.test(val); - if (empty) delete content[activeTab]; - else content[activeTab] = val; - if (!Object.keys(content).length) $field.text(''); - else $field.text(JSON.stringify(content)); + const parsed = JSON.parse(val); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return null; + const out = []; + for (const [name, v] of Object.entries(parsed)) { + if (typeof v !== 'object' || v === null || typeof v.lang !== 'string') return null; + out.push({ + name, + lang: v.lang, + ...(v.content !== undefined ? { content: v.content } : {}), + ...(v.file ? { file: v.file } : {}), + }); + } + return out; + } catch (e) { + return null; + } + } + + const draft = parseDraft(String($field.val() || '')); + let entries = draft; + // an empty draft ([]) is valid and must not fall back to server data + if (entries === null) { + entries = (UiCtx.statements || []).map((s) => ({ + name: s.name, + lang: s.lang, + ...(s.content !== undefined ? { content: s.content } : {}), + ...(s.file ? { file: s.file } : {}), + })); + } + + if (isCreate && draft === null) { + entries.push({ + name: `problem_${defaultLang()}.md`, + lang: defaultLang(), + content: String($field.val() || ''), + }); + } + + // file names known to exist: server-provided additional files plus uploads from this session + const knownFiles = new Set((UiCtx.additionalFiles || []).map((f) => f.name)); + + let activeName = entries[0]?.name || null; + let editorHandle = null; + // assigned after the render functions are defined; kept as a variable to avoid forward references + let refreshUI = () => { }; + + function serialize() { + const obj = {}; + for (const e of entries) { + obj[e.name] = e.content !== undefined + ? { lang: e.lang, content: e.content } + : { lang: e.lang, file: e.file }; + } + $field.val(JSON.stringify(obj)); + } + + function activeEntry() { + return entries.find((e) => e.name === activeName); + } + + const hasName = (n) => entries.some((e) => e.name === n); + + function addTextEntry(ext) { + const lang = activeEntry()?.lang || defaultLang(); + let name = `problem_${lang}.${ext}`; + let i = 1; + while (hasName(name)) name = `problem_${lang}_${++i}.${ext}`; + entries.push({ name, lang, content: '' }); + serialize(); + activeName = name; + refreshUI(); + } + + // no display:none here: the Drop instance manages the menu's visibility itself + const $kindMenu = $(tpl``).appendTo('body'); + getStatementKinds().forEach((kind) => { + $(tpl`${i18n(kind.label)}`) + .on('click', () => { + $('a[name="statement-add"]').data('vjDropdownInstance')?.dropInstance?.close(); + if (kind.extension) addTextEntry(kind.extension); + else if (kind.accept) $('#statement-file-input').attr('accept', kind.accept).trigger('click'); + }) + .appendTo($('').appendTo($kindMenu)); + }); + + function deleteEntry(name) { + const e = entries.find((x) => x.name === name); + _.pull(entries, e); + if (activeName === name) activeName = entries[0]?.name || null; + serialize(); + refreshUI(); + } + + function renderTabs() { + Dropdown.get($('#statement-tabs a[name="statement-add"]'))?.detach(); + const $tabs = $('#statement-tabs').empty(); + for (const e of entries) { + $(tpl` + + ${e.name} ${langs[e.lang] || e.lang} + + `).appendTo($tabs); + } + const title = i18n('Add statement'); + const $add = $(tpl` + + + `).appendTo($tabs); + Dropdown.getOrConstruct($add, { target: $kindMenu[0], position: 'bottom left' }); + } + + function renderProps() { + const e = activeEntry(); + const $props = $('#statement-props').empty(); + if (!e) return; + const $name = $(tpl``) + .on('change', async () => { + const newName = String($name.val() || '').trim(); + if (!newName || newName === e.name) { + $name.val(e.name); + return; + } + if (hasName(newName)) { + Notification.error(i18n('Duplicate statement filename.')); + $name.val(e.name); + return; + } + if (e.file) { + if (isCreate) { + // user-file storage has no rename operation; rename on the edit page after creation + Notification.error(i18n('Please rename it on the edit page after creating the problem.')); + $name.val(e.name); + return; + } + try { + await request.post('./files', { + operation: 'rename_files', + files: [e.file], + newNames: [newName], + type: 'additional_file', + }); + } catch (err) { + Notification.error(err.message); + $name.val(e.name); + return; + } + e.file = newName; + } + activeName = newName; + e.name = newName; + serialize(); + refreshUI(); + }); + const $lang = $(''); + // an unknown lang code (e.g. from an imported problem) still gets its own option, + // otherwise the select would silently display some other language + const langOptions = Object.keys(langs).includes(e.lang) + ? Object.entries(langs) + : [[e.lang, `${e.lang} (${i18n('Unknown language')})`], ...Object.entries(langs)]; + for (const [k, v] of langOptions) { + $(tpl``).appendTo($lang); + } + $lang.on('change', () => { + e.lang = String($lang.val()); + serialize(); + renderTabs(); + }); + $props.append($name).append($lang); } - const editor = Editor.getOrConstruct($main, { onChange }); - $('[data-lang]').on('click', (ev) => { - $('[data-lang]').removeClass('tab--active'); - $(ev.currentTarget).addClass('tab--active'); - const lang = $(ev.currentTarget).attr('data-lang'); - activeTab = lang; - const val = getContent(lang); - editor.value(val); + + function renderEditor() { + editorHandle?.destroy?.(); + const e = activeEntry(); + const $area = $('#statement-editor-area').empty(); + if (!e) { + $area.append($(tpl`

${i18n('No statement yet. Use the + button in the tab bar to add one.')}

`)); + return; + } + const factory = resolveStatementEditor(e.name); + editorHandle = factory({ entry: e, $container: $area }); + editorHandle.onChange((value) => { + if (e.content !== undefined) e.content = value; + serialize(); + }); + } + + refreshUI = (name) => { + if (name !== undefined) activeName = name; + renderTabs(); + renderProps(); + renderEditor(); + }; + + $('#statement-file-input').on('change', async (ev) => { + const file = ev.target.files?.[0]; + if (!file) return; + const name = file.name; + if (hasName(name)) { + Notification.error(i18n('Duplicate statement filename.')); + ev.target.value = ''; + return; + } + try { + await uploadFiles(isCreate ? '/file' : './files', [file], isCreate ? {} : { type: 'additional_file' }); + } catch (e) { + ev.target.value = ''; + return; + } + knownFiles.add(name); + entries.push({ name, lang: activeEntry()?.lang || defaultLang(), file: name }); + serialize(); + activeName = name; + refreshUI(); + ev.target.value = ''; }); + + serialize(); + refreshUI(); + + $('#statement-tabs').on('click', '.section__tab-header-item', (ev) => { + ev.preventDefault(); + const $item = $(ev.currentTarget); + if ($(ev.target).closest('.statement-tab-del').length) { + // modifier keys skip the confirmation + if (ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey) { + deleteEntry($item.attr('data-name')); + return; + } + confirm(i18n('Confirm deleting this statement?')).then((yes) => { + if (yes) deleteEntry($item.attr('data-name')); + }); + return; + } + const name = $item.attr('data-name'); + if (!name || name === activeName) return; + refreshUI(name); + }); + $('[type="submit"]').on('click', (ev) => { if (!$('[name="title"]').val().toString().length) { Notification.error(i18n('Title is required.')); @@ -209,7 +418,35 @@ export default new NamedPage(['problem_create', 'problem_edit'], () => { () => $('[name="title"]').focus(), ); ev.preventDefault(); + return; + } + for (const e of entries) { + const isEmpty = !e.file && (e.content === undefined || !String(e.content).trim().length); + if (isEmpty) { + Notification.error(i18n('Statement {0} is empty.', e.name)); + ev.preventDefault(); + return; + } + if (e.file && !knownFiles.has(e.file)) { + Notification.error(i18n('File {0} referenced by statement {1} does not exist.', e.file, e.name)); + ev.preventDefault(); + return; + } + } + const names = entries.map((e) => e.name); + if (new Set(names).size !== names.length) { + Notification.error(i18n('Duplicate statement filename.')); + ev.preventDefault(); + return; + } + for (const e of entries) { + if (!e.name || !e.name.includes('.')) { + Notification.error(i18n('Invalid statement filename: {0}', e.name)); + ev.preventDefault(); + return; + } } + serialize(); }); if (localStorage.getItem('polyhedron-hint') === 'dismiss') return; diff --git a/packages/ui-default/pages/problem_edit.page.styl b/packages/ui-default/pages/problem_edit.page.styl new file mode 100644 index 0000000000..648a672c3e --- /dev/null +++ b/packages/ui-default/pages/problem_edit.page.styl @@ -0,0 +1,16 @@ +// statement tabs share the look of the problem detail page tabs +.page--problem_edit, +.page--problem_create + .section__tab-header-item + color: #182026 !important + text-decoration: none + + &:hover + text-decoration: none + + .statement-tab-del + margin-left: rem(6px) + opacity: 0.4 + + &:hover + opacity: 1 diff --git a/packages/ui-default/templates/partials/problem_description.html b/packages/ui-default/templates/partials/problem_description.html index b56d4e189f..a4d75442e9 100644 --- a/packages/ui-default/templates/partials/problem_description.html +++ b/packages/ui-default/templates/partials/problem_description.html @@ -27,7 +27,7 @@ {% endif %} {% if not renderredContent %} {% set preferredLang = handler.request.query.lang or handler.user.viewLang or handler.session.viewLang %} - {% set renderredContent = pdoc.content|content(preferredLang, pdoc.html) %} + {% set renderredContent = statements|statementRender(preferredLang, handler.request.query.file, pdoc, handler.request.query.tid) %} {% endif %} {{ renderredContent|safe }} \ No newline at end of file diff --git a/packages/ui-default/templates/problem_detail.html b/packages/ui-default/templates/problem_detail.html index 30c2c98742..c86078c870 100644 --- a/packages/ui-default/templates/problem_detail.html +++ b/packages/ui-default/templates/problem_detail.html @@ -28,10 +28,8 @@ {% if not tdoc %} {% block ogp %} - {% set preferredLang = handler.request.query.lang or handler.user.viewLang or handler.session.viewLang %} - {% set renderredContent = pdoc.content|content(preferredLang, pdoc.html) %} - {% set langs = pdoc.content|contentLang %} - {% for k in langs %} + {% set renderredContent = statements|statementRender(preferredLang, null, pdoc) %} + {% for k in stmtLangs %} {% if k != preferredLang %} {% else %} @@ -121,12 +119,12 @@

{{ pdoc.title }}

- {% if pdoc.content|contentLang|length > 1 %} + {% if stmtLangs|length > 1 %}
{% endif %} + {% if stmtFiles|length > 1 %} +
+
+ +
+
+ {% endif %} {% include "partials/problem_description.html" %} diff --git a/packages/ui-default/templates/problem_edit.html b/packages/ui-default/templates/problem_edit.html index 1136ea309f..1265e50b24 100644 --- a/packages/ui-default/templates/problem_edit.html +++ b/packages/ui-default/templates/problem_edit.html @@ -50,16 +50,20 @@
-
    -
  • {{ _('__langname') }}
  • - {% for k, v in statementLangs %}{% if k != handler.user.viewLang %} -
  • {{ v }}
  • - {% endif %}{% endfor %} -
+
    -
    - +
    +
    + + +
    {% if page_name == 'problem_edit' %}