From 2e48e0918a5d718cff54a2ede55e1060c20b072f Mon Sep 17 00:00:00 2001 From: neko <225498830+neko782@users.noreply.github.com> Date: Wed, 18 Mar 2026 21:34:37 +0000 Subject: [PATCH 1/2] add chess.js handler --- package.json | 1 + src/handlers/chessjs.ts | 148 ++++++++++++++++++++++++++++++++++++++++ src/handlers/index.ts | 2 + 3 files changed, 151 insertions(+) create mode 100644 src/handlers/chessjs.ts diff --git a/package.json b/package.json index 0982dc3b..3a9d4860 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "@types/three": "^0.182.0", "bson": "^7.2.0", "celaria-formats": "^1.0.2", + "chess.js": "^1.4.0", "imagetracer": "^0.2.2", "js-synthesizer": "^1.11.0", "json5": "^2.2.3", diff --git a/src/handlers/chessjs.ts b/src/handlers/chessjs.ts new file mode 100644 index 00000000..fbb8529f --- /dev/null +++ b/src/handlers/chessjs.ts @@ -0,0 +1,148 @@ +// file: chessjs.ts + +import type { FileData, FileFormat, FormatHandler } from "../FormatHandler.ts"; +import CommonFormats, { Category } from "src/CommonFormats.ts"; +import { BLACK, Chess, DEFAULT_POSITION, KING, QUEEN, SQUARES, WHITE, type Color, type PieceSymbol, type Square } from 'chess.js'; + +// basically fen but represented as a type for json +type Game = { + board: ({ + square: Square; + type: PieceSymbol; + color: Color; + } | null)[][], + turn: Color, + castling: { + [WHITE]: { + [KING]: boolean; + [QUEEN]: boolean; + }, + [BLACK]: { + [KING]: boolean; + [QUEEN]: boolean; + }, + }, + epSquare: Square | null, + halfMoves: number, + moveNumber: number, +}; + +function isSquare(value: string): value is Square { // ts is cool + return (SQUARES as string[]).includes(value); +} + +class chessjsHandler implements FormatHandler { + + public name: string = "chessjs"; + public supportedFormats: FileFormat[] = [ + { + name: "Forsyth–Edwards Notation", + format: "fen", + extension: "fen", + mime: "application/vnd.chess-fen", + from: true, + to: true, + internal: "fen", + category: Category.TEXT, + lossless: false + }, + { + name: "Portable Game Notation", + format: "pgn", + extension: "pgn", + mime: "application/vnd.chess-pgn", + from: true, + to: true, + internal: "pgn", + category: Category.TEXT, + lossless: true + }, + CommonFormats.TEXT.builder("txt").allowTo().markLossless(false), + CommonFormats.JSON.builder("json").allowTo().allowFrom().markLossless(), + ]; + public ready: boolean = false; + + async init () { + this.ready = true; + } + + async doConvert ( + inputFiles: FileData[], + inputFormat: FileFormat, + outputFormat: FileFormat + ): Promise { + const outputFiles: FileData[] = []; + for (const inputFile of inputFiles) { + const chess = new Chess(); + + const input = new TextDecoder().decode(inputFile.bytes).trim(); + if (inputFormat.internal === "fen") { + chess.load(input, { skipValidation: true }); + } else if (inputFormat.internal === "pgn") { + chess.loadPgn(input); + } else if (inputFormat.internal === "json") { + chess.clear(); + + const game: Game = JSON.parse(input); + for (const row of game.board) { + for (const square of row) { + if (!square) continue; + + chess.put({ type: square.type, color: square.color }, square.square); + } + } + + chess.setTurn(game.turn); + + chess.setCastlingRights(WHITE, game.castling[WHITE]); + chess.setCastlingRights(BLACK, game.castling[BLACK]); + + // we need fen to insert into some of the fields + // without touching private apis. aw man! + const fen = chess.fen().split(" "); + + fen[3] = game.epSquare ?? "-"; + fen[4] = String(game.halfMoves); + fen[5] = String(game.moveNumber); + + chess.load(fen.join(" "), { skipValidation: true }); + } else { + throw new Error(`chessjsHandler cannot convert from ${inputFormat.mime}`); + } + + let output; + if (outputFormat.internal === "fen") { + output = chess.fen(); + } else if (outputFormat.internal === "pgn") { + output = chess.pgn(); + } else if (outputFormat.internal === "txt") { + output = chess.ascii(); + } else if (outputFormat.internal === "json") { + // aargh + const [,,,epSquare, halfMoves, moveNumber] = chess.fen().split(" "); + const game: Game = { + board: chess.board(), + turn: chess.turn(), + castling: { + [WHITE]: chess.getCastlingRights(WHITE), + [BLACK]: chess.getCastlingRights(BLACK), + }, + epSquare: isSquare(epSquare) ? epSquare : null, + halfMoves: Number(halfMoves), + moveNumber: Number(moveNumber) + }; + output = JSON.stringify(game); + } else { + throw new Error(`chessjsHandler cannot convert to ${outputFormat.mime}`); + } + + const bytes = new TextEncoder().encode(output); + const name = inputFile.name.replace(/\.[^.]+$/, "") + `.${outputFormat.extension}`; + outputFiles.push({ name, bytes }); + } + return outputFiles; + } + +} + +export default chessjsHandler; \ No newline at end of file diff --git a/src/handlers/index.ts b/src/handlers/index.ts index 8515fa47..0a7e335d 100644 --- a/src/handlers/index.ts +++ b/src/handlers/index.ts @@ -66,6 +66,7 @@ import mclangHandler from "./minecraftLangfileHandler.ts"; import celariaMapHandler from "./celariaMap.ts"; import cybergrindHandler from "./cybergrindHandler.ts"; import textToSourceHandler from "./textToSource.ts"; +import chessjsHandler from "./chessjs.ts"; const handlers: FormatHandler[] = []; try { handlers.push(new svgTraceHandler()) } catch (_) { }; @@ -140,5 +141,6 @@ try { handlers.push(new mclangHandler()) } catch (_) { }; try { handlers.push(new celariaMapHandler()) } catch (_) { }; try { handlers.push(new cybergrindHandler()) } catch (_) { }; try { handlers.push(new textToSourceHandler()) } catch (_) { }; +try { handlers.push(new chessjsHandler()) } catch (_) { }; export default handlers; From e2b26ecefe2f031004802d99f9ce781578183117 Mon Sep 17 00:00:00 2001 From: neko <225498830+neko782@users.noreply.github.com> Date: Wed, 18 Mar 2026 22:47:40 +0000 Subject: [PATCH 2/2] split fenToJson into its own thing --- src/handlers/chessjs.ts | 71 +--------------- src/handlers/fenToJson.ts | 170 ++++++++++++++++++++++++++++++++++++++ src/handlers/index.ts | 2 + 3 files changed, 173 insertions(+), 70 deletions(-) create mode 100644 src/handlers/fenToJson.ts diff --git a/src/handlers/chessjs.ts b/src/handlers/chessjs.ts index fbb8529f..f573a2ba 100644 --- a/src/handlers/chessjs.ts +++ b/src/handlers/chessjs.ts @@ -2,34 +2,7 @@ import type { FileData, FileFormat, FormatHandler } from "../FormatHandler.ts"; import CommonFormats, { Category } from "src/CommonFormats.ts"; -import { BLACK, Chess, DEFAULT_POSITION, KING, QUEEN, SQUARES, WHITE, type Color, type PieceSymbol, type Square } from 'chess.js'; - -// basically fen but represented as a type for json -type Game = { - board: ({ - square: Square; - type: PieceSymbol; - color: Color; - } | null)[][], - turn: Color, - castling: { - [WHITE]: { - [KING]: boolean; - [QUEEN]: boolean; - }, - [BLACK]: { - [KING]: boolean; - [QUEEN]: boolean; - }, - }, - epSquare: Square | null, - halfMoves: number, - moveNumber: number, -}; - -function isSquare(value: string): value is Square { // ts is cool - return (SQUARES as string[]).includes(value); -} +import { Chess } from 'chess.js'; class chessjsHandler implements FormatHandler { @@ -58,7 +31,6 @@ class chessjsHandler implements FormatHandler { lossless: true }, CommonFormats.TEXT.builder("txt").allowTo().markLossless(false), - CommonFormats.JSON.builder("json").allowTo().allowFrom().markLossless(), ]; public ready: boolean = false; @@ -80,32 +52,6 @@ class chessjsHandler implements FormatHandler { chess.load(input, { skipValidation: true }); } else if (inputFormat.internal === "pgn") { chess.loadPgn(input); - } else if (inputFormat.internal === "json") { - chess.clear(); - - const game: Game = JSON.parse(input); - for (const row of game.board) { - for (const square of row) { - if (!square) continue; - - chess.put({ type: square.type, color: square.color }, square.square); - } - } - - chess.setTurn(game.turn); - - chess.setCastlingRights(WHITE, game.castling[WHITE]); - chess.setCastlingRights(BLACK, game.castling[BLACK]); - - // we need fen to insert into some of the fields - // without touching private apis. aw man! - const fen = chess.fen().split(" "); - - fen[3] = game.epSquare ?? "-"; - fen[4] = String(game.halfMoves); - fen[5] = String(game.moveNumber); - - chess.load(fen.join(" "), { skipValidation: true }); } else { throw new Error(`chessjsHandler cannot convert from ${inputFormat.mime}`); } @@ -117,21 +63,6 @@ class chessjsHandler implements FormatHandler { output = chess.pgn(); } else if (outputFormat.internal === "txt") { output = chess.ascii(); - } else if (outputFormat.internal === "json") { - // aargh - const [,,,epSquare, halfMoves, moveNumber] = chess.fen().split(" "); - const game: Game = { - board: chess.board(), - turn: chess.turn(), - castling: { - [WHITE]: chess.getCastlingRights(WHITE), - [BLACK]: chess.getCastlingRights(BLACK), - }, - epSquare: isSquare(epSquare) ? epSquare : null, - halfMoves: Number(halfMoves), - moveNumber: Number(moveNumber) - }; - output = JSON.stringify(game); } else { throw new Error(`chessjsHandler cannot convert to ${outputFormat.mime}`); } diff --git a/src/handlers/fenToJson.ts b/src/handlers/fenToJson.ts new file mode 100644 index 00000000..7f0e586b --- /dev/null +++ b/src/handlers/fenToJson.ts @@ -0,0 +1,170 @@ +// file: fenToJson.ts + +import type { FileData, FileFormat, FormatHandler } from "../FormatHandler.ts"; +import CommonFormats, { Category } from "src/CommonFormats.ts"; +import { BLACK, KING, QUEEN, SQUARES, WHITE, type Color, type PieceSymbol, type Square } from 'chess.js'; + +// same as .board() on chess.js +type BoardSquare = { + square: Square; + type: PieceSymbol; + color: Color; +} | null; + +type Game = { + board: BoardSquare[][], + turn: Color, + castling: { + [WHITE]: { + [KING]: boolean; + [QUEEN]: boolean; + }, + [BLACK]: { + [KING]: boolean; + [QUEEN]: boolean; + }, + }, + epSquare: Square | null, + halfMoves: number, + moveNumber: number, +}; + +function isSquare(value: string): value is Square { // ts is cool + return (SQUARES as string[]).includes(value); +} + +function isPieceSymbol(value: string): value is PieceSymbol { + return "pnbrqk".includes(value); +} + +class fenToJsonHandler implements FormatHandler { + + public name: string = "fenToJson"; + public supportedFormats: FileFormat[] = [ + { + name: "Forsyth–Edwards Notation", + format: "fen", + extension: "fen", + mime: "application/vnd.chess-fen", + from: true, + to: true, + internal: "fen", + category: Category.TEXT, + lossless: true + }, + CommonFormats.JSON.builder("json").allowTo().allowFrom().markLossless(), + ]; + public ready: boolean = false; + + async init () { + this.ready = true; + } + + async doConvert ( + inputFiles: FileData[], + inputFormat: FileFormat, + outputFormat: FileFormat + ): Promise { + const outputFiles: FileData[] = []; + for (const inputFile of inputFiles) { + const input = new TextDecoder().decode(inputFile.bytes).trim(); + let output; + if (inputFormat.internal === "fen") { + const [boardFen, turn, castling, epSquare, halfMoves, moveNumber] = input.split(" "); + + let board: BoardSquare[][] = []; + let currentSquare = 0; + for (const rowFen of boardFen.split("/")) { + const row: BoardSquare[] = []; + + for (const char of rowFen) { + if (char >= '0' && char <= '9') { + row.push(...Array(Number(char)).fill(null)); + currentSquare += Number(char); + } else { + const type = char.toLowerCase(); + row.push({ + square: SQUARES[currentSquare], + color: char >= 'A' && char <= 'Z' ? WHITE : BLACK, + type: isPieceSymbol(type) ? type : 'p' + }); + currentSquare += 1; + } + } + + board.push(row); + } + + const game: Game = { + board, + turn: turn === 'w' ? WHITE : BLACK, + castling: { + [WHITE]: { + [KING]: castling.includes('K'), + [QUEEN]: castling.includes('Q'), + }, + [BLACK]: { + [KING]: castling.includes('k'), + [QUEEN]: castling.includes('q'), + }, + }, + epSquare: isSquare(epSquare) ? epSquare : null, + halfMoves: Number(halfMoves), + moveNumber: Number(moveNumber) + }; + output = JSON.stringify(game); + } else if (inputFormat.internal === "json") { + const game: Game = JSON.parse(input); + let fen: string[] = []; + + let boardFen: string[] = []; + for (const row of game.board) { + let rowFen = []; + let emptyCounter = 0; + for (const square of row) { + if (!square) { + emptyCounter++; + continue; + } + + if (emptyCounter > 0) { + rowFen.push(String(emptyCounter)); + emptyCounter = 0; + } + + rowFen.push( + square.color === WHITE + ? square.type.toUpperCase() + : square.type.toLowerCase() + ); + } + if (emptyCounter > 0) { + rowFen.push(String(emptyCounter)); + } + boardFen.push(rowFen.join('')); + } + fen.push(boardFen.join('/')); + + fen.push(game.turn); + const castling = + (game.castling[WHITE][KING] ? 'K' : '') + + (game.castling[WHITE][QUEEN] ? 'Q' : '') + + (game.castling[BLACK][KING] ? 'k' : '') + + (game.castling[BLACK][QUEEN] ? 'q' : ''); + fen.push(castling !== '' ? castling : '-'); + fen.push(game.epSquare ?? '-'); + fen.push(String(game.halfMoves)); + fen.push(String(game.moveNumber)); + + output = fen.join(' '); + } + const bytes = new TextEncoder().encode(output); + const name = inputFile.name.replace(/\.[^.]+$/, "") + `.${outputFormat.extension}`; + outputFiles.push({ name, bytes }); + } + return outputFiles; + } + +} + +export default fenToJsonHandler; \ No newline at end of file diff --git a/src/handlers/index.ts b/src/handlers/index.ts index 0a7e335d..05cb25f3 100644 --- a/src/handlers/index.ts +++ b/src/handlers/index.ts @@ -67,6 +67,7 @@ import celariaMapHandler from "./celariaMap.ts"; import cybergrindHandler from "./cybergrindHandler.ts"; import textToSourceHandler from "./textToSource.ts"; import chessjsHandler from "./chessjs.ts"; +import fenToJsonHandler from "./fenToJson.ts"; const handlers: FormatHandler[] = []; try { handlers.push(new svgTraceHandler()) } catch (_) { }; @@ -142,5 +143,6 @@ try { handlers.push(new celariaMapHandler()) } catch (_) { }; try { handlers.push(new cybergrindHandler()) } catch (_) { }; try { handlers.push(new textToSourceHandler()) } catch (_) { }; try { handlers.push(new chessjsHandler()) } catch (_) { }; +try { handlers.push(new fenToJsonHandler()) } catch (_) { }; export default handlers;