Skip to content

Commit 72ca23e

Browse files
Merge pull request #232 from CSSLab/codex/fix-custom-pgn-import
Codex/fix custom pgn import
2 parents d71e985 + dad537c commit 72ca23e

3 files changed

Lines changed: 123 additions & 123 deletions

File tree

src/components/Analysis/CustomAnalysisModal.tsx

Lines changed: 20 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,7 @@ import { motion } from 'framer-motion'
33
import { Chess } from 'chess.ts'
44
import toast from 'react-hot-toast'
55

6-
interface Props {
7-
onSubmit: (type: 'pgn' | 'fen', data: string, name?: string) => void
8-
onClose: () => void
9-
}
10-
116
const PGN_HEADER_LINE_REGEX = /^\s*\[[^\]]+\]\s*$/
12-
const PGN_RESULT_TOKENS = new Set(['1-0', '0-1', '1/2-1/2', '*'])
137

148
const ensureBlankLineAfterPgnHeaders = (pgn: string): string => {
159
const normalizedNewlines = pgn.replace(/\r\n/g, '\n')
@@ -42,82 +36,12 @@ const ensureBlankLineAfterPgnHeaders = (pgn: string): string => {
4236
lines.splice(headerEndLine, 0, '')
4337
}
4438

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]
39+
return lines.join('\n')
7940
}
8041

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-
)
42+
interface Props {
43+
onSubmit: (type: 'pgn' | 'fen', data: string, name?: string) => void
44+
onClose: () => void
12145
}
12246

12347
export const CustomAnalysisModal: React.FC<Props> = ({ onSubmit, onClose }) => {
@@ -128,7 +52,7 @@ export const CustomAnalysisModal: React.FC<Props> = ({ onSubmit, onClose }) => {
12852
const validateAndSubmit = () => {
12953
const trimmedInput = input.trim()
13054

131-
if (!input.trim()) {
55+
if (!trimmedInput) {
13256
toast.error('Please enter some data')
13357
return
13458
}
@@ -140,17 +64,28 @@ export const CustomAnalysisModal: React.FC<Props> = ({ onSubmit, onClose }) => {
14064
toast.error('Invalid FEN position: ' + validation.error)
14165
return
14266
}
143-
144-
onSubmit(mode, trimmedInput, name.trim() || undefined)
14567
} else {
14668
try {
147-
const normalizedPgn = normalizePgnForAnalysis(trimmedInput)
148-
onSubmit(mode, normalizedPgn, name.trim() || undefined)
69+
const candidates = Array.from(
70+
new Set([trimmedInput, ensureBlankLineAfterPgnHeaders(trimmedInput)]),
71+
)
72+
const isValid = candidates.some((candidate) => {
73+
const chess = new Chess()
74+
return chess.loadPgn(candidate, { sloppy: true })
75+
})
76+
77+
if (!isValid) {
78+
throw new Error(
79+
'Unable to parse PGN. If using [Tag \"...\"] headers, include a blank line before the moves.',
80+
)
81+
}
14982
} catch (error) {
15083
toast.error('Invalid PGN format: ' + (error as Error).message)
15184
return
15285
}
15386
}
87+
88+
onSubmit(mode, trimmedInput, name.trim() || undefined)
15489
}
15590

15691
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/components/Board/MovesContainer.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -360,7 +360,7 @@ export const MovesContainer: React.FC<
360360
{termination.result}
361361
{', '}
362362
{termination.winner !== 'none'
363-
? `${termination.winner} is victorious`
363+
? `${termination.winner} wins`
364364
: 'draw'}
365365
</div>
366366
)}
@@ -486,7 +486,7 @@ export const MovesContainer: React.FC<
486486
{termination.result}
487487
{', '}
488488
{termination.winner !== 'none'
489-
? `${termination.winner} is victorious`
489+
? `${termination.winner} wins`
490490
: 'draw'}
491491
</div>
492492
)}

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

