pgn.js
Version:
Chess PGN, Portable Game Notation, Javascript Library
300 lines (270 loc) • 7.35 kB
JavaScript
import { stdin } from 'node:process';
import { createReadStream } from 'node:fs';
import { createInterface } from 'node:readline';
import { parse } from './pgnparser.js';
import { Chess } from 'chess.js';
import { Game } from './game.js';
import { Util } from './util.js';
export class Pgn {
/**
* load a pgn file, or read from stdin if 'path' is '' or null
* @param {string} path stdin if '' or null
* @param {Options} opts
* @return {Pgn}
*/
static async load(path, opts = {}) {
let pgn = new Pgn('', opts);
pgn.games = await pgn._from_file(path, opts);
return pgn;
}
/**
* parse a pgn string
* @param {string} pgn
* @param {Options} opts
* @return {Pgn}
*/
constructor(pgn = '', opts = {}) {
/** @type {Game[]} */
this.games = this._from_pgn(pgn, opts);
}
/**
* total number of games
* @return {number}
*/
count() {
return this.games.length;
}
/**
* get a game
* @param {number} idx
* @return {Game}
*/
game(idx) {
return (idx>=0&&idx<this.games.length?this.games[idx]:null);
}
/**
* add new game
* @return {Game}
*/
newgame() {
let game = new Game();
this.games.push(game);
return game;
}
/**
* return the pgn string of all games
* @return {string}
*/
pgn() {
let text = '';
this.games.forEach(game => {
text += game.pgn() + '\n';
});
return text;
}
/**
* @private
*/
_from_pgn(pgn = '', opts = {}) {
const lines = pgn.split('\n');
let ctx = {
games: [], // parsed games
game: new Game(), // current game
in_movetext: false, // line parsing status whether 'in movetext'
movetext: '', // current collecting movetext
};
for(const line of lines) {
this._handle_line(ctx, line + '\n', opts);
}
// last movetext
if(ctx.in_movetext) {
// parse movetext
if(ctx.game.tags.length) {
ctx.game.moves = [];
let err = this._parse_movetext(ctx.game, ctx.movetext, ctx.game.setupFen(), opts);
ctx.games.push(ctx.game);
if(opts?.onGame)
opts.onGame(ctx.game, err);
}
}
if(opts?.onFinish)
opts.onFinish();
return ctx.games;
}
/**
* @private
*/
async _from_file(path, opts = {}) {
const rl = createInterface({
input: path?createReadStream(path):stdin,
crlfDelay: Infinity
});
// Note: we use the crlfDelay option to recognize all instances of CR LF
// ('\r\n') in input.txt as a single line break.
let ctx = {
games: [], // parsed games
game: new Game(), // current game
in_movetext: false, // line parsing status whether 'in movetext'
movetext: '', // current collecting movetext
};
for await (const line of rl) {
if(line)
await this._handle_line(ctx, line + '\n', opts);
}
// last movetext
if(ctx.in_movetext) {
// parse movetext
if(ctx.game.tags.length) {
ctx.game.moves = [];
let err = this._parse_movetext(ctx.game, ctx.movetext, ctx.game.setupFen(), opts);
ctx.games.push(ctx.game);
if(opts?.onGame)
opts.onGame(ctx.game, err);
}
}
if(opts?.onFinish)
opts.onFinish();
return ctx.games;
}
/**
* @private
*/
async _handle_line(ctx, line, opts) {
const has_tag = line.trimStart().startsWith('[');
if(ctx.in_movetext) {
if(has_tag) {
// parse movetext
if(ctx.game.tags.length) {
ctx.game.moves = [];
let err = this._parse_movetext(ctx.game, ctx.movetext, ctx.game.setupFen(), opts);
ctx.games.push(ctx.game);
if(opts?.onGame)
opts.onGame(ctx.game, err);
ctx.game = new Game(); // next cur game
}
ctx.in_movetext = false;
ctx.movetext = '';
// parse tag
let tag = line.match(/\[(\w+)\s+"([^"]+)"/);
if (tag) {
ctx.game.tags.push({ name: tag[1], value: tag[2] });
}
}
else {
ctx.movetext += line;
}
}
else {
if(has_tag) {
// parse tag
let tag = line.match(/\[(\w+)\s+"([^"]+)"/);
if (tag) {
ctx.game.tags.push({ name: tag[1], value: tag[2] });
}
}
else {
// collect movetext
ctx.movetext = line;
ctx.in_movetext = true;
}
}
}
/**
* @private
*/
_parse_movetext(game, movetext, fen = '', opts = {}) {
const verbose = !!opts?.verbose;
let err = undefined;
try{
const parsed_moves = parse(movetext); // syntax parse
err = this._make_moves(game, game.moves, parsed_moves[0], fen, undefined, 1);
if(err) {
// error
err.movetext = movetext;
if(verbose) {
console.error('error', err);
}
}
} catch(e) {
// exception from parser
err = e;
err.movetext = movetext;
if(verbose) {
console.error('error', err);
}
}
return err;
}
/**
* @private
* @return {Err}
*/
_make_moves(game, parent, parsed_moves, fen, prev_move, ply) {
const chess = fen ? new Chess(fen) : new Chess()
let moves = parent;
for (let parsed_move of parsed_moves) {
if (parsed_move.text) {
const san = parsed_move.text.san;
const move = chess.move(san, {sloppy: true});
if (move) {
move.line = parent;
move.prev = prev_move;
move.ply = ply
Util.fill_move(move, chess)
if(parsed_move.num) {
move.num = parsed_move.num;
}
else {
if(prev_move&&prev_move.num)
move.num = prev_move.num+(move.color=='w'?1:0);
else
move.num = Util.move_num_from_fen(fen);
}
if (parsed_move.nags) {
move.nags = [...new Set(parsed_move.nags)];
}
if(parsed_move.comment_pre||parsed_move.comment_before||parsed_move.comment_after)
{
move.comment = {};
if (parsed_move.comment_pre) {
move.comment.pre = parsed_move.comment_pre;
}
if (parsed_move.comment_before) {
move.comment.before = parsed_move.comment_before;
}
if (parsed_move.comment_after) {
move.comment.after = parsed_move.comment_after;
}
}
move.vars = [];
const parsedVars = parsed_move.vars;
if (parsedVars.length > 0) {
const lastFen = moves.length > 0 ? moves[moves.length - 1].fen : fen;
for (let parsedVar of parsedVars) {
let rav = [];
let err = this._make_moves(game, rav, parsedVar, lastFen, prev_move, ply);
if(rav.length>0)
move.vars.push(rav);
if(err)
return err;
}
}
moves.push(move);
prev_move = move;
} else {
return {
msg: 'Illegal move',
fen: chess.fen(),
san: san,
num: prev_move?.num
};
}
}
else if(parsed_move.gtm) {
game.gtm = parsed_move.gtm;
}
ply++;
}
return undefined;
}
};