diff --git a/.github/actions/spelling/excludes.txt b/.github/actions/spelling/excludes.txt index 20fa4123..ac097813 100644 --- a/.github/actions/spelling/excludes.txt +++ b/.github/actions/spelling/excludes.txt @@ -52,3 +52,4 @@ ignore$ ^\Qsource/templates/terminal/fonts.css\E$ ^\Qsource/templates/terminal/partials/screenshot.ejs\E$ ^\Qtests/mocks/api/github/rest/emojis/get.mjs\E$ +^\Qtests/mocks/api/axios/get/lichess.mjs\E$ diff --git a/.github/actions/spelling/expect.txt b/.github/actions/spelling/expect.txt index ea248279..ad2ff4d4 100644 --- a/.github/actions/spelling/expect.txt +++ b/.github/actions/spelling/expect.txt @@ -174,6 +174,7 @@ libxml libxmljs libxss libxtst +lichess linux lng localhost @@ -236,6 +237,8 @@ params patchnote pdated pened +PGN +Pgn PGP playcount playlists diff --git a/.github/actions/spelling/patterns.txt b/.github/actions/spelling/patterns.txt index 036d73e9..5fa34d41 100644 --- a/.github/actions/spelling/patterns.txt +++ b/.github/actions/spelling/patterns.txt @@ -90,3 +90,7 @@ place_id: ".*" # ignore long runs of a single character: \b([A-Za-z])\g{-1}{3,}\b + +# Chess patterns +\brnbqkbnr\b +\bRNBQKBNR\b diff --git a/.github/readme/imgs/plugin_chess_lichess_token_0.png b/.github/readme/imgs/plugin_chess_lichess_token_0.png new file mode 100644 index 00000000..6336af1a Binary files /dev/null and b/.github/readme/imgs/plugin_chess_lichess_token_0.png differ diff --git a/.github/readme/imgs/plugin_chess_lichess_token_1.png b/.github/readme/imgs/plugin_chess_lichess_token_1.png new file mode 100644 index 00000000..8a7a1857 Binary files /dev/null and b/.github/readme/imgs/plugin_chess_lichess_token_1.png differ diff --git a/.github/readme/imgs/plugin_chess_lichess_token_2.png b/.github/readme/imgs/plugin_chess_lichess_token_2.png new file mode 100644 index 00000000..9374a464 Binary files /dev/null and b/.github/readme/imgs/plugin_chess_lichess_token_2.png differ diff --git a/.github/scripts/files/examples.yml b/.github/scripts/files/examples.yml index b93a2e9f..edc22267 100644 --- a/.github/scripts/files/examples.yml +++ b/.github/scripts/files/examples.yml @@ -12,6 +12,8 @@ on: required: true METRICS_TOKEN_PERSONAL: required: true + CHESS_TOKEN: + required: true PAGESPEED_TOKEN: required: true GOOGLE_MAP_TOKEN: diff --git a/package-lock.json b/package-lock.json index 563d8e17..3a6909e2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@primer/css": "^20.4.3", "@primer/octicons": "^17.5.0", "axios": "^0.27.2", + "chess.js": "*", "clipboard": "^2.0.11", "color": "^4.2.3", "compression": "^1.7.4", @@ -57,6 +58,7 @@ "libxmljs2": "^0.30.1" }, "optionalDependencies": { + "chess.js": "^1.0.0-alpha.0", "gifencoder": "^2.0.1", "libxmljs2": "^0.30.1", "node-chartist": "^1.0.5" @@ -3125,6 +3127,12 @@ "entities": "^4.3.0" } }, + "node_modules/chess.js": { + "version": "1.0.0-alpha.0", + "resolved": "https://registry.npmjs.org/chess.js/-/chess.js-1.0.0-alpha.0.tgz", + "integrity": "sha512-nwEDlNEOZR/FaKW1DZLYtjgJrtAlxBW/6Fp4Hk8QXqpCCLrXweOU6/vKfJ/jNHT2gPqasxi0/ZPslcWAAdzozg==", + "optional": true + }, "node_modules/chownr": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", @@ -12435,6 +12443,12 @@ } } }, + "chess.js": { + "version": "1.0.0-alpha.0", + "resolved": "https://registry.npmjs.org/chess.js/-/chess.js-1.0.0-alpha.0.tgz", + "integrity": "sha512-nwEDlNEOZR/FaKW1DZLYtjgJrtAlxBW/6Fp4Hk8QXqpCCLrXweOU6/vKfJ/jNHT2gPqasxi0/ZPslcWAAdzozg==", + "optional": true + }, "chownr": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", diff --git a/package.json b/package.json index 32ef3b96..2f81a848 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "transform": {} }, "optionalDependencies": { + "chess.js": "^1.0.0-alpha.0", "gifencoder": "^2.0.1", "libxmljs2": "^0.30.1", "node-chartist": "^1.0.5" diff --git a/source/app/web/statics/embed/app.placeholder.js b/source/app/web/statics/embed/app.placeholder.js index 89e617b3..f735b9a4 100644 --- a/source/app/web/statics/embed/app.placeholder.js +++ b/source/app/web/statics/embed/app.placeholder.js @@ -1043,6 +1043,30 @@ }, }) : null), + //Chess + ...(set.plugins.enabled.chess + ? ({ + chess: { + platform: options["chess.platform"] || "(chess platform)", + meta: { + Event: "Casual Correspondence game", + Date: faker.date.recent().toISOString().substring(0, 10), + White: options["chess.user"], + Black: faker.internet.userName(), + WhiteElo: faker.datatype.number(3000), + BlackElo: faker.datatype.number(3000), + }, + animation: { size: 40, delay: 3, duration: 0.6 }, + result: { white: faker.datatype.number(3), get black() { return this.white + faker.helpers.arrayElement([-1, +1]) } }, + moves: [ + {color: "w", piece: "p", from: "f2", to: "f4", san: "f4", flags: "b"}, + {color: "b", piece: "p", from: "c7", to: "c5", san: "c5", flags: "b"}, + {color: "w", piece: "p", from: "e2", to: "e4", san: "e4", flags: "b"}, + {color: "b", piece: "p", from: "d7", to: "d6", san: "d6", flags: "n"}, + ] + }, + }) + : null), //Activity ...(set.plugins.enabled.activity ? ({ diff --git a/source/plugins/community/chess/README.md b/source/plugins/community/chess/README.md new file mode 100644 index 00000000..3eff2a87 --- /dev/null +++ b/source/plugins/community/chess/README.md @@ -0,0 +1,12 @@ + + + +## ➡️ Available options + + + + +## ℹ️ Examples workflows + + + diff --git a/source/plugins/community/chess/examples.yml b/source/plugins/community/chess/examples.yml new file mode 100644 index 00000000..89a72800 --- /dev/null +++ b/source/plugins/community/chess/examples.yml @@ -0,0 +1,10 @@ +- name: Last chess game from lichess.org + uses: lowlighter/metrics@latest + with: + filename: metrics.plugin.chess.svg + token: NOT_NEEDED + base: "" + plugin_chess: yes + plugin_chess_token: ${{ secrets.CHESS_TOKEN }} + plugin_chess_platform: lichess.org + diff --git a/source/plugins/community/chess/index.mjs b/source/plugins/community/chess/index.mjs new file mode 100644 index 00000000..0c3f6ed6 --- /dev/null +++ b/source/plugins/community/chess/index.mjs @@ -0,0 +1,48 @@ +//Imports +import { Chess } from "chess.js" + +//Setup +export default async function({login, q, imports, data, account}, {enabled = false, token = "", extras = false} = {}) { + //Plugin execution + try { + //Check if plugin is enabled and requirements are met + if ((!q.chess) || (!imports.metadata.plugins.chess.enabled(enabled, {extras}))) + return null + + //Load inputs + const {user, platform, animation} = imports.metadata.plugins.chess.inputs({data, account, q}) + for (const [key, defaulted] of Object.entries({size:40, delay:1, duration:4})) { + if (Number.isNaN(Number(animation[key]))) + animation[key] = defaulted + if (animation[key] < 0) + animation[key] = defaulted + } + + //Fetch PGN + console.debug(`metrics/compute/${login}/plugins > chess > fetching last game from ${platform}`) + let PGN + switch (platform) { + case "lichess.org": + PGN = (await imports.axios.get(`https://lichess.org/api/games/user/${user}?max=1`, {headers: {Authorization: `Bearer ${token}`}})).data + break + case "": + throw {error: {message: "Unspecified platform"}} + default: + throw {error: {message: `Unsupported platform "${platform}"`}} + } + + //Parse PGN + const board = new Chess() + board.loadPgn(PGN) + const moves = board.history({verbose: true}) + const meta = board.header() + const result = Object.fromEntries(meta.Result.split("-").map((score, i) => [i ? "black" : "white", Number(score)])) + + //Results + return {platform, meta, moves, animation, result} + } + //Handle errors + catch (error) { + throw imports.format.error(error) + } +} \ No newline at end of file diff --git a/source/plugins/community/chess/metadata.yml b/source/plugins/community/chess/metadata.yml new file mode 100644 index 00000000..64b6a63d --- /dev/null +++ b/source/plugins/community/chess/metadata.yml @@ -0,0 +1,61 @@ +name: ♟️ Chess +category: community +description: | + This plugin displays the last game you played on a supported chess platform. +disclaimer: | + This plugin is not affiliated, associated, authorized, endorsed by, or in any way officially connected with any of the supported provider. + All product and company names are trademarks™ or registered® trademarks of their respective holders. +examples: + default: https://github.com/lowlighter/metrics/blob/examples/metrics.plugin.chess.svg +authors: + - lowlighter +supports: + - user + - organization + - repository +scopes: [] +inputs: + + plugin_chess: + description: | + Enable chess plugin + type: boolean + default: no + + plugin_chess_token: + description: | + Chess platform token + type: token + default: "" + extras: + - metrics.api.chess.any + + plugin_chess_user: + description: | + AniList login + type: string + default: .user.login + preset: no + + plugin_chess_platform: + description: | + Chess platform + type: string + default: "" + values: + - lichess.org + + plugin_chess_animation: + description: | + Animation settings + + - `size` is the size of a single chessboard square in pixels (board will be 8 times larger) + - `delay` is the delay before starting animation (in seconds) + - `duration` is the duration of the animation of a move (in seconds) + type: json + default: | + { + "size": 40, + "delay": 3, + "duration": 0.6 + } diff --git a/source/templates/classic/partials/_.json b/source/templates/classic/partials/_.json index c65a757e..0c173bc4 100644 --- a/source/templates/classic/partials/_.json +++ b/source/templates/classic/partials/_.json @@ -37,6 +37,7 @@ "achievements", "screenshot", "code", + "chess", "sponsors", "poopmap", "fortune" diff --git a/source/templates/classic/partials/chess.ejs b/source/templates/classic/partials/chess.ejs new file mode 100644 index 00000000..8991d6ff --- /dev/null +++ b/source/templates/classic/partials/chess.ejs @@ -0,0 +1,194 @@ +<% if (plugins.chess) { %> +
+

