Skip to content

Commit dcea107

Browse files
fix: preserve custom PGN headers and strip result token
1 parent 9516044 commit dcea107

2 files changed

Lines changed: 26 additions & 177 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/pages/analysis/[...id].tsx

Lines changed: 6 additions & 92 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'
@@ -58,89 +57,15 @@ import { MAIA_MODELS } from 'src/constants/common'
5857
import { applyEngineAnalysisData } from 'src/lib/analysis'
5958

6059
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*$/
6460
const DEFAULT_STOCKFISH_EVAL_BAR = {
6561
hasEval: false,
6662
pawns: 0,
6763
displayPawns: 0,
6864
label: '--',
6965
}
7066

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-
}
67+
const stripTrailingPgnResultToken = (pgn: string): string => {
68+
return pgn.replace(/(?:\s+)(1-0|0-1|1\/2-1\/2|\*)\s*$/, '').trim()
14469
}
14570

14671
const AnalysisPage: NextPage = () => {
@@ -241,16 +166,12 @@ const AnalysisPage: NextPage = () => {
241166
updateUrl = true,
242167
) => {
243168
const game = await fetchAnalyzedMaiaGame(id, type)
244-
const gameWithOverrides =
245-
type === 'custom'
246-
? applyCustomResultOverride(game, getCustomPgnResultOverride(id))
247-
: game
248169

249170
if (setCurrentMove) setCurrentMove(0)
250171

251-
setAnalyzedGame({ ...gameWithOverrides, type })
172+
setAnalyzedGame({ ...game, type })
252173
setCurrentId([id, type])
253-
await loadGameAnalysisCache({ ...gameWithOverrides, type })
174+
await loadGameAnalysisCache({ ...game, type })
254175

255176
if (updateUrl) {
256177
router.push(`/analysis/${id}/${type}`, undefined, {
@@ -431,19 +352,12 @@ const Analysis: React.FC<Props> = ({
431352
(type: 'fen' | 'pgn', data: string, name?: string) => {
432353
;(async () => {
433354
try {
434-
const pgnPayload =
435-
type === 'pgn' ? splitTrailingPgnResult(data) : undefined
436355
const { game_id } = await storeCustomGame({
437356
name: name,
438-
pgn:
439-
type === 'pgn' ? pgnPayload?.pgnWithoutResult || data : undefined,
357+
pgn: type === 'pgn' ? stripTrailingPgnResultToken(data) : undefined,
440358
fen: type === 'fen' ? data : undefined,
441359
})
442360

443-
if (pgnPayload?.result && pgnPayload.result !== '*') {
444-
setCustomPgnResultOverride(game_id, pgnPayload.result)
445-
}
446-
447361
setShowCustomModal(false)
448362
router.push(`/analysis/${game_id}/custom`)
449363
} catch (error) {
@@ -456,7 +370,7 @@ const Analysis: React.FC<Props> = ({
456370
}
457371
})()
458372
},
459-
[],
373+
[router],
460374
)
461375

462376
const handleLearnFromMistakes = useCallback(() => {

0 commit comments

Comments
 (0)