diff --git a/README.md b/README.md index 4451521e..cc9add38 100644 --- a/README.md +++ b/README.md @@ -635,6 +635,99 @@ chess.validate_fen('4r3/8/X12XPk/1p6/pP2p1R1/P1B5/2P2K2/3r4 w - - 1 45') // error: '1st field (piece positions) is invalid [invalid piece].' } ``` +### .get_comment() + +Retrieve the comment for the current position, if it exists. + +```js +const chess = new Chess() + +chess.load_pgn("1. e4 e5 2. Nf3 Nc6 3. Bc4 Bc5 {giuoco piano} *") + +chess.get_comment() +// -> "giuoco piano" +``` + +### .set_comment(comment) + +Comment on the current position. + +```js +const chess = new Chess() + +chess.move("e4") +chess.set_comment("king's pawn opening") + +chess.pgn() +// -> "1. e4 {king's pawn opening}" +``` + +### .delete_comment() + +Delete and return the comment for the current position, if it exists. + +```js +const chess = new Chess() + +chess.load_pgn("1. e4 e5 2. Nf3 Nc6 3. Bc4 Bc5 {giuoco piano} *") + +chess.get_comment() +// -> "giuoco piano" + +chess.delete_comment() +// -> "giuoco piano" + +chess.get_comment() +// -> undefined +``` + +### .get_comments() + +Retrieve comments for all positions. + +```js +const chess = new Chess() + +chess.load_pgn("1. e4 e5 {king's pawn opening} 2. Nf3 Nc6 3. Bc4 Bc5 {giuoco piano} *") + +chess.get_comments() +// -> [ +// { +// fen: "rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq e6 0 2", +// comment: "king's pawn opening" +// }, +// { +// fen: "r1bqkbnr/pppp1ppp/2n5/4p3/4P3/5N2/PPPP1PPP/RNBQKB1R w KQkq - 2 3", +// comment: "giuoco piano" +// } +// ] +``` + +### .delete_comments() + +Delete and return comments for all positions. + +```js +const chess = new Chess() + +chess.load_pgn("1. e4 e5 {king's pawn opening} 2. Nf3 Nc6 3. Bc4 Bc5 {giuoco piano} *") + +chess.delete_comments() +// -> [ +// { +// fen: "rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq e6 0 2", +// comment: "king's pawn opening" +// }, +// { +// fen: "r1bqkbnr/pppp1ppp/2n5/4p3/4P3/5N2/PPPP1PPP/RNBQKB1R w KQkq - 2 3", +// comment: "giuoco piano" +// } +// ] + +chess.get_comments() +// -> [] +``` + ## Sites Using chess.js - [chess.com](http://www.chess.com/) diff --git a/__tests__/chess.test.js b/__tests__/chess.test.js index 991b9a2a..d68d6a1a 100644 --- a/__tests__/chess.test.js +++ b/__tests__/chess.test.js @@ -810,6 +810,289 @@ describe("Load PGN", function() { }); +describe("Manipulate Comments", function() { + var is_empty = function (object) { + for (var property in object) { + if (object.hasOwnProperty(property)) { + return false; + } + } + return true; + }; + + it('no comments', function() { + var chess = new Chess(); + expect(chess.get_comment()) + .toBeUndefined(); + expect(chess.get_comments()) + .toEqual([]); + chess.move('e4'); + expect(chess.get_comment()) + .toBeUndefined(); + expect(chess.get_comments()) + .toEqual([]); + expect(chess.pgn()) + .toEqual('1. e4'); + }); + + it('comment for initial position', function() { + var chess = new Chess(); + chess.set_comment('starting position'); + expect(chess.get_comment()) + .toEqual('starting position'); + expect(chess.get_comments()) + .toEqual([{fen: chess.fen(), comment: 'starting position'}]); + expect(chess.pgn()) + .toEqual('{starting position}'); + }); + + it('comment for first move', function() { + var chess = new Chess(); + chess.move('e4'); + var e4 = chess.fen(); + chess.set_comment('good move'); + expect(chess.get_comment()) + .toEqual('good move'); + expect(chess.get_comments()) + .toEqual([{fen: e4, comment: 'good move'}]); + chess.move('e5'); + expect(chess.get_comment()) + .toBeUndefined(); + expect(chess.get_comments()) + .toEqual([{fen: e4, comment: 'good move'}]); + expect(chess.pgn()) + .toEqual('1. e4 {good move} e5'); + }); + + it('comment for last move', function() { + var chess = new Chess(); + chess.move('e4'); + chess.move('e6'); + chess.set_comment('dubious move'); + expect(chess.get_comment()) + .toEqual('dubious move'); + expect(chess.get_comments()) + .toEqual([{fen: chess.fen(), comment: 'dubious move'}]); + expect(chess.pgn()) + .toEqual('1. e4 e6 {dubious move}'); + }); + + it('comment with brackets', function() { + var chess = new Chess(); + chess.set_comment('{starting position}'); + expect(chess.get_comment()) + .toEqual('[starting position]'); + }); + + it('comments for everything', function() { + var chess = new Chess(); + + var initial = chess.fen(); + chess.set_comment('starting position'); + expect(chess.get_comment()) + .toEqual('starting position'); + expect(chess.get_comments()) + .toEqual([{fen: initial, comment: 'starting position'}]); + expect(chess.pgn()) + .toEqual('{starting position}'); + + chess.move('e4'); + var e4 = chess.fen(); + chess.set_comment('good move'); + expect(chess.get_comment()) + .toEqual('good move'); + expect(chess.get_comments()) + .toEqual([ + {fen: initial, comment: 'starting position'}, + {fen: e4, comment: 'good move'} + ]); + expect(chess.pgn()) + .toEqual('{starting position} 1. e4 {good move}'); + + chess.move('e6'); + var e6 = chess.fen(); + chess.set_comment('dubious move'); + expect(chess.get_comment()) + .toEqual('dubious move'); + expect(chess.get_comments()) + .toEqual([ + {fen: initial, comment: 'starting position'}, + {fen: e4, comment: 'good move'}, + {fen: e6, comment: 'dubious move'} + ]); + expect(chess.pgn()) + .toEqual('{starting position} 1. e4 {good move} e6 {dubious move}'); + }); + + it('delete comments', function() { + var chess = new Chess(); + expect(chess.delete_comment()) + .toBeUndefined(); + expect(chess.delete_comments()) + .toEqual([]); + var initial = chess.fen(); + chess.set_comment('starting position'); + chess.move('e4'); + var e4 = chess.fen(); + chess.set_comment('good move'); + chess.move('e6'); + var e6 = chess.fen(); + chess.set_comment('dubious move'); + expect(chess.get_comments()) + .toEqual([ + {fen: initial, comment: 'starting position'}, + {fen: e4, comment: 'good move'}, + {fen: e6, comment: 'dubious move'} + ]); + expect(chess.delete_comment()) + .toEqual('dubious move'); + expect(chess.pgn()) + .toEqual('{starting position} 1. e4 {good move} e6'); + expect(chess.delete_comment()) + .toBeUndefined(); + expect(chess.delete_comments()) + .toEqual([ + {fen: initial, comment: 'starting position'}, + {fen: e4, comment: 'good move'} + ]); + expect(chess.pgn()) + .toEqual('1. e4 e6'); + }); + + it('prune comments', function() { + var chess = new Chess(); + chess.move('e4'); + chess.set_comment('tactical'); + chess.undo(); + chess.move('d4'); + chess.set_comment('positional'); + expect(chess.get_comments()) + .toEqual([{fen: chess.fen(), comment: 'positional'}]); + expect(chess.pgn()) + .toEqual('1. d4 {positional}'); + }); + + it('clear comments', function() { + var test = function(fn) { + var chess = new Chess(); + chess.move('e4'); + chess.set_comment('good move'); + expect(chess.get_comments()) + .toEqual([{fen: chess.fen(), comment: 'good move'}]); + fn(chess); + expect(chess.get_comments()) + .toEqual([]); + }; + test(function(chess) { chess.reset(); }); + test(function(chess) { chess.clear(); }); + test(function(chess) { chess.load(chess.fen()); }); + test(function(chess) { chess.load_pgn('1. e4'); }); + }); + +}); + +describe('Format Comments', function() { + it('wrap comments', function() { + var chess = new Chess(); + chess.move('e4'); + chess.set_comment('good move'); + chess.move('e5'); + chess.set_comment('classical response'); + expect(chess.pgn()) + .toEqual('1. e4 {good move} e5 {classical response}'); + expect(chess.pgn({max_width: 16})) + .toEqual([ + '1. e4 {good', + 'move} e5', + '{classical', + 'response}' + ].join('\n')); + expect(chess.pgn({max_width: 2})) + .toEqual([ + '1.', + 'e4', + '{good', + 'move}', + 'e5', + '{classical', + 'response}' + ].join('\n')); + }); +}); + +describe('Load Comments', function() { + var tests = [ + { + name: 'bracket comments', + input: '1. e4 {good move} e5 {classical response}', + output: '1. e4 {good move} e5 {classical response}' + }, + { + name: 'semicolon comments', + input: '1. e4 e5; romantic era\n 2. Nf3 Nc6; common continuation', + output: '1. e4 e5 {romantic era} 2. Nf3 Nc6 {common continuation}' + }, + { + name: 'bracket and semicolon comments', + input: '1. e4 {good!} e5; standard response\n 2. Nf3 Nc6 {common}', + output: '1. e4 {good!} e5 {standard response} 2. Nf3 Nc6 {common}' + }, + { + name: 'bracket comments with newlines', + input: '1. e4 {good\nmove} e5 {classical\nresponse}', + output: '1. e4 {good move} e5 {classical response}' + }, + { + name: 'initial comment', + input: '{ great game }\n1. e4 e5', + output: '{ great game } 1. e4 e5' + }, + { + name: 'empty bracket comment', + input: '1. e4 {}', + output: '1. e4 {}' + }, + { + name: 'empty semicolon comment', + input: '1. e4;\ne5', + output: '1. e4 {} e5' + }, + { + name: 'unicode comment', + input: '1. e4 {Δ, Й, ק ,م, ๗, あ, 叶, 葉, and 말}', + output: '1. e4 {Δ, Й, ק ,م, ๗, あ, 叶, 葉, and 말}' + }, + { + name: 'semicolon in bracket comment', + input: '1. e4 { a classic; well-studied } e5', + output: '1. e4 { a classic; well-studied } e5' + }, + { + name: 'bracket in semicolon comment', + input: '1. e4 e5 ; a classic {well-studied}', + output: '1. e4 e5 {a classic {well-studied}}' + }, + { + name: 'markers in bracket comment', + input: '1. e4 e5 {($1) 1. e4 is good}', + output: '1. e4 e5 {($1) 1. e4 is good}' + }, + { + name: 'markers in semicolon comment', + input: '1. e4 e5; ($1) 1. e4 is good', + output: '1. e4 e5 {($1) 1. e4 is good}' + } + ]; + + tests.forEach(function(test) { + it(`load ${test.name}`, function() { + var chess = new Chess(); + chess.load_pgn(test.input); + expect(chess.pgn()) + .toEqual(test.output); + }); + }); +}); describe("Make Move", function() { diff --git a/chess.js b/chess.js index 90461d74..ae7e73ba 100755 --- a/chess.js +++ b/chess.js @@ -159,6 +159,7 @@ var Chess = function(fen) { var move_number = 1 var history = [] var header = {} + var comments = {} /* if the user passes in a fen string, load it, else default to * starting position @@ -183,9 +184,29 @@ var Chess = function(fen) { move_number = 1 history = [] if (!keep_headers) header = {} + comments = {} update_setup(generate_fen()) } + function prune_comments() { + var reversed_history = []; + var current_comments = {}; + var copy_comment = function(fen) { + if (fen in comments) { + current_comments[fen] = comments[fen]; + } + }; + while (history.length > 0) { + reversed_history.push(undo_move()); + } + copy_comment(generate_fen()); + while (reversed_history.length > 0) { + make_move(reversed_history.pop()); + copy_comment(generate_fen()); + } + comments = current_comments; + } + function reset() { load(DEFAULT_POSITION) } @@ -1392,6 +1413,15 @@ var Chess = function(fen) { result.push(newline) } + var append_comment = function(move_string) { + var comment = comments[generate_fen()] + if (typeof comment !== 'undefined') { + var delimiter = move_string.length > 0 ? ' ' : ''; + move_string = `${move_string}${delimiter}{${comment}}` + } + return move_string + } + /* pop all of history onto reversed_history */ var reversed_history = [] while (history.length > 0) { @@ -1401,8 +1431,14 @@ var Chess = function(fen) { var moves = [] var move_string = '' + /* special case of a commented starting position with no moves */ + if (reversed_history.length === 0) { + moves.push(append_comment('')) + } + /* build the list of moves. a move_string looks like: "3. e3 e6" */ while (reversed_history.length > 0) { + move_string = append_comment(move_string) var move = reversed_history.pop() /* if the position started with black to move, start PGN with 1. ... */ @@ -1422,7 +1458,7 @@ var Chess = function(fen) { /* are there any other leftover moves? */ if (move_string.length) { - moves.push(move_string) + moves.push(append_comment(move_string)) } /* is there a result? */ @@ -1430,16 +1466,54 @@ var Chess = function(fen) { moves.push(header.Result) } - /* history should be back to what is was before we started generating PGN, + /* history should be back to what it was before we started generating PGN, * so join together moves */ if (max_width === 0) { return result.join('') + moves.join(' ') } + var strip = function() { + if (result.length > 0 && result[result.length - 1] === ' ') { + result.pop(); + return true; + } + return false; + }; + + /* NB: this does not preserve comment whitespace. */ + var wrap_comment = function(width, move) { + for (var token of move.split(' ')) { + if (!token) { + continue; + } + if (width + token.length > max_width) { + while (strip()) { + width--; + } + result.push(newline); + width = 0; + } + result.push(token); + width += token.length; + result.push(' '); + width++; + } + if (strip()) { + width--; + } + return width; + }; + /* wrap the PGN output at max_width */ var current_width = 0 for (var i = 0; i < moves.length; i++) { + if (current_width + moves[i].length > max_width) { + if (moves[i].includes('{')) { + current_width = wrap_comment(current_width, moves[i]); + continue; + } + } /* if the current move will push past max_width */ if (current_width + moves[i].length > max_width && i !== 0) { /* don't end the line with whitespace */ @@ -1541,14 +1615,60 @@ var Chess = function(fen) { } } + /* NB: the regexes below that delete move numbers, recursive + * annotations, and numeric annotation glyphs may also match + * text in comments. To prevent this, we transform comments + * by hex-encoding them in place and decoding them again after + * the other tokens have been deleted. + * + * While the spec states that PGN files should be ASCII encoded, + * we use {en,de}codeURIComponent here to support arbitrary UTF8 + * as a convenience for modern users */ + + var to_hex = function(string) { + return Array + .from(string) + .map(function(c) { + /* encodeURI doesn't transform most ASCII characters, + * so we handle these ourselves */ + return c.charCodeAt(0) < 128 + ? c.charCodeAt(0).toString(16) + : encodeURIComponent(c).replace(/\%/g, '').toLowerCase() + }) + .join('') + } + + var from_hex = function(string) { + return string.length == 0 + ? '' + : decodeURIComponent('%' + string.match(/.{1,2}/g).join('%')) + } + + var encode_comment = function(string) { + string = string.replace(new RegExp(mask(newline_char), 'g'), ' ') + return `{${to_hex(string.slice(1, string.length - 1))}}` + } + + var decode_comment = function(string) { + if (string.startsWith('{') && string.endsWith('}')) { + return from_hex(string.slice(1, string.length - 1)) + } + } + /* delete header to get the moves */ var ms = pgn .replace(header_string, '') + .replace( + /* encode comments so they don't get deleted below */ + new RegExp(`(\{[^}]*\})+?|;([^${mask(newline_char)}]*)`, 'g'), + function(match, bracket, semicolon) { + return bracket !== undefined + ? encode_comment(bracket) + : ' ' + encode_comment(`{${semicolon.slice(1)}}`) + } + ) .replace(new RegExp(mask(newline_char), 'g'), ' ') - /* delete comments */ - ms = ms.replace(/(\{[^}]+\})+?/g, '') - /* delete recursive annotation variations */ var rav_regex = /(\([^\(\)]+\))+?/g while (rav_regex.test(ms)) { @@ -1575,6 +1695,11 @@ var Chess = function(fen) { var move = '' for (var half_move = 0; half_move < moves.length - 1; half_move++) { + var comment = decode_comment(moves[half_move]) + if (comment !== undefined) { + comments[generate_fen()] = comment + continue + } move = move_from_san(moves[half_move], sloppy) /* move not possible! (don't clear the board to examine to show the @@ -1587,6 +1712,12 @@ var Chess = function(fen) { } } + comment = decode_comment(moves[moves.length - 1]) + if (comment !== undefined) { + comments[generate_fen()] = comment + moves.pop() + } + /* examine last move */ move = moves[moves.length - 1] if (POSSIBLE_RESULTS.indexOf(move) > -1) { @@ -1727,6 +1858,37 @@ var Chess = function(fen) { } return move_history + }, + + get_comment: function() { + return comments[generate_fen()]; + }, + + set_comment: function(comment) { + comments[generate_fen()] = comment.replace('{', '[').replace('}', ']'); + }, + + delete_comment: function() { + var comment = comments[generate_fen()]; + delete comments[generate_fen()]; + return comment; + }, + + get_comments: function() { + prune_comments(); + return Object.keys(comments).map(function(fen) { + return {fen: fen, comment: comments[fen]}; + }); + }, + + delete_comments: function() { + prune_comments(); + return Object.keys(comments) + .map(function(fen) { + var comment = comments[fen]; + delete comments[fen]; + return {fen: fen, comment: comment}; + }); } } }