+ + Last chess game +

+ <% if (plugins.chess.error) { %> +
+
+
+ + <%= plugins.chess.error.message %> +
+
+
+ <% } else { %> +
+
+
+ + From <%= plugins.chess.platform %> +
+
+ + <%= plugins.chess.meta["White"] %> <% if (plugins.chess.meta["WhiteElo"]) { %>(<%= plugins.chess.meta["WhiteElo"] %> ELO)<% } %> +
+
+ <% if (plugins.chess.result.white > plugins.chess.result.black) { %> + + <% } else { %> + + <% } %> + <%= plugins.chess.result.white %> victor<%= s(plugins.chess.result.white, "y") %> +
+
+
+
+ + <%= plugins.chess.meta["Date"] %> +
+
+ + <%= plugins.chess.meta["Black"] %> <% if (plugins.chess.meta["BlackElo"]) { %>(<%= plugins.chess.meta["BlackElo"] %> ELO)<% } %> +
+
+ <% if (plugins.chess.result.black > plugins.chess.result.white) { %> + + <% } else { %> + + <% } %> + <%= plugins.chess.result.black %> victor<%= s(plugins.chess.result.black, "y") %> +
+
+
+ +
+ <% { + //Utilities functions + const pieces = { + w: {p: "♙", r: "♖", n: "♘", b: "♗", q: "♕", k: "♔"}, + b: {p: "♟︎", r: "♜", n: "♞", b: "♝", q: "♛", k: "♚"}, + } + const init = ["rnbqkbnr", "pppppppp", "", "", "", "", "PPPPPPPP", "RNBQKBNR"] + const column = p => ["a", "b", "c", "d", "e", "f", "g", "h"].indexOf(p[0]) + const row = p => ["8", "7", "6", "5", "4", "3", "2", "1"].indexOf(p[1]) + const animation = plugins.chess.animation + const size = animation.size + + //Empty board + for (let i = 0; i < 8; i++) { %> +
+ <% for (let j = 0; j < 8; j++) { %> +
+ <% } %> +
+ <% } + + //Initial board state + for (let i = 0; i < init.length; i++) { for (let j = 0; j < init[i].length; j++) { + const color = /[A-Z]/.test(init[i][j]) ? "w" : "b", piece = init[i][j].toLocaleLowerCase() %> +
+
<%- pieces[color][piece] %>
+
+ <% }} + + //Draw moves + const moves = plugins.chess.moves + for (let i = 0; i < moves.length; i++) { const {color, piece, from, to} = moves[i] %> +
+
<%- pieces[color][piece] %>
+
<%- pieces[color][piece] %>
+ +
+ <% } %> + + <% } %> +
+
+
+
+ <% } %> +
+<% } %> \ No newline at end of file diff --git a/tests/mocks/api/axios/get/lichess.mjs b/tests/mocks/api/axios/get/lichess.mjs new file mode 100644 index 00000000..6abb6887 --- /dev/null +++ b/tests/mocks/api/axios/get/lichess.mjs @@ -0,0 +1,30 @@ +/**Mocked data */ +export default function({faker, url, options, login = faker.internet.userName()}) { + //Wakatime api + if (/^https:..lichess.org.api.games.user.*$/.test(url)) { + console.debug(`metrics/compute/mocks > mocking lichess api result > ${url}`) + return ({ + status: 200, + data: ` +[Event "It (cat.17)"] +[Site "Wijk aan Zee (Netherlands)"] +[Date "1999.??.??"] +[Round "?"] +[White "Garry Kasparov"] +[Black "Veselin Topalov"] +[Result "1-0"] + +1. e4 d6 2. d4 Nf6 3. Nc3 g6 4. Be3 Bg7 5. Qd2 c6 6. f3 b5 7. Nge2 Nbd7 8. Bh6 +Bxh6 9. Qxh6 Bb7 10. a3 e5 11. O-O-O Qe7 12. Kb1 a6 13. Nc1 O-O-O 14. Nb3 exd4 +15. Rxd4 c5 16. Rd1 Nb6 17. g3 Kb8 18. Na5 Ba8 19. Bh3 d5 20. Qf4+ Ka7 21. Rhe1 +d4 22. Nd5 Nbxd5 23. exd5 Qd6 24. Rxd4 cxd4 25. Re7+ Kb6 26. Qxd4+ Kxa5 27. b4+ +Ka4 28. Qc3 Qxd5 29. Ra7 Bb7 30. Rxb7 Qc4 31. Qxf6 Kxa3 32. Qxa6+ Kxb4 33. c3+ +Kxc3 34. Qa1+ Kd2 35. Qb2+ Kd1 36. Bf1 Rd2 37. Rd7 Rxd7 38. Bxc4 bxc4 39. Qxh8 +Rd3 40. Qa8 c3 41. Qa4+ Ke1 42. f4 f5 43. Kc1 Rd2 44. Qa7 1-0`.trim() + }) + } +} + + + + diff --git a/tests/secrets.json b/tests/secrets.json index b716dfbc..9ac41063 100644 --- a/tests/secrets.json +++ b/tests/secrets.json @@ -11,5 +11,6 @@ "TWITTER_TOKEN":"MOCKED_TOKEN", "GOOGLE_MAP_TOKEN": "MOCKED_TOKEN", "STOCK_TOKEN":"MOCKED_TOKEN", + "CHESS_TOKEN":"MOCKED_TOKEN", "POOPMAP_TOKEN":"MOCKED_TOKEN" } \ No newline at end of file