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..f573a2ba --- /dev/null +++ b/src/handlers/chessjs.ts @@ -0,0 +1,79 @@ +// file: chessjs.ts + +import type { FileData, FileFormat, FormatHandler } from "../FormatHandler.ts"; +import CommonFormats, { Category } from "src/CommonFormats.ts"; +import { Chess } from 'chess.js'; + +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), + ]; + 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 { + 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 { + 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/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 f49b4f5d..48c84393 100644 --- a/src/handlers/index.ts +++ b/src/handlers/index.ts @@ -66,6 +66,8 @@ 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"; +import fenToJsonHandler from "./fenToJson.ts"; import xcursorHandler from "./xcursor.ts"; const handlers: FormatHandler[] = []; @@ -141,6 +143,8 @@ 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 (_) { }; +try { handlers.push(new fenToJsonHandler()) } catch (_) { }; try { handlers.push(new xcursorHandler()) } catch (_) { }; export default handlers;