Skip to content

Commit 038bd72

Browse files
Merge pull request #228 from CSSLab/codex/fix-custom-pgn-import
Fix custom PGN import parsing
2 parents 6b23e5f + 9516044 commit 038bd72

3 files changed

Lines changed: 236 additions & 16 deletions

File tree

src/api/analysis.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -358,11 +358,18 @@ export const storeCustomGame = async (data: {
358358
body: JSON.stringify(data),
359359
})
360360

361+
const bodyText = await res.text()
362+
361363
if (!res.ok) {
362-
console.error(`Failed to store custom game: ${await res.text()}`)
364+
console.error(`Failed to store custom game: ${bodyText}`)
365+
throw new Error(
366+
`Failed to store custom game (${res.status} ${res.statusText})${
367+
bodyText ? `: ${bodyText}` : ''
368+
}`,
369+
)
363370
}
364371

365-
return res.json()
372+
return JSON.parse(bodyText)
366373
}
367374

368375
export const deleteCustomGame = async (gameId: string): Promise<void> => {

src/components/Analysis/CustomAnalysisModal.tsx

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

11+
const PGN_HEADER_LINE_REGEX = /^\s*\[[^\]]+\]\s*$/
12+
const PGN_RESULT_TOKENS = new Set(['1-0', '0-1', '1/2-1/2', '*'])
13+
14+
const ensureBlankLineAfterPgnHeaders = (pgn: string): string => {
15+
const normalizedNewlines = pgn.replace(/\r\n/g, '\n')
16+
const lines = normalizedNewlines.split('\n')
17+
18+
let firstContentLine = 0
19+
while (
20+
firstContentLine < lines.length &&
21+
lines[firstContentLine].trim().length === 0
22+
) {
23+
firstContentLine++
24+
}
25+
26+
let headerEndLine = firstContentLine
27+
while (
28+
headerEndLine < lines.length &&
29+
PGN_HEADER_LINE_REGEX.test(lines[headerEndLine])
30+
) {
31+
headerEndLine++
32+
}
33+
34+
const hasHeaderBlock = headerEndLine > firstContentLine
35+
const hasMovetextAfterHeaders = headerEndLine < lines.length
36+
const needsSeparator =
37+
hasHeaderBlock &&
38+
hasMovetextAfterHeaders &&
39+
lines[headerEndLine].trim().length > 0
40+
41+
if (needsSeparator) {
42+
lines.splice(headerEndLine, 0, '')
43+
}
44+
45+
return lines.join('\n').trim()
46+
}
47+
48+
const formatMoveHistoryAsPgn = (moves: string[]): string => {
49+
const pgnTokens: string[] = []
50+
51+
for (let i = 0; i < moves.length; i += 2) {
52+
pgnTokens.push(`${Math.floor(i / 2) + 1}. ${moves[i]}`)
53+
if (moves[i + 1]) {
54+
pgnTokens.push(moves[i + 1])
55+
}
56+
}
57+
58+
return pgnTokens.join(' ').trim()
59+
}
60+
61+
const extractPgnResultToken = (pgn: string): string | undefined => {
62+
const headerResultMatch = pgn.match(
63+
/\[\s*Result\s+"(1-0|0-1|1\/2-1\/2|\*)"\s*\]/i,
64+
)
65+
if (headerResultMatch) {
66+
return headerResultMatch[1]
67+
}
68+
69+
// Strip comments and variations before scanning for a terminal result token.
70+
let movetext = pgn.replace(/\{[^}]*\}/g, ' ').replace(/;[^\r\n]*/g, ' ')
71+
72+
const ravRegex = /\([^()]*\)/g
73+
while (ravRegex.test(movetext)) {
74+
movetext = movetext.replace(ravRegex, ' ')
75+
}
76+
77+
const tailMatch = movetext.match(/(?:^|\s)(1-0|0-1|1\/2-1\/2|\*)(?:\s*)$/)
78+
return tailMatch?.[1]
79+
}
80+
81+
const normalizePgnForAnalysis = (input: string): string => {
82+
const trimmed = input.trim()
83+
const candidates = Array.from(
84+
new Set([trimmed, ensureBlankLineAfterPgnHeaders(trimmed)]),
85+
)
86+
87+
for (const candidate of candidates) {
88+
for (const sloppy of [false, true]) {
89+
const chess = new Chess()
90+
const loaded = chess.loadPgn(candidate, { sloppy })
91+
92+
if (!loaded) continue
93+
94+
const header = chess.header()
95+
96+
// Preserve SetUp/FEN PGNs since the initial position cannot be represented
97+
// by a move list alone.
98+
if (header.SetUp === '1' && header.FEN) {
99+
return chess.pgn()
100+
}
101+
102+
const moveTextOnly = formatMoveHistoryAsPgn(chess.history())
103+
if (!moveTextOnly) {
104+
throw new Error('PGN must contain at least one move')
105+
}
106+
107+
const resultToken =
108+
(header.Result && PGN_RESULT_TOKENS.has(header.Result)
109+
? header.Result
110+
: extractPgnResultToken(candidate)) || undefined
111+
112+
return resultToken && resultToken !== '*'
113+
? `${moveTextOnly} ${resultToken}`
114+
: moveTextOnly
115+
}
116+
}
117+
118+
throw new Error(
119+
'Unable to parse PGN. If using [Tag "..."] headers, include a blank line before the moves.',
120+
)
121+
}
122+
11123
export const CustomAnalysisModal: React.FC<Props> = ({ onSubmit, onClose }) => {
12124
const [mode, setMode] = useState<'pgn' | 'fen'>('pgn')
13125
const [input, setInput] = useState('')
14126
const [name, setName] = useState('')
15127

16128
const validateAndSubmit = () => {
129+
const trimmedInput = input.trim()
130+
17131
if (!input.trim()) {
18132
toast.error('Please enter some data')
19133
return
20134
}
21135

22136
if (mode === 'fen') {
23137
const chess = new Chess()
24-
const validation = chess.validateFen(input.trim())
138+
const validation = chess.validateFen(trimmedInput)
25139
if (!validation.valid) {
26140
toast.error('Invalid FEN position: ' + validation.error)
27141
return
28142
}
143+
144+
onSubmit(mode, trimmedInput, name.trim() || undefined)
29145
} else {
30146
try {
31-
const chess = new Chess()
32-
chess.loadPgn(input.trim())
147+
const normalizedPgn = normalizePgnForAnalysis(trimmedInput)
148+
onSubmit(mode, normalizedPgn, name.trim() || undefined)
33149
} catch (error) {
34150
toast.error('Invalid PGN format: ' + (error as Error).message)
35151
return
36152
}
37153
}
38-
39-
onSubmit(mode, input.trim(), name.trim() || undefined)
40154
}
41155

42156
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`

src/pages/analysis/[...id].tsx

Lines changed: 108 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
MaiaEvaluation,
2121
StockfishEvaluation,
2222
GameNode,
23+
Termination,
2324
} from 'src/types'
2425
import { WindowSizeContext, TreeControllerContext, useTour } from 'src/contexts'
2526
import { Loading } from 'src/components'
@@ -57,13 +58,91 @@ import { MAIA_MODELS } from 'src/constants/common'
5758
import { applyEngineAnalysisData } from 'src/lib/analysis'
5859

5960
const EVAL_BAR_RANGE = 4
61+
const CUSTOM_PGN_RESULT_OVERRIDES_STORAGE_KEY =
62+
'maia_custom_pgn_result_overrides'
63+
const PGN_RESULT_TOKEN_REGEX = /(.*?)(?:\s+)(1-0|0-1|1\/2-1\/2|\*)\s*$/
6064
const DEFAULT_STOCKFISH_EVAL_BAR = {
6165
hasEval: false,
6266
pawns: 0,
6367
displayPawns: 0,
6468
label: '--',
6569
}
6670

71+
const splitTrailingPgnResult = (
72+
pgn: string,
73+
): { pgnWithoutResult: string; result?: string } => {
74+
const match = pgn.match(PGN_RESULT_TOKEN_REGEX)
75+
if (!match) {
76+
return { pgnWithoutResult: pgn }
77+
}
78+
79+
return {
80+
pgnWithoutResult: match[1].trim(),
81+
result: match[2],
82+
}
83+
}
84+
85+
const getCustomPgnResultOverrides = (): Record<string, string> => {
86+
if (typeof window === 'undefined') return {}
87+
88+
try {
89+
const raw = window.localStorage.getItem(
90+
CUSTOM_PGN_RESULT_OVERRIDES_STORAGE_KEY,
91+
)
92+
if (!raw) return {}
93+
const parsed = JSON.parse(raw)
94+
return parsed && typeof parsed === 'object' ? parsed : {}
95+
} catch (error) {
96+
console.warn('Failed to read custom PGN result overrides:', error)
97+
return {}
98+
}
99+
}
100+
101+
const setCustomPgnResultOverride = (gameId: string, result: string): void => {
102+
if (typeof window === 'undefined') return
103+
104+
try {
105+
const overrides = getCustomPgnResultOverrides()
106+
overrides[gameId] = result
107+
window.localStorage.setItem(
108+
CUSTOM_PGN_RESULT_OVERRIDES_STORAGE_KEY,
109+
JSON.stringify(overrides),
110+
)
111+
} catch (error) {
112+
console.warn('Failed to store custom PGN result override:', error)
113+
}
114+
}
115+
116+
const getCustomPgnResultOverride = (gameId: string): string | undefined => {
117+
const override = getCustomPgnResultOverrides()[gameId]
118+
return typeof override === 'string' ? override : undefined
119+
}
120+
121+
const resultTokenToWinner = (
122+
result: string,
123+
): Termination['winner'] | undefined => {
124+
if (result === '1-0') return 'white'
125+
if (result === '0-1') return 'black'
126+
if (result === '1/2-1/2') return 'none'
127+
return undefined
128+
}
129+
130+
const applyCustomResultOverride = (
131+
game: AnalyzedGame,
132+
result?: string,
133+
): AnalyzedGame => {
134+
if (!result || result === '*') return game
135+
136+
return {
137+
...game,
138+
termination: {
139+
...(game.termination || {}),
140+
result,
141+
winner: resultTokenToWinner(result),
142+
},
143+
}
144+
}
145+
67146
const AnalysisPage: NextPage = () => {
68147
const { startTour, tourState } = useTour()
69148

@@ -162,12 +241,16 @@ const AnalysisPage: NextPage = () => {
162241
updateUrl = true,
163242
) => {
164243
const game = await fetchAnalyzedMaiaGame(id, type)
244+
const gameWithOverrides =
245+
type === 'custom'
246+
? applyCustomResultOverride(game, getCustomPgnResultOverride(id))
247+
: game
165248

166249
if (setCurrentMove) setCurrentMove(0)
167250

168-
setAnalyzedGame({ ...game, type })
251+
setAnalyzedGame({ ...gameWithOverrides, type })
169252
setCurrentId([id, type])
170-
await loadGameAnalysisCache({ ...game, type })
253+
await loadGameAnalysisCache({ ...gameWithOverrides, type })
171254

172255
if (updateUrl) {
173256
router.push(`/analysis/${id}/${type}`, undefined, {
@@ -347,14 +430,30 @@ const Analysis: React.FC<Props> = ({
347430
const handleCustomAnalysis = useCallback(
348431
(type: 'fen' | 'pgn', data: string, name?: string) => {
349432
;(async () => {
350-
const { game_id } = await storeCustomGame({
351-
name: name,
352-
pgn: type === 'pgn' ? data : undefined,
353-
fen: type === 'fen' ? data : undefined,
354-
})
433+
try {
434+
const pgnPayload =
435+
type === 'pgn' ? splitTrailingPgnResult(data) : undefined
436+
const { game_id } = await storeCustomGame({
437+
name: name,
438+
pgn:
439+
type === 'pgn' ? pgnPayload?.pgnWithoutResult || data : undefined,
440+
fen: type === 'fen' ? data : undefined,
441+
})
442+
443+
if (pgnPayload?.result && pgnPayload.result !== '*') {
444+
setCustomPgnResultOverride(game_id, pgnPayload.result)
445+
}
355446

356-
setShowCustomModal(false)
357-
router.push(`/analysis/${game_id}/custom`)
447+
setShowCustomModal(false)
448+
router.push(`/analysis/${game_id}/custom`)
449+
} catch (error) {
450+
const message =
451+
error instanceof Error
452+
? error.message
453+
: 'Failed to store custom game'
454+
console.error('Custom analysis import failed:', error)
455+
toast.error(message)
456+
}
358457
})()
359458
},
360459
[],

0 commit comments

Comments
 (0)