Repository navigation
problem: refactor statement storage #1225
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
4ed329a
ffc4bf9
abe781e
e40349b
0840f05
7d394b8
c126362
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,3 +1,99 @@ | ||||||||||||||||||||||||
| 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 { | ||||||||||||||||||||||||
| readAdditionalFile: (name: string) => Promise<Buffer>; | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| export interface Renderer { | ||||||||||||||||||||||||
| render(entry: StatementEntry, ctx: RenderCtx): string; | ||||||||||||||||||||||||
| compile?(entry: StatementEntry, ctx: CompileCtx): Promise<any>; | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| // statement name: safe filename with extension, no path separators | ||||||||||||||||||||||||
| const NAME_RE = /^\w[\w .()-]*\.[A-Za-z0-9]{1,16}$/; | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||
| * Parse content field into statement entries. | ||||||||||||||||||||||||
| * - Statement list `[{name, lang, content|file}]` → entries as-is | ||||||||||||||||||||||||
| * - Legacy `{lang: content}` → normalized entries (name `${lang}.${md|html}`) | ||||||||||||||||||||||||
| * - Plain string / 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 (Array.isArray(parsed)) { | ||||||||||||||||||||||||
| for (const value of parsed) { | ||||||||||||||||||||||||
| if (typeof value !== 'object' || value === null | ||||||||||||||||||||||||
| || typeof value.name !== 'string' || typeof value.lang !== 'string') return null; | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| return parsed; | ||||||||||||||||||||||||
|
Comment on lines
+45
to
+48
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: #!/bin/bash
ast-grep outline packages/hydrooj/src/model/problem.ts --match 'setStatements|addWithId' --view expanded
rg -n -C 12 'static async setStatements|static async addWithId|normalizeStatements' packages/hydrooj/src/model/problem.tsRepository: hydro-dev/Hydro Length of output: 8262 🏁 Script executed: sed -n '1,90p' packages/hydrooj/src/lib/content.ts
sed -n '223,325p' packages/hydrooj/src/model/problem.tsRepository: hydro-dev/Hydro Length of output: 8673 Reject statement entries without non-empty
🐛 Suggested fix--- "a/packages/hydrooj/src/lib/content.ts"
+++ "b/packages/hydrooj/src/lib/content.ts"
@@ -41,10 +41,13 @@
return null;
}
if (Array.isArray(parsed)) {
for (const value of parsed) {
if (typeof value !== 'object' || value === null
|| typeof value.name !== 'string' || typeof value.lang !== 'string') return null;
+ const hasContent = typeof value.content === 'string' && value.content.length > 0;
+ const hasFile = typeof value.file === 'string' && value.file.length > 0;
+ if (!hasContent && !hasFile) return null;
}
return parsed;
}
if (typeof parsed !== 'object' || parsed === null) return null;📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| if (typeof parsed !== 'object' || parsed === null) return null; | ||||||||||||||||||||||||
| const entries: StatementEntry[] = []; | ||||||||||||||||||||||||
| for (const [lang, value] of Object.entries<any>(parsed)) { | ||||||||||||||||||||||||
| if (typeof value !== 'string') return null; | ||||||||||||||||||||||||
| entries.push({ name: `${lang}.${html ? 'html' : 'md'}`, lang, content: value }); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| return entries; | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| export function normalizeStatements(content: string, lang: string, html = false): StatementEntry[] { | ||||||||||||||||||||||||
| if (!content) return []; | ||||||||||||||||||||||||
| return parseStatements(content, html) ?? [{ name: `${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 array must be a valid statement list; 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 { | ||||||||||||||||||||||||
| return null; | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| if (!Array.isArray(parsed)) return null; | ||||||||||||||||||||||||
| const names = new Set<string>(); | ||||||||||||||||||||||||
| for (const entry of parsed) { | ||||||||||||||||||||||||
| if (typeof entry !== 'object' || entry === null | ||||||||||||||||||||||||
| || typeof entry.name !== 'string' || typeof entry.lang !== 'string') { | ||||||||||||||||||||||||
| throw new ValidationError('statement.entry'); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
|
||||||||||||||||||||||||
| 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); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| return parsed; | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| export interface ProblemSource { | ||||||||||||||||||||||||
| background?: string; | ||||||||||||||||||||||||
| description?: string; | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
Remove the empty problem if an upload fails.
ProblemModel.addnow creates the problem with empty content. If a config, test-data, or image upload rejects,Promise.all(tasks)stops execution before this statement write. The failed import leaves a problem without its description. Roll back the created problem on upload failure, or otherwise ensure that the statement is saved before the import exits.🤖 Prompt for AI Agents