From 2759e59a2a07532d6620f2e523c35001bf434b34 Mon Sep 17 00:00:00 2001 From: Ashton Anderson Date: Sun, 22 Feb 2026 10:56:07 -0500 Subject: [PATCH 1/5] Fix custom PGN import parsing --- .../Analysis/CustomAnalysisModal.tsx | 96 ++++++++++++++++++- 1 file changed, 91 insertions(+), 5 deletions(-) diff --git a/src/components/Analysis/CustomAnalysisModal.tsx b/src/components/Analysis/CustomAnalysisModal.tsx index b2c85d95..a6ba4ce3 100644 --- a/src/components/Analysis/CustomAnalysisModal.tsx +++ b/src/components/Analysis/CustomAnalysisModal.tsx @@ -8,12 +8,98 @@ interface Props { onClose: () => void } +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 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 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') + } + + return moveTextOnly + } + } + + throw new Error( + 'Unable to parse PGN. If using [Tag "..."] headers, include a blank line before the moves.', + ) +} + export const CustomAnalysisModal: React.FC = ({ onSubmit, onClose }) => { const [mode, setMode] = useState<'pgn' | 'fen'>('pgn') const [input, setInput] = useState('') const [name, setName] = useState('') const validateAndSubmit = () => { + const trimmedInput = input.trim() + if (!input.trim()) { toast.error('Please enter some data') return @@ -21,22 +107,22 @@ export const CustomAnalysisModal: React.FC = ({ onSubmit, onClose }) => { if (mode === 'fen') { const chess = new Chess() - const validation = chess.validateFen(input.trim()) + const validation = chess.validateFen(trimmedInput) if (!validation.valid) { toast.error('Invalid FEN position: ' + validation.error) return } + + onSubmit(mode, trimmedInput, name.trim() || undefined) } else { try { - const chess = new Chess() - chess.loadPgn(input.trim()) + const normalizedPgn = normalizePgnForAnalysis(trimmedInput) + onSubmit(mode, normalizedPgn, name.trim() || undefined) } catch (error) { toast.error('Invalid PGN format: ' + (error as Error).message) return } } - - onSubmit(mode, input.trim(), 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` From a9573949436fb632fd65254401cac29fd9e15d31 Mon Sep 17 00:00:00 2001 From: Ashton Anderson Date: Sun, 22 Feb 2026 11:16:53 -0500 Subject: [PATCH 2/5] Preserve PGN result when normalizing custom imports --- .../Analysis/CustomAnalysisModal.tsx | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/src/components/Analysis/CustomAnalysisModal.tsx b/src/components/Analysis/CustomAnalysisModal.tsx index a6ba4ce3..2680ba34 100644 --- a/src/components/Analysis/CustomAnalysisModal.tsx +++ b/src/components/Analysis/CustomAnalysisModal.tsx @@ -9,6 +9,7 @@ interface Props { } 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') @@ -57,6 +58,26 @@ const formatMoveHistoryAsPgn = (moves: string[]): string => { 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] +} + const normalizePgnForAnalysis = (input: string): string => { const trimmed = input.trim() const candidates = Array.from( @@ -83,7 +104,14 @@ const normalizePgnForAnalysis = (input: string): string => { throw new Error('PGN must contain at least one move') } - return moveTextOnly + const resultToken = + (header.Result && PGN_RESULT_TOKENS.has(header.Result) + ? header.Result + : extractPgnResultToken(candidate)) || undefined + + return resultToken && resultToken !== '*' + ? `${moveTextOnly} ${resultToken}` + : moveTextOnly } } From b698822c77bd55a41db91cfecc73c201126db21e Mon Sep 17 00:00:00 2001 From: Ashton Anderson Date: Sun, 22 Feb 2026 11:25:17 -0500 Subject: [PATCH 3/5] Surface custom game store errors --- src/api/analysis.ts | 11 +++++++++-- src/pages/analysis/[...id].tsx | 25 +++++++++++++++++-------- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/src/api/analysis.ts b/src/api/analysis.ts index efd100d4..f4edcd49 100644 --- a/src/api/analysis.ts +++ b/src/api/analysis.ts @@ -358,11 +358,18 @@ export const storeCustomGame = async (data: { body: JSON.stringify(data), }) + const bodyText = await res.text() + if (!res.ok) { - console.error(`Failed to store custom game: ${await res.text()}`) + console.error(`Failed to store custom game: ${bodyText}`) + throw new Error( + `Failed to store custom game (${res.status} ${res.statusText})${ + bodyText ? `: ${bodyText}` : '' + }`, + ) } - return res.json() + return JSON.parse(bodyText) } export const deleteCustomGame = async (gameId: string): Promise => { diff --git a/src/pages/analysis/[...id].tsx b/src/pages/analysis/[...id].tsx index 7bae0041..6f33311b 100644 --- a/src/pages/analysis/[...id].tsx +++ b/src/pages/analysis/[...id].tsx @@ -347,14 +347,23 @@ const Analysis: React.FC = ({ const handleCustomAnalysis = useCallback( (type: 'fen' | 'pgn', data: string, name?: string) => { ;(async () => { - const { game_id } = await storeCustomGame({ - name: name, - pgn: type === 'pgn' ? data : undefined, - fen: type === 'fen' ? data : undefined, - }) - - setShowCustomModal(false) - router.push(`/analysis/${game_id}/custom`) + try { + const { game_id } = await storeCustomGame({ + name: name, + pgn: type === 'pgn' ? data : undefined, + fen: type === 'fen' ? data : undefined, + }) + + setShowCustomModal(false) + router.push(`/analysis/${game_id}/custom`) + } catch (error) { + const message = + error instanceof Error + ? error.message + : 'Failed to store custom game' + console.error('Custom analysis import failed:', error) + toast.error(message) + } })() }, [], From fe5fad76321527180627e9b41934b0fb262c5fb7 Mon Sep 17 00:00:00 2001 From: Ashton Anderson Date: Sun, 22 Feb 2026 11:29:36 -0500 Subject: [PATCH 4/5] fix: strip custom PGN result token before backend store --- src/pages/analysis/[...id].tsx | 96 ++++++++++++++++++++++++++++++++-- 1 file changed, 93 insertions(+), 3 deletions(-) diff --git a/src/pages/analysis/[...id].tsx b/src/pages/analysis/[...id].tsx index 6f33311b..40ad83f7 100644 --- a/src/pages/analysis/[...id].tsx +++ b/src/pages/analysis/[...id].tsx @@ -20,6 +20,7 @@ import { MaiaEvaluation, StockfishEvaluation, GameNode, + Termination, } from 'src/types' import { WindowSizeContext, TreeControllerContext, useTour } from 'src/contexts' import { Loading } from 'src/components' @@ -57,6 +58,9 @@ 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, @@ -64,6 +68,81 @@ 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 AnalysisPage: NextPage = () => { const { startTour, tourState } = useTour() @@ -162,12 +241,16 @@ 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({ ...game, type }) + setAnalyzedGame({ ...gameWithOverrides, type }) setCurrentId([id, type]) - await loadGameAnalysisCache({ ...game, type }) + await loadGameAnalysisCache({ ...gameWithOverrides, type }) if (updateUrl) { router.push(`/analysis/${id}/${type}`, undefined, { @@ -348,12 +431,19 @@ 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' ? data : undefined, + pgn: + type === 'pgn' ? pgnPayload?.pgnWithoutResult || 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) { From 951604451aafac4810cb0f5620fee6c38f0e38ef Mon Sep 17 00:00:00 2001 From: Ashton Anderson Date: Sun, 22 Feb 2026 17:41:51 -0500 Subject: [PATCH 5/5] fix: resolve prettier build check failures --- src/components/Board/GameBoard.tsx | 2 +- src/components/Common/PlaySetupModal.tsx | 4 +--- src/hooks/useAnalysisController/useAnalysisController.ts | 6 +----- 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/src/components/Board/GameBoard.tsx b/src/components/Board/GameBoard.tsx index efc59f53..9891424e 100644 --- a/src/components/Board/GameBoard.tsx +++ b/src/components/Board/GameBoard.tsx @@ -198,7 +198,7 @@ export const GameBoard: React.FC = ({ diff --git a/src/components/Common/PlaySetupModal.tsx b/src/components/Common/PlaySetupModal.tsx index acc284cb..97eae638 100644 --- a/src/components/Common/PlaySetupModal.tsx +++ b/src/components/Common/PlaySetupModal.tsx @@ -58,9 +58,7 @@ function OptionSelect({