Skip to content

Commit 2759e59

Browse files
Fix custom PGN import parsing
1 parent e668b74 commit 2759e59

1 file changed

Lines changed: 91 additions & 5 deletions

File tree

src/components/Analysis/CustomAnalysisModal.tsx

Lines changed: 91 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,35 +8,121 @@ interface Props {
88
onClose: () => void
99
}
1010

11+
const PGN_HEADER_LINE_REGEX = /^\s*\[[^\]]+\]\s*$/
12+
13+
const ensureBlankLineAfterPgnHeaders = (pgn: string): string => {
14+
const normalizedNewlines = pgn.replace(/\r\n/g, '\n')
15+
const lines = normalizedNewlines.split('\n')
16+
17+
let firstContentLine = 0
18+
while (
19+
firstContentLine < lines.length &&
20+
lines[firstContentLine].trim().length === 0
21+
) {
22+
firstContentLine++
23+
}
24+
25+
let headerEndLine = firstContentLine
26+
while (
27+
headerEndLine < lines.length &&
28+
PGN_HEADER_LINE_REGEX.test(lines[headerEndLine])
29+
) {
30+
headerEndLine++
31+
}
32+
33+
const hasHeaderBlock = headerEndLine > firstContentLine
34+
const hasMovetextAfterHeaders = headerEndLine < lines.length
35+
const needsSeparator =
36+
hasHeaderBlock &&
37+
hasMovetextAfterHeaders &&
38+
lines[headerEndLine].trim().length > 0
39+
40+
if (needsSeparator) {
41+
lines.splice(headerEndLine, 0, '')
42+
}
43+
44+
return lines.join('\n').trim()
45+
}
46+
47+
const formatMoveHistoryAsPgn = (moves: string[]): string => {
48+
const pgnTokens: string[] = []
49+
50+
for (let i = 0; i < moves.length; i += 2) {
51+
pgnTokens.push(`${Math.floor(i / 2) + 1}. ${moves[i]}`)
52+
if (moves[i + 1]) {
53+
pgnTokens.push(moves[i + 1])
54+
}
55+
}
56+
57+
return pgnTokens.join(' ').trim()
58+
}
59+
60+
const normalizePgnForAnalysis = (input: string): string => {
61+
const trimmed = input.trim()
62+
const candidates = Array.from(
63+
new Set([trimmed, ensureBlankLineAfterPgnHeaders(trimmed)]),
64+
)
65+
66+
for (const candidate of candidates) {
67+
for (const sloppy of [false, true]) {
68+
const chess = new Chess()
69+
const loaded = chess.loadPgn(candidate, { sloppy })
70+
71+
if (!loaded) continue
72+
73+
const header = chess.header()
74+
75+
// Preserve SetUp/FEN PGNs since the initial position cannot be represented
76+
// by a move list alone.
77+
if (header.SetUp === '1' && header.FEN) {
78+
return chess.pgn()
79+
}
80+
81+
const moveTextOnly = formatMoveHistoryAsPgn(chess.history())
82+
if (!moveTextOnly) {
83+
throw new Error('PGN must contain at least one move')
84+
}
85+
86+
return moveTextOnly
87+
}
88+
}
89+
90+
throw new Error(
91+
'Unable to parse PGN. If using [Tag "..."] headers, include a blank line before the moves.',
92+
)
93+
}
94+
1195
export const CustomAnalysisModal: React.FC<Props> = ({ onSubmit, onClose }) => {
1296
const [mode, setMode] = useState<'pgn' | 'fen'>('pgn')
1397
const [input, setInput] = useState('')
1498
const [name, setName] = useState('')
1599

16100
const validateAndSubmit = () => {
101+
const trimmedInput = input.trim()
102+
17103
if (!input.trim()) {
18104
toast.error('Please enter some data')
19105
return
20106
}
21107

22108
if (mode === 'fen') {
23109
const chess = new Chess()
24-
const validation = chess.validateFen(input.trim())
110+
const validation = chess.validateFen(trimmedInput)
25111
if (!validation.valid) {
26112
toast.error('Invalid FEN position: ' + validation.error)
27113
return
28114
}
115+
116+
onSubmit(mode, trimmedInput, name.trim() || undefined)
29117
} else {
30118
try {
31-
const chess = new Chess()
32-
chess.loadPgn(input.trim())
119+
const normalizedPgn = normalizePgnForAnalysis(trimmedInput)
120+
onSubmit(mode, normalizedPgn, name.trim() || undefined)
33121
} catch (error) {
34122
toast.error('Invalid PGN format: ' + (error as Error).message)
35123
return
36124
}
37125
}
38-
39-
onSubmit(mode, input.trim(), name.trim() || undefined)
40126
}
41127

42128
const examplePGN = `1. e4 e5 2. Nf3 Nc6 3. Bb5 a6 4. Ba4 Nf6 5. O-O Be7 6. Re1 b5 7. Bb3 d6 8. c3 O-O 9. h3 Bb7 10. d4 Re8`

0 commit comments

Comments
 (0)