From dcea1075a356c1a4ac3dc13124a32c185277f128 Mon Sep 17 00:00:00 2001 From: Ashton Anderson Date: Tue, 24 Feb 2026 13:15:44 -0500 Subject: [PATCH 1/2] fix: preserve custom PGN headers and strip result token --- .../Analysis/CustomAnalysisModal.tsx | 105 ++++-------------- src/pages/analysis/[...id].tsx | 98 +--------------- 2 files changed, 26 insertions(+), 177 deletions(-) diff --git a/src/components/Analysis/CustomAnalysisModal.tsx b/src/components/Analysis/CustomAnalysisModal.tsx index 2680ba34..6d9c8273 100644 --- a/src/components/Analysis/CustomAnalysisModal.tsx +++ b/src/components/Analysis/CustomAnalysisModal.tsx @@ -3,13 +3,7 @@ import { motion } from 'framer-motion' import { Chess } from 'chess.ts' import toast from 'react-hot-toast' -interface Props { - onSubmit: (type: 'pgn' | 'fen', data: string, name?: string) => void - onClose: () => void -} - const PGN_HEADER_LINE_REGEX = /^\s*\[[^\]]+\]\s*$/ -const PGN_RESULT_TOKENS = new Set(['1-0', '0-1', '1/2-1/2', '*']) const ensureBlankLineAfterPgnHeaders = (pgn: string): string => { const normalizedNewlines = pgn.replace(/\r\n/g, '\n') @@ -42,82 +36,12 @@ const ensureBlankLineAfterPgnHeaders = (pgn: string): string => { lines.splice(headerEndLine, 0, '') } - return lines.join('\n').trim() -} - -const formatMoveHistoryAsPgn = (moves: string[]): string => { - const pgnTokens: string[] = [] - - for (let i = 0; i < moves.length; i += 2) { - pgnTokens.push(`${Math.floor(i / 2) + 1}. ${moves[i]}`) - if (moves[i + 1]) { - pgnTokens.push(moves[i + 1]) - } - } - - return pgnTokens.join(' ').trim() -} - -const extractPgnResultToken = (pgn: string): string | undefined => { - const headerResultMatch = pgn.match( - /\[\s*Result\s+"(1-0|0-1|1\/2-1\/2|\*)"\s*\]/i, - ) - if (headerResultMatch) { - return headerResultMatch[1] - } - - // Strip comments and variations before scanning for a terminal result token. - let movetext = pgn.replace(/\{[^}]*\}/g, ' ').replace(/;[^\r\n]*/g, ' ') - - const ravRegex = /\([^()]*\)/g - while (ravRegex.test(movetext)) { - movetext = movetext.replace(ravRegex, ' ') - } - - const tailMatch = movetext.match(/(?:^|\s)(1-0|0-1|1\/2-1\/2|\*)(?:\s*)$/) - return tailMatch?.[1] + return lines.join('\n') } -const normalizePgnForAnalysis = (input: string): string => { - const trimmed = input.trim() - const candidates = Array.from( - new Set([trimmed, ensureBlankLineAfterPgnHeaders(trimmed)]), - ) - - for (const candidate of candidates) { - for (const sloppy of [false, true]) { - const chess = new Chess() - const loaded = chess.loadPgn(candidate, { sloppy }) - - if (!loaded) continue - - const header = chess.header() - - // Preserve SetUp/FEN PGNs since the initial position cannot be represented - // by a move list alone. - if (header.SetUp === '1' && header.FEN) { - return chess.pgn() - } - - const moveTextOnly = formatMoveHistoryAsPgn(chess.history()) - if (!moveTextOnly) { - throw new Error('PGN must contain at least one move') - } - - const resultToken = - (header.Result && PGN_RESULT_TOKENS.has(header.Result) - ? header.Result - : extractPgnResultToken(candidate)) || undefined - - return resultToken && resultToken !== '*' - ? `${moveTextOnly} ${resultToken}` - : moveTextOnly - } - } - - throw new Error( - 'Unable to parse PGN. If using [Tag "..."] headers, include a blank line before the moves.', - ) +interface Props { + onSubmit: (type: 'pgn' | 'fen', data: string, name?: string) => void + onClose: () => void } export const CustomAnalysisModal: React.FC = ({ onSubmit, onClose }) => { @@ -128,7 +52,7 @@ export const CustomAnalysisModal: React.FC = ({ onSubmit, onClose }) => { const validateAndSubmit = () => { const trimmedInput = input.trim() - if (!input.trim()) { + if (!trimmedInput) { toast.error('Please enter some data') return } @@ -140,17 +64,28 @@ export const CustomAnalysisModal: React.FC = ({ onSubmit, onClose }) => { toast.error('Invalid FEN position: ' + validation.error) return } - - onSubmit(mode, trimmedInput, name.trim() || undefined) } else { try { - const normalizedPgn = normalizePgnForAnalysis(trimmedInput) - onSubmit(mode, normalizedPgn, name.trim() || undefined) + const candidates = Array.from( + new Set([trimmedInput, ensureBlankLineAfterPgnHeaders(trimmedInput)]), + ) + const isValid = candidates.some((candidate) => { + const chess = new Chess() + return chess.loadPgn(candidate, { sloppy: true }) + }) + + if (!isValid) { + throw new Error( + 'Unable to parse PGN. If using [Tag \"...\"] headers, include a blank line before the moves.', + ) + } } catch (error) { toast.error('Invalid PGN format: ' + (error as Error).message) return } } + + onSubmit(mode, trimmedInput, name.trim() || undefined) } 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` diff --git a/src/pages/analysis/[...id].tsx b/src/pages/analysis/[...id].tsx index 40ad83f7..3b3a7518 100644 --- a/src/pages/analysis/[...id].tsx +++ b/src/pages/analysis/[...id].tsx @@ -20,7 +20,6 @@ import { MaiaEvaluation, StockfishEvaluation, GameNode, - Termination, } from 'src/types' import { WindowSizeContext, TreeControllerContext, useTour } from 'src/contexts' import { Loading } from 'src/components' @@ -58,9 +57,6 @@ import { MAIA_MODELS } from 'src/constants/common' import { applyEngineAnalysisData } from 'src/lib/analysis' const EVAL_BAR_RANGE = 4 -const CUSTOM_PGN_RESULT_OVERRIDES_STORAGE_KEY = - 'maia_custom_pgn_result_overrides' -const PGN_RESULT_TOKEN_REGEX = /(.*?)(?:\s+)(1-0|0-1|1\/2-1\/2|\*)\s*$/ const DEFAULT_STOCKFISH_EVAL_BAR = { hasEval: false, pawns: 0, @@ -68,79 +64,8 @@ const DEFAULT_STOCKFISH_EVAL_BAR = { label: '--', } -const splitTrailingPgnResult = ( - pgn: string, -): { pgnWithoutResult: string; result?: string } => { - const match = pgn.match(PGN_RESULT_TOKEN_REGEX) - if (!match) { - return { pgnWithoutResult: pgn } - } - - return { - pgnWithoutResult: match[1].trim(), - result: match[2], - } -} - -const getCustomPgnResultOverrides = (): Record => { - if (typeof window === 'undefined') return {} - - try { - const raw = window.localStorage.getItem( - CUSTOM_PGN_RESULT_OVERRIDES_STORAGE_KEY, - ) - if (!raw) return {} - const parsed = JSON.parse(raw) - return parsed && typeof parsed === 'object' ? parsed : {} - } catch (error) { - console.warn('Failed to read custom PGN result overrides:', error) - return {} - } -} - -const setCustomPgnResultOverride = (gameId: string, result: string): void => { - if (typeof window === 'undefined') return - - try { - const overrides = getCustomPgnResultOverrides() - overrides[gameId] = result - window.localStorage.setItem( - CUSTOM_PGN_RESULT_OVERRIDES_STORAGE_KEY, - JSON.stringify(overrides), - ) - } catch (error) { - console.warn('Failed to store custom PGN result override:', error) - } -} - -const getCustomPgnResultOverride = (gameId: string): string | undefined => { - const override = getCustomPgnResultOverrides()[gameId] - return typeof override === 'string' ? override : undefined -} - -const resultTokenToWinner = ( - result: string, -): Termination['winner'] | undefined => { - if (result === '1-0') return 'white' - if (result === '0-1') return 'black' - if (result === '1/2-1/2') return 'none' - return undefined -} - -const applyCustomResultOverride = ( - game: AnalyzedGame, - result?: string, -): AnalyzedGame => { - if (!result || result === '*') return game - - return { - ...game, - termination: { - ...(game.termination || {}), - result, - winner: resultTokenToWinner(result), - }, - } +const stripTrailingPgnResultToken = (pgn: string): string => { + return pgn.replace(/(?:\s+)(1-0|0-1|1\/2-1\/2|\*)\s*$/, '').trim() } const AnalysisPage: NextPage = () => { @@ -241,16 +166,12 @@ const AnalysisPage: NextPage = () => { updateUrl = true, ) => { const game = await fetchAnalyzedMaiaGame(id, type) - const gameWithOverrides = - type === 'custom' - ? applyCustomResultOverride(game, getCustomPgnResultOverride(id)) - : game if (setCurrentMove) setCurrentMove(0) - setAnalyzedGame({ ...gameWithOverrides, type }) + setAnalyzedGame({ ...game, type }) setCurrentId([id, type]) - await loadGameAnalysisCache({ ...gameWithOverrides, type }) + await loadGameAnalysisCache({ ...game, type }) if (updateUrl) { router.push(`/analysis/${id}/${type}`, undefined, { @@ -431,19 +352,12 @@ const Analysis: React.FC = ({ (type: 'fen' | 'pgn', data: string, name?: string) => { ;(async () => { try { - const pgnPayload = - type === 'pgn' ? splitTrailingPgnResult(data) : undefined const { game_id } = await storeCustomGame({ name: name, - pgn: - type === 'pgn' ? pgnPayload?.pgnWithoutResult || data : undefined, + pgn: type === 'pgn' ? stripTrailingPgnResultToken(data) : undefined, fen: type === 'fen' ? data : undefined, }) - if (pgnPayload?.result && pgnPayload.result !== '*') { - setCustomPgnResultOverride(game_id, pgnPayload.result) - } - setShowCustomModal(false) router.push(`/analysis/${game_id}/custom`) } catch (error) { @@ -456,7 +370,7 @@ const Analysis: React.FC = ({ } })() }, - [], + [router], ) const handleLearnFromMistakes = useCallback(() => { From dad537c7a4a746f9c63e0f6426cb107cbd7493fa Mon Sep 17 00:00:00 2001 From: Ashton Anderson Date: Tue, 24 Feb 2026 18:23:01 -0500 Subject: [PATCH 2/2] fix: preserve custom PGN results in analysis UI --- src/components/Board/MovesContainer.tsx | 4 +- src/pages/analysis/[...id].tsx | 159 +++++++++++++++++++++++- 2 files changed, 157 insertions(+), 6 deletions(-) diff --git a/src/components/Board/MovesContainer.tsx b/src/components/Board/MovesContainer.tsx index c55486eb..61986098 100644 --- a/src/components/Board/MovesContainer.tsx +++ b/src/components/Board/MovesContainer.tsx @@ -360,7 +360,7 @@ export const MovesContainer: React.FC< {termination.result} {', '} {termination.winner !== 'none' - ? `${termination.winner} is victorious` + ? `${termination.winner} wins` : 'draw'} )} @@ -486,7 +486,7 @@ export const MovesContainer: React.FC< {termination.result} {', '} {termination.winner !== 'none' - ? `${termination.winner} is victorious` + ? `${termination.winner} wins` : 'draw'} )} diff --git a/src/pages/analysis/[...id].tsx b/src/pages/analysis/[...id].tsx index 3b3a7518..e191edbc 100644 --- a/src/pages/analysis/[...id].tsx +++ b/src/pages/analysis/[...id].tsx @@ -57,6 +57,8 @@ import { MAIA_MODELS } from 'src/constants/common' import { applyEngineAnalysisData } from 'src/lib/analysis' const EVAL_BAR_RANGE = 4 +const CUSTOM_PGN_RESULT_OVERRIDES_STORAGE_KEY = + 'maia_custom_pgn_result_overrides' const DEFAULT_STOCKFISH_EVAL_BAR = { hasEval: false, pawns: 0, @@ -64,8 +66,146 @@ const DEFAULT_STOCKFISH_EVAL_BAR = { label: '--', } -const stripTrailingPgnResultToken = (pgn: string): string => { - return pgn.replace(/(?:\s+)(1-0|0-1|1\/2-1\/2|\*)\s*$/, '').trim() +const PGN_HEADER_LINE_REGEX = /^\s*\[[^\]]+\]\s*$/ + +const ensureBlankLineAfterPgnHeaders = (pgn: string): string => { + const normalizedNewlines = pgn.replace(/\r\n/g, '\n') + const lines = normalizedNewlines.split('\n') + + let firstContentLine = 0 + while ( + firstContentLine < lines.length && + lines[firstContentLine].trim().length === 0 + ) { + firstContentLine++ + } + + let headerEndLine = firstContentLine + while ( + headerEndLine < lines.length && + PGN_HEADER_LINE_REGEX.test(lines[headerEndLine]) + ) { + headerEndLine++ + } + + const hasHeaderBlock = headerEndLine > firstContentLine + const hasMovetextAfterHeaders = headerEndLine < lines.length + const needsSeparator = + hasHeaderBlock && + hasMovetextAfterHeaders && + lines[headerEndLine].trim().length > 0 + + if (needsSeparator) { + lines.splice(headerEndLine, 0, '') + } + + return lines.join('\n').trim() +} + +const formatMoveHistoryAsPgn = (moves: string[]): string => { + const tokens: string[] = [] + + for (let i = 0; i < moves.length; i += 2) { + tokens.push(`${Math.floor(i / 2) + 1}. ${moves[i]}`) + if (moves[i + 1]) { + tokens.push(moves[i + 1]) + } + } + + return tokens.join(' ').trim() +} + +const normalizeCustomPgnForBackendStore = (pgn: string): string => { + const trimmed = pgn.trim() + const candidates = Array.from( + new Set([trimmed, ensureBlankLineAfterPgnHeaders(trimmed)]), + ) + + for (const candidate of candidates) { + const chess = new Chess() + if (!chess.loadPgn(candidate, { sloppy: true })) { + continue + } + + const header = { ...chess.header() } + + const moveText = formatMoveHistoryAsPgn(chess.history()) + const headerText = Object.entries(header) + .map(([key, value]) => `[${key} "${value}"]`) + .join('\n') + + if (!headerText) { + return moveText + } + + return moveText ? `${headerText}\n\n${moveText}` : headerText + } + + return ensureBlankLineAfterPgnHeaders(trimmed) +} + +const extractPgnResultToken = (pgn: string): string | undefined => { + const headerMatch = pgn.match(/\[\s*Result\s+"(1-0|0-1|1\/2-1\/2|\*)"\s*\]/i) + if (headerMatch) { + return headerMatch[1] + } + + const tailMatch = pgn.match(/(?:^|\s)(1-0|0-1|1\/2-1\/2|\*)(?:\s*)$/) + return tailMatch?.[1] +} + +const getCustomPgnResultOverrides = (): Record => { + if (typeof window === 'undefined') return {} + + try { + const raw = window.localStorage.getItem( + CUSTOM_PGN_RESULT_OVERRIDES_STORAGE_KEY, + ) + if (!raw) return {} + const parsed = JSON.parse(raw) + return parsed && typeof parsed === 'object' ? parsed : {} + } catch (error) { + console.warn('Failed to read custom PGN result overrides:', error) + return {} + } +} + +const setCustomPgnResultOverride = (gameId: string, result: string): void => { + if (typeof window === 'undefined') return + + try { + const overrides = getCustomPgnResultOverrides() + overrides[gameId] = result + window.localStorage.setItem( + CUSTOM_PGN_RESULT_OVERRIDES_STORAGE_KEY, + JSON.stringify(overrides), + ) + } catch (error) { + console.warn('Failed to store custom PGN result override:', error) + } +} + +const resultTokenToWinner = (result: string): 'white' | 'black' | 'none' => { + if (result === '1-0') return 'white' + if (result === '0-1') return 'black' + return 'none' +} + +const applyCustomPgnResultOverride = ( + gameId: string, + game: AnalyzedGame, +): AnalyzedGame => { + const result = getCustomPgnResultOverrides()[gameId] + if (!result || result === '*') return game + + return { + ...game, + termination: { + ...(game.termination || {}), + result, + winner: resultTokenToWinner(result), + }, + } } const AnalysisPage: NextPage = () => { @@ -165,7 +305,9 @@ const AnalysisPage: NextPage = () => { setCurrentMove?: Dispatch>, updateUrl = true, ) => { - const game = await fetchAnalyzedMaiaGame(id, type) + const rawGame = await fetchAnalyzedMaiaGame(id, type) + const game = + type === 'custom' ? applyCustomPgnResultOverride(id, rawGame) : rawGame if (setCurrentMove) setCurrentMove(0) @@ -352,12 +494,21 @@ const Analysis: React.FC = ({ (type: 'fen' | 'pgn', data: string, name?: string) => { ;(async () => { try { + const pgnResult = + type === 'pgn' ? extractPgnResultToken(data) : undefined const { game_id } = await storeCustomGame({ name: name, - pgn: type === 'pgn' ? stripTrailingPgnResultToken(data) : undefined, + pgn: + type === 'pgn' + ? normalizeCustomPgnForBackendStore(data) + : undefined, fen: type === 'fen' ? data : undefined, }) + if (pgnResult && pgnResult !== '*') { + setCustomPgnResultOverride(game_id, pgnResult) + } + setShowCustomModal(false) router.push(`/analysis/${game_id}/custom`) } catch (error) {