Lines changed: 101 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ import {
2020
MaiaEvaluation,
2121
StockfishEvaluation,
2222
GameNode,
23-
Termination,
2423
} from 'src/types'
2524
import { WindowSizeContext, TreeControllerContext, useTour } from 'src/contexts'
2625
import { Loading } from 'src/components'
@@ -60,26 +59,99 @@ import { applyEngineAnalysisData } from 'src/lib/analysis'
6059
const EVAL_BAR_RANGE = 4
6160
const CUSTOM_PGN_RESULT_OVERRIDES_STORAGE_KEY =
6261
'maia_custom_pgn_result_overrides'
63-
const PGN_RESULT_TOKEN_REGEX = /(.*?)(?:\s+)(1-0|0-1|1\/2-1\/2|\*)\s*$/
6462
const DEFAULT_STOCKFISH_EVAL_BAR = {
6563
hasEval: false,
6664
pawns: 0,
6765
displayPawns: 0,
6866
label: '--',
6967
}
7068

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 }
69+
const PGN_HEADER_LINE_REGEX = /^\s*\[[^\]]+\]\s*$/
70+
71+
const ensureBlankLineAfterPgnHeaders = (pgn: string): string => {
72+
const normalizedNewlines = pgn.replace(/\r\n/g, '\n')
73+
const lines = normalizedNewlines.split('\n')
74+
75+
let firstContentLine = 0
76+
while (
77+
firstContentLine < lines.length &&
78+
lines[firstContentLine].trim().length === 0
79+
) {
80+
firstContentLine++
7781
}
7882

79-
return {
80-
pgnWithoutResult: match[1].trim(),
81-
result: match[2],
83+
let headerEndLine = firstContentLine
84+
while (
85+
headerEndLine < lines.length &&
86+
PGN_HEADER_LINE_REGEX.test(lines[headerEndLine])
87+
) {
88+
headerEndLine++
89+
}
90+
91+
const hasHeaderBlock = headerEndLine > firstContentLine
92+
const hasMovetextAfterHeaders = headerEndLine < lines.length
93+
const needsSeparator =
94+
hasHeaderBlock &&
95+
hasMovetextAfterHeaders &&
96+
lines[headerEndLine].trim().length > 0
97+
98+
if (needsSeparator) {
99+
lines.splice(headerEndLine, 0, '')
100+
}
101+
102+
return lines.join('\n').trim()
103+
}
104+
105+
const formatMoveHistoryAsPgn = (moves: string[]): string => {
106+
const tokens: string[] = []
107+
108+
for (let i = 0; i < moves.length; i += 2) {
109+
tokens.push(`${Math.floor(i / 2) + 1}. ${moves[i]}`)
110+
if (moves[i + 1]) {
111+
tokens.push(moves[i + 1])
112+
}
113+
}
114+
115+
return tokens.join(' ').trim()
116+
}
117+
118+
const normalizeCustomPgnForBackendStore = (pgn: string): string => {
119+
const trimmed = pgn.trim()
120+
const candidates = Array.from(
121+
new Set([trimmed, ensureBlankLineAfterPgnHeaders(trimmed)]),
122+
)
123+
124+
for (const candidate of candidates) {
125+
const chess = new Chess()
126+
if (!chess.loadPgn(candidate, { sloppy: true })) {
127+
continue
128+
}
129+
130+
const header = { ...chess.header() }
131+
132+
const moveText = formatMoveHistoryAsPgn(chess.history())
133+
const headerText = Object.entries(header)
134+
.map(([key, value]) => `[${key} "${value}"]`)
135+
.join('\n')
136+
137+
if (!headerText) {
138+
return moveText
139+
}
140+
141+
return moveText ? `${headerText}\n\n${moveText}` : headerText
82142
}
143+
144+
return ensureBlankLineAfterPgnHeaders(trimmed)
145+
}
146+
147+
const extractPgnResultToken = (pgn: string): string | undefined => {
148+
const headerMatch = pgn.match(/\[\s*Result\s+"(1-0|0-1|1\/2-1\/2|\*)"\s*\]/i)
149+
if (headerMatch) {
150+
return headerMatch[1]
151+
}
152+
153+
const tailMatch = pgn.match(/(?:^|\s)(1-0|0-1|1\/2-1\/2|\*)(?:\s*)$/)
154+
return tailMatch?.[1]
83155
}
84156

85157
const getCustomPgnResultOverrides = (): Record<string, string> => {
@@ -113,24 +185,17 @@ const setCustomPgnResultOverride = (gameId: string, result: string): void => {
113185
}
114186
}
115187

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 => {
188+
const resultTokenToWinner = (result: string): 'white' | 'black' | 'none' => {
124189
if (result === '1-0') return 'white'
125190
if (result === '0-1') return 'black'
126-
if (result === '1/2-1/2') return 'none'
127-
return undefined
191+
return 'none'
128192
}
129193

130-
const applyCustomResultOverride = (
194+
const applyCustomPgnResultOverride = (
195+
gameId: string,
131196
game: AnalyzedGame,
132-
result?: string,
133197
): AnalyzedGame => {
198+
const result = getCustomPgnResultOverrides()[gameId]
134199
if (!result || result === '*') return game
135200

136201
return {
@@ -240,17 +305,15 @@ const AnalysisPage: NextPage = () => {
240305
setCurrentMove?: Dispatch<SetStateAction<number>>,
241306
updateUrl = true,
242307
) => {
243-
const game = await fetchAnalyzedMaiaGame(id, type)
244-
const gameWithOverrides =
245-
type === 'custom'
246-
? applyCustomResultOverride(game, getCustomPgnResultOverride(id))
247-
: game
308+
const rawGame = await fetchAnalyzedMaiaGame(id, type)
309+
const game =
310+
type === 'custom' ? applyCustomPgnResultOverride(id, rawGame) : rawGame
248311

249312
if (setCurrentMove) setCurrentMove(0)
250313

251-
setAnalyzedGame({ ...gameWithOverrides, type })
314+
setAnalyzedGame({ ...game, type })
252315
setCurrentId([id, type])
253-
await loadGameAnalysisCache({ ...gameWithOverrides, type })
316+
await loadGameAnalysisCache({ ...game, type })
254317

255318
if (updateUrl) {
256319
router.push(`/analysis/${id}/${type}`, undefined, {
@@ -431,17 +494,19 @@ const Analysis: React.FC<Props> = ({
431494
(type: 'fen' | 'pgn', data: string, name?: string) => {
432495
;(async () => {
433496
try {
434-
const pgnPayload =
435-
type === 'pgn' ? splitTrailingPgnResult(data) : undefined
497+
const pgnResult =
498+
type === 'pgn' ? extractPgnResultToken(data) : undefined
436499
const { game_id } = await storeCustomGame({
437500
name: name,
438501
pgn:
439-
type === 'pgn' ? pgnPayload?.pgnWithoutResult || data : undefined,
502+
type === 'pgn'
503+
? normalizeCustomPgnForBackendStore(data)
504+
: undefined,
440505
fen: type === 'fen' ? data : undefined,
441506
})
442507

443-
if (pgnPayload?.result && pgnPayload.result !== '*') {
444-
setCustomPgnResultOverride(game_id, pgnPayload.result)
508+
if (pgnResult && pgnResult !== '*') {
509+
setCustomPgnResultOverride(game_id, pgnResult)
445510
}
446511

447512
setShowCustomModal(false)
@@ -456,7 +521,7 @@ const Analysis: React.FC<Props> = ({
456521
}
457522
})()
458523
},
459-
[],
524+
[router],
460525
)
461526

462527
const handleLearnFromMistakes = useCallback(() => {

0 commit comments

Comments
 (0)