diff --git a/src/backend/src/controllers/rules.controllers.ts b/src/backend/src/controllers/rules.controllers.ts index d3e75ca083..af182a9048 100644 --- a/src/backend/src/controllers/rules.controllers.ts +++ b/src/backend/src/controllers/rules.controllers.ts @@ -338,7 +338,7 @@ export default class RulesController { static async parseRuleset(req: Request, res: Response, next: NextFunction) { try { - const { fileId, parserType } = req.body; + const { fileId, parserType, firstRulePage } = req.body; const { rulesetId } = req.params as Record; const parseResult = await RulesService.parseRuleset( @@ -346,7 +346,8 @@ export default class RulesController { req.organization.organizationId, fileId, rulesetId, - parserType + parserType, + firstRulePage ); res.status(200).json(parseResult); diff --git a/src/backend/src/routes/rules.routes.ts b/src/backend/src/routes/rules.routes.ts index 7627316e7f..3b9c17c21c 100644 --- a/src/backend/src/routes/rules.routes.ts +++ b/src/backend/src/routes/rules.routes.ts @@ -120,6 +120,7 @@ rulesRouter.post( '/ruleset/:rulesetId/parse', nonEmptyString(body('fileId')), nonEmptyString(body('parserType')), // 'FSAE' or 'FHE' + body('firstRulePage').optional().isInt({ min: 1 }), validateInputs, RulesController.parseRuleset ); diff --git a/src/backend/src/services/rules.services.ts b/src/backend/src/services/rules.services.ts index 5070b11f94..26b63c80ec 100644 --- a/src/backend/src/services/rules.services.ts +++ b/src/backend/src/services/rules.services.ts @@ -1554,6 +1554,7 @@ export default class RulesService { * @param fileId google drive file id of the ruleset pdf * @param rulesetId id of the ruleset to save the parsed rules into * @param parserType type of parser to use (FSAE or FHE) + * @param firstRulePage 1-indexed page rules start on; pages before this are skipped instead of parsed * @returns array of saved rules with parent relationships established * @throws AccessDeniedException if user lacks permissions or ruleset belongs to another organization * @throws NotFoundException if ruleset doesn't exist @@ -1566,7 +1567,8 @@ export default class RulesService { organizationId: string, fileId: string, rulesetId: string, - parserType: 'FSAE' | 'FHE' + parserType: 'FSAE' | 'FHE', + firstRulePage?: number ): Promise { if (!(await userHasPermission(user.userId, organizationId, isLeadership))) { throw new AccessDeniedException('You do not have permissions to upload and parse rulesets'); @@ -1604,7 +1606,7 @@ export default class RulesService { } let parsedRules: ParsedRule[]; try { - parsedRules = await parseRulesFromPdf(buffer, parserType); + parsedRules = await parseRulesFromPdf(buffer, parserType, firstRulePage); if (parsedRules.length === 0) { throw new HttpException(400, 'No rules found in provided file'); } diff --git a/src/backend/src/utils/parse.utils.ts b/src/backend/src/utils/parse.utils.ts index 8da38dc61f..90aeb7cc69 100644 --- a/src/backend/src/utils/parse.utils.ts +++ b/src/backend/src/utils/parse.utils.ts @@ -6,12 +6,33 @@ export interface ParsedRule { parentRuleCode?: string; } -export const parseRulesFromPdf = async (buffer: Buffer, parserType: 'FSAE' | 'FHE'): Promise => { +const defaultPageRender = pdf.DEFAULT_OPTIONS.pagerender!; + +/** + * Skip text extraction for pages before firstRulePage. + * Useful for skipping TOC and other beginning content. + * + * @param firstRulePage page number to start parsing rules from (1-indexed) + */ +export const makePageRenderer = (firstRulePage?: number) => { + if (!firstRulePage || firstRulePage <= 1) return defaultPageRender; + return (pageData: { pageNumber: number }) => { + if (pageData.pageNumber < firstRulePage) return ''; + return defaultPageRender(pageData); + }; +}; + +export const parseRulesFromPdf = async ( + buffer: Buffer, + parserType: 'FSAE' | 'FHE', + firstRulePage?: number +): Promise => { const options = { // max page number to parse, 0 = all pages max: 0, // errors: 0, warnings: 1, infos: 5 - verbosityLevel: 0 as const + verbosityLevel: 0 as const, + pagerender: makePageRenderer(firstRulePage) }; const pdfData = await pdf(buffer, options); @@ -25,17 +46,39 @@ export const parseRulesFromPdf = async (buffer: Buffer, parserType: 'FSAE' | 'FH }; /** - * Extracts lettered sub-rules from rule content (a, b, c, etc.) - * "EV.5.2 Main text a. Sub-rule" becomes: + * Checks whether a line is only a page number (e.g. "7" or "Page 7 of 143"). + * Safe to skip without risking dropping rule content. + * @param line line to check + */ +export const isPageNumberLine = (line: string): boolean => /^\d+$/.test(line); + +/** + * Removes "Page X of Y" wherever it occurs in a line + * @param line line to strip + * @returns the line with any "Page X of Y" occurrences removed and whitespace collapsed + */ +export const stripPageNumberPhrase = (line: string): string => + line + .replace(/Page\s+\d+\s+of\s+\d+/gi, '') + .replace(/\s+/g, ' ') + .trim(); + +// clean up whitespace and newlines in rule content +export const normalizeContent = (text: string): string => text.replace(/\s+/g, ' ').trim(); + +/** + * Extracts lettered sub-rules from rule content (a, b, c, etc.) when sub-rule starts on its own line. + * "EV.5.2 Main text\na. Sub-rule" becomes: * - EV.5.2 Main text * - EV.5.2.a Sub-rule - * If no subrules exist, returns the original rule + * If no subrules exist, returns the original rule. * @param ruleCode parent rule code * @param content rule content to extract from * @returns array of parsed rules including main rule and any subrules */ -const extractSubRules = (ruleCode: string, content: string): ParsedRule[] => { - const letterPattern = /\s+([a-z])\.\s+/g; +export const extractSubRules = (ruleCode: string, content: string): ParsedRule[] => { + // "a." and "(a)" subrule styles + const letterPattern = /(?<=^|:\s)\s*(?:([a-z])\.|\(([a-z])\))\s+/gm; const matches = [...content.matchAll(letterPattern)]; if (matches.length === 0) { @@ -43,7 +86,7 @@ const extractSubRules = (ruleCode: string, content: string): ParsedRule[] => { return [ { ruleCode, - ruleContent: content.trim(), + ruleContent: normalizeContent(content), parentRuleCode: findParentRuleCode(ruleCode) } ]; @@ -52,7 +95,7 @@ const extractSubRules = (ruleCode: string, content: string): ParsedRule[] => { // Extract the main rule content (everything before the first lettered item) const firstMatchIndex = matches[0].index!; - const mainContent = content.substring(0, firstMatchIndex).trim(); + const mainContent = normalizeContent(content.substring(0, firstMatchIndex)); // add main rule subRules.push({ @@ -63,12 +106,13 @@ const extractSubRules = (ruleCode: string, content: string): ParsedRule[] => { // Extract lettered sub-rules for (let i = 0; i < matches.length; i++) { - const [, letter] = matches[i]; + const [, dotLetter, parenLetter] = matches[i]; + const letter = dotLetter ?? parenLetter; const startIndex = matches[i].index! + matches[i][0].length; // Find where this sub-rule ends (either at next letter or end of rule content) const endIndex = i < matches.length - 1 ? matches[i + 1].index! : content.length; - const subRuleContent = content.substring(startIndex, endIndex).trim(); + const subRuleContent = normalizeContent(content.substring(startIndex, endIndex)); const subRuleCode = `${ruleCode}.${letter}`; subRules.push({ @@ -88,7 +132,7 @@ const extractSubRules = (ruleCode: string, content: string): ParsedRule[] => { * @param ruleCode rule code to find a parent for * @returns Parent rule code, or undefined if top level */ -const findParentRuleCode = (ruleCode: string): string | undefined => { +export const findParentRuleCode = (ruleCode: string): string | undefined => { const parts = ruleCode.split('.'); if (parts.length <= 1) { return undefined; @@ -102,7 +146,7 @@ const findParentRuleCode = (ruleCode: string): string | undefined => { * @param rules array of parsed rules * @returns array of rules without duplicate rule codes and updated parent references */ -const handleDuplicateCodes = (rules: ParsedRule[]): ParsedRule[] => { +export const handleDuplicateCodes = (rules: ParsedRule[]): ParsedRule[] => { const seenRuleCodes = new Map(); const codeMapping = new Map(); // Maps original code to new code for duplicates @@ -141,13 +185,25 @@ const handleDuplicateCodes = (rules: ParsedRule[]): ParsedRule[] => { }); }; -/**************** FSAE ****************/ - -const parseFSAERules = (text: string): ParsedRule[] => { +/** + * Shared line-by-line parsing loop used by FSAE and FHE parsers. + * Parsers differ in how rule code is recognized and logic for orphaned parent ref fixes. + * @param text full extracted PDF text to parse + * @param parseRuleNumber recognizes whether a line starts a new rule (FSAE- or FHE-specific) + * @param fixOrphanedRules re-parents rules whose inferred parent code doesn't exist (FSAE- or FHE-specific) + * @returns array of parsed rules + */ +const parseRules = ( + text: string, + parseRuleNumber: (line: string) => ParsedRule | null, + fixOrphanedRules: (rules: ParsedRule[]) => ParsedRule[] +): ParsedRule[] => { const rules: ParsedRule[] = []; const lines = text.split('\n'); let currentRule: { code: string; text: string } | null = null; + let unparsedText = ''; + let unparsedCount = 0; const saveCurrentRule = () => { if (!currentRule) return; @@ -155,49 +211,66 @@ const parseFSAERules = (text: string): ParsedRule[] => { rules.push(...parsedRules); }; + // Text encountered with no rule open yet would otherwise disappear (e.g. before the first + // recognized rule) - keep it as its own top-level rule under an abstract code instead of dropping it. + const saveUnparsed = () => { + if (!unparsedText.trim()) return; + unparsedCount += 1; + rules.push({ + ruleCode: `UNPARSED.${unparsedCount}`, + ruleContent: unparsedText.trim(), + parentRuleCode: undefined + }); + unparsedText = ''; + }; + for (const line of lines) { const trimmedLine = line.trim(); if (!trimmedLine) continue; - // Skip page headers/footers - if (isHeaderFooterFSAE(trimmedLine)) { - continue; - } - - // Skip table of contents - if (/\.{4,}\s+\d+\s*$/.test(trimmedLine)) { - continue; - } + // Remove "Page X of Y", then if nothing real remains or just a page number left, skip entire line + const cleanedLine = stripPageNumberPhrase(trimmedLine); + if (!cleanedLine || isPageNumberLine(cleanedLine)) continue; // Check if this line starts a new rule - const rule = parseRuleNumberFSAE(trimmedLine); + const rule = parseRuleNumber(cleanedLine); if (rule) { saveCurrentRule(); + saveUnparsed(); currentRule = { code: rule.ruleCode, text: rule.ruleContent }; } else if (currentRule) { - currentRule.text += ' ' + trimmedLine; // else append to existing rule + currentRule.text += '\n' + cleanedLine; // else append to existing rule + } else { + unparsedText += (unparsedText ? ' ' : '') + cleanedLine; } } saveCurrentRule(); + saveUnparsed(); - const fixedRules = fixOrphanedRulesFSAE(rules); + const fixedRules = fixOrphanedRules(rules); return handleDuplicateCodes(fixedRules); }; +/**************** FSAE ****************/ + +export const parseFSAERules = (text: string): ParsedRule[] => parseRules(text, parseRuleNumberFSAE, fixOrphanedRulesFSAE); + /** * Determines if this line starts a new rule, if so extracts code and content of the rule * Matches rule pattern (e.g. GR.1.1 some text) or section pattern (e.g. GR - TEXT) * @param line single line in the extracted text from the ruleset pdf * @returns rule code and content, or null if this line does not start a new rule */ -const parseRuleNumberFSAE = (line: string): ParsedRule | null => { +export const parseRuleNumberFSAE = (line: string): ParsedRule | null => { // Match rule patterns like "GR.1.1" followed by text const rulePattern = /^([A-Z]{1,4}(?:\.[\d]+)+)\s+(.+)$/; // Match section patterns like "GR - GENERAL REGULATIONS or PS - PRE-COMPETITION SUBMISSIONS" const sectionPattern = /^([A-Z]{1,4})\s*-\s*(.+)$/; + // Match a rule code alone on its own line, with body text starting on the next line + const bareCodePattern = /^([A-Z]{1,4}(?:\.[\d]+)+)$/; const match = line.match(rulePattern) || line.match(sectionPattern); if (match) { @@ -207,31 +280,16 @@ const parseRuleNumberFSAE = (line: string): ParsedRule | null => { ruleContent: cleanContent }; } - return null; -}; - -/** - * Checks if a line is a page header/footer that should be skipped - * @param line line to check - * @returns true if line should be skipped - */ -const isHeaderFooterFSAE = (line: string): boolean => { - const trimmed = line.trim(); - // Match FSAE headers like "Formula SAE® Rules 2025 © 2024 SAE International Page 7 of 143 Version 1.0 31 Aug 2024" - if (/Formula SAE.*Rules.*\d{4}.*SAE International.*Page \d+ of \d+/i.test(trimmed)) { - return true; - } - // Match standalone page numbers - if (/^Page \d+ of \d+$/i.test(trimmed)) { - return true; - } - // Match version strings - if (/^Version \d+\.\d+.*\d{1,2}\s+(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{4}$/i.test(trimmed)) { - return true; + const bareMatch = line.match(bareCodePattern); + if (bareMatch) { + return { + ruleCode: bareMatch[1], + ruleContent: '' + }; } - return false; + return null; }; /** @@ -240,7 +298,7 @@ const isHeaderFooterFSAE = (line: string): boolean => { * @param rules array of parsed rules * @returns rules with corrected parent references */ -const fixOrphanedRulesFSAE = (rules: ParsedRule[]): ParsedRule[] => { +export const fixOrphanedRulesFSAE = (rules: ParsedRule[]): ParsedRule[] => { const existingCodes = new Set(rules.map((r) => r.ruleCode)); return rules.map((rule) => { @@ -265,51 +323,7 @@ const fixOrphanedRulesFSAE = (rules: ParsedRule[]): ParsedRule[] => { /**************** FHE *****************/ -const parseFHERules = (text: string): ParsedRule[] => { - const rules: ParsedRule[] = []; - const lines = text.split('\n'); - let inRulesSection = false; - let currentRule: { code: string; text: string } | null = null; - - const saveCurrentRule = () => { - if (!currentRule) return; - const parsedRules = extractSubRules(currentRule.code, currentRule.text); - rules.push(...parsedRules); - }; - - for (const line of lines) { - const trimmedLine = line.trim(); - if (!trimmedLine) continue; - if (/^Index of Tables/i.test(trimmedLine)) { - inRulesSection = true; - } - // Skip table of contents - if (inRulesSection) { - if (/^2025 Formula Hybrid.*Rules/i.test(trimmedLine)) { - saveCurrentRule(); - currentRule = null; - continue; - } - - // Check if this line starts a new rule - const rule = parseRuleNumberFHE(trimmedLine); - if (rule) { - saveCurrentRule(); - currentRule = { - code: rule.ruleCode, - text: rule.ruleContent - }; - } else if (currentRule) { - // Append to existing rule - currentRule.text += ' ' + trimmedLine; - } - } - } - saveCurrentRule(); - - const fixedRules = fixOrphanedRulesFHE(rules); - return handleDuplicateCodes(fixedRules); -}; +export const parseFHERules = (text: string): ParsedRule[] => parseRules(text, parseRuleNumberFHE, fixOrphanedRulesFHE); /** * Determines if this line starts a new rule, if so extracts code and content of the rule @@ -317,9 +331,13 @@ const parseFHERules = (text: string): ParsedRule[] => { * @param line single line in the extracted text from the ruleset pdf * @returns rule code and content, or null if this line does not start a new rule */ -const parseRuleNumberFHE = (line: string): ParsedRule | null => { +export const parseRuleNumberFHE = (line: string): ParsedRule | null => { // Match FHE rule patterns like "1T3.17.1" followed by text const rulePattern = /^(\d+[A-Z]+\d+(?:\.\d+)*)\s+(.+)$/; + // Match FHE rule codes with no leading digit, like "EV5.6" (e.g. Electric Vehicle sections) + const plainLetterPattern = /^([A-Z]{1,4}\d+(?:\.\d+)*)\s+(.+)$/; + // Match a rule code alone on its own line, with body text starting on the next line + const bareCodePattern = /^(\d+[A-Z]+\d+(?:\.\d+)*|[A-Z]{1,4}\d+(?:\.\d+)*)$/; // "PART A1 - ADMINISTRATIVE REGULATIONS" removes "PART" and captures "A1" as rule code, rest as content const partMatch = line.match(/^PART\s+([A-Z0-9]+)\s+-\s+(.+)$/); @@ -340,7 +358,7 @@ const parseRuleNumberFHE = (line: string): ParsedRule | null => { }; } - const match = line.match(rulePattern); + const match = line.match(rulePattern) || line.match(plainLetterPattern); if (match) { return { ruleCode: match[1], @@ -348,6 +366,14 @@ const parseRuleNumberFHE = (line: string): ParsedRule | null => { }; } + const bareMatch = line.match(bareCodePattern); + if (bareMatch) { + return { + ruleCode: bareMatch[1], + ruleContent: '' + }; + } + return null; }; @@ -358,7 +384,7 @@ const parseRuleNumberFHE = (line: string): ParsedRule | null => { * @param rules array of parsed rules * @returns rules with corrected parent references */ -const fixOrphanedRulesFHE = (rules: ParsedRule[]): ParsedRule[] => { +export const fixOrphanedRulesFHE = (rules: ParsedRule[]): ParsedRule[] => { const existingCodes = new Set(rules.map((r) => r.ruleCode)); return rules.map((rule) => { diff --git a/src/backend/tests/unit/parse.utils.test.ts b/src/backend/tests/unit/parse.utils.test.ts new file mode 100644 index 0000000000..9d650ef4f7 --- /dev/null +++ b/src/backend/tests/unit/parse.utils.test.ts @@ -0,0 +1,541 @@ +import { vi } from 'vitest'; +import { + ParsedRule, + isPageNumberLine, + stripPageNumberPhrase, + normalizeContent, + findParentRuleCode, + handleDuplicateCodes, + extractSubRules, + parseRuleNumberFSAE, + fixOrphanedRulesFSAE, + parseFSAERules, + parseRuleNumberFHE, + fixOrphanedRulesFHE, + parseFHERules, + makePageRenderer, + parseRulesFromPdf +} from '../../src/utils/parse.utils.js'; + +describe('Parse Utils Tests', () => { + describe('isPageNumberLine', () => { + it('matches a bare page number', () => { + expect(isPageNumberLine('7')).toBe(true); + expect(isPageNumberLine('143')).toBe(true); + }); + + it('does not match a real rule line', () => { + expect(isPageNumberLine('T.1.1 Some requirement text')).toBe(false); + }); + + it('does not match a line that merely contains a number', () => { + expect(isPageNumberLine('See page 7 for details')).toBe(false); + }); + }); + + describe('stripPageNumberPhrase', () => { + it('removes "Page X of Y" when it is the whole line', () => { + expect(stripPageNumberPhrase('Page 7 of 143')).toBe(''); + }); + + it('matches case-insensitively', () => { + expect(stripPageNumberPhrase('page 7 of 143')).toBe(''); + }); + + it('removes "Page X of Y" embedded in a longer line, keeping the real content on both sides', () => { + const line = + 'Violations on Intent The violation of the intent of a rule will be considered a violation of the rule itself Formula SAE® Rules 2026 © 2025 SAE International Page 8 of 145 Version 1.0 10 Sept 2025'; + expect(stripPageNumberPhrase(line)).toBe( + 'Violations on Intent The violation of the intent of a rule will be considered a violation of the rule itself Formula SAE® Rules 2026 © 2025 SAE International Version 1.0 10 Sept 2025' + ); + }); + + it('leaves a line unchanged when it does not contain the phrase', () => { + expect(stripPageNumberPhrase('GR.1.1 Cars must have a roll bar.')).toBe('GR.1.1 Cars must have a roll bar.'); + }); + }); + + describe('normalizeContent', () => { + it('collapses embedded newlines and repeated whitespace into single spaces', () => { + expect(normalizeContent('Main text\na. Sub-rule\n\nb. Another')).toBe('Main text a. Sub-rule b. Another'); + }); + + it('trims leading and trailing whitespace', () => { + expect(normalizeContent(' \n padded content \n ')).toBe('padded content'); + }); + }); + + describe('findParentRuleCode', () => { + it('strips the last segment for a nested code', () => { + expect(findParentRuleCode('EV.5.2.2')).toBe('EV.5.2'); + }); + + it('returns undefined for a top-level code', () => { + expect(findParentRuleCode('GR')).toBeUndefined(); + }); + }); + + describe('handleDuplicateCodes', () => { + it('leaves rules with unique codes unchanged', () => { + const input: ParsedRule[] = [ + { ruleCode: 'GR.1', ruleContent: 'First' }, + { ruleCode: 'GR.2', ruleContent: 'Second' } + ]; + expect(handleDuplicateCodes(input)).toEqual(input); + }); + + it('suffixes a duplicate code and remaps children pointing at the original code', () => { + const input: ParsedRule[] = [ + { ruleCode: 'GR.1', ruleContent: 'First occurrence' }, + { ruleCode: 'GR.1', ruleContent: 'Second occurrence (duplicate)' }, + { ruleCode: 'GR.1.a', ruleContent: 'Child', parentRuleCode: 'GR.1' } + ]; + const result = handleDuplicateCodes(input); + expect(result[0].ruleCode).toBe('GR.1'); + expect(result[1].ruleCode).toBe('GR.1.duplicate'); + expect(result[2].parentRuleCode).toBe('GR.1.duplicate'); + }); + + it('suffixes a third occurrence with an incrementing number', () => { + const input: ParsedRule[] = [ + { ruleCode: 'GR.1', ruleContent: 'First' }, + { ruleCode: 'GR.1', ruleContent: 'Second' }, + { ruleCode: 'GR.1', ruleContent: 'Third' } + ]; + const result = handleDuplicateCodes(input); + expect(result.map((r) => r.ruleCode)).toEqual(['GR.1', 'GR.1.duplicate', 'GR.1.duplicate2']); + }); + }); + + describe('extractSubRules', () => { + it('returns the original rule unsplit when there are no lettered items', () => { + const result = extractSubRules('T.1', 'Just some plain rule text.'); + expect(result).toEqual([{ ruleCode: 'T.1', ruleContent: 'Just some plain rule text.', parentRuleCode: 'T' }]); + }); + + it('splits lettered sub-rules that each start their own line', () => { + const content = 'Main text\na. First sub-rule\nb. Second sub-rule'; + const result = extractSubRules('EV.5.2', content); + expect(result).toEqual([ + { ruleCode: 'EV.5.2', ruleContent: 'Main text', parentRuleCode: 'EV.5' }, + { ruleCode: 'EV.5.2.a', ruleContent: 'First sub-rule', parentRuleCode: 'EV.5.2' }, + { ruleCode: 'EV.5.2.b', ruleContent: 'Second sub-rule', parentRuleCode: 'EV.5.2' } + ]); + }); + + it('splits a lettered item that immediately follows a list-introducing colon on the same line', () => { + const content = 'Requirements: a. First item\nb. Second item'; + const result = extractSubRules('T.1', content); + expect(result.map((r) => [r.ruleCode, r.ruleContent])).toEqual([ + ['T.1', 'Requirements:'], + ['T.1.a', 'First item'], + ['T.1.b', 'Second item'] + ]); + }); + + it('splits a lettered item starting at the very beginning of the content, with no intro text', () => { + const content = 'a. First item\nb. Second item'; + const result = extractSubRules('T.1', content); + expect(result.map((r) => [r.ruleCode, r.ruleContent])).toEqual([ + ['T.1', ''], + ['T.1.a', 'First item'], + ['T.1.b', 'Second item'] + ]); + }); + + it('does not split on a cross-reference like "p. 10" mid-sentence', () => { + const content = 'See p. 10 for the full diagram.'; + const result = extractSubRules('T.1', content); + expect(result).toEqual([{ ruleCode: 'T.1', ruleContent: content, parentRuleCode: 'T' }]); + }); + + it('does not split on an abbreviation like "fig. b." mid-sentence', () => { + const content = 'Refer to fig. b. above for clarification.'; + const result = extractSubRules('T.1', content); + expect(result).toEqual([{ ruleCode: 'T.1', ruleContent: content, parentRuleCode: 'T' }]); + }); + }); + + // FSAE Testing + describe('parseRuleNumberFSAE', () => { + it('matches a rule code followed by inline content', () => { + expect(parseRuleNumberFSAE('GR.1.1 Cars must have a roll bar.')).toEqual({ + ruleCode: 'GR.1.1', + ruleContent: 'Cars must have a roll bar.' + }); + }); + + it('matches a section header', () => { + expect(parseRuleNumberFSAE('GR - GENERAL REGULATIONS')).toEqual({ + ruleCode: 'GR', + ruleContent: 'GENERAL REGULATIONS' + }); + }); + + it('matches a bare rule code with no inline content, returning empty content', () => { + expect(parseRuleNumberFSAE('GR.1.1')).toEqual({ ruleCode: 'GR.1.1', ruleContent: '' }); + }); + + it('collapses runs of 5+ dots down to exactly 5', () => { + const result = parseRuleNumberFSAE('GR.1.1 See table.......... 12'); + expect(result?.ruleContent).toBe('See table..... 12'); + }); + + it('returns null for a line that does not look like a rule', () => { + expect(parseRuleNumberFSAE('This is just ordinary prose.')).toBeNull(); + }); + }); + + describe('fixOrphanedRulesFSAE', () => { + it('leaves a rule unchanged when its parent exists', () => { + const input: ParsedRule[] = [ + { ruleCode: 'GR.1', ruleContent: 'Parent' }, + { ruleCode: 'GR.1.1', ruleContent: 'Child', parentRuleCode: 'GR.1' } + ]; + expect(fixOrphanedRulesFSAE(input)).toEqual(input); + }); + + it('walks up to the nearest existing ancestor when the direct parent is missing', () => { + const input: ParsedRule[] = [ + { ruleCode: 'D', ruleContent: 'Top level' }, + { ruleCode: 'D.8.1.2', ruleContent: 'Deep child', parentRuleCode: 'D.8.1' } + ]; + const result = fixOrphanedRulesFSAE(input); + expect(result[1].parentRuleCode).toBe('D'); + }); + + it('becomes top-level when no ancestor exists at all', () => { + const input: ParsedRule[] = [{ ruleCode: 'D.8.1.2', ruleContent: 'Orphan', parentRuleCode: 'D.8.1' }]; + const result = fixOrphanedRulesFSAE(input); + expect(result[0].parentRuleCode).toBeUndefined(); + }); + }); + + describe('parseRuleNumberFHE', () => { + it('matches a digit-prefixed rule code', () => { + expect(parseRuleNumberFHE('1T3.17.1 Battery enclosures must be sealed.')).toEqual({ + ruleCode: '1T3.17.1', + ruleContent: 'Battery enclosures must be sealed.' + }); + }); + + it('matches a plain letter+digit rule code with no leading digit', () => { + expect(parseRuleNumberFHE('EV5.6 Accumulator systems must address stack arrangement.')).toEqual({ + ruleCode: 'EV5.6', + ruleContent: 'Accumulator systems must address stack arrangement.' + }); + }); + + it('matches a PART header and strips the "PART" keyword from the code', () => { + expect(parseRuleNumberFHE('PART A1 - ADMINISTRATIVE REGULATIONS')).toEqual({ + ruleCode: 'A1', + ruleContent: 'ADMINISTRATIVE REGULATIONS' + }); + }); + + it('matches an ARTICLE header and strips the "ARTICLE" keyword from the code', () => { + expect(parseRuleNumberFHE('ARTICLE A11 FORMULA HYBRID + ELECTRIC OVERVIEW')).toEqual({ + ruleCode: 'A11', + ruleContent: 'FORMULA HYBRID + ELECTRIC OVERVIEW' + }); + }); + + it('matches a bare rule code with no inline content, returning empty content', () => { + expect(parseRuleNumberFHE('EV5.6')).toEqual({ ruleCode: 'EV5.6', ruleContent: '' }); + expect(parseRuleNumberFHE('1T3.17.1')).toEqual({ ruleCode: '1T3.17.1', ruleContent: '' }); + }); + + it('returns null for a line that does not look like a rule', () => { + expect(parseRuleNumberFHE('This is just ordinary prose.')).toBeNull(); + }); + }); + + describe('fixOrphanedRulesFHE', () => { + it('leaves a rule unchanged when its parent exists', () => { + const input: ParsedRule[] = [ + { ruleCode: '1T3', ruleContent: 'Parent' }, + { ruleCode: '1T3.17', ruleContent: 'Child', parentRuleCode: '1T3' } + ]; + expect(fixOrphanedRulesFHE(input)).toEqual(input); + }); + + it('falls back to the article-format parent (1A11 -> A11) when the digit-prefixed parent is missing', () => { + const input: ParsedRule[] = [ + { ruleCode: 'A11', ruleContent: 'Article A11 overview' }, + { ruleCode: '1A11.1', ruleContent: 'Sub-rule', parentRuleCode: '1A11' } + ]; + const result = fixOrphanedRulesFHE(input); + expect(result[1].parentRuleCode).toBe('A11'); + }); + + it('strips a leading digit from an intermediate ancestor found while walking up the hierarchy', () => { + const input: ParsedRule[] = [ + { ruleCode: 'A5', ruleContent: 'Article A5 overview' }, + { ruleCode: '1A5.1.2', ruleContent: 'Deep sub-rule', parentRuleCode: '1A5.1' } + ]; + const result = fixOrphanedRulesFHE(input); + expect(result[1].parentRuleCode).toBe('A5'); + }); + + it('becomes top-level when no ancestor exists at all', () => { + const input: ParsedRule[] = [{ ruleCode: '1A5.1', ruleContent: 'Orphan', parentRuleCode: '1A5' }]; + const result = fixOrphanedRulesFHE(input); + expect(result[0].parentRuleCode).toBeUndefined(); + }); + }); + + describe('parseFSAERules', () => { + it('parses multiple top-level and nested rules from multi-line text', () => { + const text = ['T - TECHNICAL ASPECTS', 'T.1 COCKPIT', 'T.1.1 Cockpit Opening'].join('\n'); + const result = parseFSAERules(text); + expect(result.map((r) => [r.ruleCode, r.ruleContent])).toEqual([ + ['T', 'TECHNICAL ASPECTS'], + ['T.1', 'COCKPIT'], + ['T.1.1', 'Cockpit Opening'] + ]); + }); + + it('opens a new rule from a bare code line instead of merging it into the previous rule', () => { + const text = ['GR.1.1 First rule content.', 'GR.1.2', 'Body text starting on the next line.'].join('\n'); + const result = parseFSAERules(text); + expect(result.map((r) => [r.ruleCode, r.ruleContent])).toEqual([ + ['GR.1.1', 'First rule content.'], + ['GR.1.2', 'Body text starting on the next line.'] + ]); + }); + + it('captures text before the first recognized rule as an UNPARSED rule instead of dropping it', () => { + const text = ['Cover page filler text.', 'More preamble.', 'GR.1.1 The real first rule.'].join('\n'); + const result = parseFSAERules(text); + expect(result[0]).toEqual({ + ruleCode: 'UNPARSED.1', + ruleContent: 'Cover page filler text. More preamble.', + parentRuleCode: undefined + }); + expect(result[1].ruleCode).toBe('GR.1.1'); + }); + + it('skips bare page number lines without dropping surrounding content', () => { + const text = ['GR.1.1 First rule.', '7', 'Page 7 of 143', 'GR.1.2 Second rule.'].join('\n'); + const result = parseFSAERules(text); + expect(result.map((r) => r.ruleCode)).toEqual(['GR.1.1', 'GR.1.2']); + }); + }); + + describe('parseFHERules', () => { + it('recognizes a plain letter+digit code (EV5.6) as its own rule instead of merging into the enclosing ARTICLE', () => { + const text = [ + 'ARTICLE EV1 POUCH TYPE LITHIUM-ION CELLS', + 'Important Note: Designing an accumulator system utilizing pouch cells is a substantial undertaking.', + 'EV5.6 Accumulator systems using pouch cells must address stack arrangement.', + 'EV5.7 Teams must provide details of the design in their ESF1 and ESF2 submissions.' + ].join('\n'); + const result = parseFHERules(text); + expect(result.map((r) => r.ruleCode)).toEqual(['EV1', 'EV5.6', 'EV5.7']); + expect(result[1].ruleContent).toBe('Accumulator systems using pouch cells must address stack arrangement.'); + expect(result[2].ruleContent).toBe('Teams must provide details of the design in their ESF1 and ESF2 submissions.'); + }); + + it('opens a new rule from a bare code line instead of merging it into the previous rule', () => { + const text = ['1T3.17.1 First rule content.', '1T3.17.2', 'Body text starting on the next line.'].join('\n'); + const result = parseFHERules(text); + expect(result.map((r) => [r.ruleCode, r.ruleContent])).toEqual([ + ['1T3.17.1', 'First rule content.'], + ['1T3.17.2', 'Body text starting on the next line.'] + ]); + }); + + it('captures text before the first recognized rule as an UNPARSED rule instead of dropping it', () => { + const text = ['2026 Formula Hybrid + Electric Rules', 'Table of contents filler.', '1T3.1 The real first rule.'].join( + '\n' + ); + const result = parseFHERules(text); + expect(result[0]).toEqual({ + ruleCode: 'UNPARSED.1', + ruleContent: '2026 Formula Hybrid + Electric Rules Table of contents filler.', + parentRuleCode: undefined + }); + expect(result[1].ruleCode).toBe('1T3.1'); + }); + }); +}); + +// mocks pdf-parse-new, used to test parsing logic without real PDF files +vi.mock('pdf-parse-new', () => { + // loop over pages 1..N, call options.pagerender for each one and join with '\n\n'. + const fn: any = async (_buffer: Buffer, options: any) => { + const pages = (globalThis as any).__testPages as string[]; + let text = ''; + for (let i = 1; i <= pages.length; i++) { + text += `\n\n${await options.pagerender({ pageNumber: i })}`; + } + return { text, numpages: pages.length, numrender: pages.length, info: null, metadata: null }; + }; + // mimics the library's default per-page text extractor + fn.DEFAULT_OPTIONS = { + pagerender: async (pageData: any) => { + const pages = (globalThis as any).__testPages as string[]; + return pages[pageData.pageNumber - 1] ?? ''; + } + }; + return { default: fn }; +}); + +describe('Parsing pdf Tests', () => { + describe('makePageRenderer', () => { + beforeEach(() => { + (globalThis as any).__testPages = ['page one text', 'page two text', 'page three text']; + }); + + it('delegates to the default renderer for every page when firstRulePage is not given', async () => { + expect(await makePageRenderer(undefined)({ pageNumber: 1 })).toBe('page one text'); + expect(await makePageRenderer(undefined)({ pageNumber: 3 })).toBe('page three text'); + }); + + it('returns empty text for pages before firstRulePage and delegates from firstRulePage onward', async () => { + expect(await makePageRenderer(3)({ pageNumber: 1 })).toBe(''); + expect(await makePageRenderer(3)({ pageNumber: 2 })).toBe(''); + expect(await makePageRenderer(3)({ pageNumber: 3 })).toBe('page three text'); + }); + }); + + describe('parseRulesFromPdf', () => { + it('dispatches to the FSAE parser for parserType "FSAE"', async () => { + (globalThis as any).__testPages = ['GR.1.1 Cars must have a roll bar.']; + const result = await parseRulesFromPdf(Buffer.from(''), 'FSAE'); + expect(result).toEqual([{ ruleCode: 'GR.1.1', ruleContent: 'Cars must have a roll bar.', parentRuleCode: undefined }]); + }); + + it('dispatches to the FHE parser for parserType "FHE"', async () => { + (globalThis as any).__testPages = ['EV5.6 Accumulator systems must address stack arrangement.']; + const result = await parseRulesFromPdf(Buffer.from(''), 'FHE'); + expect(result).toEqual([ + { ruleCode: 'EV5.6', ruleContent: 'Accumulator systems must address stack arrangement.', parentRuleCode: undefined } + ]); + }); + + it('throws for an unrecognized parser type', async () => { + (globalThis as any).__testPages = ['GR.1.1 Cars must have a roll bar.']; + await expect(parseRulesFromPdf(Buffer.from(''), 'INVALID' as unknown as 'FSAE')).rejects.toThrow( + "Invalid parser type: INVALID. Must be 'FSAE' or 'FHE'" + ); + }); + + it('skips pages before firstRulePage entirely', async () => { + (globalThis as any).__testPages = ['TABLE OF CONTENTS\nGR.1.1 ..... 5', 'GR.1.1 Cars must have a roll bar.']; + const result = await parseRulesFromPdf(Buffer.from(''), 'FSAE', 2); + expect(result).toEqual([{ ruleCode: 'GR.1.1', ruleContent: 'Cars must have a roll bar.', parentRuleCode: undefined }]); + }); + + it('real 2026 FSAE rules with subrules', async () => { + (globalThis as any).__testPages = [ + '2026 FSAE Rules\nTABLE OF CONTENTS', + [ + 'PS.3.2 Penalty Detail', + 'PS.3.2.1 Late Submissions get a point penalty as shown in Table PS-2, subject to official discretion', + 'PS.3.2.2 Additional penalties will apply if Not Submitted, subject to official discretion', + 'PS.3.2.3 Penalties up to and including Removal of Team Entry may apply based on document reviews,', + 'subject to official discretion', + 'PS.3.3 Removal of Team Entry', + 'PS.3.3.1 The organizer may remove the team entry when a:' + ].join('\n'), + [ + 'a. Grounds for Removal document is Not Submitted in 24 hours or less after the deadline.', + 'Removals will occur after each Document Submission deadline', + 'b. Team does not respond to Reviewer requests or organizer communications', + 'PS.3.3.2 When a team entry will be removed:', + 'a. The team will be notified prior to cancelling registration', + 'b. No refund of entry fees will be given' + ].join('\n') + ]; + const result = await parseRulesFromPdf(Buffer.from(''), 'FSAE', 2); + expect(result.map((r) => [r.ruleCode, r.ruleContent])).toEqual([ + ['PS.3.2', 'Penalty Detail'], + ['PS.3.2.1', 'Late Submissions get a point penalty as shown in Table PS-2, subject to official discretion'], + ['PS.3.2.2', 'Additional penalties will apply if Not Submitted, subject to official discretion'], + [ + 'PS.3.2.3', + 'Penalties up to and including Removal of Team Entry may apply based on document reviews, subject to official discretion' + ], + ['PS.3.3', 'Removal of Team Entry'], + ['PS.3.3.1', 'The organizer may remove the team entry when a:'], + [ + 'PS.3.3.1.a', + 'Grounds for Removal document is Not Submitted in 24 hours or less after the deadline. Removals will occur after each Document Submission deadline' + ], + ['PS.3.3.1.b', 'Team does not respond to Reviewer requests or organizer communications'], + ['PS.3.3.2', 'When a team entry will be removed:'], + ['PS.3.3.2.a', 'The team will be notified prior to cancelling registration'], + ['PS.3.3.2.b', 'No refund of entry fees will be given'] + ]); + }); + + it('real 2026 FHE rules with a page boundary', async () => { + (globalThis as any).__testPages = [ + '2026 Formula Hybrid + Electric Rules\nTABLE OF CONTENTS', + [ + 'A2.3.2 Teams planning to enter a vehicle in the HIP category will initially register as a Hybrid. To', + 'change to the HIP category, the team must submit a request to the organizers in writing before', + 'the start of the design event.', + 'Note: The advantages of entering as an HIP are:', + '(a) Receive a full technical inspection of the vehicle and electrical drive systems.', + '(b) Participate in all the competition events. (Provided tech inspection is passed).', + '(c) Receive feedback from the design judges.', + 'Note: Teams can maximize the benefits of an HIP entry by including the full-hybrid', + 'designs in their document submissions and design event presentations, as well as', + 'including the full multi-year program in their Project Management materials.', + '(d) When the vehicle is completed and entered as a hybrid, in a subsequent competition, it is', + 'considered an all-new vehicle, and not a second-year entry.', + '2026 Formula Hybrid + Electric Rules – Rev. 1 3 September 11, 2025' + ].join('\n'), + [ + 'A2.4 Static Events Only (SEO)', + 'A2.4.1 SEO is a category that may only be declared after arrival at the competition. All teams must', + 'initially register as either Hybrid/HIP or Electric.', + 'A2.4.2 A team may declare themselves as SEO and participate in the design2 and other static events', + 'even if the vehicle is in an unfinished state.', + '(a) An SEO vehicle may not participate in any of the dynamic events.', + '(b) An SEO vehicle may continue the technical inspection process, but will be given a lower', + 'priority than the non-SEO teams.', + 'A2.4.3 An SEO declaration must be submitted in writing to the organizers before the scheduled start of', + 'the design events.' + ].join('\n') + ]; + const result = await parseRulesFromPdf(Buffer.from(''), 'FHE', 2); + expect(result.map((r) => [r.ruleCode, r.ruleContent])).toEqual([ + [ + 'A2.3.2', + 'Teams planning to enter a vehicle in the HIP category will initially register as a Hybrid. To change to the HIP category, the team must submit a request to the organizers in writing before the start of the design event. Note: The advantages of entering as an HIP are:' + ], + ['A2.3.2.a', 'Receive a full technical inspection of the vehicle and electrical drive systems.'], + ['A2.3.2.b', 'Participate in all the competition events. (Provided tech inspection is passed).'], + [ + 'A2.3.2.c', + 'Receive feedback from the design judges. Note: Teams can maximize the benefits of an HIP entry by including the full-hybrid designs in their document submissions and design event presentations, as well as including the full multi-year program in their Project Management materials.' + ], + [ + 'A2.3.2.d', + 'When the vehicle is completed and entered as a hybrid, in a subsequent competition, it is considered an all-new vehicle, and not a second-year entry. 2026 Formula Hybrid + Electric Rules – Rev. 1 3 September 11, 2025' + ], + ['A2.4', 'Static Events Only (SEO)'], + [ + 'A2.4.1', + 'SEO is a category that may only be declared after arrival at the competition. All teams must initially register as either Hybrid/HIP or Electric.' + ], + [ + 'A2.4.2', + 'A team may declare themselves as SEO and participate in the design2 and other static events even if the vehicle is in an unfinished state.' + ], + ['A2.4.2.a', 'An SEO vehicle may not participate in any of the dynamic events.'], + [ + 'A2.4.2.b', + 'An SEO vehicle may continue the technical inspection process, but will be given a lower priority than the non-SEO teams.' + ], + [ + 'A2.4.3', + 'An SEO declaration must be submitted in writing to the organizers before the scheduled start of the design events.' + ] + ]); + }); + }); +}); diff --git a/src/frontend/src/apis/rules.api.ts b/src/frontend/src/apis/rules.api.ts index e2df230649..bdf0cbd61c 100644 --- a/src/frontend/src/apis/rules.api.ts +++ b/src/frontend/src/apis/rules.api.ts @@ -234,7 +234,8 @@ export const createRuleset = (payload: CreateRulesetPayload) => { export const parseRuleset = (payload: ParseRulesetPayload) => { return axios.post(apiUrls.parseRuleset(payload.rulesetId), { fileId: payload.fileId, - parserType: payload.parserType + parserType: payload.parserType, + firstRulePage: payload.firstRulePage }); }; diff --git a/src/frontend/src/hooks/rules.hooks.ts b/src/frontend/src/hooks/rules.hooks.ts index 8e3cddf799..ac65d749b9 100644 --- a/src/frontend/src/hooks/rules.hooks.ts +++ b/src/frontend/src/hooks/rules.hooks.ts @@ -131,6 +131,7 @@ export interface ParseRulesetPayload { rulesetId: string; fileId: string; parserType: 'FSAE' | 'FHE'; + firstRulePage?: number; } export interface CreateRulesetPayload { diff --git a/src/frontend/src/pages/RulesPage/RulesetPage.tsx b/src/frontend/src/pages/RulesPage/RulesetPage.tsx index 64775f3c7a..5f89869cf8 100644 --- a/src/frontend/src/pages/RulesPage/RulesetPage.tsx +++ b/src/frontend/src/pages/RulesPage/RulesetPage.tsx @@ -31,7 +31,13 @@ const RulesetPage: React.FC = () => { const [AddFileModalShow, setAddFileModalShow] = React.useState(false); const { data: rulesetType, isLoading, isError, error } = useRulesetType(rulesetTypeId); - const handleFileConfirm = async (data: { fileId: string; name: string; carNumber: number; parserType: string }) => { + const handleFileConfirm = async (data: { + fileId: string; + name: string; + carNumber: number; + parserType: string; + firstRulePage?: number; + }) => { setAddFileModalShow(false); toast.info('Creating ruleset and parsing rules...'); @@ -56,7 +62,8 @@ const RulesetPage: React.FC = () => { const parsedRules = await parseRuleset({ rulesetId, fileId: data.fileId, - parserType: data.parserType as 'FSAE' | 'FHE' + parserType: data.parserType as 'FSAE' | 'FHE', + firstRulePage: data.firstRulePage }); toast.success(`Successfully parsed ${parsedRules.length} rules!`); } catch (e) { diff --git a/src/frontend/src/pages/RulesPage/components/AddNewFileModal.tsx b/src/frontend/src/pages/RulesPage/components/AddNewFileModal.tsx index 8df5e0bd7d..aaf20a6464 100644 --- a/src/frontend/src/pages/RulesPage/components/AddNewFileModal.tsx +++ b/src/frontend/src/pages/RulesPage/components/AddNewFileModal.tsx @@ -1,11 +1,25 @@ import NERFormModal from '../../../components/NERFormModal'; import { useForm, Controller } from 'react-hook-form'; -import { Box, FormControl, TextField, Typography, FormLabel, FormHelperText, Button, Select, MenuItem } from '@mui/material'; +import { + Box, + FormControl, + TextField, + Typography, + FormLabel, + FormHelperText, + Button, + Select, + MenuItem, + Accordion, + AccordionSummary, + AccordionDetails +} from '@mui/material'; import { useEffect, useState } from 'react'; import * as yup from 'yup'; import { yupResolver } from '@hookform/resolvers/yup'; import { useToast } from '../../../hooks/toasts.hooks'; import { FileUpload } from '@mui/icons-material'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import { MAX_FILE_SIZE } from 'shared'; import { useUploadRulesetFile } from '../../../hooks/rules.hooks'; import { useGetAllCars } from '../../../hooks/cars.hooks'; @@ -21,6 +35,7 @@ interface NewFileFormData { name: string; carNumber: number; parserType: 'FSAE' | 'FHE'; + firstRulePage?: number; } interface ButtonGroupProps { @@ -47,7 +62,8 @@ const schema = yup.object({ fileId: yup.string().required('File is required'), name: yup.string().required('Name is required'), carNumber: yup.number().min(0).required('Car is required'), - parserType: yup.string().oneOf(['FSAE', 'FHE']).required('Parser type is required') + parserType: yup.string().oneOf(['FSAE', 'FHE']).required('Parser type is required'), + firstRulePage: yup.number().min(1, 'Minimum is page 1').integer('Must be a whole number').optional() }); const ButtonGroup: React.FC = ({ options, value, onChange }) => { @@ -88,6 +104,7 @@ const AddNewFileModal: React.FC = ({ open, onHide, onFormS const toast = useToast(); const [file, setFile] = useState(null); const [uploading, setUploading] = useState(false); + const [additionalOptionsOpen, setAdditionalOptionsOpen] = useState(false); const { mutateAsync: uploadFile } = useUploadRulesetFile(); const { data: cars, isLoading: carsLoading, isError: carsError } = useGetAllCars(); @@ -103,7 +120,8 @@ const AddNewFileModal: React.FC = ({ open, onHide, onFormS fileId: '', name: '', carNumber: 100, - parserType: 'FSAE' + parserType: 'FSAE', + firstRulePage: undefined } }); @@ -168,12 +186,14 @@ const AddNewFileModal: React.FC = ({ open, onHide, onFormS const handleModalClose = () => { setFile(null); reset(); + setAdditionalOptionsOpen(false); onHide(); }; const handleReset = () => { setFile(null); reset(); + setAdditionalOptionsOpen(false); }; return ( @@ -190,7 +210,7 @@ const AddNewFileModal: React.FC = ({ open, onHide, onFormS > - + {/* File Upload */} Upload Ruleset File: @@ -262,6 +282,59 @@ const AddNewFileModal: React.FC = ({ open, onHide, onFormS /> {errors.name?.message} + + {/* Additional Options */} + setAdditionalOptionsOpen(isExpanded)} + disableGutters + elevation={0} + square + sx={{ + backgroundColor: 'transparent', + backgroundImage: 'none', + '&:before': { display: 'none' } + }} + > + } + sx={{ + flexDirection: 'row-reverse', + gap: 1, + minHeight: 'unset', + px: 0, + '& .MuiAccordionSummary-content': { my: 0 } + }} + > + + Additional Options + + + + {/* First Rule Page */} + + First Page #: + ( + onChange(e.target.value === '' ? undefined : Number(e.target.value))} + slotProps={{ htmlInput: { min: 1 } }} + error={!!errors.firstRulePage} + sx={{ width: 160 }} + /> + )} + /> + + {errors.firstRulePage?.message ?? 'Optional, skips earlier pages'} + + + +