├── .gitattributes ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── ci.yml │ └── create-release.yml ├── .gitignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── banner.png ├── build ├── icon.icns └── icon.ico ├── ci ├── createI18n.js ├── dolmGetKey.js ├── extractInfo.js └── mv.js ├── data ├── 0.mp3 ├── 1.mp3 ├── 2.mp3 ├── 3.mp3 ├── 4.mp3 ├── capture0.mp3 ├── capture1.mp3 ├── capture2.mp3 ├── capture3.mp3 ├── capture4.mp3 ├── newgame.mp3 └── pass.mp3 ├── docs ├── README.md └── guides │ ├── building-tests.md │ ├── create-themes.md │ ├── debugging.md │ ├── engine-analysis-integration.md │ ├── engines.md │ ├── markdown.md │ ├── theme-directory.md │ └── userstyle-tutorial.md ├── img ├── edit │ ├── arrow.svg │ ├── circle.svg │ ├── cross.svg │ ├── label.svg │ ├── line.svg │ ├── number.svg │ ├── square.svg │ ├── stone_-1.svg │ ├── stone_1.svg │ └── triangle.svg └── ui │ ├── badmove.svg │ ├── balance.svg │ ├── black.svg │ ├── doubtfulmove.svg │ ├── goodmove.svg │ ├── interestingmove.svg │ ├── player_-1.svg │ ├── player_1.svg │ ├── tatami.png │ ├── unclear.svg │ └── white.svg ├── index.html ├── logo.png ├── package-lock.json ├── package.json ├── screenshot.png ├── src ├── components │ ├── App.js │ ├── BusyScreen.js │ ├── ContentDisplay.js │ ├── DrawerManager.js │ ├── Goban.js │ ├── InfoOverlay.js │ ├── InputBox.js │ ├── LeftSidebar.js │ ├── MainMenu.js │ ├── MainView.js │ ├── MarkdownContentDisplay.js │ ├── MiniGoban.js │ ├── Sidebar.js │ ├── TextSpinner.js │ ├── ThemeManager.js │ ├── ToolBar.js │ ├── bars │ │ ├── AutoplayBar.js │ │ ├── Bar.js │ │ ├── EditBar.js │ │ ├── FindBar.js │ │ ├── GuessBar.js │ │ ├── PlayBar.js │ │ └── ScoringBar.js │ ├── drawers │ │ ├── AdvancedPropertiesDrawer.js │ │ ├── CleanMarkupDrawer.js │ │ ├── Drawer.js │ │ ├── GameChooserDrawer.js │ │ ├── InfoDrawer.js │ │ ├── PreferencesDrawer.js │ │ └── ScoreDrawer.js │ ├── helpers │ │ ├── SplitContainer.js │ │ └── TripleSplitContainer.js │ └── sidebars │ │ ├── CommentBox.js │ │ ├── GameGraph.js │ │ ├── GtpConsole.js │ │ ├── PeerList.js │ │ ├── Slider.js │ │ └── WinrateGraph.js ├── i18n.js ├── main.js ├── menu.js ├── modules │ ├── dialog.js │ ├── enginesyncer.js │ ├── fileformats │ │ ├── gib.js │ │ ├── index.js │ │ ├── ngf.js │ │ ├── sgf.js │ │ └── ugf.js │ ├── gamesort.js │ ├── gametree.js │ ├── gobantransformer.js │ ├── gtplogger.js │ ├── helper.js │ ├── sabaki.js │ ├── shims │ │ └── prop-types.js │ └── sound.js ├── setting.js └── updater.js ├── style ├── app.css ├── comments.css └── index.css ├── test ├── engines │ └── resignEngine.js ├── gamesortTests.js ├── gib │ ├── euc-kr.gib │ ├── gb2312.gib │ └── utf8.gib ├── gibTests.js ├── ngf │ ├── even.ngf │ ├── gb2312.ngf │ └── handicap2.ngf ├── ngfTests.js ├── sgf │ ├── beginner_game.sgf │ ├── blank_game.sgf │ ├── pro_game.sgf │ └── shodan_game.sgf ├── ugf │ ├── amateur.ugf │ └── review.ugi └── ugfTests.js └── webpack.config.js /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | custom: https://www.paypal.me/yishn/5 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: "npm" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | assignees: 9 | - "apetresc" 10 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v3 14 | - uses: actions/setup-node@v3 15 | with: 16 | node-version: 14.x 17 | - name: npm install, build, and test 18 | run: | 19 | npm install 20 | npm run format-check 21 | npm run build 22 | npm test 23 | env: 24 | CI: true 25 | -------------------------------------------------------------------------------- /.github/workflows/create-release.yml: -------------------------------------------------------------------------------- 1 | name: Create Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | create-release: 10 | strategy: 11 | matrix: 12 | os: [ubuntu-latest, windows-latest, macos-latest] 13 | 14 | runs-on: ${{ matrix.os }} 15 | 16 | steps: 17 | - uses: actions/checkout@v3 18 | - uses: actions/setup-node@v3 19 | with: 20 | node-version: 14.x 21 | - uses: actions/setup-go@v3 22 | with: 23 | go-version: 1.17.x 24 | - name: Extract info 25 | id: info 26 | run: | 27 | node ./ci/extractInfo.js 28 | env: 29 | GITHUB_REF: ${{ github.ref }} 30 | - name: Create & upload artifact 31 | run: | 32 | npm install 33 | npm run ${{ steps.info.outputs.distcommand }} 34 | npx rimraf ./dist/*.yml ./dist/*.yaml ./dist/*.blockmap 35 | go get -u github.com/tcnksm/ghr 36 | ./ci/bin/ghr -n "Sabaki v${{ steps.info.outputs.version }}" -prerelease ${{ steps.info.outputs.tag }} ./dist 37 | env: 38 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 39 | GOPATH: ${{ steps.info.outputs.ci }} 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | i18n/ 3 | engines/ 4 | node_modules/ 5 | 6 | bundle.js 7 | bundle.js.LICENSE.txt 8 | bundle.js.map 9 | 10 | *.pdn 11 | *.vbs 12 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to Contribute 2 | 3 | First of all, thank you for taking the time to contribute to Sabaki! 4 | 5 | ## Reporting Bugs 6 | 7 | - Before submitting bug reports, please check the 8 | [issues](https://github.com/SabakiHQ/Sabaki/issues) for already existing 9 | reports. You might not need to create one. 10 | - Use a clear and descriptive title. 11 | - Please include as many details as possible in your issue, especially the 12 | version of Sabaki you're using and the name and version of the OS you're 13 | using. 14 | - It's also helpful to provide specific steps to reproduce the problem. 15 | 16 | ## Code Contribution 17 | 18 | - Before you begin, make sure there's an issue for your task and that you've let 19 | us known that you'd like to take it on. 20 | - You can look at 21 | [the documentation](https://github.com/SabakiHQ/Sabaki/tree/master/docs) to 22 | get an overview how Sabaki's code is structured. 23 | - Run prettier to make sure your code adheres to the coding style standards. You 24 | can use the command `npm run format` to format all the files. 25 | - Avoid platform-dependent code. 26 | - Create mocha tests if possible and applicable. 27 | - Document new code in the documentation if applicable. 28 | - Note the issue number in your pull request. 29 | 30 | ## Translation 31 | 32 | If you speak multiple languages, you can help us translate Sabaki. Head over to 33 | [Sabaki I18n](https://github.com/SabakiHQ/sabaki-i18n) for progress and 34 | instructions. 35 | 36 | ## Donate 37 | 38 | You can also support this project by [donating](https://paypal.me/yishn/5). 39 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015-2020 Yichuan Shen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ![Sabaki: An elegant Go/Baduk/Weiqi board and SGF editor for a more civilized age.](./banner.png) 2 | 3 | [![Download the latest release](https://img.shields.io/github/downloads/SabakiHQ/Sabaki/latest/total?label=download)](https://github.com/SabakiHQ/Sabaki/releases) 4 | [![CI](https://github.com/SabakiHQ/Sabaki/workflows/CI/badge.svg?branch=master&event=push)](https://github.com/SabakiHQ/Sabaki/actions) 5 | [![Donate](https://img.shields.io/badge/donate-paypal-blue.svg)](https://www.paypal.me/yishn/5) 6 | 7 | ## Features 8 | 9 | - Fuzzy stone placement 10 | - Read and save SGF games and collections, open wBaduk NGF and Tygem GIB files 11 | - Display formatted SGF comments using a 12 | [subset of Markdown](https://github.com/SabakiHQ/Sabaki/blob/master/docs/guides/markdown.md) 13 | and annotate board positions & moves 14 | - Personalize board appearance with 15 | [textures & themes](https://github.com/SabakiHQ/Sabaki/blob/master/docs/guides/theme-directory.md) 16 | - SGF editing tools, including lines & arrows board markup 17 | - Copy & paste variations 18 | - Powerful undo/redo 19 | - Fast game tree 20 | - Score estimator & scoring tool 21 | - Find move by move position and comment text 22 | - [GTP engines](https://github.com/SabakiHQ/Sabaki/blob/master/docs/guides/engines.md) 23 | support with 24 | [board analysis for supported engines](https://github.com/SabakiHQ/Sabaki/blob/master/docs/guides/engine-analysis-integration.md) 25 | - Guess mode 26 | - Autoplay games 27 | 28 | ![Screenshot](screenshot.png) 29 | 30 | ## Documentation 31 | 32 | For more information visit the 33 | [documentation](https://github.com/SabakiHQ/Sabaki/blob/master/docs/README.md). 34 | You're welcome to 35 | [contribute](https://github.com/SabakiHQ/Sabaki/blob/master/CONTRIBUTING.md) to 36 | this project. 37 | 38 | ## Building & Tests 39 | 40 | See 41 | [Building & Tests](https://github.com/SabakiHQ/Sabaki/blob/master/docs/guides/building-tests.md) 42 | in the documentation. 43 | 44 | ## License 45 | 46 | This project is licensed under the 47 | [MIT license](https://github.com/SabakiHQ/Sabaki/blob/master/LICENSE.md). 48 | 49 | ## Donators 50 | 51 | A big thank you to these lovely people: 52 | 53 | - Eric Wainwright 54 | - Michael Noll 55 | - John Hager 56 | - Azim Palmer 57 | - Nicolas Puyaubreau 58 | - Hans Christian Poerschke 59 | - David Göbel 60 | - Dominik Olszewski 61 | - Brian Weaver 62 | - Philippe Fanaro 63 | - James Tudor 64 | - Frank Orben 65 | - Dekun Song 66 | - Dimitri Rusin 67 | - Andrew Thieman 68 | - Adrian Petrescu 69 | - Karlheinz Agsteiner 70 | - Petr Růžička 71 | - Sergio Villegas 72 | - Jake Pivnik 73 | 74 | ## Related 75 | 76 | - [Shudan](https://github.com/SabakiHQ/Shudan) - A highly customizable, 77 | low-level Preact Goban component. 78 | - [boardmatcher](https://github.com/SabakiHQ/boardmatcher) - Finds patterns & 79 | shapes in Go board arrangements and names moves. 80 | - [deadstones](https://github.com/SabakiHQ/deadstones) - Simple Monte Carlo 81 | functions to determine dead stones. 82 | - [go-board](https://github.com/SabakiHQ/go-board) - A Go board data type. 83 | - [gtp](https://github.com/SabakiHQ/gtp) - A Node.js module for handling GTP 84 | engines. 85 | - [immutable-gametree](https://github.com/SabakiHQ/immutable-gametree) - An 86 | immutable game tree data type. 87 | - [influence](https://github.com/SabakiHQ/influence) - Simple heuristics for 88 | estimating influence maps on Go positions. 89 | - [sgf](https://github.com/SabakiHQ/sgf) - A library for parsing and creating 90 | SGF files. 91 | -------------------------------------------------------------------------------- /banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SabakiHQ/Sabaki/2dd5882a65cdf7b800f9baaf1282d0712608cc25/banner.png -------------------------------------------------------------------------------- /build/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SabakiHQ/Sabaki/2dd5882a65cdf7b800f9baaf1282d0712608cc25/build/icon.icns -------------------------------------------------------------------------------- /build/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SabakiHQ/Sabaki/2dd5882a65cdf7b800f9baaf1282d0712608cc25/build/icon.ico -------------------------------------------------------------------------------- /ci/createI18n.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const {spawnSync} = require('child_process') 3 | const {readFileSync, writeFileSync} = require('fs') 4 | const dolmTools = require('dolm/tools') 5 | const boardmatcherLibrary = require('@sabaki/boardmatcher/library') 6 | 7 | let codeGlobs = ['src/**/*.js'] 8 | let getKeyPath = path.resolve(__dirname, './dolmGetKey.js') 9 | let defaultPath = path.resolve(__dirname, '../i18n/en.i18n.js') 10 | let templatePath = path.resolve(__dirname, '../i18n/template.i18n.js') 11 | 12 | let spawnDolmGen = args => 13 | spawnSync( 14 | process.platform === 'win32' ? 'npx.cmd' : 'npx', 15 | [ 16 | 'dolm', 17 | 'gen', 18 | '--dolm-identifier', 19 | 'i18n', 20 | '--get-key', 21 | getKeyPath, 22 | ...args 23 | ], 24 | { 25 | stdio: 'inherit' 26 | } 27 | ) 28 | 29 | let boardmatcherStringsArr = [ 30 | ...boardmatcherLibrary.map(pattern => pattern.name), 31 | 'Pass', 32 | 'Take', 33 | 'Atari', 34 | 'Suicide', 35 | 'Fill', 36 | 'Connect', 37 | 'Tengen', 38 | 'Hoshi' 39 | ] 40 | 41 | let boardmatcherStrings = { 42 | boardmatcher: Object.assign( 43 | {}, 44 | ...boardmatcherStringsArr.map(str => ({[str]: str})) 45 | ) 46 | } 47 | 48 | let boardmatcherStringsTemplate = { 49 | boardmatcher: Object.assign( 50 | {}, 51 | ...boardmatcherStringsArr.map(str => ({[str]: null})) 52 | ) 53 | } 54 | 55 | // Create default i18n file 56 | 57 | spawnDolmGen(['-o', defaultPath, ...codeGlobs]) 58 | 59 | let defaultStrings = dolmTools.mergeStrings([ 60 | dolmTools.safeModuleEval(readFileSync(defaultPath, 'utf8')), 61 | boardmatcherStrings 62 | ]) 63 | 64 | writeFileSync( 65 | defaultPath, 66 | 'module.exports = ' + dolmTools.serializeStrings(defaultStrings) 67 | ) 68 | 69 | // Create template i18n file 70 | 71 | spawnDolmGen(['-t', '-o', templatePath, ...codeGlobs]) 72 | 73 | let templateStrings = dolmTools.mergeStrings([ 74 | dolmTools.safeModuleEval(readFileSync(templatePath, 'utf8')), 75 | boardmatcherStringsTemplate 76 | ]) 77 | 78 | writeFileSync( 79 | templatePath, 80 | 'module.exports = ' + dolmTools.serializeStrings(templateStrings) 81 | ) 82 | -------------------------------------------------------------------------------- /ci/dolmGetKey.js: -------------------------------------------------------------------------------- 1 | module.exports = require('../src/i18n').getKey 2 | -------------------------------------------------------------------------------- /ci/extractInfo.js: -------------------------------------------------------------------------------- 1 | const os = require('os') 2 | const path = require('path') 3 | const {version} = require('../package.json') 4 | 5 | function printSetOutputs(outputs) { 6 | for (let [name, value] of Object.entries(outputs)) { 7 | console.log(`::set-output name=${name}::${value}`) 8 | } 9 | } 10 | 11 | printSetOutputs({ 12 | version, 13 | tag: (process.env.GITHUB_REF || '').replace('refs/tags/', ''), 14 | ci: path.resolve(process.cwd(), './ci'), 15 | distcommand: { 16 | win32: 'dist:win', 17 | linux: 'dist:linux', 18 | darwin: 'dist:macos' 19 | }[os.platform()] 20 | }) 21 | -------------------------------------------------------------------------------- /ci/mv.js: -------------------------------------------------------------------------------- 1 | const {renameSync} = require('fs') 2 | const {version} = require('../package.json') 3 | 4 | let [from, to] = [2, 3] 5 | .map(i => process.argv[i]) 6 | .map(x => x.replace(/x\.x\.x/g, version)) 7 | 8 | renameSync(from, to) 9 | -------------------------------------------------------------------------------- /data/0.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SabakiHQ/Sabaki/2dd5882a65cdf7b800f9baaf1282d0712608cc25/data/0.mp3 -------------------------------------------------------------------------------- /data/1.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SabakiHQ/Sabaki/2dd5882a65cdf7b800f9baaf1282d0712608cc25/data/1.mp3 -------------------------------------------------------------------------------- /data/2.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SabakiHQ/Sabaki/2dd5882a65cdf7b800f9baaf1282d0712608cc25/data/2.mp3 -------------------------------------------------------------------------------- /data/3.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SabakiHQ/Sabaki/2dd5882a65cdf7b800f9baaf1282d0712608cc25/data/3.mp3 -------------------------------------------------------------------------------- /data/4.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SabakiHQ/Sabaki/2dd5882a65cdf7b800f9baaf1282d0712608cc25/data/4.mp3 -------------------------------------------------------------------------------- /data/capture0.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SabakiHQ/Sabaki/2dd5882a65cdf7b800f9baaf1282d0712608cc25/data/capture0.mp3 -------------------------------------------------------------------------------- /data/capture1.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SabakiHQ/Sabaki/2dd5882a65cdf7b800f9baaf1282d0712608cc25/data/capture1.mp3 -------------------------------------------------------------------------------- /data/capture2.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SabakiHQ/Sabaki/2dd5882a65cdf7b800f9baaf1282d0712608cc25/data/capture2.mp3 -------------------------------------------------------------------------------- /data/capture3.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SabakiHQ/Sabaki/2dd5882a65cdf7b800f9baaf1282d0712608cc25/data/capture3.mp3 -------------------------------------------------------------------------------- /data/capture4.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SabakiHQ/Sabaki/2dd5882a65cdf7b800f9baaf1282d0712608cc25/data/capture4.mp3 -------------------------------------------------------------------------------- /data/newgame.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SabakiHQ/Sabaki/2dd5882a65cdf7b800f9baaf1282d0712608cc25/data/newgame.mp3 -------------------------------------------------------------------------------- /data/pass.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SabakiHQ/Sabaki/2dd5882a65cdf7b800f9baaf1282d0712608cc25/data/pass.mp3 -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Documentation 2 | 3 | Please make sure that you use the documents that match your Sabaki version. To 4 | view older versions of the documentation, you can browse by tag on GitHub. 5 | 6 | ## Development 7 | 8 | - [Building & Tests](guides/building-tests.md) 9 | - [Debugging](guides/debugging.md) 10 | 11 | ## User Guides 12 | 13 | - [Engines](guides/engines.md) 14 | - [Markdown in Sabaki](guides/markdown.md) 15 | - [Textures & Theme Directory](guides/theme-directory.md) 16 | 17 | ## Tutorials 18 | 19 | - [Create Themes](guides/create-themes.md) 20 | - [Userstyle Tutorial](guides/userstyle-tutorial.md) 21 | - [Engine Analysis Integration](guides/engine-analysis-integration.md) 22 | -------------------------------------------------------------------------------- /docs/guides/building-tests.md: -------------------------------------------------------------------------------- 1 | # Building & Tests 2 | 3 | ## Building 4 | 5 | Building Sabaki requires 6 | [Node.js 6.2.x or later](https://nodejs.org/en/download/) and npm. First, clone 7 | Sabaki: 8 | 9 | ``` 10 | $ git clone https://github.com/SabakiHQ/Sabaki 11 | $ cd Sabaki 12 | ``` 13 | 14 | Install the dependencies of Sabaki using npm: 15 | 16 | ``` 17 | $ npm install 18 | ``` 19 | 20 | Sabaki uses webpack to bundle all files into one single file. For development 21 | use the following command to create bundles automatically while you edit files: 22 | 23 | ``` 24 | $ npm run watch 25 | ``` 26 | 27 | To start Sabaki while in development, use the start command: 28 | 29 | ``` 30 | $ npm start 31 | ``` 32 | 33 | You can build Sabaki binaries with Electron by using: 34 | 35 | ``` 36 | $ npm run build 37 | ``` 38 | 39 | This will bundle everything and create a folder with the executables in 40 | `Sabaki/dist`. To create installers/archives you can use one of the following 41 | instructions depending on the target OS: 42 | 43 | - `$ npm run dist:win32` for Windows 32-bit 44 | - `$ npm run dist:win64` for Windows 64-bit 45 | - `$ npm run dist:win32-portable` for Windows 32-bit portable 46 | - `$ npm run dist:win64-portable` for Windows 64-bit portable 47 | - `$ npm run dist:linux` for Linux 32-bit and 64-bit 48 | - `$ npm run dist:macos` for macOS 64-bit 49 | 50 | Before sending in a pull request, please run prettier to make sure your code 51 | adheres to the coding style standards: 52 | 53 | ``` 54 | $ npm run format 55 | ``` 56 | 57 | ## Tests 58 | 59 | Make sure you have the master branch checked out since there are no test in the 60 | web branch. To run the (currently very limited) unit tests, use: 61 | 62 | ``` 63 | $ npm test 64 | ``` 65 | -------------------------------------------------------------------------------- /docs/guides/create-themes.md: -------------------------------------------------------------------------------- 1 | # Create Themes 2 | 3 | Themes are just a fancy way to pack a [userstyle](userstyle-tutorial.md) and its 4 | assets into a single file that can be redistributed. 5 | 6 | ## Structure 7 | 8 | First, create a userstyle. A userstyle consists of a CSS file `styles.css` and 9 | other assets such as images, fonts, or other CSS files. Take a look at 10 | [Shudan's documentation](https://github.com/SabakiHQ/Shudan/tree/master/docs#styling) 11 | to see how to change board and stone images in a userstyle. Put the files in a 12 | folder, say `theme`, and make sure no other files or folders are in there. 13 | 14 | ## `package.json` 15 | 16 | Create a text file named `package.json` inside `theme`. Its structure is 17 | compatible with 18 | [npm](https://docs.npmjs.com/getting-started/using-a-package.json), but Sabaki 19 | will only use the following fields: 20 | 21 | - `name` - All lowercase, no spaces, dashes allowed 22 | - `description` _(optional)_ 23 | - `version` - In the form of `x.x.x` 24 | - `author` _(optional)_ 25 | - `homepage` _(optional)_ 26 | - `main` - The CSS file to include in Sabaki, usually `styles.css` 27 | 28 | For example: 29 | 30 | ```json 31 | { 32 | "name": "my-theme", 33 | "version": "0.1.0", 34 | "main": "styles.css" 35 | } 36 | ``` 37 | 38 | ## Packing 39 | 40 | Make sure you have [node.js](https://nodejs.org/) and npm installed. If you 41 | don't have `asar` installed already, run: 42 | 43 | $ npm install asar -g 44 | 45 | To pack your userstyle into a theme, run the following command: 46 | 47 | $ asar pack ./theme ./theme.asar 48 | 49 | `theme.asar` will be created and is ready for distribution. It can be installed 50 | in Preferences. 51 | -------------------------------------------------------------------------------- /docs/guides/debugging.md: -------------------------------------------------------------------------------- 1 | # Debugging 2 | 3 | Sabaki is a desktop application built with web technologies, HTML, CSS and 4 | JavaScript, using [Electron](http://electron.atom.io). Since Electron is built 5 | on Chrome, it ships with the exact same developer tools Chrome has. To activate 6 | the developer tools in Sabaki, follow these steps: 7 | 8 | 1. Close Sabaki if necessary 9 | 2. First, determine where Sabaki saves its settings: 10 | - `%APPDATA%\Sabaki` on Windows 11 | - `$XDG_CONFIG_HOME/Sabaki` or `~/.config/Sabaki` on Linux 12 | - `~/Library/Application Support/Sabaki` on macOS 13 | 3. Open `settings.json` and search for the key `debug.dev_tools` 14 | 4. Set the value to `true` and save `settings.json` 15 | 5. When you start Sabaki, it has an extra main menu item named 'Developer' 16 | 6. Click on 'Toggle Developer Tools' in the menu 17 | -------------------------------------------------------------------------------- /docs/guides/engine-analysis-integration.md: -------------------------------------------------------------------------------- 1 | # Engine Analysis Integration 2 | 3 | > This guide is for engine developers. 4 | 5 | There a couple of extra GTP commands you can implement to integrate with 6 | Sabaki's engine analysis. 7 | 8 | ## `analyze ` 9 | 10 | ### Arguments 11 | 12 | - `color` - The 13 | [GTP color](https://www.lysator.liu.se/~gunnar/gtp/gtp2-spec-draft2/gtp2-spec.html#SECTION00042000000000000000) 14 | of the player whose perspective we want to analyze. 15 | - `interval` - The interval in which updates should occur in centiseconds. 16 | 17 | ### Response 18 | 19 | First, output a `=` to indicate the start of the response. Every `interval` 20 | centisecond, output a line of the following form: 21 | 22 | ``` 23 | info ... pv ... (info ... pv ...)... 24 | 25 | # Example 26 | 27 | info move D4 visits 836 winrate 4656 prior 839 lcb 4640 order 0 pv D4 Q16 D16 Q3 R5 info move D16 visits 856 winrate 4655 prior 856 lcb 4639 order 1 pv D16 Q4 Q16 C4 E3 info move Q4 visits 828 winrate 4653 prior 877 lcb 4633 order 2 pv Q4 D16 Q16 D3 C5 28 | ``` 29 | 30 | where `keyvalue` is two non-whitespace strings joined by a space and `vertex` a 31 | [GTP vertex](https://www.lysator.liu.se/~gunnar/gtp/gtp2-spec-draft2/gtp2-spec.html#SECTION00042000000000000000). 32 | 33 | Necessary key value pairs are: 34 | 35 | - `move` - The 36 | [GTP vertex](https://www.lysator.liu.se/~gunnar/gtp/gtp2-spec-draft2/gtp2-spec.html#SECTION00042000000000000000) 37 | of the move being analyzed. 38 | - `visits` - The number of visits invested in `move` so far. 39 | - `winrate` - If specified as an integer, it represents the win rate percentage 40 | times 100 of `move`, e.g. `9543` for `95.43%`. If specified as a float, i.e. 41 | includes a `.`, it represents the win rate percentage given between `0.0` and 42 | `1.0`. 43 | 44 | Optional key value pairs: 45 | 46 | - `scoreLead` - The predicted average number of points that the current side is 47 | leading by when playing `move`. 48 | 49 | The response will terminate when any input is written to `stdin`. 50 | 51 | ## `genmove_analyze ` 52 | 53 | ### Arguments 54 | 55 | - `color` - The 56 | [GTP color](https://www.lysator.liu.se/~gunnar/gtp/gtp2-spec-draft2/gtp2-spec.html#SECTION00042000000000000000) 57 | of the player whose move we want to generate. 58 | - `interval` - The interval in which updates should occur, in centiseconds. 59 | 60 | ### Response 61 | 62 | First, output a `=` to indicate the start of the response. Every `interval` 63 | centisecond, output a line of the following form: 64 | 65 | ``` 66 | info ... pv ... (info ... pv ...)... 67 | ``` 68 | 69 | See [`analyze`](#analyze-color-interval) for more details. 70 | 71 | In the end, when the engine has finished generating the move, the response 72 | should terminate with the following line: 73 | 74 | ``` 75 | play 76 | ``` 77 | -------------------------------------------------------------------------------- /docs/guides/engines.md: -------------------------------------------------------------------------------- 1 | # Engines 2 | 3 | You can add Go engines to Sabaki to play offline against an AI opponent. Sabaki 4 | then acts as a graphical UI for any Go software that supports 5 | [GTP (Go Text Protocol)](https://www.lysator.liu.se/~gunnar/gtp/). 6 | 7 | Most of the Go engines support optional parameters to tune their capacities. 8 | List of this parameters can be found in engine documentation. 9 | 10 | - [**Leela Zero**](http://zero.sjeng.org/): Download the latest appropriate 11 | version for you system (binary and source code available). Then get a network 12 | hash file, likely the [best network](http://zero.sjeng.org/best-network) is 13 | the one you want. This engine supports analysis as well. 14 | 15 | Arguments: `--gtp -w path/to/weightsfile` 16 | 17 | - [**KataGo**](https://github.com/lightvector/KataGo): Download the latest 18 | appropriate version from releases page for you system with pretrained models. 19 | This engine supports analysis as well. 20 | 21 | Arguments: `gtp -model /path/to/model.txt.gz -config /path/to/gtp_example.cfg` 22 | 23 | - [**GNU Go**](http://www.gnu.org/software/gnugo): There are binaries available 24 | for Windows. On Linux and macOS you can compile the engine from source. There 25 | are also 26 | [binaries for OS X 10.4.3 and above (universal binary) here](http://www.sente.ch/pub/software/goban/gnugo-3.7.11.dmg). 27 | 28 | Arguments: `--mode gtp` 29 | 30 | - [**Pachi**](https://github.com/pasky/pachi): There are binaries available for 31 | Windows and Linux. The source code is available to compile the engine. 32 | 33 | Arguments: None 34 | 35 | - [**Leela**](https://www.sjeng.org/leela.html): Download the _engine only 36 | (commandline/GTP engine)_ version. 37 | 38 | Arguments: `--gtp` 39 | 40 | - [**AQ**](https://github.com/ymgaq/AQ): AQ is an open-source Go engine with 41 | level of expert players. 42 | 43 | Arguments: None 44 | 45 | - [**Ray**](https://github.com/zakki/Ray): Ray is an open-source Go engine with 46 | level of expert players. 47 | 48 | Arguments: None 49 | -------------------------------------------------------------------------------- /docs/guides/markdown.md: -------------------------------------------------------------------------------- 1 | # Markdown in Sabaki 2 | 3 | Sabaki supports a subset of _Markdown_ in the comments. If you're not familiar 4 | with Markdown, I suggest you read the 5 | [original syntax documentation](http://daringfireball.net/projects/markdown/syntax) 6 | by John Gruber. Markdown is very straight-forward and simple to learn. 7 | 8 | ## Changes 9 | 10 | To make a simple line break in Markdown, normally you'd have to end the line 11 | with two spaces, but this is not required in Sabaki. 12 | 13 | Code blocks don't work in Sabaki. Images will be simply converted into links. 14 | HTML input is not allowed. 15 | 16 | ## Auto linking 17 | 18 | Sabaki will automatically create clickable links for URLs and email addresses. 19 | 20 | Board coordinates can also be detected automatically. Hovering over them will 21 | show the position on the board. 22 | 23 | As of Sabaki v0.12.3 you can link to specific moves in the main variation by 24 | move number. Just write `#` followed by the move number and Sabaki will 25 | automatically create a link, e.g: 26 | 27 | ``` 28 | > Soon after Shuusaku released his last move #127, although Gennan seems calm, 29 | > however his ears suddenly got red. This is a natural response from the human 30 | > body when one is in panic, Black must have played an outstanding move. 31 | ``` 32 | -------------------------------------------------------------------------------- /docs/guides/theme-directory.md: -------------------------------------------------------------------------------- 1 | # Themes 2 | 3 | ## Textures 4 | 5 | You can customize the appearance by replacing stone and board images. Here are 6 | some resources: 7 | 8 | - [by @ParmuzinAlexander](https://github.com/ParmuzinAlexander/go-themes) 9 | 10 | ## Theme Directory 11 | 12 | Sabaki v0.40.0 has introduced changes the DOM structure, so themes that worked 13 | under Sabaki v0.30.x may not work in Sabaki v0.40.0 or newer. 14 | 15 | | Theme | Screenshot | 16 | | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | 17 | | [Photorealistic](https://github.com/SabakiHQ/theme-photorealistic) | ![Screenshot](https://github.com/SabakiHQ/theme-photorealistic/raw/master/screenshot.png) | 18 | | [Happy Stones](https://github.com/upsided/upsided-sabaki-themes/raw/main/packs/happy-stones.asar) | ![Screenshot](https://github.com/upsided/Upsided-Sabaki-Themes/raw/main/happy-stones/happystones-screenshot.jpg) | 19 | | [Hikaru](https://github.com/upsided/upsided-sabaki-themes/raw/main/packs/hikaru.asar) | ![Screenshot](https://github.com/upsided/Upsided-Sabaki-Themes/raw/main/hikaru/hikaru-screenshot.jpg) | 20 | | [BadukTV](https://github.com/upsided/upsided-sabaki-themes/raw/main/packs/baduktv.asar) | ![Screenshot](https://github.com/upsided/Upsided-Sabaki-Themes/raw/main/baduktv/baduktv-screenshot.jpg) | 21 | | [Kifu](https://github.com/upsided/upsided-sabaki-themes/raw/main/packs/kifu.asar) | ![Screenshot](https://github.com/upsided/Upsided-Sabaki-Themes/raw/main/kifu/kifu-screenshot.jpg) | 22 | | [Wood Stone](https://github.com/geovens/Sabaki-Theme#wood-stone) | ![Screenshot](https://github.com/geovens/sabaki-theme/raw/master/woodstone/screenshot.jpg) | 23 | | [Cartoon](https://github.com/geovens/Sabaki-Theme#cartoon) | ![Screenshot](https://github.com/geovens/sabaki-theme/raw/master/cartoon/screenshot.jpg) | 24 | | [Subdued](https://github.com/fohristiwhirl/sabaki_subdued_theme_40) | ![Screenshot](https://user-images.githubusercontent.com/16438795/47953994-c773e480-df7c-11e8-87d9-002d833cca18.png) | 25 | | [Real Stones](https://github.com/ParmuzinAlexander/go-themes/raw/master/non-free/real-stones.asar) | ![Screenshot](https://github.com/ParmuzinAlexander/go-themes/raw/master/non-free/real-stones.png) | 26 | | [BattsGo](https://github.com/JJscott/BattsGo) | ![Screenshot](https://github.com/JJscott/BattsGo/raw/master/board_example.png) | 27 | | [Yunzi](https://github.com/billhails/SabakiThemes/tree/main/yunzi) | ![Screenshot](https://github.com/billhails/SabakiThemes/blob/main/yunzi/YunziScreenshot.png) | 28 | | [Antique](https://github.com/billhails/SabakiThemes/tree/main/antique) | ![Screenshot](https://github.com/billhails/SabakiThemes/blob/main/antique/AntiqueScreenshot.png) | 29 | | [Jade](https://github.com/billhails/SabakiThemes/tree/main/jade) | ![Screenshot](https://github.com/billhails/SabakiThemes/blob/main/jade/JadeScreenshot.png) | 30 | 31 | You can also customize Sabaki using a [userstyle](userstyle-tutorial.md). Learn 32 | [how to package a userstyle into a theme](create-themes.md) and feel free to 33 | send in a pull request to add yours to the list! 34 | -------------------------------------------------------------------------------- /docs/guides/userstyle-tutorial.md: -------------------------------------------------------------------------------- 1 | # Userstyle Tutorial 2 | 3 | Some Go players are quite picky when it comes to stones and board textures. I'm 4 | not saying Sabaki's textures look the best or are realistic, but I daresay 5 | they're quite good. However, if you really have to, you can actually change them 6 | using userstyles. 7 | 8 | Using userstyles is an easy way to change Sabaki's appearance without having to 9 | replace any important files and without having the changes reverted with the 10 | next update. 11 | 12 | ## Determine `styles.css` location 13 | 14 | First, determine where Sabaki saves its settings: 15 | 16 | - `%APPDATA%\Sabaki` on Windows 17 | - `$XDG_CONFIG_HOME/Sabaki` or `~/.config/Sabaki` on Linux 18 | - `~/Library/Application Support/Sabaki` on macOS 19 | 20 | Inside the folder there's a file named `styles.css`. Any CSS statement inside 21 | this file will be loaded when Sabaki starts up. It can be helpful to 22 | [open the developer tools](debugging.md) to look at the DOM. 23 | 24 | Take a look at 25 | [Shudan's documentation](https://github.com/SabakiHQ/Shudan/tree/master/docs#styling) 26 | to see how to change board and stone images. 27 | -------------------------------------------------------------------------------- /img/edit/arrow.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /img/edit/circle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /img/edit/cross.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /img/edit/label.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /img/edit/line.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /img/edit/number.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /img/edit/square.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /img/edit/stone_-1.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /img/edit/stone_1.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /img/edit/triangle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /img/ui/badmove.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /img/ui/balance.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /img/ui/black.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /img/ui/doubtfulmove.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /img/ui/goodmove.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /img/ui/interestingmove.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /img/ui/player_-1.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /img/ui/player_1.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /img/ui/tatami.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SabakiHQ/Sabaki/2dd5882a65cdf7b800f9baaf1282d0712608cc25/img/ui/tatami.png -------------------------------------------------------------------------------- /img/ui/unclear.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /img/ui/white.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Sabaki 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SabakiHQ/Sabaki/2dd5882a65cdf7b800f9baaf1282d0712608cc25/logo.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sabaki", 3 | "productName": "Sabaki", 4 | "version": "0.52.2", 5 | "description": "An elegant Go/Baduk/Weiqi board and SGF editor for a more civilized age.", 6 | "author": "Yichuan Shen ", 7 | "homepage": "http://sabaki.yichuanshen.de", 8 | "license": "MIT", 9 | "main": "./src/main.js", 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/SabakiHQ/Sabaki" 13 | }, 14 | "bugs": { 15 | "url": "https://github.com/SabakiHQ/Sabaki/issues" 16 | }, 17 | "build": { 18 | "appId": "de.yichuanshen.sabaki", 19 | "copyright": "Copyright © 2015-2022 Yichuan Shen", 20 | "compression": "normal", 21 | "linux": { 22 | "target": "AppImage", 23 | "category": "Game", 24 | "artifactName": "sabaki-v${version}-linux-${arch}.${ext}" 25 | }, 26 | "mac": { 27 | "target": [ 28 | "7z" 29 | ], 30 | "category": "public.app-category.board-games", 31 | "artifactName": "sabaki-v${version}-mac-${arch}.${ext}" 32 | }, 33 | "win": { 34 | "artifactName": "sabaki-v${version}-win.exe" 35 | }, 36 | "nsis": { 37 | "oneClick": false, 38 | "perMachine": true, 39 | "allowToChangeInstallationDirectory": true 40 | }, 41 | "fileAssociations": [ 42 | { 43 | "ext": "sgf", 44 | "name": "SGF", 45 | "description": "Smart Game Format" 46 | }, 47 | { 48 | "ext": "ngf", 49 | "name": "NGF", 50 | "description": "wBaduk NGF" 51 | }, 52 | { 53 | "ext": "gib", 54 | "name": "GIB", 55 | "description": "Tygem GIB" 56 | }, 57 | { 58 | "ext": "ugf", 59 | "name": "UGF", 60 | "description": "PandaNET UGF" 61 | } 62 | ], 63 | "files": [ 64 | "**/*", 65 | "!**/{.github,.c9,scss,docs,test,tests,devtools,plugins,examples}${/*}", 66 | "!ci${/*}", 67 | "!src/{components,modules}${/*}", 68 | "!engines", 69 | "!node_modules", 70 | "node_modules/@electron/remote", 71 | "node_modules/@primer/octicons/build/svg/*", 72 | "node_modules/@sabaki/deadstones/wasm/*", 73 | "node_modules/@sabaki/shudan/css/*", 74 | "node_modules/@sabaki/i18n/**/*", 75 | "node_modules/dolm/**/*", 76 | "node_modules/{iconv-lite,safer-buffer}/**/*", 77 | "node_modules/pikaday/css/*" 78 | ] 79 | }, 80 | "prettier": { 81 | "semi": false, 82 | "singleQuote": true, 83 | "bracketSpacing": false, 84 | "proseWrap": "always" 85 | }, 86 | "dependencies": { 87 | "@electron/remote": "^1.2.0", 88 | "@mariotacke/color-thief": "^3.0.1", 89 | "@primer/octicons": "^9.6.0", 90 | "@sabaki/boardmatcher": "^1.2.0", 91 | "@sabaki/deadstones": "^2.1.2", 92 | "@sabaki/go-board": "^1.4.1", 93 | "@sabaki/gtp": "^3.0.0", 94 | "@sabaki/i18n": "0.51.1-1", 95 | "@sabaki/immutable-gametree": "^1.9.4", 96 | "@sabaki/influence": "^1.2.2", 97 | "@sabaki/sgf": "^3.4.7", 98 | "@sabaki/shudan": "^1.7.1", 99 | "argv-split": "^2.0.1", 100 | "classnames": "^2.2.6", 101 | "dolm": "^0.7.3-beta", 102 | "fix-path": "<4.0.0", 103 | "iconv-lite": "^0.5.1", 104 | "jschardet": "^3.0.0", 105 | "natsort": "^2.0.2", 106 | "pikaday": "^1.8.0", 107 | "preact": "^10.4.0", 108 | "react-markdown": "^8.0.3", 109 | "remark-breaks": "^3.0.2", 110 | "rimraf": "^3.0.2", 111 | "uuid": "^7.0.3", 112 | "winston": "^3.2.1" 113 | }, 114 | "devDependencies": { 115 | "concurrently": "^7.3.0", 116 | "electron": "<14.0.0", 117 | "electron-builder": "^23.3.3", 118 | "esm": "^3.2.25", 119 | "mocha": "<9.0", 120 | "onchange": "^6.1.0", 121 | "prettier": "1.19.1", 122 | "tmp": "^0.2.1", 123 | "webpack": "^5.74.0", 124 | "webpack-cli": "^4.10.0" 125 | }, 126 | "scripts": { 127 | "test": "mocha --require esm", 128 | "start": "electron ./", 129 | "bundle": "webpack --mode production", 130 | "build": "npm run bundle && electron-builder --dir", 131 | "format-base": "prettier \"**/*.{js,html,md}\" \"!{{dist,i18n}/**,bundle.js*}\"", 132 | "format-check": "npm run format-base -- --check", 133 | "format": "npm run format-base -- --write", 134 | "watch-format": "onchange \"**/*.{js,html,md}\" \"!{{dist,i18n}/**,bundle.js*}\" -- prettier --write {{changed}}", 135 | "watch": "concurrently \"webpack --mode development --watch\" \"npm run watch-format\"", 136 | "dist:macos": "npm run bundle && electron-builder -m --x64 --arm64", 137 | "dist:linux": "npm run bundle && electron-builder -l --ia32 --x64 && node ./ci/mv.js ./dist/sabaki-vx.x.x-linux-i386.AppImage ./dist/sabaki-vx.x.x-linux-ia32.AppImage && node ./ci/mv.js ./dist/sabaki-vx.x.x-linux-x86_64.AppImage ./dist/sabaki-vx.x.x-linux-x64.AppImage", 138 | "dist:arm64": "npm run bundle && electron-builder -l --arm64", 139 | "dist:win32": "npm run bundle && electron-builder -w --ia32 && node ./ci/mv.js ./dist/sabaki-vx.x.x-win.exe ./dist/sabaki-vx.x.x-win-ia32-setup.exe", 140 | "dist:win64": "npm run bundle && electron-builder -w --x64 && node ./ci/mv.js ./dist/sabaki-vx.x.x-win.exe ./dist/sabaki-vx.x.x-win-x64-setup.exe", 141 | "dist:win32-portable": "npm run bundle && electron-builder -w portable --ia32 && node ./ci/mv.js ./dist/sabaki-vx.x.x-win.exe ./dist/sabaki-vx.x.x-win-ia32-portable.exe", 142 | "dist:win64-portable": "npm run bundle && electron-builder -w portable --x64 && node ./ci/mv.js ./dist/sabaki-vx.x.x-win.exe ./dist/sabaki-vx.x.x-win-x64-portable.exe", 143 | "dist:win": "npm run dist:win32 && npm run dist:win64 && npm run dist:win32-portable && npm run dist:win64-portable", 144 | "i18n": "node ./ci/createI18n.js" 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SabakiHQ/Sabaki/2dd5882a65cdf7b800f9baaf1282d0712608cc25/screenshot.png -------------------------------------------------------------------------------- /src/components/BusyScreen.js: -------------------------------------------------------------------------------- 1 | import * as remote from '@electron/remote' 2 | import {h, Component} from 'preact' 3 | 4 | const setting = remote.require('./setting') 5 | 6 | export default class BusyScreen extends Component { 7 | componentWillReceiveProps({show}) { 8 | if (show === this.props.show) return 9 | 10 | clearTimeout(this.busyId) 11 | 12 | if (show) { 13 | this.setState({show: true}) 14 | document.activeElement.blur() 15 | } else { 16 | let delay = setting.get('app.hide_busy_delay') 17 | this.busyId = setTimeout(() => this.setState({show: false}), delay) 18 | } 19 | } 20 | 21 | render(_, {show}) { 22 | return h('section', { 23 | id: 'busy', 24 | style: {display: show ? 'block' : 'none'} 25 | }) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/components/ContentDisplay.js: -------------------------------------------------------------------------------- 1 | import {shell} from 'electron' 2 | import * as remote from '@electron/remote' 3 | import {h, Component} from 'preact' 4 | 5 | import i18n from '../i18n.js' 6 | import sabaki from '../modules/sabaki.js' 7 | 8 | const t = i18n.context('ContentDisplay') 9 | const setting = remote.require('./setting') 10 | 11 | function htmlify(input) { 12 | let urlRegex = /\b(ht|f)tps?:\/\/[^\s<]+[^<.,:;"\')\]\s](\/\B|\b)/i 13 | let emailRegex = /\b[^\s@<]+@[^\s@<]+\b/i 14 | let variationRegex = /\b(black\s+?|white\s+?|[bw]\s*)(([a-hj-z]\d{1,2}[ ]+)+[a-hj-z]\d{1,2})\b/i 15 | let coordRegex = /\b[a-hj-z]\d{1,2}\b/i 16 | let movenumberRegex = /(\B#|\bmove[ ]+)(\d+)\b/i 17 | let totalRegex = new RegExp( 18 | `(${[urlRegex, emailRegex, variationRegex, coordRegex, movenumberRegex] 19 | .map(regex => regex.source) 20 | .join('|')})`, 21 | 'gi' 22 | ) 23 | 24 | input = input.replace(totalRegex, match => { 25 | let tokens 26 | 27 | if (urlRegex.test(match)) 28 | return `${match}` 29 | if (emailRegex.test(match)) 30 | return `${match}` 31 | if ((tokens = variationRegex.exec(match))) 32 | return `${match}` 37 | if (coordRegex.test(match)) 38 | return `${match}` 39 | if ((tokens = movenumberRegex.exec(match))) 40 | return `${match}` 46 | }) 47 | 48 | return input 49 | } 50 | 51 | export default class ContentDisplay extends Component { 52 | constructor(props) { 53 | super(props) 54 | 55 | this.handleLinkClick = evt => { 56 | let linkElement = evt.currentTarget 57 | 58 | if (linkElement.classList.contains('comment-external')) { 59 | evt.preventDefault() 60 | shell.openExternal(linkElement.href) 61 | } else if (linkElement.classList.contains('comment-movenumber')) { 62 | evt.preventDefault() 63 | let moveNumber = +linkElement.dataset.movenumber 64 | 65 | sabaki.goToMainVariation() 66 | sabaki.goToMoveNumber(moveNumber) 67 | } 68 | } 69 | 70 | let getVariationInfo = target => { 71 | let {board, currentPlayer} = sabaki.inferredState 72 | let currentVertex = board.currentVertex 73 | let currentVertexSign = currentVertex && board.get(currentVertex) 74 | let {color} = target.dataset 75 | let sign = color === '' ? currentPlayer : color === 'b' ? 1 : -1 76 | let moves = target.dataset.moves 77 | .split(/\s+/) 78 | .map(x => board.parseVertex(x)) 79 | let sibling = currentVertexSign === sign 80 | 81 | return {sign, moves, sibling} 82 | } 83 | 84 | this.handleVariationMouseEnter = evt => { 85 | let {currentTarget} = evt 86 | let {sign, moves, sibling} = getVariationInfo(currentTarget) 87 | let counter = 1 88 | 89 | sabaki.setState({playVariation: {sign, moves, sibling}}) 90 | 91 | if (setting.get('board.variation_replay_mode') === 'move_by_move') { 92 | clearInterval(this.variationIntervalId) 93 | this.variationIntervalId = setInterval(() => { 94 | if (counter >= moves.length) { 95 | clearInterval(this.variationIntervalId) 96 | return 97 | } 98 | 99 | let percent = (counter * 100) / (moves.length - 1) 100 | 101 | currentTarget.style.backgroundSize = `${percent}% 100%` 102 | counter++ 103 | }, setting.get('board.variation_replay_interval')) 104 | } else { 105 | currentTarget.style.backgroundSize = '100% 100%' 106 | } 107 | } 108 | 109 | this.handleVariationMouseLeave = evt => { 110 | sabaki.setState({playVariation: null}) 111 | 112 | clearInterval(this.variationIntervalId) 113 | evt.currentTarget.style.backgroundSize = '' 114 | } 115 | 116 | this.handleVariationMouseUp = evt => { 117 | if (evt.button !== 2) return 118 | 119 | let {sign, moves, sibling} = getVariationInfo(evt.currentTarget) 120 | 121 | sabaki.openVariationMenu(sign, moves, { 122 | x: evt.clientX, 123 | y: evt.clientY, 124 | appendSibling: sibling 125 | }) 126 | } 127 | 128 | this.handleCoordMouseEnter = evt => { 129 | let {board} = sabaki.inferredState 130 | let vertex = board.parseVertex(evt.currentTarget.innerText) 131 | 132 | sabaki.setState({highlightVertices: [vertex]}) 133 | } 134 | 135 | this.handleCoordMouseLeave = evt => { 136 | sabaki.setState({highlightVertices: []}) 137 | } 138 | } 139 | 140 | componentDidMount() { 141 | this.componentDidUpdate() 142 | } 143 | 144 | componentDidUpdate() { 145 | // Handle link clicks 146 | 147 | for (let el of this.element.querySelectorAll('a')) { 148 | el.addEventListener('click', this.handleLinkClick) 149 | } 150 | 151 | // Hover on variations 152 | 153 | for (let el of this.element.querySelectorAll('.comment-variation')) { 154 | el.addEventListener('mouseenter', this.handleVariationMouseEnter) 155 | el.addEventListener('mouseleave', this.handleVariationMouseLeave) 156 | el.addEventListener('mouseup', this.handleVariationMouseUp) 157 | } 158 | 159 | // Hover on coordinates 160 | 161 | for (let el of this.element.querySelectorAll('.comment-coord')) { 162 | el.addEventListener('mouseenter', this.handleCoordMouseEnter) 163 | el.addEventListener('mouseleave', this.handleCoordMouseLeave) 164 | } 165 | } 166 | 167 | render({tag, content, children}) { 168 | return content != null 169 | ? h(tag, { 170 | ref: el => (this.element = el), 171 | dangerouslySetInnerHTML: { 172 | __html: htmlify( 173 | content 174 | .replace(/&/g, '&') 175 | .replace(//g, '>') 177 | ) 178 | }, 179 | ...this.props, 180 | tag: undefined, 181 | content: undefined, 182 | children: undefined 183 | }) 184 | : h( 185 | tag, 186 | Object.assign( 187 | { 188 | ref: el => (this.element = el) 189 | }, 190 | this.props 191 | ), 192 | children 193 | ) 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /src/components/DrawerManager.js: -------------------------------------------------------------------------------- 1 | import {h, Component} from 'preact' 2 | import sabaki from '../modules/sabaki.js' 3 | import {getRootProperty} from '../modules/gametree.js' 4 | 5 | import InfoDrawer from './drawers/InfoDrawer.js' 6 | import ScoreDrawer from './drawers/ScoreDrawer.js' 7 | import PreferencesDrawer from './drawers/PreferencesDrawer.js' 8 | import GameChooserDrawer from './drawers/GameChooserDrawer.js' 9 | import CleanMarkupDrawer from './drawers/CleanMarkupDrawer.js' 10 | import AdvancedPropertiesDrawer from './drawers/AdvancedPropertiesDrawer.js' 11 | 12 | export default class DrawerManager extends Component { 13 | constructor() { 14 | super() 15 | 16 | this.handleScoreSubmit = ({resultString}) => { 17 | let gameTree = this.props.gameTrees[this.props.gameIndex] 18 | let newTree = gameTree.mutate(draft => { 19 | draft.updateProperty(draft.root.id, 'RE', [resultString]) 20 | }) 21 | 22 | sabaki.setCurrentTreePosition(newTree, this.props.treePosition) 23 | sabaki.closeDrawer() 24 | setTimeout(() => sabaki.setMode('play'), 500) 25 | } 26 | 27 | this.handleGameSelect = ({selectedTree}) => { 28 | sabaki.closeDrawer() 29 | sabaki.setMode('play') 30 | sabaki.setCurrentTreePosition(selectedTree, selectedTree.root.id) 31 | } 32 | 33 | this.handleGameTreesChange = evt => { 34 | let newGameTrees = evt.gameTrees 35 | let {gameTrees, gameCurrents, gameIndex} = this.props 36 | let tree = gameTrees[gameIndex] 37 | let newIndex = newGameTrees.findIndex(t => t.root.id === tree.root.id) 38 | 39 | if (newIndex < 0) { 40 | if (newGameTrees.length === 0) 41 | newGameTrees = [sabaki.getEmptyGameTree()] 42 | 43 | newIndex = Math.min(Math.max(gameIndex - 1, 0), newGameTrees.length - 1) 44 | tree = newGameTrees[newIndex] 45 | } 46 | 47 | sabaki.setState({ 48 | gameTrees: newGameTrees, 49 | gameCurrents: newGameTrees.map((tree, i) => { 50 | let oldIndex = gameTrees.findIndex(t => t.root.id === tree.root.id) 51 | if (oldIndex < 0) return {} 52 | 53 | return gameCurrents[oldIndex] 54 | }) 55 | }) 56 | 57 | sabaki.setCurrentTreePosition(tree, tree.root.id) 58 | } 59 | } 60 | 61 | render({ 62 | mode, 63 | openDrawer, 64 | gameTree, 65 | gameTrees, 66 | gameIndex, 67 | treePosition, 68 | 69 | gameInfo, 70 | currentPlayer, 71 | attachedEngineSyncers, 72 | blackEngineSyncerId, 73 | whiteEngineSyncerId, 74 | 75 | scoringMethod, 76 | scoreBoard, 77 | areaMap, 78 | 79 | engines, 80 | graphGridSize, 81 | preferencesTab 82 | }) { 83 | return h( 84 | 'section', 85 | {}, 86 | h(InfoDrawer, { 87 | show: openDrawer === 'info', 88 | gameTree, 89 | gameInfo, 90 | currentPlayer, 91 | attachedEngineSyncers, 92 | blackEngineSyncerId, 93 | whiteEngineSyncerId 94 | }), 95 | 96 | h(PreferencesDrawer, { 97 | show: openDrawer === 'preferences', 98 | tab: preferencesTab, 99 | engines, 100 | graphGridSize 101 | }), 102 | 103 | h(GameChooserDrawer, { 104 | show: openDrawer === 'gamechooser', 105 | gameTrees, 106 | gameIndex, 107 | 108 | onItemClick: this.handleGameSelect, 109 | onChange: this.handleGameTreesChange 110 | }), 111 | 112 | h(CleanMarkupDrawer, { 113 | show: openDrawer === 'cleanmarkup', 114 | gameTree, 115 | treePosition 116 | }), 117 | 118 | h(AdvancedPropertiesDrawer, { 119 | show: openDrawer === 'advancedproperties', 120 | gameTree, 121 | treePosition 122 | }), 123 | 124 | h(ScoreDrawer, { 125 | show: openDrawer === 'score', 126 | estimating: mode === 'estimator', 127 | areaMap, 128 | board: scoreBoard, 129 | method: scoringMethod, 130 | komi: +getRootProperty(gameTree, 'KM', 0), 131 | handicap: +getRootProperty(gameTree, 'HA', 0), 132 | 133 | onSubmitButtonClick: this.handleScoreSubmit 134 | }) 135 | ) 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/components/InfoOverlay.js: -------------------------------------------------------------------------------- 1 | import {h, Component} from 'preact' 2 | import classNames from 'classnames' 3 | 4 | export default class InfoOverlay extends Component { 5 | shouldComponentUpdate({text, show}) { 6 | return text !== this.props.text || show !== this.props.show 7 | } 8 | 9 | render({text, show}) { 10 | return h( 11 | 'section', 12 | { 13 | id: 'info-overlay', 14 | class: classNames({show}) 15 | }, 16 | text 17 | ) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/components/InputBox.js: -------------------------------------------------------------------------------- 1 | import {h, Component} from 'preact' 2 | import classNames from 'classnames' 3 | 4 | import sabaki from '../modules/sabaki.js' 5 | import {noop} from '../modules/helper.js' 6 | 7 | export default class InputBox extends Component { 8 | constructor() { 9 | super() 10 | 11 | this.state = {value: ''} 12 | 13 | this.handleInput = evt => this.setState({value: evt.currentTarget.value}) 14 | this.stopPropagation = evt => evt.stopPropagation() 15 | 16 | this.handleKeyUp = evt => { 17 | if (!this.props.show) return 18 | 19 | if (evt.key === 'Escape') { 20 | evt.stopPropagation() 21 | this.cancel() 22 | } else if (evt.key == 'Enter') { 23 | evt.stopPropagation() 24 | sabaki.setState({showInputBox: false}) 25 | 26 | let {onSubmit = noop} = this.props 27 | onSubmit(this.state) 28 | 29 | if (document.activeElement === this.inputElement) 30 | this.inputElement.blur() 31 | } 32 | } 33 | 34 | this.cancel = this.cancel.bind(this) 35 | } 36 | 37 | shouldComponentUpdate({show, text, onSubmit, onCancel}) { 38 | return ( 39 | show !== this.props.show || 40 | text !== this.props.text || 41 | onSubmit !== this.props.onSubmit || 42 | onCancel !== this.props.onCancel 43 | ) 44 | } 45 | 46 | componentWillReceiveProps(nextProps) { 47 | if (nextProps.show && !this.props.show) { 48 | this.setState({value: ''}) 49 | } 50 | } 51 | 52 | componentDidUpdate(prevProps) { 53 | if (!prevProps.show && this.props.show) { 54 | this.inputElement.focus() 55 | } 56 | } 57 | 58 | cancel() { 59 | if (!this.props.show) return 60 | 61 | if (document.activeElement === this.inputElement) this.inputElement.blur() 62 | 63 | let {onCancel = noop} = this.props 64 | sabaki.setState({showInputBox: false}) 65 | onCancel() 66 | } 67 | 68 | render({show, text}, {value}) { 69 | return h( 70 | 'section', 71 | { 72 | id: 'input-box', 73 | class: classNames({show}), 74 | 75 | onClick: this.cancel 76 | }, 77 | 78 | h( 79 | 'div', 80 | {class: 'inner', onClick: this.stopPropagation}, 81 | h('input', { 82 | ref: el => (this.inputElement = el), 83 | type: 'text', 84 | name: 'input', 85 | value, 86 | placeholder: text, 87 | 88 | onInput: this.handleInput, 89 | onKeyUp: this.handleKeyUp, 90 | onBlur: this.cancel 91 | }) 92 | ) 93 | ) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/components/LeftSidebar.js: -------------------------------------------------------------------------------- 1 | import * as remote from '@electron/remote' 2 | import {h, Component} from 'preact' 3 | 4 | import SplitContainer from './helpers/SplitContainer.js' 5 | import GtpConsole from './sidebars/GtpConsole.js' 6 | import {EnginePeerList} from './sidebars/PeerList.js' 7 | 8 | const setting = remote.require('./setting') 9 | const peerListMinHeight = setting.get('view.peerlist_minheight') 10 | 11 | export default class LeftSidebar extends Component { 12 | constructor() { 13 | super() 14 | 15 | this.state = { 16 | peerListHeight: setting.get('view.peerlist_height'), 17 | selectedEngineSyncerId: null 18 | } 19 | 20 | this.handlePeerListHeightChange = ({sideSize}) => { 21 | this.setState({peerListHeight: Math.max(sideSize, peerListMinHeight)}) 22 | } 23 | 24 | this.handlePeerListHeightFinish = () => { 25 | setting.set('view.peerlist_height', this.state.peerListHeight) 26 | } 27 | 28 | this.handleCommandControlStep = ({step}) => { 29 | let {attachedEngineSyncers} = this.props 30 | let engineIndex = attachedEngineSyncers.findIndex( 31 | syncer => syncer.id === this.state.selectedEngineSyncerId 32 | ) 33 | 34 | let stepEngineIndex = Math.min( 35 | Math.max(0, engineIndex + step), 36 | attachedEngineSyncers.length - 1 37 | ) 38 | let stepEngine = this.props.attachedEngineSyncers[stepEngineIndex] 39 | 40 | if (stepEngine != null) { 41 | this.setState({selectedEngineSyncerId: stepEngine.id}) 42 | } 43 | } 44 | 45 | this.handleEngineSelect = ({syncer}) => { 46 | this.setState({selectedEngineSyncerId: syncer.id}, () => { 47 | let input = this.element.querySelector('.gtp-console .input .command') 48 | 49 | if (input != null) { 50 | input.focus() 51 | } 52 | }) 53 | } 54 | 55 | this.handleCommandSubmit = ({command}) => { 56 | let syncer = this.props.attachedEngineSyncers.find( 57 | syncer => syncer.id === this.state.selectedEngineSyncerId 58 | ) 59 | 60 | if (syncer != null) { 61 | syncer.queueCommand(command) 62 | } 63 | } 64 | } 65 | 66 | shouldComponentUpdate(nextProps) { 67 | return ( 68 | nextProps.showLeftSidebar != this.props.showLeftSidebar || 69 | nextProps.showLeftSidebar 70 | ) 71 | } 72 | 73 | render( 74 | { 75 | attachedEngineSyncers, 76 | analyzingEngineSyncerId, 77 | blackEngineSyncerId, 78 | whiteEngineSyncerId, 79 | engineGameOngoing, 80 | showLeftSidebar, 81 | consoleLog 82 | }, 83 | {peerListHeight, selectedEngineSyncerId} 84 | ) { 85 | return h( 86 | 'section', 87 | { 88 | ref: el => (this.element = el), 89 | id: 'leftsidebar' 90 | }, 91 | 92 | h(SplitContainer, { 93 | vertical: true, 94 | invert: true, 95 | sideSize: peerListHeight, 96 | 97 | sideContent: h(EnginePeerList, { 98 | attachedEngineSyncers, 99 | analyzingEngineSyncerId, 100 | blackEngineSyncerId, 101 | whiteEngineSyncerId, 102 | selectedEngineSyncerId, 103 | engineGameOngoing, 104 | 105 | onEngineSelect: this.handleEngineSelect 106 | }), 107 | 108 | mainContent: h(GtpConsole, { 109 | show: showLeftSidebar, 110 | consoleLog, 111 | attachedEngine: attachedEngineSyncers 112 | .map(syncer => 113 | syncer.id !== selectedEngineSyncerId 114 | ? null 115 | : { 116 | name: syncer.engine.name, 117 | get commands() { 118 | return syncer.commands 119 | } 120 | } 121 | ) 122 | .find(x => x != null), 123 | 124 | onSubmit: this.handleCommandSubmit, 125 | onControlStep: this.handleCommandControlStep 126 | }), 127 | 128 | onChange: this.handlePeerListHeightChange, 129 | onFinish: this.handlePeerListHeightFinish 130 | }) 131 | ) 132 | } 133 | } 134 | 135 | LeftSidebar.getDerivedStateFromProps = (props, state) => { 136 | if ( 137 | props.attachedEngineSyncers.length > 0 && 138 | props.attachedEngineSyncers.find( 139 | syncer => syncer.id === state.selectedEngineSyncerId 140 | ) == null 141 | ) { 142 | return {selectedEngineSyncerId: props.attachedEngineSyncers[0].id} 143 | } else if (props.attachedEngineSyncers.length === 0) { 144 | return {selectedEngineSyncerId: null} 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/components/MainMenu.js: -------------------------------------------------------------------------------- 1 | import {Component} from 'preact' 2 | import {ipcRenderer} from 'electron' 3 | import * as remote from '@electron/remote' 4 | import * as dialog from '../modules/dialog.js' 5 | import * as menu from '../menu.js' 6 | 7 | export default class MainMenu extends Component { 8 | constructor(props) { 9 | super(props) 10 | 11 | this.menuData = menu.get() 12 | this.window = remote.getCurrentWindow() 13 | this.listeners = {} 14 | 15 | this.buildMenu = () => { 16 | ipcRenderer.send('build-menu', this.props) 17 | } 18 | } 19 | 20 | componentDidMount() { 21 | this.window.on('focus', this.buildMenu) 22 | 23 | let handleMenuClicks = menu => { 24 | for (let item of menu) { 25 | if (item.click != null) { 26 | this.listeners[item.id] = () => { 27 | if (!this.props.showMenuBar) { 28 | this.window.setMenuBarVisibility(false) 29 | } 30 | 31 | dialog.closeInputBox() 32 | item.click() 33 | } 34 | 35 | ipcRenderer.on(`menu-click-${item.id}`, this.listeners[item.id]) 36 | } 37 | 38 | if (item.submenu != null) { 39 | handleMenuClicks(item.submenu) 40 | } 41 | } 42 | } 43 | 44 | handleMenuClicks(this.menuData) 45 | } 46 | 47 | componentWillUnmount() { 48 | this.window.removeListener('focus', this.buildMenu) 49 | 50 | for (let id in this.listeners) { 51 | ipcRenderer.removeListener(`menu-click-${item.id}`, this.listeners[id]) 52 | } 53 | } 54 | 55 | shouldComponentUpdate(nextProps) { 56 | for (let key in nextProps) { 57 | if (nextProps[key] !== this.props[key]) return true 58 | } 59 | 60 | return false 61 | } 62 | 63 | render() { 64 | this.buildMenu() 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/components/MainView.js: -------------------------------------------------------------------------------- 1 | import {h, Component} from 'preact' 2 | 3 | import Goban from './Goban.js' 4 | import PlayBar from './bars/PlayBar.js' 5 | import EditBar from './bars/EditBar.js' 6 | import GuessBar from './bars/GuessBar.js' 7 | import AutoplayBar from './bars/AutoplayBar.js' 8 | import ScoringBar from './bars/ScoringBar.js' 9 | import FindBar from './bars/FindBar.js' 10 | 11 | import sabaki from '../modules/sabaki.js' 12 | import * as gametree from '../modules/gametree.js' 13 | 14 | export default class MainView extends Component { 15 | constructor(props) { 16 | super(props) 17 | 18 | this.handleTogglePlayer = () => { 19 | let {gameTree, treePosition, currentPlayer} = this.props 20 | sabaki.setPlayer(treePosition, -currentPlayer) 21 | } 22 | 23 | this.handleToolButtonClick = evt => { 24 | sabaki.setState({selectedTool: evt.tool}) 25 | } 26 | 27 | this.handleFindButtonClick = evt => 28 | sabaki.findMove(evt.step, { 29 | vertex: this.props.findVertex, 30 | text: this.props.findText 31 | }) 32 | 33 | this.handleGobanVertexClick = this.handleGobanVertexClick.bind(this) 34 | this.handleGobanLineDraw = this.handleGobanLineDraw.bind(this) 35 | } 36 | 37 | componentDidMount() { 38 | // Pressing Ctrl/Cmd should show crosshair cursor on Goban in edit mode 39 | 40 | document.addEventListener('keydown', evt => { 41 | if (evt.key !== 'Control' || evt.key !== 'Meta') return 42 | 43 | if (this.props.mode === 'edit') { 44 | this.setState({gobanCrosshair: true}) 45 | } 46 | }) 47 | 48 | document.addEventListener('keyup', evt => { 49 | if (evt.key !== 'Control' || evt.key !== 'Meta') return 50 | 51 | if (this.props.mode === 'edit') { 52 | this.setState({gobanCrosshair: false}) 53 | } 54 | }) 55 | } 56 | 57 | componentWillReceiveProps(nextProps) { 58 | if (nextProps.mode !== 'edit') { 59 | this.setState({gobanCrosshair: false}) 60 | } 61 | } 62 | 63 | handleGobanVertexClick(evt) { 64 | sabaki.clickVertex(evt.vertex, evt) 65 | } 66 | 67 | handleGobanLineDraw(evt) { 68 | let {v1, v2} = evt.line 69 | sabaki.useTool(this.props.selectedTool, v1, v2) 70 | sabaki.editVertexData = null 71 | } 72 | 73 | render( 74 | { 75 | mode, 76 | gameIndex, 77 | gameTree, 78 | gameCurrents, 79 | treePosition, 80 | currentPlayer, 81 | gameInfo, 82 | 83 | deadStones, 84 | scoringMethod, 85 | scoreBoard, 86 | playVariation, 87 | analysis, 88 | analysisTreePosition, 89 | areaMap, 90 | blockedGuesses, 91 | 92 | highlightVertices, 93 | analysisType, 94 | showAnalysis, 95 | showCoordinates, 96 | showMoveColorization, 97 | showMoveNumbers, 98 | showNextMoves, 99 | showSiblings, 100 | fuzzyStonePlacement, 101 | animateStonePlacement, 102 | boardTransformation, 103 | 104 | selectedTool, 105 | findText, 106 | findVertex 107 | }, 108 | {gobanCrosshair} 109 | ) { 110 | let node = gameTree.get(treePosition) 111 | let board = gametree.getBoard(gameTree, treePosition) 112 | let komi = +gametree.getRootProperty(gameTree, 'KM', 0) 113 | let handicap = +gametree.getRootProperty(gameTree, 'HA', 0) 114 | let paintMap 115 | 116 | if (['scoring', 'estimator'].includes(mode)) { 117 | paintMap = areaMap 118 | } else if (mode === 'guess') { 119 | paintMap = [...Array(board.height)].map(_ => Array(board.width).fill(0)) 120 | 121 | for (let [x, y] of blockedGuesses) { 122 | paintMap[y][x] = 1 123 | } 124 | } 125 | 126 | return h( 127 | 'section', 128 | {id: 'main'}, 129 | 130 | h( 131 | 'main', 132 | {ref: el => (this.mainElement = el)}, 133 | 134 | h(Goban, { 135 | gameTree, 136 | treePosition, 137 | board, 138 | highlightVertices: 139 | findVertex && mode === 'find' ? [findVertex] : highlightVertices, 140 | analysisType, 141 | analysis: 142 | showAnalysis && 143 | analysisTreePosition != null && 144 | analysisTreePosition === treePosition 145 | ? analysis 146 | : null, 147 | paintMap, 148 | dimmedStones: ['scoring', 'estimator'].includes(mode) 149 | ? deadStones 150 | : [], 151 | 152 | crosshair: gobanCrosshair, 153 | showCoordinates, 154 | showMoveColorization, 155 | showMoveNumbers: mode !== 'edit' && showMoveNumbers, 156 | showNextMoves: mode !== 'guess' && showNextMoves, 157 | showSiblings: mode !== 'guess' && showSiblings, 158 | fuzzyStonePlacement, 159 | animateStonePlacement, 160 | 161 | playVariation, 162 | drawLineMode: 163 | mode === 'edit' && ['arrow', 'line'].includes(selectedTool) 164 | ? selectedTool 165 | : null, 166 | transformation: boardTransformation, 167 | 168 | onVertexClick: this.handleGobanVertexClick, 169 | onLineDraw: this.handleGobanLineDraw 170 | }) 171 | ), 172 | 173 | h( 174 | 'section', 175 | {id: 'bar'}, 176 | h(PlayBar, { 177 | mode, 178 | engineSyncers: [ 179 | this.props.blackEngineSyncerId, 180 | this.props.whiteEngineSyncerId 181 | ].map(id => 182 | this.props.attachedEngineSyncers.find(syncer => syncer.id === id) 183 | ), 184 | playerNames: gameInfo.playerNames, 185 | playerRanks: gameInfo.playerRanks, 186 | playerCaptures: [1, -1].map(sign => board.getCaptures(sign)), 187 | currentPlayer, 188 | showHotspot: node.data.HO != null, 189 | onCurrentPlayerClick: this.handleTogglePlayer 190 | }), 191 | 192 | h(EditBar, { 193 | mode, 194 | selectedTool, 195 | onToolButtonClick: this.handleToolButtonClick 196 | }), 197 | 198 | h(GuessBar, { 199 | mode, 200 | treePosition 201 | }), 202 | 203 | h(AutoplayBar, { 204 | mode, 205 | gameTree, 206 | gameCurrents: gameCurrents[gameIndex], 207 | treePosition 208 | }), 209 | 210 | h(ScoringBar, { 211 | type: 'scoring', 212 | mode, 213 | method: scoringMethod, 214 | scoreBoard, 215 | areaMap, 216 | komi, 217 | handicap 218 | }), 219 | 220 | h(ScoringBar, { 221 | type: 'estimator', 222 | mode, 223 | method: scoringMethod, 224 | scoreBoard, 225 | areaMap, 226 | komi, 227 | handicap 228 | }), 229 | 230 | h(FindBar, { 231 | mode, 232 | findText, 233 | onButtonClick: this.handleFindButtonClick 234 | }) 235 | ) 236 | ) 237 | } 238 | } 239 | -------------------------------------------------------------------------------- /src/components/MarkdownContentDisplay.js: -------------------------------------------------------------------------------- 1 | import {h, Component, toChildArray} from 'preact' 2 | import breaks from 'remark-breaks' 3 | import * as helper from '../modules/helper.js' 4 | 5 | import ReactMarkdown from 'react-markdown' 6 | import ContentDisplay from './ContentDisplay.js' 7 | 8 | function typographer(children) { 9 | if (!Array.isArray(children)) { 10 | return typographer([children])[0] 11 | } 12 | 13 | return children.map(child => { 14 | if (typeof child !== 'string') return child 15 | return helper.typographer(child) 16 | }) 17 | } 18 | 19 | function htmlify(children) { 20 | return toChildArray(children).map(child => { 21 | if (typeof child !== 'string') return child 22 | 23 | return h(ContentDisplay, { 24 | tag: 'span', 25 | content: typographer(child) 26 | }) 27 | }) 28 | } 29 | 30 | const generateBasicComponent = tag => ({children}) => 31 | h(tag, {}, htmlify(children)) 32 | 33 | const Emphasis = generateBasicComponent('em') 34 | const Strong = generateBasicComponent('strong') 35 | const Delete = generateBasicComponent('del') 36 | const ListItem = generateBasicComponent('li') 37 | const Table = generateBasicComponent('table') 38 | 39 | function Paragraph({children}) { 40 | return h('p', {}, htmlify(children)) 41 | } 42 | 43 | function Link({href, title, children}) { 44 | if (href.match(/^((ht|f)tps?:\/\/|mailto:)/) == null) 45 | return h('span', {}, typographer(children)) 46 | 47 | return h( 48 | ContentDisplay, 49 | {}, 50 | h('a', {class: 'comment-external', href, title}, typographer(children)) 51 | ) 52 | } 53 | 54 | function Image({src, alt}) { 55 | return h(Link, {href: src}, typographer(alt)) 56 | } 57 | 58 | function Heading({level, children}) { 59 | return h(`h${level}`, {}, typographer(children)) 60 | } 61 | 62 | function Html({isBlock, value}) { 63 | return h(isBlock ? Paragraph : 'span', {}, value) 64 | } 65 | 66 | class MarkdownContentDisplay extends Component { 67 | render({source}) { 68 | return h(ReactMarkdown, { 69 | children: source, 70 | remarkPlugins: [breaks], 71 | components: { 72 | p: Paragraph, 73 | em: Emphasis, 74 | strong: Strong, 75 | del: Delete, 76 | a: Link, 77 | img: Image, 78 | table: Table, 79 | li: ListItem, 80 | h1: Heading, 81 | h2: Heading, 82 | h3: Heading, 83 | h4: Heading, 84 | h5: Heading, 85 | h6: Heading, 86 | code: Paragraph, 87 | html: Html 88 | } 89 | }) 90 | } 91 | } 92 | 93 | export default MarkdownContentDisplay 94 | -------------------------------------------------------------------------------- /src/components/MiniGoban.js: -------------------------------------------------------------------------------- 1 | import {h, Component} from 'preact' 2 | 3 | const range = n => [...Array(n)].map((_, i) => i) 4 | 5 | export default class MiniGoban extends Component { 6 | shouldComponentUpdate({board, maxSize, visible}) { 7 | return ( 8 | visible !== this.props.visible || 9 | maxSize !== this.props.maxSize || 10 | board !== this.props.board 11 | ) 12 | } 13 | 14 | render({board, maxSize, visible = true}) { 15 | let fieldSize = (maxSize - 1) / Math.max(board.width, board.height) 16 | let radius = fieldSize / 2 17 | let rangeX = range(board.width) 18 | let rangeY = range(board.height) 19 | 20 | return h( 21 | 'svg', 22 | { 23 | width: fieldSize * board.width + 1, 24 | height: fieldSize * board.height + 1, 25 | style: {visibility: visible ? 'visible' : 'hidden'} 26 | }, 27 | 28 | // Draw hoshi points 29 | 30 | board.getHandicapPlacement(9).map(([x, y]) => 31 | h('circle', { 32 | cx: x * fieldSize + radius + 1, 33 | cy: y * fieldSize + radius + 1, 34 | r: 2, 35 | fill: '#5E2E0C' 36 | }) 37 | ), 38 | 39 | // Draw shadows 40 | 41 | rangeX.map(x => 42 | rangeY.map( 43 | y => 44 | board.get([x, y]) !== 0 && 45 | h('circle', { 46 | cx: x * fieldSize + radius + 1, 47 | cy: y * fieldSize + radius + 2, 48 | r: radius, 49 | fill: 'rgba(0, 0, 0, .5)' 50 | }) 51 | ) 52 | ), 53 | 54 | // Draw stones 55 | 56 | rangeX.map(x => 57 | rangeY.map( 58 | y => 59 | board.get([x, y]) !== 0 && 60 | h('circle', { 61 | cx: x * fieldSize + radius + 1, 62 | cy: y * fieldSize + radius + 1, 63 | r: radius, 64 | fill: board.get([x, y]) < 0 ? 'white' : 'black' 65 | }) 66 | ) 67 | ) 68 | ) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/components/TextSpinner.js: -------------------------------------------------------------------------------- 1 | import EventEmitter from 'events' 2 | import {h, Component} from 'preact' 3 | 4 | let pulse 5 | 6 | function getPulse() { 7 | if (pulse == null) { 8 | let frame = 0 9 | let m = 8 * 9 * 5 * 7 * 11 10 | 11 | pulse = new EventEmitter() 12 | pulse.setMaxListeners(Infinity) 13 | 14 | setInterval(() => { 15 | pulse.emit('tick', {frame}) 16 | frame = (frame + 1) % m 17 | }, 100) 18 | } 19 | 20 | return pulse 21 | } 22 | 23 | export default class TextSpinner extends Component { 24 | constructor(props) { 25 | super(props) 26 | 27 | this.state = { 28 | frame: 0 29 | } 30 | 31 | this.handleTick = evt => { 32 | this.setState({frame: evt.frame}) 33 | } 34 | } 35 | 36 | componentDidMount() { 37 | getPulse().on('tick', this.handleTick) 38 | } 39 | 40 | componentWillUnmount() { 41 | getPulse().removeListener('tick', this.handleTick) 42 | } 43 | 44 | render() { 45 | let {enabled = true, frames = '-\\|/'} = this.props 46 | 47 | return h( 48 | 'span', 49 | {class: 'text-spinner'}, 50 | !enabled ? '' : frames[this.state.frame % frames.length] 51 | ) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/components/ThemeManager.js: -------------------------------------------------------------------------------- 1 | import {join} from 'path' 2 | import * as remote from '@electron/remote' 3 | import {h, Component} from 'preact' 4 | import sabaki from '../modules/sabaki.js' 5 | import ColorThief from '@mariotacke/color-thief' 6 | 7 | const setting = remote.require('./setting') 8 | const colorThief = new ColorThief() 9 | 10 | async function getColorFromPath(path) { 11 | try { 12 | let img = new Image() 13 | img.src = path 14 | await new Promise(resolve => img.addEventListener('load', resolve)) 15 | 16 | return colorThief.getColor(img) 17 | } catch (err) {} 18 | } 19 | 20 | function getForeground([r, g, b]) { 21 | return Math.max(r, g, b) < 255 / 2 ? 'white' : 'black' 22 | } 23 | 24 | export default class ThemeManager extends Component { 25 | constructor() { 26 | super() 27 | 28 | this.updateSettingState() 29 | 30 | setting.events.on(sabaki.window.id, 'change', ({key}) => 31 | this.updateSettingState(key) 32 | ) 33 | } 34 | 35 | shouldComponentUpdate(_, state) { 36 | return ( 37 | state.currentThemeId !== this.state.currentThemeId || 38 | state.blackStonePath !== this.state.blackStonePath || 39 | state.whiteStonePath !== this.state.whiteStonePath || 40 | state.boardPath !== this.state.boardPath || 41 | state.backgroundPath !== this.state.backgroundPath || 42 | state.boardBackground !== this.state.boardBackground || 43 | state.blackStoneBackground !== this.state.blackStoneBackground || 44 | state.blackStoneForeground !== this.state.blackStoneForeground || 45 | state.whiteStoneBackground !== this.state.whiteStoneBackground || 46 | state.whiteStoneForeground !== this.state.whiteStoneForeground 47 | ) 48 | } 49 | 50 | updateSettingState(key) { 51 | let data = { 52 | 'theme.current': 'currentThemeId', 53 | 'theme.custom_blackstones': 'blackStonePath', 54 | 'theme.custom_whitestones': 'whiteStonePath', 55 | 'theme.custom_board': 'boardPath', 56 | 'theme.custom_background': 'backgroundPath' 57 | } 58 | 59 | if (key == null) { 60 | for (let k in data) this.updateSettingState(k) 61 | return 62 | } 63 | 64 | if (key in data) { 65 | this.setState({[data[key]]: setting.get(key)}) 66 | } 67 | } 68 | 69 | componentDidMount() { 70 | this.componentDidUpdate(null, {}) 71 | } 72 | 73 | componentDidUpdate(_, prevState) { 74 | let {currentThemeId, blackStonePath, whiteStonePath, boardPath} = this.state 75 | 76 | if (prevState.currentThemeId !== currentThemeId) { 77 | setTimeout(() => window.dispatchEvent(new Event('resize')), 0) 78 | } 79 | 80 | if (boardPath != null && prevState.boardPath !== boardPath) { 81 | getColorFromPath(boardPath).then(color => 82 | this.setState({ 83 | boardBackground: color ? `rgb(${color.join(',')})` : undefined 84 | }) 85 | ) 86 | } 87 | 88 | if (blackStonePath != null && prevState.blackStonePath !== blackStonePath) { 89 | getColorFromPath(blackStonePath).then(color => 90 | this.setState({ 91 | blackStoneBackground: color ? `rgb(${color.join(',')})` : undefined, 92 | blackStoneForeground: color ? getForeground(color) : undefined 93 | }) 94 | ) 95 | } 96 | 97 | if (whiteStonePath != null && prevState.whiteStonePath !== whiteStonePath) { 98 | getColorFromPath(whiteStonePath).then(color => 99 | this.setState({ 100 | whiteStoneBackground: color ? `rgb(${color.join(',')})` : undefined, 101 | whiteStoneForeground: color ? getForeground(color) : undefined 102 | }) 103 | ) 104 | } 105 | } 106 | 107 | render( 108 | _, 109 | { 110 | currentThemeId, 111 | blackStonePath, 112 | whiteStonePath, 113 | boardPath, 114 | backgroundPath, 115 | boardBackground = '#EBB55B', 116 | blackStoneBackground = 'black', 117 | blackStoneForeground = 'white', 118 | whiteStoneBackground = 'white', 119 | whiteStoneForeground = 'black' 120 | } 121 | ) { 122 | let currentTheme = setting.getThemes()[currentThemeId] 123 | 124 | return h( 125 | 'section', 126 | {}, 127 | // Theme stylesheet 128 | 129 | currentTheme != null && 130 | h('link', { 131 | rel: 'stylesheet', 132 | type: 'text/css', 133 | href: join(currentTheme.path, currentTheme.main || 'styles.css') 134 | }), 135 | 136 | // Custom images 137 | 138 | h( 139 | 'style', 140 | {}, 141 | blackStonePath != null && 142 | `.shudan-stone-image.shudan-sign_1 { 143 | background-image: url('${blackStonePath.replace( 144 | /\\/g, 145 | '/' 146 | )}'); 147 | } .shudan-goban { 148 | --shudan-black-background-color: ${blackStoneBackground}; 149 | --shudan-black-foreground-color: ${blackStoneForeground}; 150 | }`, 151 | 152 | whiteStonePath != null && 153 | `.shudan-stone-image.shudan-sign_-1 { 154 | background-image: url('${whiteStonePath.replace( 155 | /\\/g, 156 | '/' 157 | )}'); 158 | } .shudan-goban { 159 | --shudan-white-background-color: ${whiteStoneBackground}; 160 | --shudan-white-foreground-color: ${whiteStoneForeground}; 161 | }`, 162 | 163 | boardPath != null && 164 | `.shudan-goban-image { 165 | background-image: url('${boardPath.replace(/\\/g, '/')}'); 166 | } .shudan-goban { 167 | --shudan-board-background-color: ${boardBackground}; 168 | --shudan-board-border-color: rgba(33, 24, 9, .2); 169 | --shudan-board-foreground-color: rgba(33, 24, 9, 1); 170 | }`, 171 | 172 | backgroundPath != null && 173 | `main { 174 | background-image: url('${backgroundPath.replace( 175 | /\\/g, 176 | '/' 177 | )}'); 178 | }` 179 | ), 180 | 181 | // Userstyles 182 | 183 | h('link', { 184 | rel: 'stylesheet', 185 | type: 'text/css', 186 | href: setting.stylesPath 187 | }) 188 | ) 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /src/components/ToolBar.js: -------------------------------------------------------------------------------- 1 | import {h, Component} from 'preact' 2 | import classnames from 'classnames' 3 | 4 | export class ToolBarButton extends Component { 5 | constructor(props) { 6 | super(props) 7 | 8 | this.handleClick = evt => { 9 | evt.preventDefault() 10 | 11 | let {onClick = () => {}} = this.props 12 | onClick(evt) 13 | } 14 | } 15 | 16 | render() { 17 | let {tooltip, icon, checked, menu} = this.props 18 | 19 | return h( 20 | 'li', 21 | { 22 | class: classnames('tool-bar-button', {menu, checked}) 23 | }, 24 | 25 | h( 26 | 'a', 27 | {href: '#', title: tooltip, onClick: this.handleClick}, 28 | 29 | h('img', { 30 | class: 'icon', 31 | height: 16, 32 | src: icon, 33 | alt: tooltip 34 | }), 35 | 36 | menu && 37 | h('img', { 38 | class: 'dropdown', 39 | height: 8, 40 | src: './node_modules/@primer/octicons/build/svg/triangle-down.svg', 41 | alt: '' 42 | }) 43 | ) 44 | ) 45 | } 46 | } 47 | 48 | export default class ToolBar extends Component { 49 | render() { 50 | return h('div', {class: 'tool-bar'}, h('ul', {}, this.props.children)) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/components/bars/AutoplayBar.js: -------------------------------------------------------------------------------- 1 | import * as remote from '@electron/remote' 2 | import {h, Component} from 'preact' 3 | import classNames from 'classnames' 4 | import {parseVertex} from '@sabaki/sgf' 5 | 6 | import sabaki from '../../modules/sabaki.js' 7 | import i18n from '../../i18n.js' 8 | 9 | import Bar from './Bar.js' 10 | 11 | const t = i18n.context('AutoplayBar') 12 | const setting = remote.require('./setting') 13 | 14 | let maxSecPerMove = setting.get('autoplay.max_sec_per_move') 15 | let secondsPerMove = setting.get('autoplay.sec_per_move') 16 | 17 | export default class AutoplayBar extends Component { 18 | constructor() { 19 | super() 20 | 21 | this.state = { 22 | playing: false, 23 | secondsPerMove, 24 | secondsPerMoveInput: secondsPerMove 25 | } 26 | 27 | this.handleFormSubmit = evt => { 28 | evt.preventDefault() 29 | } 30 | 31 | this.handleValueInput = evt => { 32 | this.setState({secondsPerMoveInput: evt.currentTarget.value}) 33 | } 34 | 35 | this.handleValueChange = evt => { 36 | let value = Math.round( 37 | Math.min(maxSecPerMove, Math.max(1, +evt.currentTarget.value)) 38 | ) 39 | 40 | if (!isNaN(value)) { 41 | this.setState({ 42 | secondsPerMove: value, 43 | secondsPerMoveInput: value 44 | }) 45 | } 46 | 47 | setting.set('autoplay.sec_per_move', this.state.secondsPerMove) 48 | } 49 | 50 | this.handlePlayButtonClick = () => { 51 | if (this.state.playing) this.stopAutoplay() 52 | else this.startAutoplay() 53 | } 54 | 55 | this.startAutoplay = this.startAutoplay.bind(this) 56 | this.stopAutoplay = this.stopAutoplay.bind(this) 57 | } 58 | 59 | shouldComponentUpdate(nextProps) { 60 | return nextProps.mode !== this.props.mode || nextProps.mode === 'autoplay' 61 | } 62 | 63 | componentWillReceiveProps(nextProps) { 64 | if (this.state.playing && nextProps.mode !== 'autoplay') this.stopAutoplay() 65 | } 66 | 67 | startAutoplay() { 68 | let autoplay = () => { 69 | sabaki.events.removeListener('navigate', this.stopAutoplay) 70 | if (!this.state.playing) return 71 | 72 | let {gameTree, gameCurrents, treePosition} = this.props 73 | let node = gameTree.navigate(treePosition, 1, gameCurrents) 74 | if (!node) return this.stopAutoplay() 75 | 76 | if (node.data.B == null && node.data.W == null) { 77 | sabaki.setCurrentTreePosition(gameTree, treePosition) 78 | } else { 79 | let vertex = parseVertex( 80 | node.data.B != null ? node.data.B[0] : node.data.W[0] 81 | ) 82 | sabaki.makeMove(vertex, {player: node.data.B ? 1 : -1}) 83 | } 84 | 85 | sabaki.events.addListener('navigate', this.stopAutoplay) 86 | this.autoplayId = setTimeout(autoplay, this.state.secondsPerMove * 1000) 87 | } 88 | 89 | this.setState({playing: true}, autoplay) 90 | } 91 | 92 | stopAutoplay() { 93 | sabaki.events.removeListener('navigate', this.stopAutoplay) 94 | clearTimeout(this.autoplayId) 95 | 96 | this.setState({playing: false}) 97 | } 98 | 99 | render(_, {secondsPerMoveInput, playing}) { 100 | return h( 101 | Bar, 102 | Object.assign( 103 | {type: 'autoplay', class: classNames({playing})}, 104 | this.props 105 | ), 106 | h( 107 | 'form', 108 | {onSubmit: this.handleFormSubmit}, 109 | h( 110 | 'label', 111 | {}, 112 | h('input', { 113 | type: 'number', 114 | value: secondsPerMoveInput, 115 | min: 1, 116 | max: maxSecPerMove, 117 | step: 1, 118 | 119 | onInput: this.handleValueInput, 120 | onChange: this.handleValueChange 121 | }), 122 | ' ', 123 | t('sec per move') 124 | ) 125 | ), 126 | 127 | h('a', {class: 'play', href: '#', onClick: this.handlePlayButtonClick}) 128 | ) 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/components/bars/Bar.js: -------------------------------------------------------------------------------- 1 | import {h, Component} from 'preact' 2 | import classNames from 'classnames' 3 | import sabaki from '../../modules/sabaki.js' 4 | 5 | export default class Bar extends Component { 6 | constructor(props) { 7 | super(props) 8 | 9 | this.state = { 10 | hidecontent: props.type !== props.mode 11 | } 12 | 13 | this.onCloseButtonClick = () => sabaki.setMode('play') 14 | } 15 | 16 | componentWillReceiveProps(nextProps) { 17 | if (nextProps.type === nextProps.mode) { 18 | clearTimeout(this.hidecontentId) 19 | 20 | if (this.state.hidecontent) this.setState({hidecontent: false}) 21 | } else { 22 | if (!this.state.hidecontent) 23 | this.hidecontentId = setTimeout( 24 | () => this.setState({hidecontent: true}), 25 | 500 26 | ) 27 | } 28 | } 29 | 30 | shouldComponentUpdate(nextProps) { 31 | return ( 32 | nextProps.mode !== this.props.mode || nextProps.mode === nextProps.type 33 | ) 34 | } 35 | 36 | render({children, type, mode, class: c = ''}, {hidecontent}) { 37 | return h( 38 | 'section', 39 | { 40 | id: type, 41 | class: classNames(c, { 42 | bar: true, 43 | current: type === mode, 44 | hidecontent 45 | }) 46 | }, 47 | 48 | children, 49 | h('a', {class: 'close', href: '#', onClick: this.onCloseButtonClick}) 50 | ) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/components/bars/EditBar.js: -------------------------------------------------------------------------------- 1 | import {h, Component} from 'preact' 2 | import classNames from 'classnames' 3 | 4 | import i18n from '../../i18n.js' 5 | import {noop} from '../../modules/helper.js' 6 | import Bar from './Bar.js' 7 | 8 | const t = i18n.context('EditBar') 9 | 10 | class EditBar extends Component { 11 | constructor() { 12 | super() 13 | 14 | this.state = { 15 | stoneTool: 1 16 | } 17 | 18 | this.handleToolButtonClick = this.handleToolButtonClick.bind(this) 19 | } 20 | 21 | componentWillReceiveProps({selectedTool}) { 22 | if (selectedTool === this.props.selectedTool) return 23 | 24 | if (selectedTool.indexOf('stone') === 0) { 25 | this.setState({stoneTool: +selectedTool.replace('stone_', '')}) 26 | } 27 | } 28 | 29 | shouldComponentUpdate(nextProps) { 30 | return nextProps.mode !== this.props.mode || nextProps.mode === 'edit' 31 | } 32 | 33 | handleToolButtonClick(evt) { 34 | let {selectedTool, onToolButtonClick = noop} = this.props 35 | 36 | evt.tool = evt.currentTarget.dataset.id 37 | 38 | if ( 39 | evt.tool.indexOf('stone') === 0 && 40 | selectedTool.indexOf('stone') === 0 41 | ) { 42 | evt.tool = `stone_${-this.state.stoneTool}` 43 | this.setState(({stoneTool}) => ({stoneTool: -stoneTool})) 44 | } 45 | 46 | onToolButtonClick(evt) 47 | } 48 | 49 | renderButton(title, toolId, selected = false) { 50 | return h( 51 | 'li', 52 | {class: classNames({selected})}, 53 | h( 54 | 'a', 55 | { 56 | title, 57 | href: '#', 58 | 'data-id': toolId, 59 | onClick: this.handleToolButtonClick 60 | }, 61 | 62 | h('img', {src: `./img/edit/${toolId}.svg`}) 63 | ) 64 | ) 65 | } 66 | 67 | render({selectedTool}, {stoneTool}) { 68 | let isSelected = ([, id]) => 69 | id.replace(/_-?1$/, '') === selectedTool.replace(/_-?1$/, '') 70 | 71 | return h( 72 | Bar, 73 | Object.assign({type: 'edit'}, this.props), 74 | h( 75 | 'ul', 76 | {}, 77 | [ 78 | [t('Stone Tool'), `stone_${stoneTool}`], 79 | [t('Cross Tool'), 'cross'], 80 | [t('Triangle Tool'), 'triangle'], 81 | [t('Square Tool'), 'square'], 82 | [t('Circle Tool'), 'circle'], 83 | [t('Line Tool'), 'line'], 84 | [t('Arrow Tool'), 'arrow'], 85 | [t('Label Tool'), 'label'], 86 | [t('Number Tool'), 'number'] 87 | ].map(x => this.renderButton(...x, isSelected(x))) 88 | ) 89 | ) 90 | } 91 | } 92 | 93 | export default EditBar 94 | -------------------------------------------------------------------------------- /src/components/bars/FindBar.js: -------------------------------------------------------------------------------- 1 | import {h, Component} from 'preact' 2 | import i18n from '../../i18n.js' 3 | import sabaki from '../../modules/sabaki.js' 4 | import {noop} from '../../modules/helper.js' 5 | import Bar from './Bar.js' 6 | 7 | const t = i18n.context('FindBar') 8 | 9 | export default class FindBar extends Component { 10 | constructor() { 11 | super() 12 | 13 | this.handleChange = evt => { 14 | sabaki.setState({findText: evt.currentTarget.value}) 15 | } 16 | 17 | this.handleButtonClick = evt => { 18 | evt.preventDefault() 19 | 20 | let step = evt.currentTarget.classList.contains('next') ? 1 : -1 21 | let {onButtonClick = noop} = this.props 22 | 23 | evt.step = step 24 | onButtonClick(evt) 25 | } 26 | } 27 | 28 | componentDidUpdate(prevProps) { 29 | if (prevProps.mode !== this.props.mode) { 30 | if (this.props.mode === 'find') { 31 | this.inputElement.focus() 32 | } else { 33 | this.inputElement.blur() 34 | } 35 | } 36 | } 37 | 38 | render({findText}) { 39 | return h( 40 | Bar, 41 | Object.assign({type: 'find'}, this.props), 42 | h( 43 | 'form', 44 | {}, 45 | h('input', { 46 | ref: el => (this.inputElement = el), 47 | type: 'text', 48 | placeholder: t('Find'), 49 | value: findText, 50 | onInput: this.handleChange 51 | }), 52 | 53 | h( 54 | 'button', 55 | {class: 'next', onClick: this.handleButtonClick}, 56 | h('img', { 57 | src: './node_modules/@primer/octicons/build/svg/chevron-down.svg', 58 | height: 20, 59 | alt: t('Next') 60 | }) 61 | ), 62 | h( 63 | 'button', 64 | {class: 'prev', onClick: this.handleButtonClick}, 65 | h('img', { 66 | src: './node_modules/@primer/octicons/build/svg/chevron-up.svg', 67 | height: 20, 68 | alt: t('Previous') 69 | }) 70 | ) 71 | ) 72 | ) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/components/bars/GuessBar.js: -------------------------------------------------------------------------------- 1 | import {h, Component} from 'preact' 2 | import Bar from './Bar.js' 3 | import i18n from '../../i18n.js' 4 | 5 | const t = i18n.context('GuessBar') 6 | 7 | class GuessBar extends Component { 8 | render(props) { 9 | return h( 10 | Bar, 11 | Object.assign({type: 'guess'}, props), 12 | t('Click on the board to guess the next move.') 13 | ) 14 | } 15 | } 16 | 17 | export default GuessBar 18 | -------------------------------------------------------------------------------- /src/components/bars/PlayBar.js: -------------------------------------------------------------------------------- 1 | import {h, Component} from 'preact' 2 | import classNames from 'classnames' 3 | 4 | import i18n from '../../i18n.js' 5 | import sabaki from '../../modules/sabaki.js' 6 | import * as helper from '../../modules/helper.js' 7 | import TextSpinner from '../TextSpinner.js' 8 | 9 | const t = i18n.context('PlayBar') 10 | 11 | export default class PlayBar extends Component { 12 | constructor(props) { 13 | super(props) 14 | 15 | this.state = { 16 | playerBusy: [false, false] 17 | } 18 | 19 | this.syncState = () => { 20 | this.setState({ 21 | playerBusy: this.props.engineSyncers.map(syncer => 22 | syncer == null ? false : syncer.busy 23 | ) 24 | }) 25 | } 26 | 27 | this.handleCurrentPlayerClick = () => this.props.onCurrentPlayerClick 28 | 29 | this.handleMenuClick = () => { 30 | let {left, top} = this.menuButtonElement.getBoundingClientRect() 31 | 32 | helper.popupMenu( 33 | [ 34 | { 35 | label: t('&Pass'), 36 | click: () => sabaki.makeMove([-1, -1]) 37 | }, 38 | { 39 | label: t('&Resign'), 40 | click: () => sabaki.makeResign() 41 | }, 42 | {type: 'separator'}, 43 | { 44 | label: t('Es&timate'), 45 | click: () => sabaki.setMode('estimator') 46 | }, 47 | { 48 | label: t('&Score'), 49 | click: () => sabaki.setMode('scoring') 50 | }, 51 | { 52 | label: t('&Edit'), 53 | click: () => sabaki.setMode('edit') 54 | }, 55 | { 56 | label: t('&Find'), 57 | click: () => sabaki.setMode('find') 58 | }, 59 | {type: 'separator'}, 60 | { 61 | label: t('&Info'), 62 | click: () => sabaki.openDrawer('info') 63 | } 64 | ], 65 | left, 66 | top 67 | ) 68 | } 69 | 70 | for (let syncer of this.props.engineSyncers) { 71 | if (syncer == null) continue 72 | 73 | syncer.on('busy-changed', this.syncState) 74 | } 75 | } 76 | 77 | shouldComponentUpdate(nextProps) { 78 | return nextProps.mode !== this.props.mode || nextProps.mode === 'play' 79 | } 80 | 81 | componentWillReceiveProps(nextProps) { 82 | for (let i = 0; i < nextProps.engineSyncers.length; i++) { 83 | if (nextProps.engineSyncers !== this.props.engineSyncers) { 84 | if (this.props.engineSyncers[i] != null) { 85 | this.props.engineSyncers[i].removeListener( 86 | 'busy-changed', 87 | this.syncState 88 | ) 89 | } 90 | 91 | if (nextProps.engineSyncers[i] != null) { 92 | nextProps.engineSyncers[i].on('busy-changed', this.syncState) 93 | } 94 | } 95 | } 96 | } 97 | 98 | render( 99 | { 100 | mode, 101 | engineSyncers, 102 | playerNames, 103 | playerRanks, 104 | playerCaptures, 105 | currentPlayer, 106 | showHotspot, 107 | 108 | onCurrentPlayerClick = helper.noop 109 | }, 110 | {playerBusy} 111 | ) { 112 | let captureStyle = index => ({ 113 | opacity: playerCaptures[index] === 0 ? 0 : 0.7 114 | }) 115 | 116 | return h( 117 | 'header', 118 | { 119 | class: classNames({ 120 | hotspot: showHotspot, 121 | current: mode === 'play' 122 | }) 123 | }, 124 | 125 | h('div', {class: 'hotspot', title: t('Hotspot')}), 126 | 127 | h( 128 | 'span', 129 | {class: 'playercontent player_1'}, 130 | h( 131 | 'span', 132 | {class: 'captures', style: captureStyle(0)}, 133 | playerCaptures[0] 134 | ), 135 | ' ', 136 | 137 | engineSyncers[0] == null && 138 | playerRanks[0] && 139 | h('span', {class: 'rank'}, playerRanks[0]), 140 | ' ', 141 | 142 | engineSyncers[0] != null && playerBusy[0] && h(TextSpinner), 143 | ' ', 144 | 145 | h( 146 | 'span', 147 | { 148 | class: classNames('name', {engine: engineSyncers[0] != null}), 149 | title: engineSyncers[0] != null ? t('Engine') : null 150 | }, 151 | 152 | engineSyncers[0] == null 153 | ? playerNames[0] || t('Black') 154 | : engineSyncers[0].engine.name 155 | ) 156 | ), 157 | 158 | h( 159 | 'a', 160 | { 161 | class: 'current-player', 162 | title: t('Change Player'), 163 | onClick: onCurrentPlayerClick 164 | }, 165 | h('img', { 166 | src: `./img/ui/player_${currentPlayer}.svg`, 167 | height: 21, 168 | alt: currentPlayer < 0 ? t('White to play') : t('Black to play') 169 | }) 170 | ), 171 | 172 | h( 173 | 'span', 174 | {class: 'playercontent player_-1'}, 175 | h( 176 | 'span', 177 | { 178 | class: classNames('name', {engine: engineSyncers[1] != null}), 179 | title: engineSyncers[1] != null ? t('Engine') : null 180 | }, 181 | engineSyncers[1] == null 182 | ? playerNames[1] || t('White') 183 | : engineSyncers[1].engine.name 184 | ), 185 | ' ', 186 | 187 | engineSyncers[1] != null && playerBusy[1] && h(TextSpinner), 188 | ' ', 189 | 190 | engineSyncers[1] == null && 191 | playerRanks[1] && 192 | h('span', {class: 'rank'}, playerRanks[1]), 193 | ' ', 194 | 195 | h( 196 | 'span', 197 | {class: 'captures', style: captureStyle(1)}, 198 | playerCaptures[1] 199 | ) 200 | ), 201 | 202 | h( 203 | 'a', 204 | { 205 | ref: el => (this.menuButtonElement = el), 206 | class: 'menu', 207 | onClick: this.handleMenuClick 208 | }, 209 | h('img', { 210 | src: './node_modules/@primer/octicons/build/svg/three-bars.svg', 211 | height: 22 212 | }) 213 | ) 214 | ) 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /src/components/bars/ScoringBar.js: -------------------------------------------------------------------------------- 1 | import {h, Component} from 'preact' 2 | import Bar from './Bar.js' 3 | 4 | import i18n from '../../i18n.js' 5 | import sabaki from '../../modules/sabaki.js' 6 | import {getScore} from '../../modules/helper.js' 7 | 8 | const t = i18n.context('ScoringBar') 9 | 10 | export default class ScoringBar extends Component { 11 | constructor() { 12 | super() 13 | 14 | this.handleButtonClick = () => sabaki.openDrawer('score') 15 | } 16 | 17 | render({type, method, areaMap, scoreBoard, komi, handicap}) { 18 | let score = scoreBoard && getScore(scoreBoard, areaMap, {komi, handicap}) 19 | let result = 20 | score && (method === 'area' ? score.areaScore : score.territoryScore) 21 | 22 | return h( 23 | Bar, 24 | Object.assign({type}, this.props), 25 | h( 26 | 'div', 27 | {class: 'result'}, 28 | h('button', {onClick: this.handleButtonClick}, t('Details')), 29 | h( 30 | 'strong', 31 | {}, 32 | result == null 33 | ? '' 34 | : result > 0 35 | ? `B+${result}` 36 | : result < 0 37 | ? `W+${-result}` 38 | : t('Draw') 39 | ) 40 | ), 41 | ' ', 42 | 43 | type === 'scoring' 44 | ? t('Please select dead stones.') 45 | : t('Toggle group status.') 46 | ) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/components/drawers/AdvancedPropertiesDrawer.js: -------------------------------------------------------------------------------- 1 | import {h, Component} from 'preact' 2 | 3 | import i18n from '../../i18n.js' 4 | import sabaki from '../../modules/sabaki.js' 5 | import {showInputBox, showMessageBox} from '../../modules/dialog.js' 6 | import {noop} from '../../modules/helper.js' 7 | 8 | import Drawer from './Drawer.js' 9 | 10 | const t = i18n.context('AdvancedPropertiesDrawer') 11 | const blockedProperties = ['AP', 'CA'] 12 | const clearCacheProperties = ['AE', 'AW', 'AB', 'SZ', 'W', 'B'] 13 | 14 | class PropertyItem extends Component { 15 | constructor(props) { 16 | super(props) 17 | 18 | this.handleRemoveButtonClick = evt => { 19 | evt.preventDefault() 20 | 21 | let {property, index, onRemove = noop} = this.props 22 | onRemove({property, index}) 23 | } 24 | 25 | this.handleChange = evt => { 26 | let {property, index, onChange = noop} = this.props 27 | onChange({property, index, value: evt.currentTarget.value}) 28 | } 29 | 30 | this.handleKeyDown = evt => { 31 | if (evt.key === 'Enter') { 32 | if (!evt.shiftKey) { 33 | evt.preventDefault() 34 | 35 | let {onSubmit = noop} = this.props 36 | onSubmit() 37 | } 38 | } 39 | } 40 | } 41 | 42 | shouldComponentUpdate({property, index, value, disabled}) { 43 | return ( 44 | property !== this.props.property || 45 | index !== this.props.index || 46 | value !== this.props.value || 47 | disabled !== this.props.disabled 48 | ) 49 | } 50 | 51 | render({property, index, value, disabled}) { 52 | return h( 53 | 'tr', 54 | {}, 55 | h( 56 | 'th', 57 | { 58 | onClick: () => this.inputElement.focus() 59 | }, 60 | 61 | index == null ? property : [property, h('em', {}, `[${index}]`)] 62 | ), 63 | 64 | h( 65 | 'td', 66 | {}, 67 | h('textarea', { 68 | ref: el => (this.inputElement = el), 69 | 'data-property': property, 70 | 'data-index': index, 71 | 72 | value, 73 | disabled, 74 | rows: 1, 75 | 76 | onInput: this.handleChange, 77 | onKeyDown: this.handleKeyDown, 78 | onBlur: () => (this.inputElement.scrollTop = 0) 79 | }) 80 | ), 81 | 82 | h( 83 | 'td', 84 | {class: 'action'}, 85 | !disabled && 86 | h( 87 | 'a', 88 | { 89 | class: 'remove', 90 | title: t('Remove'), 91 | href: '#', 92 | onClick: this.handleRemoveButtonClick 93 | }, 94 | 95 | h('img', {src: './node_modules/@primer/octicons/build/svg/x.svg'}) 96 | ) 97 | ) 98 | ) 99 | } 100 | } 101 | 102 | export default class AdvancedPropertiesDrawer extends Component { 103 | constructor(props) { 104 | super(props) 105 | 106 | this.handleCloseButtonClick = evt => { 107 | if (evt) evt.preventDefault() 108 | sabaki.closeDrawer() 109 | } 110 | 111 | this.handleAddButtonClick = async evt => { 112 | evt.preventDefault() 113 | 114 | let value = await showInputBox(t('Enter property name')) 115 | if (value == null) return 116 | 117 | let property = value.toUpperCase() 118 | 119 | if (blockedProperties.includes(property)) { 120 | showMessageBox(t('This property has been blocked.'), 'warning') 121 | return 122 | } 123 | 124 | let {gameTree, treePosition} = this.props 125 | let newTree = gameTree.mutate(draft => { 126 | draft.addToProperty(treePosition, property, '') 127 | }) 128 | 129 | sabaki.setCurrentTreePosition(newTree, treePosition) 130 | await sabaki.waitForRender() 131 | 132 | let textareas = this.propertiesElement.querySelectorAll( 133 | `textarea[data-property="${property}"]` 134 | ) 135 | textareas.item(textareas.length - 1).focus() 136 | } 137 | 138 | this.handlePropertyChange = ({property, index, value}) => { 139 | let {gameTree, treePosition} = this.props 140 | 141 | let newTree = gameTree.mutate(draft => { 142 | let values = draft.get(treePosition).data[property] 143 | 144 | if (values == null || index == null) values = [value] 145 | else values = values.map((x, i) => (i === index ? value : x)) 146 | 147 | draft.updateProperty(treePosition, property, values) 148 | }) 149 | 150 | let clearCache = clearCacheProperties.includes(property) 151 | sabaki.setCurrentTreePosition(newTree, treePosition, {clearCache}) 152 | } 153 | 154 | this.handlePropertyRemove = ({property, index}) => { 155 | let {gameTree, treePosition} = this.props 156 | let newTree = gameTree.mutate(draft => { 157 | let values = draft.get(treePosition).data[property] 158 | 159 | if (values[index] == null) draft.removeProperty(treePosition, property) 160 | else draft.removeFromProperty(treePosition, property, values[index]) 161 | }) 162 | 163 | let clearCache = clearCacheProperties.includes(property) 164 | sabaki.setCurrentTreePosition(newTree, treePosition, {clearCache}) 165 | } 166 | } 167 | 168 | shouldComponentUpdate({show}) { 169 | return show || show !== this.props.show 170 | } 171 | 172 | componentWillReceiveProps({treePosition}) { 173 | if (treePosition !== this.props.treePosition) { 174 | this.propertiesElement.scrollTop = 0 175 | } 176 | } 177 | 178 | render({gameTree, treePosition, show}) { 179 | let node = gameTree.get(treePosition) 180 | let properties = Object.keys(node.data) 181 | .filter(x => x.toUpperCase() === x) 182 | .sort() 183 | 184 | return h( 185 | Drawer, 186 | { 187 | type: 'advancedproperties', 188 | show 189 | }, 190 | 191 | h( 192 | 'form', 193 | {}, 194 | h( 195 | 'div', 196 | { 197 | ref: el => (this.propertiesElement = el), 198 | class: 'properties-list' 199 | }, 200 | 201 | h( 202 | 'table', 203 | {}, 204 | properties.map(property => 205 | node.data[property].map((value, i) => 206 | h(PropertyItem, { 207 | key: `${property}-${i}`, 208 | 209 | property, 210 | value, 211 | index: node.data[property].length === 1 ? null : i, 212 | disabled: blockedProperties.includes(property), 213 | 214 | onChange: this.handlePropertyChange, 215 | onRemove: this.handlePropertyRemove, 216 | onSubmit: this.handleCloseButtonClick 217 | }) 218 | ) 219 | ) 220 | ) 221 | ), 222 | 223 | h( 224 | 'p', 225 | {}, 226 | h( 227 | 'button', 228 | {class: 'add', type: 'button', onClick: this.handleAddButtonClick}, 229 | t('Add') 230 | ), 231 | h('button', {onClick: this.handleCloseButtonClick}, t('Close')) 232 | ) 233 | ) 234 | ) 235 | } 236 | } 237 | -------------------------------------------------------------------------------- /src/components/drawers/CleanMarkupDrawer.js: -------------------------------------------------------------------------------- 1 | import * as remote from '@electron/remote' 2 | import {h, Component} from 'preact' 3 | 4 | import i18n from '../../i18n.js' 5 | import sabaki from '../../modules/sabaki.js' 6 | import {wait, popupMenu} from '../../modules/helper.js' 7 | 8 | import Drawer from './Drawer.js' 9 | 10 | const t = i18n.context('CleanMarkupDrawer') 11 | const setting = remote.require('./setting') 12 | 13 | class CleanMarkupItem extends Component { 14 | constructor() { 15 | super() 16 | 17 | this.handleChange = evt => { 18 | setting.set(this.props.id, evt.currentTarget.checked) 19 | } 20 | } 21 | 22 | render({id, text}) { 23 | return h( 24 | 'li', 25 | {}, 26 | h( 27 | 'label', 28 | {}, 29 | h('input', { 30 | type: 'checkbox', 31 | checked: setting.get(id), 32 | onChange: this.handleChange 33 | }), 34 | ' ', 35 | 36 | text 37 | ) 38 | ) 39 | } 40 | } 41 | 42 | export default class CleanMarkupDrawer extends Component { 43 | constructor() { 44 | super() 45 | 46 | this.handleCloseButtonClick = evt => { 47 | evt.preventDefault() 48 | sabaki.closeDrawer() 49 | } 50 | 51 | this.handleRemoveButtonClick = evt => { 52 | evt.preventDefault() 53 | 54 | let doRemove = async work => { 55 | sabaki.setBusy(true) 56 | 57 | let data = { 58 | cross: ['MA'], 59 | triangle: ['TR'], 60 | square: ['SQ'], 61 | circle: ['CR'], 62 | line: ['LN'], 63 | arrow: ['AR'], 64 | label: ['LB'], 65 | comments: ['C', 'N'], 66 | annotations: ['DM', 'GB', 'GW', 'UC', 'BM', 'DO', 'IT', 'TE'], 67 | hotspots: ['HO'], 68 | winrate: ['SBKV'] 69 | } 70 | 71 | let properties = Object.keys(data) 72 | .filter(id => setting.get(`cleanmarkup.${id}`)) 73 | .map(id => data[id]) 74 | .reduce((acc, x) => [...acc, ...x], []) 75 | 76 | await wait(100) 77 | 78 | let newTree = work(properties) 79 | 80 | sabaki.setCurrentTreePosition(newTree, this.props.treePosition) 81 | sabaki.setBusy(false) 82 | sabaki.closeDrawer() 83 | } 84 | 85 | let template = [ 86 | { 87 | label: t('From Current &Position'), 88 | click: () => 89 | doRemove(properties => { 90 | return this.props.gameTree.mutate(draft => { 91 | for (let prop of properties) { 92 | draft.removeProperty(this.props.treePosition, prop) 93 | } 94 | }) 95 | }) 96 | }, 97 | { 98 | label: t('From Entire &Game'), 99 | click: () => 100 | doRemove(properties => { 101 | return this.props.gameTree.mutate(draft => { 102 | for (let node of this.props.gameTree.listNodes()) { 103 | for (let prop of properties) { 104 | draft.removeProperty(node.id, prop) 105 | } 106 | } 107 | }) 108 | }) 109 | } 110 | ] 111 | 112 | let element = evt.currentTarget 113 | let {left, bottom} = element.getBoundingClientRect() 114 | 115 | popupMenu(template, left, bottom) 116 | } 117 | } 118 | 119 | shouldComponentUpdate({show}) { 120 | return show !== this.props.show 121 | } 122 | 123 | render({show}) { 124 | return h( 125 | Drawer, 126 | { 127 | type: 'cleanmarkup', 128 | show 129 | }, 130 | 131 | h('h2', {}, t('Clean Markup')), 132 | 133 | h( 134 | 'form', 135 | {}, 136 | h( 137 | 'ul', 138 | {}, 139 | h(CleanMarkupItem, { 140 | id: 'cleanmarkup.cross', 141 | text: t('Cross markers') 142 | }), 143 | h(CleanMarkupItem, { 144 | id: 'cleanmarkup.triangle', 145 | text: t('Triangle markers') 146 | }), 147 | h(CleanMarkupItem, { 148 | id: 'cleanmarkup.square', 149 | text: t('Square markers') 150 | }), 151 | h(CleanMarkupItem, { 152 | id: 'cleanmarkup.circle', 153 | text: t('Circle markers') 154 | }) 155 | ), 156 | h( 157 | 'ul', 158 | {}, 159 | h(CleanMarkupItem, { 160 | id: 'cleanmarkup.line', 161 | text: t('Line markers') 162 | }), 163 | h(CleanMarkupItem, { 164 | id: 'cleanmarkup.arrow', 165 | text: t('Arrow markers') 166 | }), 167 | h(CleanMarkupItem, { 168 | id: 'cleanmarkup.label', 169 | text: t('Label markers') 170 | }) 171 | ), 172 | h( 173 | 'ul', 174 | {}, 175 | h(CleanMarkupItem, { 176 | id: 'cleanmarkup.comments', 177 | text: t('Comments') 178 | }), 179 | h(CleanMarkupItem, { 180 | id: 'cleanmarkup.annotations', 181 | text: t('Annotations') 182 | }), 183 | h(CleanMarkupItem, { 184 | id: 'cleanmarkup.hotspots', 185 | text: t('Hotspots markers') 186 | }), 187 | h(CleanMarkupItem, { 188 | id: 'cleanmarkup.winrate', 189 | text: t('Winrate data') 190 | }) 191 | ), 192 | 193 | h( 194 | 'p', 195 | {}, 196 | h( 197 | 'button', 198 | { 199 | type: 'button', 200 | class: 'dropdown', 201 | onClick: this.handleRemoveButtonClick 202 | }, 203 | t('Remove') 204 | ), 205 | ' ', 206 | 207 | h('button', {onClick: this.handleCloseButtonClick}, t('Close')) 208 | ) 209 | ) 210 | ) 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /src/components/drawers/Drawer.js: -------------------------------------------------------------------------------- 1 | import {h, Component} from 'preact' 2 | import classNames from 'classnames' 3 | 4 | export default class Drawer extends Component { 5 | constructor(props) { 6 | super(props) 7 | 8 | this.state = { 9 | hidecontent: props.show 10 | } 11 | } 12 | 13 | componentWillReceiveProps(nextProps) { 14 | if (nextProps.show) { 15 | clearTimeout(this.hidecontentId) 16 | 17 | if (this.state.hidecontent) this.setState({hidecontent: false}) 18 | } else { 19 | if (!this.state.hidecontent) 20 | this.hidecontentId = setTimeout( 21 | () => this.setState({hidecontent: true}), 22 | 500 23 | ) 24 | } 25 | } 26 | 27 | render({type, show, children}, {hidecontent}) { 28 | return h( 29 | 'section', 30 | { 31 | id: type, 32 | class: classNames({ 33 | drawer: true, 34 | hidecontent, 35 | show 36 | }) 37 | }, 38 | children 39 | ) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/components/drawers/ScoreDrawer.js: -------------------------------------------------------------------------------- 1 | import * as remote from '@electron/remote' 2 | import {h, Component} from 'preact' 3 | import classNames from 'classnames' 4 | 5 | import i18n from '../../i18n.js' 6 | import sabaki from '../../modules/sabaki.js' 7 | import {noop, getScore} from '../../modules/helper.js' 8 | 9 | import Drawer from './Drawer.js' 10 | 11 | const t = i18n.context('ScoreDrawer') 12 | const setting = remote.require('./setting') 13 | 14 | class ScoreRow extends Component { 15 | render({method, score, komi, handicap, sign}) { 16 | let index = sign > 0 ? 0 : 1 17 | 18 | let total = !score 19 | ? 0 20 | : method === 'area' 21 | ? score.area[index] 22 | : score.territory[index] + score.captures[index] 23 | 24 | if (sign < 0) total += komi 25 | if (method === 'area' && sign < 0) total += handicap 26 | 27 | return h( 28 | 'tr', 29 | {}, 30 | h( 31 | 'th', 32 | {}, 33 | h('img', { 34 | src: `./node_modules/@sabaki/shudan/css/stone_${sign}.svg`, 35 | alt: sign > 0 ? t('Black') : t('White'), 36 | width: 24, 37 | height: 24 38 | }) 39 | ), 40 | h( 41 | 'td', 42 | {class: classNames({disabled: method === 'territory'})}, 43 | score ? score.area[index] : '-' 44 | ), 45 | h( 46 | 'td', 47 | {class: classNames({disabled: method === 'area'})}, 48 | score ? score.territory[index] : '-' 49 | ), 50 | h( 51 | 'td', 52 | {class: classNames({disabled: method === 'area'})}, 53 | score ? score.captures[index] : '-' 54 | ), 55 | h('td', {}, sign < 0 ? komi : '-'), 56 | h( 57 | 'td', 58 | {class: classNames({disabled: method === 'territory'})}, 59 | sign < 0 ? handicap : '-' 60 | ), 61 | h('td', {}, total) 62 | ) 63 | } 64 | } 65 | 66 | export default class ScoreDrawer extends Component { 67 | constructor() { 68 | super() 69 | 70 | this.handleTerritoryButtonClick = () => 71 | setting.set('scoring.method', 'territory') 72 | this.handleAreaButtonClick = () => setting.set('scoring.method', 'area') 73 | this.handleCloseButtonClick = () => sabaki.closeDrawer() 74 | 75 | this.handleSubmitButtonClick = evt => { 76 | evt.preventDefault() 77 | 78 | let {onSubmitButtonClick = noop} = this.props 79 | evt.resultString = this.resultString 80 | onSubmitButtonClick(evt) 81 | } 82 | } 83 | 84 | render({show, estimating, method, areaMap, board, komi, handicap}) { 85 | if (isNaN(komi)) komi = 0 86 | if (isNaN(handicap)) handicap = 0 87 | 88 | let score = areaMap && board && getScore(board, areaMap, {handicap, komi}) 89 | let result = 90 | score && (method === 'area' ? score.areaScore : score.territoryScore) 91 | 92 | this.resultString = 93 | result > 0 ? `B+${result}` : result < 0 ? `W+${-result}` : t('Draw') 94 | 95 | return h( 96 | Drawer, 97 | { 98 | type: 'score', 99 | show 100 | }, 101 | 102 | h('h2', {}, t('Score')), 103 | 104 | h( 105 | 'ul', 106 | {class: 'tab-bar'}, 107 | h( 108 | 'li', 109 | {class: classNames({current: method === 'area'})}, 110 | h( 111 | 'a', 112 | { 113 | href: '#', 114 | onClick: this.handleAreaButtonClick 115 | }, 116 | t('Area') 117 | ) 118 | ), 119 | h( 120 | 'li', 121 | {class: classNames({current: method === 'territory'})}, 122 | h( 123 | 'a', 124 | { 125 | href: '#', 126 | onClick: this.handleTerritoryButtonClick 127 | }, 128 | t('Territory') 129 | ) 130 | ) 131 | ), 132 | 133 | h( 134 | 'table', 135 | {}, 136 | h( 137 | 'thead', 138 | {}, 139 | h( 140 | 'tr', 141 | {}, 142 | h('th'), 143 | h('th', {disabled: method === 'territory'}, t('Area')), 144 | h('th', {disabled: method === 'area'}, t('Territory')), 145 | h('th', {disabled: method === 'area'}, t('Captures')), 146 | h('th', {}, t('Komi')), 147 | h('th', {disabled: method === 'territory'}, t('Handicap')), 148 | h('th', {}, t('Total')) 149 | ) 150 | ), 151 | h( 152 | 'tbody', 153 | {}, 154 | h(ScoreRow, {method, score, komi, handicap: 0, sign: 1}), 155 | h(ScoreRow, {method, score, komi, handicap, sign: -1}) 156 | ) 157 | ), 158 | 159 | h( 160 | 'form', 161 | {}, 162 | h( 163 | 'p', 164 | {}, 165 | t('Result:'), 166 | ' ', 167 | h('span', {class: 'result'}, this.resultString), 168 | ' ', 169 | 170 | !estimating && 171 | h( 172 | 'button', 173 | { 174 | type: 'submit', 175 | onClick: this.handleSubmitButtonClick 176 | }, 177 | t('Update Result') 178 | ), 179 | ' ', 180 | 181 | h( 182 | 'button', 183 | { 184 | type: 'reset', 185 | onClick: this.handleCloseButtonClick 186 | }, 187 | t('Close') 188 | ) 189 | ) 190 | ) 191 | ) 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /src/components/helpers/SplitContainer.js: -------------------------------------------------------------------------------- 1 | import {h, Component} from 'preact' 2 | 3 | export default class SplitContainer extends Component { 4 | constructor(props) { 5 | super(props) 6 | 7 | this.handleResizerMouseDown = evt => { 8 | if (evt.button !== 0) return 9 | this.resizerMouseDown = true 10 | } 11 | 12 | this.handleMouseUp = evt => { 13 | if (!this.resizerMouseDown) return 14 | 15 | let {onFinish = () => {}} = this.props 16 | 17 | this.resizerMouseDown = false 18 | onFinish() 19 | } 20 | 21 | this.handleMouseMove = evt => { 22 | if (!this.resizerMouseDown) return 23 | 24 | let {vertical, invert, procentualSplit, onChange = () => {}} = this.props 25 | let rect = this.element.getBoundingClientRect() 26 | 27 | let mousePosition = !vertical ? evt.clientX : evt.clientY 28 | let containerBegin = !vertical ? rect.left : rect.top 29 | let containerEnd = !vertical ? rect.right : rect.bottom 30 | let sideSize = Math.min( 31 | !invert ? containerEnd - mousePosition : mousePosition - containerBegin, 32 | containerEnd - containerBegin 33 | ) 34 | 35 | if (procentualSplit) { 36 | sideSize = 37 | containerEnd === containerBegin 38 | ? 0 39 | : (sideSize * 100) / (containerEnd - containerBegin) 40 | } 41 | 42 | onChange({sideSize}) 43 | } 44 | } 45 | 46 | componentDidMount() { 47 | document.addEventListener('mouseup', this.handleMouseUp) 48 | document.addEventListener('mousemove', this.handleMouseMove) 49 | } 50 | 51 | componentWillUnmount() { 52 | document.removeEventListener('mouseup', this.handleMouseUp) 53 | document.removeEventListener('mousemove', this.handleMouseMove) 54 | } 55 | 56 | render() { 57 | let { 58 | id, 59 | class: classNames = '', 60 | style = {}, 61 | vertical, 62 | invert, 63 | procentualSplit, 64 | mainContent, 65 | sideContent, 66 | sideSize = 200, 67 | splitterSize = 5 68 | } = this.props 69 | 70 | let gridTemplate = procentualSplit 71 | ? [`${100 - sideSize}%`, `${sideSize}%`] 72 | : [`calc(100% - ${sideSize}px)`, `${sideSize}px`] 73 | if (invert) gridTemplate.reverse() 74 | 75 | let gridTemplateRows = !vertical ? '100%' : gridTemplate.join(' ') 76 | let gridTemplateColumns = vertical ? '100%' : gridTemplate.join(' ') 77 | 78 | let resizer = h('div', { 79 | class: 'resizer', 80 | style: { 81 | position: 'absolute', 82 | width: vertical ? null : splitterSize, 83 | height: !vertical ? null : splitterSize, 84 | cursor: vertical ? 'ns-resize' : 'ew-resize', 85 | left: vertical ? 0 : !invert ? 0 : null, 86 | right: vertical ? 0 : invert ? 0 : null, 87 | top: !vertical ? 0 : !invert ? 0 : null, 88 | bottom: !vertical ? 0 : invert ? 0 : null, 89 | zIndex: 999 90 | }, 91 | 92 | onMouseDown: this.handleResizerMouseDown 93 | }) 94 | 95 | return h( 96 | 'div', 97 | { 98 | ref: el => (this.element = el), 99 | id, 100 | class: `split-container ${classNames}`, 101 | style: { 102 | ...style, 103 | display: 'grid', 104 | gridTemplate: `${gridTemplateRows} / ${gridTemplateColumns}` 105 | } 106 | }, 107 | 108 | !invert && mainContent, 109 | 110 | h( 111 | 'div', 112 | { 113 | class: 'side', 114 | style: { 115 | position: 'relative', 116 | display: 'grid', 117 | gridTemplate: '100% / 100%' 118 | } 119 | }, 120 | [sideContent, resizer] 121 | ), 122 | 123 | invert && mainContent 124 | ) 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/components/helpers/TripleSplitContainer.js: -------------------------------------------------------------------------------- 1 | import {h, Component} from 'preact' 2 | import SplitContainer from './SplitContainer.js' 3 | 4 | export default class TripleSplitContainer extends Component { 5 | constructor(props) { 6 | super(props) 7 | 8 | this.handleBeginSideContentChange = ({sideSize}) => { 9 | let {onChange = () => {}} = this.props 10 | 11 | onChange({ 12 | beginSideSize: sideSize, 13 | endSideSize: this.props.endSideSize 14 | }) 15 | } 16 | 17 | this.handleEndSideContentChange = ({sideSize}) => { 18 | let {onChange = () => {}} = this.props 19 | 20 | onChange({ 21 | beginSideSize: this.props.beginSideSize, 22 | endSideSize: sideSize 23 | }) 24 | } 25 | } 26 | 27 | render() { 28 | let { 29 | id, 30 | class: classNames, 31 | style, 32 | vertical, 33 | beginSideContent, 34 | mainContent, 35 | endSideContent, 36 | beginSideSize, 37 | endSideSize, 38 | splitterSize, 39 | onFinish 40 | } = this.props 41 | 42 | return h(SplitContainer, { 43 | id, 44 | class: classNames, 45 | style, 46 | vertical, 47 | splitterSize, 48 | invert: true, 49 | sideSize: beginSideSize, 50 | 51 | mainContent: h(SplitContainer, { 52 | vertical, 53 | splitterSize, 54 | sideSize: endSideSize, 55 | 56 | mainContent, 57 | sideContent: endSideContent, 58 | 59 | onChange: this.handleEndSideContentChange, 60 | onFinish 61 | }), 62 | 63 | sideContent: beginSideContent, 64 | 65 | onChange: this.handleBeginSideContentChange, 66 | onFinish 67 | }) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/components/sidebars/PeerList.js: -------------------------------------------------------------------------------- 1 | import {h, Component} from 'preact' 2 | import classnames from 'classnames' 3 | 4 | import sabaki from '../../modules/sabaki.js' 5 | import i18n from '../../i18n.js' 6 | import TextSpinner from '../TextSpinner.js' 7 | import ToolBar, {ToolBarButton} from '../ToolBar.js' 8 | 9 | const t = i18n.context('PeerList') 10 | 11 | class EnginePeerListItem extends Component { 12 | constructor(props) { 13 | super(props) 14 | 15 | this.state = { 16 | busy: props.syncer.busy, 17 | suspended: props.syncer.suspended 18 | } 19 | 20 | this.syncState = () => { 21 | this.setState({ 22 | busy: this.props.syncer.busy, 23 | suspended: this.props.syncer.suspended 24 | }) 25 | } 26 | 27 | this.handleClick = evt => { 28 | let {syncer, onClick = () => {}} = this.props 29 | onClick({syncer}) 30 | } 31 | 32 | this.handleContextMenu = evt => { 33 | let {syncer, onContextMenu = () => {}} = this.props 34 | onContextMenu(evt, {syncer}) 35 | } 36 | } 37 | 38 | componentDidMount() { 39 | this.props.syncer 40 | .on('busy-changed', this.syncState) 41 | .on('suspended-changed', this.syncState) 42 | } 43 | 44 | componentWillUnmount() { 45 | this.props.syncer 46 | .removeListener('busy-changed', this.syncState) 47 | .removeListener('suspended-changed', this.syncState) 48 | } 49 | 50 | render({syncer, analyzing, selected, blackPlayer, whitePlayer}) { 51 | return h( 52 | 'li', 53 | { 54 | class: classnames('item', { 55 | analyzing, 56 | selected, 57 | busy: this.state.busy, 58 | suspended: this.state.suspended 59 | }), 60 | 61 | onClick: this.handleClick, 62 | onContextMenu: this.handleContextMenu 63 | }, 64 | 65 | !this.state.busy 66 | ? h( 67 | 'div', 68 | { 69 | class: 'icon', 70 | title: !this.state.suspended ? t('Running') : t('Stopped') 71 | }, 72 | h('img', { 73 | src: `./node_modules/@primer/octicons/build/svg/${ 74 | !this.state.suspended ? 'triangle-right' : 'primitive-square' 75 | }.svg`, 76 | alt: !this.state.suspended ? t('Running') : t('Stopped') 77 | }) 78 | ) 79 | : h(TextSpinner), 80 | 81 | h('span', {key: 'name', class: 'name'}, syncer.engine.name), 82 | 83 | analyzing && 84 | h( 85 | 'div', 86 | { 87 | key: 'analyzing', 88 | class: 'icon analyzing', 89 | title: t('Analyzer') 90 | }, 91 | h('img', { 92 | src: './node_modules/@primer/octicons/build/svg/pulse.svg', 93 | alt: t('Analyzer') 94 | }) 95 | ), 96 | 97 | blackPlayer && 98 | h( 99 | 'div', 100 | { 101 | key: 'player_1', 102 | class: 'icon player', 103 | title: t('Plays as Black') 104 | }, 105 | h('img', { 106 | height: 14, 107 | src: './img/ui/black.svg', 108 | alt: t('Plays as Black') 109 | }) 110 | ), 111 | 112 | whitePlayer && 113 | h( 114 | 'div', 115 | { 116 | key: 'player_-1', 117 | class: 'icon player', 118 | title: t('Plays as White') 119 | }, 120 | h('img', { 121 | height: 14, 122 | src: './img/ui/white.svg', 123 | alt: t('Plays as White') 124 | }) 125 | ) 126 | ) 127 | } 128 | } 129 | 130 | export class EnginePeerList extends Component { 131 | constructor(props) { 132 | super(props) 133 | 134 | this.handleEngineClick = evt => { 135 | let {onEngineSelect = () => {}} = this.props 136 | onEngineSelect(evt) 137 | } 138 | 139 | this.handleEngineContextMenu = (evt, {syncer}) => { 140 | let {onEngineSelect = () => {}} = this.props 141 | onEngineSelect({syncer}) 142 | 143 | sabaki.openEngineActionMenu(syncer.id, { 144 | x: evt.clientX, 145 | y: evt.clientY 146 | }) 147 | } 148 | 149 | this.handleAttachEngineButtonClick = evt => { 150 | let {left, bottom} = evt.currentTarget.getBoundingClientRect() 151 | 152 | sabaki.openEnginesMenu({x: left, y: bottom}) 153 | } 154 | 155 | this.handleStartStopGameButtonClick = evt => { 156 | sabaki.startStopEngineGame(sabaki.state.treePosition) 157 | } 158 | } 159 | 160 | render({ 161 | attachedEngineSyncers, 162 | selectedEngineSyncerId, 163 | blackEngineSyncerId, 164 | whiteEngineSyncerId, 165 | analyzingEngineSyncerId, 166 | engineGameOngoing 167 | }) { 168 | return h( 169 | 'div', 170 | { 171 | class: 'engine-peer-list' 172 | }, 173 | 174 | h( 175 | ToolBar, 176 | {}, 177 | 178 | h(ToolBarButton, { 179 | icon: './node_modules/@primer/octicons/build/svg/play.svg', 180 | tooltip: t('Attach Engine…'), 181 | menu: true, 182 | onClick: this.handleAttachEngineButtonClick 183 | }), 184 | 185 | h(ToolBarButton, { 186 | icon: './node_modules/@primer/octicons/build/svg/zap.svg', 187 | tooltip: !engineGameOngoing 188 | ? t('Start Engine vs. Engine Game') 189 | : t('Stop Engine vs. Engine Game'), 190 | checked: !!engineGameOngoing, 191 | onClick: this.handleStartStopGameButtonClick 192 | }) 193 | ), 194 | 195 | h( 196 | 'ul', 197 | {}, 198 | attachedEngineSyncers.map(syncer => 199 | h(EnginePeerListItem, { 200 | key: syncer.id, 201 | 202 | syncer, 203 | analyzing: syncer.id === analyzingEngineSyncerId, 204 | selected: syncer.id === selectedEngineSyncerId, 205 | blackPlayer: syncer.id === blackEngineSyncerId, 206 | whitePlayer: syncer.id === whiteEngineSyncerId, 207 | 208 | onClick: this.handleEngineClick, 209 | onContextMenu: this.handleEngineContextMenu 210 | }) 211 | ) 212 | ) 213 | ) 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /src/components/sidebars/Slider.js: -------------------------------------------------------------------------------- 1 | import * as remote from '@electron/remote' 2 | import {h, Component} from 'preact' 3 | import * as helper from '../../modules/helper.js' 4 | 5 | const setting = remote.require('./setting') 6 | 7 | class Slider extends Component { 8 | constructor() { 9 | super() 10 | 11 | this.handleSliderAreaMouseDown = evt => { 12 | if (evt.button !== 0) return 13 | 14 | this.sliderAreaMouseDown = true 15 | document.dispatchEvent(new MouseEvent('mousemove', evt)) 16 | } 17 | 18 | this.handleButtonMouseDown = evt => { 19 | if (evt.button !== 0) return 20 | 21 | let type = evt.currentTarget.className 22 | let {onStartAutoscrolling = helper.noop} = this.props 23 | 24 | this.buttonMouseDown = type 25 | onStartAutoscrolling({step: type === 'prev' ? -1 : 1}) 26 | } 27 | } 28 | 29 | componentDidMount() { 30 | document.addEventListener('mouseup', () => { 31 | this.sliderAreaMouseDown = false 32 | 33 | if (this.buttonMouseDown != null) { 34 | let type = this.buttonMouseDown 35 | let {onStopAutoscrolling = helper.noop} = this.props 36 | 37 | this.buttonMouseDown = null 38 | onStopAutoscrolling({step: type === 'prev' ? -1 : 1}) 39 | } 40 | }) 41 | 42 | document.addEventListener('mousemove', evt => { 43 | if (!this.sliderAreaMouseDown) return 44 | 45 | let {onChange = helper.noop} = this.props 46 | let {top, height} = this.slidingAreaElement.getBoundingClientRect() 47 | let percent = Math.min(1, Math.max(0, (evt.clientY - top) / height)) 48 | 49 | onChange({percent}) 50 | }) 51 | } 52 | 53 | shouldComponentUpdate({showSlider}) { 54 | return ( 55 | showSlider && 56 | (this.sliderAreaMouseDown || this.buttonMouseDown || !this.dirty) 57 | ) 58 | } 59 | 60 | componentWillReceiveProps() { 61 | // Debounce rendering 62 | 63 | this.dirty = true 64 | 65 | clearTimeout(this.renderId) 66 | this.renderId = setTimeout(() => { 67 | this.dirty = false 68 | this.setState(this.state) 69 | }, setting.get('graph.delay')) 70 | } 71 | 72 | render({text, percent}) { 73 | return h( 74 | 'section', 75 | {id: 'slider'}, 76 | h( 77 | 'a', 78 | { 79 | href: '#', 80 | class: 'prev', 81 | onMouseDown: this.handleButtonMouseDown 82 | }, 83 | '▲' 84 | ), 85 | 86 | h( 87 | 'a', 88 | { 89 | href: '#', 90 | class: 'next', 91 | onMouseDown: this.handleButtonMouseDown 92 | }, 93 | '▼' 94 | ), 95 | 96 | h( 97 | 'div', 98 | { 99 | ref: el => (this.slidingAreaElement = el), 100 | class: 'inner', 101 | onMouseDown: this.handleSliderAreaMouseDown 102 | }, 103 | 104 | h('span', {style: {top: percent + '%'}}, text) 105 | ) 106 | ) 107 | } 108 | } 109 | 110 | export default Slider 111 | -------------------------------------------------------------------------------- /src/i18n.js: -------------------------------------------------------------------------------- 1 | const nativeRequire = eval('require') 2 | 3 | const {ipcMain} = require('electron') 4 | var remote = null 5 | try { 6 | remote = require('@electron/remote') 7 | } catch (e) {} 8 | const {readFileSync} = require('fs') 9 | const path = require('path') 10 | const {load: dolmLoad, getKey: dolmGetKey} = require('dolm') 11 | const languages = require('@sabaki/i18n') 12 | 13 | const isElectron = process.versions.electron != null 14 | const isRenderer = isElectron && remote != null 15 | 16 | const mainI18n = isRenderer ? remote.require('./i18n') : null 17 | const setting = isRenderer 18 | ? remote.require('./setting') 19 | : isElectron 20 | ? nativeRequire('./setting') 21 | : null 22 | 23 | function getKey(input, params = {}) { 24 | let key = dolmGetKey(input, params) 25 | return key.replace(/&(?=\w)/g, '') 26 | } 27 | 28 | const dolm = dolmLoad({}, getKey) 29 | 30 | let appLang = setting == null ? undefined : setting.get('app.lang') 31 | 32 | exports.getKey = getKey 33 | exports.t = dolm.t 34 | exports.context = dolm.context 35 | 36 | exports.formatNumber = function(num) { 37 | return new Intl.NumberFormat(appLang).format(num) 38 | } 39 | 40 | exports.formatMonth = function(month) { 41 | let date = new Date() 42 | date.setMonth(month) 43 | return date.toLocaleString(appLang, {month: 'long'}) 44 | } 45 | 46 | exports.formatWeekday = function(weekday) { 47 | let date = new Date(2020, 2, 1 + (weekday % 7)) 48 | return date.toLocaleString(appLang, {weekday: 'long'}) 49 | } 50 | 51 | exports.formatWeekdayShort = function(weekday) { 52 | let date = new Date(2020, 2, 1 + (weekday % 7)) 53 | return date.toLocaleString(appLang, {weekday: 'short'}) 54 | } 55 | 56 | function loadStrings(strings) { 57 | dolm.load(strings) 58 | 59 | if (isElectron && !isRenderer) { 60 | ipcMain.emit('build-menu') 61 | } 62 | } 63 | 64 | exports.loadFile = function(filename) { 65 | if (isRenderer) { 66 | mainI18n.loadFile(filename) 67 | } 68 | 69 | try { 70 | loadStrings( 71 | Function(` 72 | "use strict" 73 | 74 | let exports = {} 75 | let module = {exports} 76 | 77 | ;(() => (${readFileSync(filename, 'utf8')}))() 78 | 79 | return module.exports 80 | `)() 81 | ) 82 | } catch (err) { 83 | loadStrings({}) 84 | } 85 | } 86 | 87 | exports.loadLang = function(lang) { 88 | appLang = lang 89 | 90 | exports.loadFile(languages[lang].filename) 91 | } 92 | 93 | exports.getLanguages = function() { 94 | return languages 95 | } 96 | 97 | if (appLang != null) { 98 | exports.loadLang(appLang) 99 | } 100 | -------------------------------------------------------------------------------- /src/modules/dialog.js: -------------------------------------------------------------------------------- 1 | import * as remote from '@electron/remote' 2 | import i18n from '../i18n.js' 3 | import sabaki from './sabaki.js' 4 | import {noop} from './helper.js' 5 | 6 | const t = i18n.context('dialog') 7 | const {app, dialog} = remote 8 | 9 | export function showMessageBox( 10 | message, 11 | type = 'info', 12 | buttons = [t('OK')], 13 | cancelId = 0 14 | ) { 15 | sabaki.setBusy(true) 16 | 17 | let result = dialog.showMessageBoxSync(remote.getCurrentWindow(), { 18 | type, 19 | buttons, 20 | title: app.name, 21 | message, 22 | cancelId, 23 | noLink: true 24 | }) 25 | 26 | sabaki.setBusy(false) 27 | return result 28 | } 29 | 30 | export function showFileDialog(type, options) { 31 | sabaki.setBusy(true) 32 | 33 | let [t, ...ype] = [...type] 34 | type = t.toUpperCase() + ype.join('').toLowerCase() 35 | 36 | let result = dialog[`show${type}DialogSync`]( 37 | remote.getCurrentWindow(), 38 | options 39 | ) 40 | 41 | sabaki.setBusy(false) 42 | return result 43 | } 44 | 45 | export function showOpenDialog(options) { 46 | return showFileDialog('open', options) 47 | } 48 | 49 | export function showSaveDialog(options) { 50 | return showFileDialog('save', options) 51 | } 52 | 53 | export async function showInputBox(message) { 54 | return new Promise(resolve => { 55 | sabaki.setState({ 56 | inputBoxText: message, 57 | showInputBox: true, 58 | onInputBoxSubmit: evt => resolve(evt.value), 59 | onInputBoxCancel: () => resolve(null) 60 | }) 61 | }) 62 | } 63 | 64 | export function closeInputBox() { 65 | let {onInputBoxCancel = noop} = sabaki.state 66 | sabaki.setState({showInputBox: false}) 67 | onInputBoxCancel() 68 | } 69 | -------------------------------------------------------------------------------- /src/modules/fileformats/gib.js: -------------------------------------------------------------------------------- 1 | import {readFileSync} from 'fs' 2 | import {decode} from 'iconv-lite' 3 | import {detect} from 'jschardet' 4 | import {fromDimensions} from '@sabaki/go-board' 5 | import {stringifyVertex} from '@sabaki/sgf' 6 | import i18n from '../../i18n.js' 7 | import * as gametree from '../gametree.js' 8 | 9 | const t = i18n.context('fileformats') 10 | 11 | export const meta = { 12 | name: t('Tygem GIB'), 13 | extensions: ['gib'] 14 | } 15 | 16 | function makeResult(grlt, zipsu) { 17 | // Arguments are expected to be numbers 18 | // Given a game result type and a score, return a text result. 19 | 20 | // The GRLT tag contains the type of result: 21 | // 0: B+n 1: W+n 3: B+R 4: W+R 7: B+T 8: W+T 22 | 23 | let easycases = {'3': 'B+R', '4': 'W+R', '7': 'B+T', '8': 'W+T'} 24 | 25 | if (easycases[grlt] !== undefined) { 26 | return easycases[grlt] 27 | } 28 | 29 | // If there is a score, the ZIPSU tag contains it (multiplied by 10). 30 | 31 | if (grlt === 0 || grlt === 1) { 32 | let winner = grlt === 0 ? 'B' : 'W' 33 | let margin = (zipsu / 10).toString() 34 | return winner + '+' + margin 35 | } 36 | 37 | // We couldn't work it out... 38 | 39 | return '' 40 | } 41 | 42 | function getResult(line, grltRegex, zipsuRegex) { 43 | // Takes a line and two regexes, the first finding the GRLT (game 44 | // result type, e.g. 3 == B+R) and the second finding the score. 45 | 46 | let result = '' 47 | let match = grltRegex.exec(line) 48 | 49 | if (match) { 50 | let grlt = parseFloat(match[1]) 51 | match = zipsuRegex.exec(line) 52 | if (match) { 53 | let zipsu = parseFloat(match[1]) 54 | result = makeResult(grlt, zipsu) 55 | } 56 | } 57 | 58 | return result 59 | } 60 | 61 | function parsePlayerName(raw) { 62 | let name = '' 63 | let rank = '' 64 | 65 | // If there's exactly one opening bracket... 66 | 67 | let foo = raw.split('(') 68 | if (foo.length === 2) { 69 | // And if the closing bracket is right at the end... 70 | 71 | if (foo[1].indexOf(')') === foo[1].length - 1) { 72 | // Then extract the rank... 73 | 74 | name = foo[0].trim() 75 | rank = foo[1].slice(0, foo[1].length - 1) 76 | } 77 | } 78 | 79 | if (name === '') { 80 | return [raw, ''] 81 | } else { 82 | return [name, rank] 83 | } 84 | } 85 | 86 | export function parse(content) { 87 | return [ 88 | gametree.new().mutate(draft => { 89 | let lines = content.split('\n') 90 | let rootId = draft.root.id 91 | let lastNodeId = rootId 92 | 93 | draft.updateProperty(rootId, 'CA', ['UTF-8']) 94 | draft.updateProperty(rootId, 'FF', ['4']) 95 | draft.updateProperty(rootId, 'GM', ['1']) 96 | draft.updateProperty(rootId, 'SZ', ['19']) 97 | 98 | for (let n = 0; n < lines.length; n++) { 99 | let line = lines[n].trim() 100 | 101 | if (line.startsWith('\\[GAMEBLACKNAME=') && line.endsWith('\\]')) { 102 | let s = line.slice(16, -2) 103 | let [name, rank] = parsePlayerName(s) 104 | 105 | if (name) draft.updateProperty(rootId, 'PB', [name]) 106 | if (rank) draft.updateProperty(rootId, 'BR', [rank]) 107 | } else if ( 108 | line.startsWith('\\[GAMEWHITENAME=') && 109 | line.endsWith('\\]') 110 | ) { 111 | let s = line.slice(16, -2) 112 | let [name, rank] = parsePlayerName(s) 113 | 114 | if (name) draft.updateProperty(rootId, 'PW', [name]) 115 | if (rank) draft.updateProperty(rootId, 'WR', [rank]) 116 | } else if (line.startsWith('\\[GAMEINFOMAIN=')) { 117 | if (draft.root.data.RE == null) { 118 | let result = getResult(line, /GRLT:(\d+),/, /ZIPSU:(\d+),/) 119 | if (result !== '') draft.updateProperty(rootId, 'RE', [result]) 120 | } 121 | 122 | if (draft.root.data.KM == null) { 123 | let regex = /GONGJE:(\d+),/ 124 | let match = regex.exec(line) 125 | 126 | if (match) { 127 | let komi = parseFloat(match[1]) / 10 128 | draft.updateProperty(rootId, 'KM', [komi.toString()]) 129 | } 130 | } 131 | } else if (line.startsWith('\\[GAMETAG=')) { 132 | if (draft.root.data.DT == null) { 133 | let regex = /C(\d\d\d\d):(\d\d):(\d\d)/ 134 | let match = regex.exec(line) 135 | 136 | if (match) 137 | draft.updateProperty(rootId, 'DT', [match.slice(1, 4).join('-')]) 138 | } 139 | 140 | if (draft.root.data.RE == null) { 141 | let result = getResult(line, /,W(\d+),/, /,Z(\d+),/) 142 | 143 | if (result !== '') draft.updateProperty(rootId, 'RE', [result]) 144 | } 145 | 146 | if (draft.root.data.KM == null) { 147 | let regex = /,G(\d+),/ 148 | let match = regex.exec(line) 149 | 150 | if (match) { 151 | let komi = parseFloat(match[1]) / 10 152 | draft.updateProperty(rootId, 'KM', [komi.toString()]) 153 | } 154 | } 155 | } else if (line.slice(0, 3) === 'INI') { 156 | let setup = line.split(' ') 157 | let handicap = 0 158 | 159 | let p = Math.floor(parseFloat(setup[3])) 160 | if (!isNaN(p)) handicap = p 161 | 162 | if (handicap >= 2 && handicap <= 9) { 163 | draft.updateProperty(rootId, 'HA', [handicap.toString()]) 164 | 165 | let points = fromDimensions(19, 19).getHandicapPlacement(handicap, { 166 | tygem: true 167 | }) 168 | 169 | for (let [x, y] of points) { 170 | let s = stringifyVertex([x, y]) 171 | draft.addToProperty(rootId, 'AB', s) 172 | } 173 | } 174 | } else if (line.slice(0, 3) === 'STO') { 175 | let elements = line.split(' ') 176 | if (elements.length < 6) continue 177 | 178 | let key = elements[3] === '1' ? 'B' : 'W' 179 | let x = Math.floor(parseFloat(elements[4])) 180 | let y = Math.floor(parseFloat(elements[5])) 181 | if (isNaN(x) || isNaN(y)) continue 182 | 183 | let val = stringifyVertex([x, y]) 184 | lastNodeId = draft.appendNode(lastNodeId, {[key]: [val]}) 185 | } 186 | } 187 | }) 188 | ] 189 | } 190 | 191 | export function parseFile(filename) { 192 | let buffer = readFileSync(filename) 193 | let encoding = 'utf8' 194 | let detected = detect(buffer) 195 | if (detected.confidence > 0.2) encoding = detected.encoding 196 | 197 | let content = decode(buffer, encoding) 198 | return parse(content) 199 | } 200 | -------------------------------------------------------------------------------- /src/modules/fileformats/index.js: -------------------------------------------------------------------------------- 1 | import {extname} from 'path' 2 | import i18n from '../../i18n.js' 3 | import * as sgf from './sgf.js' 4 | import * as ngf from './ngf.js' 5 | import * as gib from './gib.js' 6 | import * as ugf from './ugf.js' 7 | 8 | const t = i18n.context('fileformats') 9 | 10 | let modules = {sgf, ngf, gib, ugf} 11 | let extensions = Object.keys(modules).map(key => modules[key].meta) 12 | let combinedExtensions = [].concat(...extensions.map(x => x.extensions)) 13 | 14 | export {sgf, ngf, gib, ugf} 15 | 16 | export const meta = [ 17 | {name: t('Game Records'), extensions: combinedExtensions}, 18 | ...extensions 19 | ] 20 | 21 | export function getModuleByExtension(extension) { 22 | return ( 23 | modules[ 24 | Object.keys(modules).find(key => 25 | modules[key].meta.extensions.includes(extension.toLowerCase()) 26 | ) 27 | ] || sgf 28 | ) 29 | } 30 | 31 | export function parseFile(filename, onProgress) { 32 | let extension = extname(filename).slice(1) 33 | let m = getModuleByExtension(extension) 34 | 35 | return m.parseFile(filename, onProgress) 36 | } 37 | -------------------------------------------------------------------------------- /src/modules/fileformats/ngf.js: -------------------------------------------------------------------------------- 1 | import {readFileSync} from 'fs' 2 | import {decode} from 'iconv-lite' 3 | import {detect} from 'jschardet' 4 | import {fromDimensions} from '@sabaki/go-board' 5 | import {stringifyVertex} from '@sabaki/sgf' 6 | import i18n from '../../i18n.js' 7 | import * as gametree from '../gametree.js' 8 | 9 | const t = i18n.context('fileformats') 10 | 11 | export const meta = { 12 | name: t('wBaduk NGF'), 13 | extensions: ['ngf'] 14 | } 15 | 16 | export function parse(content) { 17 | return [ 18 | gametree.new().mutate(draft => { 19 | let lines = content.split('\n') 20 | let rootId = draft.root.id 21 | 22 | draft.updateProperty(rootId, 'CA', ['UTF-8']) 23 | draft.updateProperty(rootId, 'FF', ['4']) 24 | draft.updateProperty(rootId, 'GM', ['1']) 25 | draft.updateProperty(rootId, 'SZ', ['19']) 26 | 27 | // These array accesses might throw if out of range, that's fine. 28 | // The caller will deal with the exception. 29 | 30 | let boardsize = Math.floor(parseFloat(lines[1])) 31 | let handicap = Math.floor(parseFloat(lines[5])) 32 | let pw = lines[2].split(' ')[0] 33 | let pb = lines[3].split(' ')[0] 34 | let rawdate = lines[8].slice(0, 8) 35 | let komi = Math.floor(parseFloat(lines[7])) 36 | 37 | if (isNaN(boardsize)) boardsize = 19 38 | if (isNaN(handicap)) handicap = 0 39 | if (isNaN(komi)) komi = 0 40 | 41 | let line2 = lines[2].trim().split(' ') 42 | if (line2.length > 1) { 43 | let whiterank = line2[line2.length - 1] 44 | whiterank = whiterank 45 | .replace('DP', 'p') 46 | .replace('K', 'k') 47 | .replace('D', 'd') 48 | 49 | draft.updateProperty(rootId, 'WR', [whiterank]) 50 | } 51 | 52 | let line3 = lines[3].trim().split(' ') 53 | if (line3.length > 1) { 54 | let blackrank = line3[line3.length - 1] 55 | blackrank = blackrank 56 | .replace('DP', 'p') 57 | .replace('K', 'k') 58 | .replace('D', 'd') 59 | 60 | draft.updateProperty(rootId, 'BR', [blackrank]) 61 | } 62 | 63 | if (handicap === 0 && komi === Math.floor(komi)) komi += 0.5 64 | 65 | let winner = '' 66 | let margin = '' 67 | 68 | if (lines[10].includes('resign')) margin = 'R' 69 | if (lines[10].includes('time')) margin = 'T' 70 | if (lines[10].includes('hite win') || lines[10].includes('lack lose')) 71 | winner = 'W' 72 | if (lines[10].includes('lack win') || lines[10].includes('hite lose')) 73 | winner = 'B' 74 | 75 | if (margin === '') { 76 | let score = null 77 | let strings = lines[10].split(' ') 78 | 79 | // Try to find score by assuming any float found is the score. 80 | 81 | for (let s of strings) { 82 | let p = parseFloat(s) 83 | if (isNaN(p) === false) score = p 84 | } 85 | 86 | if (score !== null) { 87 | margin = score.toString() 88 | } 89 | } 90 | 91 | if (winner !== '') { 92 | draft.updateProperty(rootId, 'RE', [`${winner}+${margin}`]) 93 | } 94 | 95 | draft.updateProperty(rootId, 'SZ', [boardsize.toString()]) 96 | 97 | if (handicap >= 2) { 98 | draft.updateProperty(rootId, 'HA', [handicap.toString()]) 99 | 100 | let points = fromDimensions( 101 | boardsize, 102 | boardsize 103 | ).getHandicapPlacement(handicap, {tygem: true}) 104 | 105 | for (let [x, y] of points) { 106 | let s = stringifyVertex([x, y]) 107 | draft.addToProperty(rootId, 'AB', s) 108 | } 109 | } 110 | 111 | if (komi) { 112 | draft.updateProperty(rootId, 'KM', [komi.toString()]) 113 | } 114 | 115 | if (rawdate.length === 8) { 116 | let ok = true 117 | 118 | for (let n = 0; n < 8; n++) { 119 | let tmp = parseFloat(rawdate.charAt(n)) 120 | 121 | if (isNaN(tmp)) { 122 | ok = false 123 | break 124 | } 125 | } 126 | 127 | if (ok) { 128 | let date = '' 129 | date += rawdate.slice(0, 4) 130 | date += '-' + rawdate.slice(4, 6) 131 | date += '-' + rawdate.slice(6, 8) 132 | 133 | draft.updateProperty(rootId, 'DT', [date]) 134 | } 135 | } 136 | 137 | draft.updateProperty(rootId, 'PW', [pw]) 138 | draft.updateProperty(rootId, 'PB', [pb]) 139 | 140 | // We currently search for moves in all lines. Current files start moves at line 12. 141 | // But some older files have less headers and start moves earlier. 142 | 143 | let lastNodeId = rootId 144 | 145 | for (let n = 0; n < lines.length; n++) { 146 | let line = lines[n].trim() 147 | 148 | if (line.length >= 7) { 149 | if (line.slice(0, 2) === 'PM') { 150 | let key = line.charAt(4) 151 | 152 | if (key === 'B' || key === 'W') { 153 | // Coordinates are letters but with 'B' as the lowest. 154 | 155 | let x = line.charCodeAt(5) - 66 156 | let y = line.charCodeAt(6) - 66 157 | let val = stringifyVertex([x, y]) 158 | 159 | lastNodeId = draft.appendNode(lastNodeId, {[key]: [val]}) 160 | } 161 | } 162 | } 163 | } 164 | }) 165 | ] 166 | } 167 | 168 | export function parseFile(filename) { 169 | // NGF files have a huge amount of ASCII-looking text. To help 170 | // the detector, we just send it the first few lines. 171 | 172 | let buffer = readFileSync(filename) 173 | let encoding = 'utf8' 174 | let detected = detect(buffer.slice(0, 200)) 175 | if (detected.confidence > 0.2) encoding = detected.encoding 176 | 177 | let content = decode(buffer, encoding) 178 | return parse(content) 179 | } 180 | -------------------------------------------------------------------------------- /src/modules/fileformats/sgf.js: -------------------------------------------------------------------------------- 1 | import * as sgf from '@sabaki/sgf' 2 | import i18n from '../../i18n.js' 3 | import {getId} from '../helper.js' 4 | import * as gametree from '../gametree.js' 5 | 6 | const t = i18n.context('fileformats') 7 | 8 | export const meta = { 9 | name: t('Smart Game Format'), 10 | extensions: ['sgf', 'rsgf'] 11 | } 12 | 13 | let toGameTrees = rootNodes => 14 | rootNodes.map(root => gametree.new({getId, root})) 15 | 16 | export function parse(content, onProgress = () => {}) { 17 | let rootNodes = sgf.parse(content, {getId, onProgress}) 18 | return toGameTrees(rootNodes) 19 | } 20 | 21 | export function parseFile(filename, onProgress = () => {}) { 22 | let rootNodes = sgf.parseFile(filename, {getId, onProgress}) 23 | return toGameTrees(rootNodes) 24 | } 25 | -------------------------------------------------------------------------------- /src/modules/fileformats/ugf.js: -------------------------------------------------------------------------------- 1 | import {readFileSync} from 'fs' 2 | import {decode} from 'iconv-lite' 3 | import {detect} from 'jschardet' 4 | import i18n from '../../i18n.js' 5 | import * as gametree from '../gametree.js' 6 | 7 | const t = i18n.context('fileformats') 8 | 9 | export const meta = { 10 | name: t('PandaNET UGF'), 11 | extensions: ['ugf'] 12 | } 13 | 14 | function convertVertex(ugfVertex, boardSize) { 15 | return ( 16 | ugfVertex[0] + 17 | String.fromCharCode(boardSize - ugfVertex.charCodeAt(1) + 129) 18 | ).toLowerCase() 19 | } 20 | 21 | export function parse(content) { 22 | return [ 23 | gametree.new().mutate(draft => { 24 | let lines = content.split('\n') 25 | let rootId = draft.root.id 26 | let lastNodeId = rootId 27 | let nodeMap = {'0': rootId} 28 | 29 | draft.updateProperty(rootId, 'CA', ['UTF-8']) 30 | draft.updateProperty(rootId, 'FF', ['4']) 31 | draft.updateProperty(rootId, 'GM', ['1']) 32 | draft.updateProperty(rootId, 'SZ', ['19']) 33 | 34 | let currentMode = null 35 | for (let n = 0; n < lines.length; n++) { 36 | let line = lines[n].trim() 37 | 38 | if (line == '') { 39 | continue 40 | } else if (line.startsWith('[') && line.endsWith(']')) { 41 | currentMode = line.slice(1, -1) 42 | continue 43 | } 44 | 45 | switch (currentMode) { 46 | case 'Header': 47 | let [key, value] = line.split('=') 48 | switch (key) { 49 | case 'PlayerB': 50 | draft.updateProperty(rootId, 'PB', [value.split(',')[0]]) 51 | draft.updateProperty(rootId, 'BR', [value.split(',')[1]]) 52 | break 53 | case 'PlayerW': 54 | draft.updateProperty(rootId, 'PW', [value.split(',')[0]]) 55 | draft.updateProperty(rootId, 'WR', [value.split(',')[1]]) 56 | break 57 | case 'Size': 58 | draft.updateProperty(rootId, 'SZ', [value]) 59 | break 60 | case 'Hdcp': 61 | if (value.split(',')[0] != '0') { 62 | draft.updateProperty(rootId, 'HA', [value.split(',')[0]]) 63 | } 64 | draft.updateProperty(rootId, 'KM', [value.split(',')[1]]) 65 | break 66 | case 'Rules': 67 | draft.updateProperty(rootId, 'RU', [value]) 68 | break 69 | case 'Date': 70 | draft.updateProperty(rootId, 'DT', [ 71 | value.split(',')[0].replace(/\//g, '-') 72 | ]) 73 | break 74 | case 'Copyright': 75 | draft.updateProperty(rootId, 'CP', [value]) 76 | break 77 | case 'Winner': 78 | draft.updateProperty(rootId, 'RE', [ 79 | value.split(',')[0] + '+' + value.split(',')[1] 80 | ]) 81 | break 82 | } 83 | break 84 | case 'Data': 85 | let [coords, color, nodeNum, _] = line.split(',') 86 | if (nodeNum > 0) { 87 | lastNodeId = draft.appendNode(lastNodeId, { 88 | [color[0]]: [ 89 | convertVertex(coords, parseInt(draft.root.data.SZ)) 90 | ] 91 | }) 92 | nodeMap[nodeNum] = lastNodeId 93 | } else { 94 | // UGF assigns all handicap placements to node number 0 95 | draft.addToProperty( 96 | rootId, 97 | 'AB', 98 | convertVertex(coords, parseInt(draft.root.data.SZ)) 99 | ) 100 | } 101 | break 102 | case 'ReviewNode': 103 | break 104 | case 'ReviewComment': 105 | if (line.startsWith('.Comment')) { 106 | let commentNodeId = (parseInt(line.split(',')[1]) - 1).toString() 107 | while ( 108 | n + 1 < lines.length && 109 | !lines[n + 1].startsWith('.Comment') 110 | ) { 111 | n += 1 112 | if (commentNodeId in nodeMap) { 113 | draft.updateProperty(nodeMap[commentNodeId], 'C', [ 114 | (draft.get(nodeMap[commentNodeId]).data.C || '') + 115 | lines[n].trim() + 116 | '\n' 117 | ]) 118 | } 119 | } 120 | } 121 | break 122 | default: 123 | break 124 | } 125 | } 126 | }) 127 | ] 128 | } 129 | 130 | export function parseFile(filename) { 131 | let buffer = readFileSync(filename) 132 | let encoding = 'utf8' 133 | let detected = detect(buffer) 134 | if (detected.confidence > 0.2) encoding = detected.encoding 135 | 136 | let content = decode(buffer, encoding) 137 | return parse(content) 138 | } 139 | -------------------------------------------------------------------------------- /src/modules/gamesort.js: -------------------------------------------------------------------------------- 1 | import natsort from 'natsort' 2 | import {parseDates, stringifyDates} from '@sabaki/sgf' 3 | import {lexicalCompare} from './helper.js' 4 | 5 | function extractProperty(tree, property) { 6 | return property in tree.root.data ? tree.root.data[property][0] : '' 7 | } 8 | 9 | // player : 'BR' | 'WR' 10 | function sortRank(trees, player) { 11 | return [...trees].sort((tr1, tr2) => { 12 | let [weighted1, weighted2] = [tr1, tr2].map(tree => 13 | weightRank(extractProperty(tree, player)) 14 | ) 15 | return compareResult(weighted1, weighted2) 16 | }) 17 | } 18 | 19 | // rank : string like '30k', '1d', '1p' 20 | function weightRank(rank) { 21 | let rankNumber = parseFloat(rank) 22 | 23 | if (isNaN(rankNumber)) { 24 | return -Infinity 25 | } else { 26 | let weight = rank.includes('k') ? -1 : rank.includes('p') ? 10 : 1 27 | return weight * rankNumber 28 | } 29 | } 30 | 31 | // name : 'PB' | 'PW' | 'GN' | 'EV' 32 | function sortName(trees, name) { 33 | return [...trees].sort((tr1, tr2) => { 34 | let [name1, name2] = [tr1, tr2].map(tree => extractProperty(tree, name)) 35 | return natsort({insensitive: true})(name1, name2) 36 | }) 37 | } 38 | 39 | function compareResult(item1, item2) { 40 | return item1 < item2 ? -1 : +(item1 !== item2) 41 | } 42 | 43 | export function reverse(trees) { 44 | return trees.slice().reverse() 45 | } 46 | 47 | export function byBlackRank(trees) { 48 | return sortRank(trees, 'BR') 49 | } 50 | 51 | export function byWhiteRank(trees) { 52 | return sortRank(trees, 'WR') 53 | } 54 | 55 | export function byPlayerBlack(trees) { 56 | return sortName(trees, 'PB') 57 | } 58 | 59 | export function byPlayerWhite(trees) { 60 | return sortName(trees, 'PW') 61 | } 62 | 63 | export function byGameName(trees) { 64 | return sortName(trees, 'GN') 65 | } 66 | 67 | export function byEvent(trees) { 68 | return sortName(trees, 'EV') 69 | } 70 | 71 | export function byDate(trees) { 72 | return [...trees].sort((tr1, tr2) => { 73 | let [date1, date2] = [tr1, tr2] 74 | .map(tree => extractProperty(tree, 'DT')) 75 | .map(x => parseDates(x)) 76 | .map(x => (x ? stringifyDates(x.sort(lexicalCompare)) : '')) 77 | return compareResult(date1, date2) 78 | }) 79 | } 80 | 81 | export function byNumberOfMoves(trees) { 82 | return [...trees].sort((tr1, tr2) => { 83 | return compareResult(tr1.getHeight(), tr2.getHeight()) 84 | }) 85 | } 86 | -------------------------------------------------------------------------------- /src/modules/gobantransformer.js: -------------------------------------------------------------------------------- 1 | export function normalize(transformation) { 2 | // Change transformation so that we rotate first, flip next, then invert 3 | // i.e. replace 'fr' by 'rrrf', 'if' by 'fi', 'ir' by 'ri' 4 | // 5 | // 'i' denotes a color inversion 6 | // 'r' denotes a clockwise rotation 7 | // 'f' denotes a horizontal flip 8 | 9 | let inversions = [...transformation].filter(c => c === 'i').length 10 | let rf = [...transformation].filter(c => 'rf'.includes(c)) 11 | 12 | while (true) { 13 | let firstFlipIndex = rf.findIndex( 14 | (c, i, arr) => c === 'f' && arr[i + 1] === 'r' 15 | ) 16 | if (firstFlipIndex < 0) break 17 | 18 | rf.splice(firstFlipIndex, 2, ...'rrrf') 19 | } 20 | 21 | // Eliminate unnecessary rotations/flips 22 | // i.e. remove full rotations, double flips 23 | 24 | return ( 25 | rf.join('').replace(/(rrrr|ff)/g, '') + (inversions % 2 === 1 ? 'i' : '') 26 | ) 27 | } 28 | 29 | export function invert(transformation) { 30 | transformation = normalize(transformation) 31 | 32 | let result = '' 33 | let flipped = transformation.includes('f') 34 | let inverted = transformation.includes('i') 35 | 36 | if (flipped) result += 'f' 37 | if (inverted) result += 'i' 38 | 39 | let rotations = transformation.length - +flipped - +inverted 40 | result += Array(4 - (rotations % 4)) 41 | .fill('r') 42 | .join('') 43 | 44 | return normalize(result) 45 | } 46 | 47 | export function transformationSwapsSides(transformation) { 48 | let rotations = [...transformation].filter(c => c === 'r').length 49 | return rotations % 2 === 1 50 | } 51 | 52 | export function transformSize(width, height, transformation) { 53 | if (transformationSwapsSides(transformation)) 54 | [width, height] = [height, width] 55 | 56 | return {width, height} 57 | } 58 | 59 | export function transformCoords(coordX, coordY, transformation, width, height) { 60 | let inverse = invert(transformation) 61 | let sidesSwapped = transformationSwapsSides(transformation) 62 | if (sidesSwapped) [width, height] = [height, width] 63 | 64 | let inner = v => { 65 | let [x, y] = transformVertex(v, inverse, width, height) 66 | return [coordX(x), coordY(y)] 67 | } 68 | 69 | return { 70 | coordX: x => inner([x, 0])[!sidesSwapped ? 0 : 1], 71 | coordY: y => inner([0, y])[!sidesSwapped ? 1 : 0] 72 | } 73 | } 74 | 75 | export function transformVertex([x, y], transformation, width, height) { 76 | if (x < 0 || y < 0 || x >= width || y >= height) return [-1, -1] 77 | 78 | let {width: newWidth, height: newHeight} = transformSize( 79 | width, 80 | height, 81 | transformation 82 | ) 83 | let [nx, ny] = [...transformation].reduce( 84 | ([x, y], c) => (c === 'f' ? [-x - 1, y] : c === 'r' ? [-y - 1, x] : [x, y]), 85 | [x, y] 86 | ) 87 | 88 | return [(nx + newWidth) % newWidth, (ny + newHeight) % newHeight] 89 | } 90 | 91 | export function transformLine(line, transformation, width, height) { 92 | let transform = v => transformVertex(v, transformation, width, height) 93 | 94 | return Object.assign({}, line, { 95 | v1: transform(line.v1), 96 | v2: transform(line.v2) 97 | }) 98 | } 99 | 100 | export function transformMap(map, transformation, {ignoreInvert = false} = {}) { 101 | let inverse = invert(transformation) 102 | let inverted = inverse.includes('i') 103 | let {width, height} = transformSize( 104 | map.length === 0 ? 0 : map[0].length, 105 | map.length, 106 | inverse 107 | ) 108 | 109 | return [...Array(height)].map((_, y) => 110 | [...Array(width)].map((__, x) => { 111 | let [ix, iy] = transformVertex([x, y], inverse, width, height) 112 | let entry = map[iy][ix] 113 | 114 | if (!ignoreInvert && inverted && entry != null) { 115 | if (typeof entry === 'number') entry = -entry 116 | else if (entry.sign != null) entry.sign = -entry.sign 117 | } 118 | 119 | return entry 120 | }) 121 | ) 122 | } 123 | -------------------------------------------------------------------------------- /src/modules/gtplogger.js: -------------------------------------------------------------------------------- 1 | import * as remote from '@electron/remote' 2 | import winston from 'winston' 3 | import {resolve, join} from 'path' 4 | 5 | import i18n from '../i18n.js' 6 | import {showMessageBox} from './dialog.js' 7 | import * as helper from './helper.js' 8 | 9 | const t = i18n.context('gtplogger') 10 | const setting = remote.require('./setting') 11 | 12 | let filename = null 13 | 14 | let winstonLogger = winston.createLogger({ 15 | format: winston.format.combine( 16 | winston.format.timestamp({format: 'YYYY-MM-DD HH:mm:ss.SSS'}), 17 | winston.format.printf(info => `\[${info.timestamp}\] ${info.message}`) 18 | ), 19 | handleExceptions: false, 20 | exitOnError: false 21 | }) 22 | 23 | export function write(stream) { 24 | let enabled = setting.get('gtp.console_log_enabled') 25 | if (!enabled) return 26 | 27 | let typeText = 28 | { 29 | stderr: ' (err)', 30 | stdin: ' (in)', 31 | stdout: ' (out)', 32 | meta: ' (meta)' 33 | }[stream.type] || '' 34 | 35 | try { 36 | winstonLogger.log( 37 | 'info', 38 | `<${stream.engine}> ${typeText} : ${stream.message}` 39 | ) 40 | } catch (err) {} 41 | } 42 | 43 | let timestamp = function() { 44 | let now = new Date() 45 | let t = { 46 | month: 1 + now.getMonth(), 47 | day: now.getDate(), 48 | hour: now.getHours(), 49 | minute: now.getMinutes(), 50 | second: now.getSeconds(), 51 | year: now.getFullYear() 52 | } 53 | 54 | for (let key in t) { 55 | if (t[key] < 10) t[key] = `0${t[key]}` 56 | } 57 | 58 | return `${t.year}-${t.month}-${t.day}-${t.hour}-${t.minute}-${t.second}` 59 | } 60 | 61 | let validate = function() { 62 | if (!helper.isWritableDirectory(setting.get('gtp.console_log_path'))) { 63 | showMessageBox( 64 | t( 65 | [ 66 | 'You have an invalid log folder for GTP console logging in your settings.', 67 | 'Please make sure the log directory is valid and writable, or disable GTP console logging.' 68 | ].join('\n\n') 69 | ), 70 | 'warning' 71 | ) 72 | 73 | return false 74 | } 75 | 76 | return true 77 | } 78 | 79 | export function updatePath() { 80 | // Return false when we did not update the log path but wanted to 81 | 82 | let enabled = setting.get('gtp.console_log_enabled') 83 | if (!enabled) return true 84 | if (!validate()) return false 85 | 86 | // Remove trailing separators and normalize 87 | 88 | let logPath = setting.get('gtp.console_log_path') 89 | if (logPath == null) return false 90 | 91 | let newDir = resolve(logPath) 92 | 93 | if (filename == null) { 94 | // Generate a new log file name 95 | 96 | let pid = remote.getCurrentWebContents().getOSProcessId() 97 | filename = `sabaki_${timestamp()}_${pid}.log` 98 | } 99 | 100 | try { 101 | let newPath = join(newDir, filename) 102 | let matching = winstonLogger.transports.find( 103 | transport => 104 | transport.filename === filename && resolve(transport.dirname) === newDir 105 | ) 106 | 107 | if (matching != null) { 108 | // Log file path has not changed 109 | return true 110 | } 111 | 112 | let notMatching = winstonLogger.transports.find( 113 | transport => 114 | transport.filename !== filename || resolve(transport.dirname) !== newDir 115 | ) 116 | 117 | winstonLogger.add(new winston.transports.File({filename: newPath})) 118 | 119 | if (notMatching != null) { 120 | winstonLogger.remove(notMatching) 121 | } 122 | 123 | return true 124 | } catch (err) { 125 | return false 126 | } 127 | } 128 | 129 | export function rotate() { 130 | // On next engine attach, we will use a new log file 131 | filename = null 132 | } 133 | 134 | export function close() { 135 | try { 136 | winstonLogger.close() 137 | } catch (err) {} 138 | } 139 | -------------------------------------------------------------------------------- /src/modules/helper.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import {join} from 'path' 3 | 4 | let id = 0 5 | 6 | export const linebreak = process.platform === 'win32' ? '\r\n' : '\n' 7 | 8 | export function noop() {} 9 | 10 | export function getId() { 11 | return ++id 12 | } 13 | 14 | export function hash(str) { 15 | let chr 16 | let hash = 0 17 | if (str.length == 0) return hash 18 | 19 | for (let i = 0; i < str.length; i++) { 20 | chr = str.charCodeAt(i) 21 | hash = (hash << 5) - hash + chr 22 | hash = hash & hash 23 | } 24 | 25 | return hash 26 | } 27 | 28 | export function equals(a, b) { 29 | if (a === b) return true 30 | if (a == null || b == null) return a == b 31 | 32 | let t = Object.prototype.toString.call(a) 33 | if (t !== Object.prototype.toString.call(b)) return false 34 | 35 | let aa = t === '[object Array]' 36 | let ao = t === '[object Object]' 37 | 38 | if (aa) { 39 | if (a.length !== b.length) return false 40 | for (let i = 0; i < a.length; i++) if (!equals(a[i], b[i])) return false 41 | return true 42 | } else if (ao) { 43 | let kk = Object.keys(a) 44 | if (kk.length !== Object.keys(b).length) return false 45 | for (let i = 0; i < kk.length; i++) { 46 | let k = kk[i] 47 | if (!(k in b)) return false 48 | if (!equals(a[k], b[k])) return false 49 | } 50 | return true 51 | } 52 | 53 | return false 54 | } 55 | 56 | export function shallowEquals(a, b) { 57 | return a == null || b == null 58 | ? a === b 59 | : a === b || (a.length === b.length && a.every((x, i) => x == b[i])) 60 | } 61 | 62 | export function vertexEquals([a, b], [c, d]) { 63 | return a === c && b === d 64 | } 65 | 66 | export function lexicalCompare(a, b) { 67 | if (!a.length || !b.length) return a.length - b.length 68 | return a[0] < b[0] 69 | ? -1 70 | : a[0] > b[0] 71 | ? 1 72 | : lexicalCompare(a.slice(1), b.slice(1)) 73 | } 74 | 75 | export function typographer(input) { 76 | return input 77 | .replace(/\.{3}/g, '…') 78 | .replace(/(\S)'/g, '$1’') 79 | .replace(/(\S)"/g, '$1”') 80 | .replace(/'(\S)/g, '‘$1') 81 | .replace(/"(\S)/g, '“$1') 82 | .replace(/(\s)-(\s)/g, '$1–$2') 83 | } 84 | 85 | export function normalizeEndings(input) { 86 | return input.replace(/\r/g, '') 87 | } 88 | 89 | export function isTextLikeElement(element) { 90 | return ( 91 | ['textarea', 'select'].includes(element.tagName.toLowerCase()) || 92 | (element.tagName.toLowerCase() === 'input' && 93 | ![ 94 | 'submit', 95 | 'reset', 96 | 'button', 97 | 'checkbox', 98 | 'radio', 99 | 'color', 100 | 'file' 101 | ].includes(element.type)) 102 | ) 103 | } 104 | 105 | export function popupMenu(template, x, y) { 106 | const remote = require('@electron/remote') 107 | 108 | let setting = remote.require('./setting') 109 | let zoomFactor = +setting.get('app.zoom_factor') 110 | 111 | remote.Menu.buildFromTemplate(template).popup({ 112 | x: Math.round(x * zoomFactor), 113 | y: Math.round(y * zoomFactor) 114 | }) 115 | } 116 | 117 | export function wait(ms) { 118 | return new Promise(resolve => setTimeout(resolve, ms)) 119 | } 120 | 121 | export function isWritableDirectory(path) { 122 | if (path == null) return false 123 | 124 | let fileStats = null 125 | 126 | try { 127 | fileStats = fs.statSync(path) 128 | } catch (err) {} 129 | 130 | if (fileStats != null) { 131 | if (fileStats.isDirectory()) { 132 | try { 133 | fs.accessSync(path, fs.W_OK) 134 | return true 135 | } catch (err) {} 136 | } 137 | 138 | // Path exists, either no write permissions to directory or path is not a directory 139 | return false 140 | } else { 141 | // Path doesn't exist 142 | return false 143 | } 144 | } 145 | 146 | export function copyFolderSync(from, to) { 147 | fs.mkdirSync(to) 148 | fs.readdirSync(from).forEach(element => { 149 | if (fs.lstatSync(join(from, element)).isFile()) { 150 | fs.copyFileSync(join(from, element), join(to, element)) 151 | } else { 152 | copyFolderSync(join(from, element), join(to, element)) 153 | } 154 | }) 155 | } 156 | 157 | export function getScore(board, areaMap, {komi = 0, handicap = 0} = {}) { 158 | let score = { 159 | area: [0, 0], 160 | territory: [0, 0], 161 | captures: [1, -1].map(sign => board.getCaptures(sign)) 162 | } 163 | 164 | for (let x = 0; x < board.width; x++) { 165 | for (let y = 0; y < board.height; y++) { 166 | let z = areaMap[y][x] 167 | let index = z > 0 ? 0 : 1 168 | 169 | score.area[index] += Math.abs(Math.sign(z)) 170 | if (board.get([x, y]) === 0) 171 | score.territory[index] += Math.abs(Math.sign(z)) 172 | } 173 | } 174 | 175 | score.area = score.area.map(Math.round) 176 | score.territory = score.territory.map(Math.round) 177 | 178 | score.areaScore = score.area[0] - score.area[1] - komi - handicap 179 | score.territoryScore = 180 | score.territory[0] - 181 | score.territory[1] + 182 | score.captures[0] - 183 | score.captures[1] - 184 | komi 185 | 186 | return score 187 | } 188 | -------------------------------------------------------------------------------- /src/modules/shims/prop-types.js: -------------------------------------------------------------------------------- 1 | import {noop} from '../helper.js' 2 | 3 | export const arrayOf = noop 4 | export const oneOf = noop 5 | export const oneOfType = noop 6 | -------------------------------------------------------------------------------- /src/modules/sound.js: -------------------------------------------------------------------------------- 1 | import * as remote from '@electron/remote' 2 | import {wait} from './helper.js' 3 | 4 | const setting = remote.require('./setting') 5 | 6 | function prepareFunction(sounds) { 7 | let lastIndex = -1 8 | 9 | return async (delay = 0) => { 10 | try { 11 | let index = 0 12 | 13 | if (sounds.length === 0) return 14 | if (sounds.length > 1) { 15 | index = lastIndex 16 | while (index === lastIndex) 17 | index = Math.floor(Math.random() * sounds.length) 18 | lastIndex = index 19 | } 20 | 21 | await wait(delay) 22 | await sounds[index].play() 23 | } catch (err) { 24 | // Do nothing 25 | } 26 | } 27 | } 28 | 29 | export const playPachi = prepareFunction( 30 | [...Array(5)].map((_, i) => new Audio(`./data/${i}.mp3`)) 31 | ) 32 | 33 | export const playCapture = (() => { 34 | let f = prepareFunction( 35 | [...Array(5)].map((_, i) => new Audio(`./data/capture${i}.mp3`)) 36 | ) 37 | 38 | return async delay => { 39 | if (delay == null) { 40 | delay = setting.get('sound.capture_delay_min') 41 | delay += Math.floor( 42 | Math.random() * (setting.get('sound.capture_delay_max') - delay) 43 | ) 44 | } 45 | 46 | await f(delay) 47 | } 48 | })() 49 | 50 | export const playPass = prepareFunction([new Audio('./data/pass.mp3')]) 51 | 52 | export const playNewGame = prepareFunction([new Audio('./data/newgame.mp3')]) 53 | -------------------------------------------------------------------------------- /src/updater.js: -------------------------------------------------------------------------------- 1 | const os = require('os') 2 | const {app, net} = require('electron') 3 | 4 | function lexicalCompare(a, b) { 5 | if (!a.length || !b.length) return a.length - b.length 6 | return a[0] < b[0] 7 | ? -1 8 | : a[0] > b[0] 9 | ? 1 10 | : lexicalCompare(a.slice(1), b.slice(1)) 11 | } 12 | 13 | exports.check = async function(repo) { 14 | let address = `https://api.github.com/repos/${repo}/releases/latest` 15 | 16 | let response = await new Promise((resolve, reject) => { 17 | let request = net.request(address) 18 | 19 | request 20 | .on('response', response => { 21 | let content = '' 22 | 23 | response 24 | .on('data', chunk => { 25 | content += chunk 26 | }) 27 | .on('end', () => { 28 | resolve(content) 29 | }) 30 | }) 31 | .on('error', reject) 32 | 33 | request.end() 34 | }) 35 | 36 | let data = JSON.parse(response) 37 | if (!('tag_name' in data) || !('assets' in data)) 38 | throw new Error('No version information found.') 39 | 40 | let latestVersion = data.tag_name 41 | .slice(1) 42 | .split('.') 43 | .map(x => +x) 44 | let currentVersion = app 45 | .getVersion() 46 | .split('.') 47 | .map(x => +x) 48 | let downloadUrls = data.assets.map(x => x.browser_download_url) 49 | 50 | let arch = os.arch() 51 | let needles = { 52 | linux: ['linux'], 53 | win32: ['win', 'setup'], 54 | darwin: ['mac'] 55 | }[os.platform()] 56 | 57 | return { 58 | url: `https://github.com/${repo}/releases/latest`, 59 | downloadUrl: 60 | arch && 61 | needles && 62 | downloadUrls.find( 63 | url => 64 | url.includes(arch) && needles.every(needle => url.includes(needle)) 65 | ), 66 | latestVersion: latestVersion.join('.'), 67 | hasUpdates: lexicalCompare(latestVersion, currentVersion) > 0 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /style/app.css: -------------------------------------------------------------------------------- 1 | @import url('../node_modules/pikaday/css/pikaday.css'); 2 | 3 | /** 4 | * Provides general styles for native app like behavior. 5 | */ 6 | 7 | * { 8 | margin: 0; 9 | padding: 0; 10 | } 11 | 12 | html, body { 13 | height: 100%; 14 | overflow: hidden; 15 | background: #111; 16 | } 17 | 18 | body, input, button, select, option, textarea { 19 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Ubuntu, Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; 20 | font-size: 14px; 21 | line-height: 1.5; 22 | } 23 | 24 | body, a { 25 | cursor: default; 26 | overflow: hidden; 27 | user-select: none; 28 | -ms-user-select: none; 29 | -moz-user-select: none; 30 | -webkit-user-select: none; 31 | } 32 | 33 | pre, code { 34 | font-family: Consolas, Menlo, Monaco, 'Ubuntu Mono', monospace; 35 | line-height: 1.3; 36 | } 37 | 38 | #main { 39 | position: absolute; 40 | left: 0; 41 | right: 0; 42 | top: 0; 43 | bottom: 0; 44 | } 45 | 46 | h2 { 47 | font-size: 1.7em; 48 | font-weight: normal; 49 | text-transform: lowercase; 50 | } 51 | 52 | a, img { 53 | user-drag: none; 54 | -webkit-user-drag: none; 55 | object-fit: contain; 56 | } 57 | 58 | img { 59 | vertical-align: bottom; 60 | } 61 | 62 | #busy { 63 | display: none; 64 | position: fixed; 65 | left: 0; 66 | right: 0; 67 | top: 0; 68 | bottom: 0; 69 | cursor: wait; 70 | z-index: 999; 71 | } 72 | 73 | #input-box { 74 | position: fixed; 75 | top: 0; 76 | left: 0; 77 | right: 0; 78 | bottom: 0; 79 | z-index: 999; 80 | pointer-events: none; 81 | } 82 | #input-box .inner { 83 | position: absolute; 84 | margin-left: -205px; 85 | top: 0; 86 | left: 50%; 87 | width: 400px; 88 | padding: 5px; 89 | background: rgba(38, 41, 44, .97); 90 | color: #eee; 91 | transform: translateY(-100%); 92 | transition: transform .1s; 93 | } 94 | #input-box.show { 95 | pointer-events: auto; 96 | } 97 | #input-box.show .inner { 98 | transform: translateY(0); 99 | box-shadow: 0 0 10px rgba(0, 0, 0, .5); 100 | } 101 | #input-box input { 102 | box-sizing: border-box; 103 | background: #111; 104 | color: #eee; 105 | width: 100%; 106 | } 107 | #input-box input:focus { 108 | background: #1a1a1a; 109 | } 110 | 111 | form p, form ul li { 112 | margin-bottom: 7px; 113 | } 114 | form ul { 115 | list-style: none; 116 | } 117 | input[type="text"] { 118 | width: 125px; 119 | } 120 | textarea { 121 | resize: none; 122 | } 123 | input[type="checkbox"] { 124 | -webkit-appearance: none; 125 | -moz-appearance: none; 126 | -ms-appearance: none; 127 | display: inline-block; 128 | position: relative; 129 | height: 1.5em; 130 | width: 1.5em; 131 | margin-right: 5px; 132 | vertical-align: text-bottom; 133 | } 134 | input[type="checkbox"]::before { 135 | content: ''; 136 | position: absolute; 137 | top: 0; 138 | left: 0; 139 | right: 0; 140 | bottom: 0; 141 | background: url('../node_modules/@primer/octicons/build/svg/check.svg') center / auto 16px no-repeat; 142 | opacity: 0; 143 | } 144 | input[type="checkbox"]:checked::before { 145 | opacity: 1; 146 | } 147 | input, button, select, textarea { 148 | border: 0; 149 | padding: 5px 10px; 150 | } 151 | button { 152 | padding: 5px 20px; 153 | } 154 | button.dropdown { 155 | position: relative; 156 | padding-right: 42px; 157 | } 158 | button.dropdown::after { 159 | content: ''; 160 | display: block; 161 | position: absolute; 162 | width: 16px; 163 | height: 16px; 164 | right: 20px; 165 | top: 50%; 166 | margin-top: -8px; 167 | background: url('../node_modules/@primer/octicons/build/svg/chevron-down.svg') center / auto 16px no-repeat; 168 | filter: invert(100%); 169 | } 170 | input:focus, button:focus, select:focus, textarea:focus { 171 | outline: none; 172 | } 173 | input:disabled, select:disabled, button:disabled, textarea:disabled { 174 | opacity: .5; 175 | } 176 | form > p:last-child { 177 | text-align: right; 178 | } 179 | 180 | /** 181 | * Scrollbars 182 | */ 183 | 184 | ::-webkit-scrollbar { 185 | height: 8px; 186 | width: 8px; 187 | background: transparent; 188 | } 189 | ::-webkit-scrollbar-thumb { 190 | background: #393939; 191 | } 192 | ::-webkit-scrollbar-thumb:hover { 193 | background: #494949; 194 | } 195 | ::-webkit-scrollbar-corner { 196 | background: transparent; 197 | } 198 | 199 | /** 200 | * Pikaday 201 | */ 202 | 203 | .pika-single { 204 | position: absolute; 205 | color: white; 206 | background: #111; 207 | border: none; 208 | font-family: inherit; 209 | box-shadow: 0 -5px 15px rgba(0,0,0,.5); 210 | transition: left .2s, top .2s; 211 | } 212 | 213 | .pika-label { 214 | overflow: visible; 215 | font-weight: normal; 216 | background-color: transparent; 217 | } 218 | 219 | .pika-title select { 220 | background: #1a1a1a; 221 | color: white; 222 | cursor: default; 223 | } 224 | 225 | .pika-prev, .pika-next { 226 | background-size: auto 16px; 227 | cursor: default; 228 | filter: invert(100%); 229 | -webkit-filter: invert(100%); 230 | } 231 | .pika-prev { 232 | background-image: url('../node_modules/@primer/octicons/build/svg/chevron-left.svg'); 233 | } 234 | .pika-next { 235 | background-image: url('../node_modules/@primer/octicons/build/svg/chevron-right.svg'); 236 | } 237 | 238 | .pika-table th { 239 | color: #999; 240 | font-weight: normal; 241 | } 242 | 243 | .pika-button { 244 | cursor: default; 245 | color: white; 246 | background: #1a1a1a; 247 | } 248 | .pika-button:active, .pika-prev:active, .pika-next:active { 249 | position: static; 250 | } 251 | 252 | .pika-week { color: #999; } 253 | 254 | .is-selected .pika-button { 255 | color: #fff; 256 | font-weight: normal; 257 | background: #1a1a1a; 258 | box-shadow: none; 259 | border-radius: 0; 260 | } 261 | 262 | .is-today .pika-button { color: #F75644; } 263 | 264 | .pika-button:hover { 265 | color: #fff; 266 | background: #823499; 267 | border-radius: 0; 268 | } 269 | 270 | .is-multi-selected .pika-button { 271 | color: #fff; 272 | background: #0050C0; 273 | } 274 | 275 | .is-today .pika-button { font-weight: bold; } 276 | 277 | .pika-table abbr { 278 | cursor: default; 279 | text-decoration: none; 280 | } 281 | -------------------------------------------------------------------------------- /style/comments.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Markdown comments styling 3 | */ 4 | 5 | #properties .inner p, 6 | #properties .inner ul, 7 | #properties .inner ol, 8 | #properties .inner blockquote, 9 | #properties .inner h1, 10 | #properties .inner h2, 11 | #properties .inner h3, 12 | #properties .inner h4, 13 | #properties .inner h5, 14 | #properties .inner h6 { 15 | margin: 1em 0; 16 | } 17 | 18 | #properties .inner h1, 19 | #properties .inner h2, 20 | #properties .inner h3, 21 | #properties .inner h4, 22 | #properties .inner h5, 23 | #properties .inner h6 { 24 | font-size: 1em; 25 | font-weight: bold; 26 | text-transform: none; 27 | } 28 | 29 | #properties .inner ul, 30 | #properties .inner ol { 31 | margin-left: 1.5em; 32 | } 33 | #properties .inner ul ul, 34 | #properties .inner ul ol, 35 | #properties .inner ol ol, 36 | #properties .inner ol ul { 37 | margin-top: 0; 38 | margin-bottom: 0; 39 | } 40 | 41 | #properties .inner blockquote { 42 | background-image: linear-gradient(to right, #393939 4px, transparent 4px); 43 | padding-left: 1.2em; 44 | margin-left: .3em; 45 | } 46 | 47 | #properties .inner a { 48 | color: white; 49 | cursor: pointer; 50 | } 51 | 52 | #properties .inner h1::after, 53 | #properties .inner h2::after, 54 | #properties .inner hr { 55 | content: ''; 56 | display: block; 57 | border: 0; 58 | margin: 1em 0; 59 | height: 2px; 60 | background: #393939; 61 | } 62 | -------------------------------------------------------------------------------- /test/engines/resignEngine.js: -------------------------------------------------------------------------------- 1 | const {Engine} = require('@sabaki/gtp') 2 | 3 | let engine = new Engine('Resign Engine', '1.0') 4 | let alpha = 'ABCDEFGHJKLMNOPQRST' 5 | let stop = 3 6 | let stopword = process.argv[2] === '--pass' ? 'pass' : 'resign' 7 | 8 | for (let commandName of ['boardsize', 'clear_board', 'komi', 'play', 'undo']) { 9 | engine.command(commandName, '') 10 | } 11 | 12 | engine.command('genmove', (_, out) => { 13 | if (stop === 0) { 14 | out.send(stopword) 15 | } else { 16 | let rand = () => Math.floor(Math.random() * alpha.length) 17 | stop-- 18 | 19 | out.send(`${alpha[rand()]}${rand() + 1}`) 20 | } 21 | }) 22 | 23 | engine.start() 24 | -------------------------------------------------------------------------------- /test/gamesortTests.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert' 2 | import * as fileformats from '../src/modules/fileformats/index.js' 3 | import * as gamesort from '../src/modules/gamesort.js' 4 | 5 | const blank = fileformats.parseFile(`${__dirname}/sgf/blank_game.sgf`)[0] 6 | 7 | // BR = 30k WR = 10k, GN: Teaching Game, EV: Go Club, DT: 2018-05-22 8 | const beginner = fileformats.parseFile(`${__dirname}/sgf/beginner_game.sgf`)[0] 9 | 10 | // BR = 1k WR = 1d, GN: A Challenge, EV: Tournament 11 | const shodan = fileformats.parseFile(`${__dirname}/sgf/shodan_game.sgf`)[0] 12 | 13 | // BR = 1p WR = 1p, EV: 1st Kisei, DT: 1976-01-28 14 | const pro = fileformats.parseFile(`${__dirname}/sgf/pro_game.sgf`)[0] 15 | 16 | describe('gamesort', () => { 17 | describe('byBlackRank', () => { 18 | let gameTrees = [blank, pro, beginner, shodan] 19 | 20 | it('sorts games by rank of black player low to high', () => { 21 | assert.deepEqual(gamesort.byBlackRank(gameTrees), [ 22 | blank, 23 | beginner, 24 | shodan, 25 | pro 26 | ]) 27 | }) 28 | }) 29 | 30 | describe('byWhiteRank', () => { 31 | let gameTrees = [blank, pro, beginner, shodan] 32 | 33 | it('sorts games by rank of white player low to high', () => { 34 | assert.deepEqual(gamesort.byWhiteRank(gameTrees), [ 35 | blank, 36 | beginner, 37 | shodan, 38 | pro 39 | ]) 40 | }) 41 | }) 42 | 43 | describe('byPlayerBlack', () => { 44 | let gameTrees = [blank, pro, beginner, shodan] 45 | 46 | it('sorts games alphabetically by name of black player', () => { 47 | assert.deepEqual(gamesort.byPlayerBlack(gameTrees), [ 48 | blank, 49 | beginner, 50 | pro, 51 | shodan 52 | ]) 53 | }) 54 | }) 55 | 56 | describe('byPlayerWhite', () => { 57 | let gameTrees = [blank, pro, beginner, shodan] 58 | 59 | it('sorts games alphabetically by name of white player', () => { 60 | assert.deepEqual(gamesort.byPlayerWhite(gameTrees), [ 61 | blank, 62 | pro, 63 | beginner, 64 | shodan 65 | ]) 66 | }) 67 | }) 68 | 69 | describe('byGameName', () => { 70 | let gameTrees = [blank, pro, beginner, shodan] 71 | 72 | it('sorts games naturally by game name', () => { 73 | assert.deepEqual(gamesort.byGameName(gameTrees), [ 74 | blank, 75 | pro, 76 | shodan, 77 | beginner 78 | ]) 79 | }) 80 | }) 81 | 82 | describe('byEvent', () => { 83 | let gameTrees = [blank, pro, beginner, shodan] 84 | 85 | it('sorts games naturally by event', () => { 86 | assert.deepEqual(gamesort.byEvent(gameTrees), [ 87 | blank, 88 | pro, 89 | beginner, 90 | shodan 91 | ]) 92 | }) 93 | }) 94 | 95 | describe('byDate', () => { 96 | let gameTrees = [blank, pro, beginner, shodan] 97 | 98 | it('sorts games by date', () => { 99 | assert.deepEqual(gamesort.byDate(gameTrees), [ 100 | blank, 101 | pro, 102 | shodan, 103 | beginner 104 | ]) 105 | }) 106 | }) 107 | 108 | describe('byNumberOfMoves', () => { 109 | let gameTrees = [blank, pro, beginner, shodan] 110 | 111 | it('sorts games by height of game tree', () => { 112 | assert.deepEqual(gamesort.byNumberOfMoves(gameTrees), [ 113 | blank, 114 | beginner, 115 | shodan, 116 | pro 117 | ]) 118 | }) 119 | }) 120 | 121 | describe('reverse', () => { 122 | let gameTrees = [blank, pro, beginner, shodan] 123 | 124 | it('reverses the array of gametrees', () => { 125 | assert.deepEqual(gamesort.reverse(gameTrees), [ 126 | shodan, 127 | beginner, 128 | pro, 129 | blank 130 | ]) 131 | }) 132 | }) 133 | }) 134 | -------------------------------------------------------------------------------- /test/gib/euc-kr.gib: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SabakiHQ/Sabaki/2dd5882a65cdf7b800f9baaf1282d0712608cc25/test/gib/euc-kr.gib -------------------------------------------------------------------------------- /test/gib/gb2312.gib: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SabakiHQ/Sabaki/2dd5882a65cdf7b800f9baaf1282d0712608cc25/test/gib/gb2312.gib -------------------------------------------------------------------------------- /test/gib/utf8.gib: -------------------------------------------------------------------------------- 1 | \HS 2 | \[GIBOKIND=Global\] 3 | \[TYPE=0\] 4 | \[SZAUDIO=0\] 5 | \[GAMECONDITION=3 Handicap : White Dum(5)\] 6 | \[GAMETIME=Time limit 20minute : 30 second countdown 3 time\] 7 | \[GAMERESULT=\] 8 | \[GAMEZIPSU=0\] 9 | \[GAMEDUM=5\] 10 | \[GAMEGONGJE=0\] 11 | \[GAMETOTALNUM=0\] 12 | \[GAMENAME=Free\] 13 | \[GAMEDATE=2016- 3-26-17-29-25\] 14 | \[GAMEPLACE=Tygem Baduk\] 15 | \[GAMELECNAME=\] 16 | \[GAMEWHITENAME=leejw977 (10K)\] 17 | \[GAMEWHITELEVEL=leejw977\] 18 | \[GAMEWHITECOUNTRY=0\] 19 | \[GAMEWAVATA=60012\] 20 | \[GAMEWIMAG=\] 21 | \[GAMEBLACKNAME=jy512 (15K)\] 22 | \[GAMEBLACKLEVEL=3\] 23 | \[GAMEBLACKNICK=jy512\] 24 | \[GAMEBLACKCOUNTRY=0\] 25 | \[GAMEBAVATA=60001\] 26 | \[GAMEBIMAGE=\] 27 | \[GAMECOMMENT=\] 28 | \[GAMEINFOMAIN=GBKIND:3,GTYPE:1,GCDT:3,GTIME:1200-30-3,GRLT:255,ZIPSU:0,DUM:5,GONGJE:0,TCNT:0,AUSZ:0\] 29 | \[GAMEINFOSUB=GNAMEF:0,GPLCF:0,GNAME:Free,GDATE:2016- 3-26-17-29-25,GPLC:Tygem Baduk,GCMT:\] 30 | \[WUSERINFO=WID:leejw977,WLV:8,WNICK:leejw977,WNCD:0,WAID:60012,WIMSG:\] 31 | \[BUSERINFO=BID:jy512,BLV:3,BNICK:jy512,BNCD:0,BAID:60001,BIMSG:\] 32 | \[GAMETAG=S1,R3,D5,G0,W255,Z0,T30-3-1200,C2016:03:26:17:29,I:leejw977,L:8,M:jy512,N:3,A:leejw977,B:jy512,J:0,K:0\] 33 | \HE 34 | \GS 35 | 2 1 0 36 | 119 0 &4 37 | INI 0 1 3 &4 38 | STO 0 2 2 15 15 39 | STO 0 3 1 13 16 40 | STO 0 4 2 16 13 41 | STO 0 5 1 13 14 42 | STO 0 6 2 15 16 43 | STO 0 7 1 13 12 44 | STO 0 8 2 8 16 45 | STO 0 9 1 9 16 46 | STO 0 10 2 9 15 47 | STO 0 11 1 10 16 48 | STO 0 12 2 6 15 49 | STO 0 13 1 10 15 50 | STO 0 14 2 9 14 51 | STO 0 15 1 10 14 52 | STO 0 16 2 4 16 53 | STO 0 17 1 3 16 54 | STO 0 18 2 2 9 55 | STO 0 19 1 3 17 56 | STO 0 20 2 4 17 57 | STO 0 21 1 4 15 58 | STO 0 22 2 6 16 59 | STO 0 23 1 5 15 60 | STO 0 24 2 2 12 61 | STO 0 25 1 2 13 62 | STO 0 26 2 1 12 63 | STO 0 27 1 3 13 64 | STO 0 28 2 2 5 65 | STO 0 29 1 1 13 66 | STO 0 30 2 5 2 67 | STO 0 31 1 5 3 68 | STO 0 32 2 6 2 69 | STO 0 33 1 4 2 70 | STO 0 34 2 9 2 71 | STO 0 35 1 6 3 72 | STO 0 36 2 7 3 73 | STO 0 37 1 2 4 74 | STO 0 38 2 4 4 75 | STO 0 39 1 4 3 76 | STO 0 40 2 3 5 77 | STO 0 41 1 3 4 78 | STO 0 42 2 4 5 79 | STO 0 43 1 4 1 80 | STO 0 44 2 7 2 81 | STO 0 45 1 1 5 82 | STO 0 46 2 1 6 83 | STO 0 47 1 1 4 84 | STO 0 48 2 2 7 85 | STO 0 49 1 6 5 86 | STO 0 50 2 13 2 87 | STO 0 51 1 7 4 88 | STO 0 52 2 11 3 89 | STO 0 53 1 9 4 90 | STO 0 54 2 9 3 91 | STO 0 55 1 10 4 92 | STO 0 56 2 16 5 93 | STO 0 57 1 14 2 94 | STO 0 58 2 13 3 95 | STO 0 59 1 14 3 96 | STO 0 60 2 14 5 97 | STO 0 61 1 16 3 98 | STO 0 62 2 15 5 99 | STO 0 63 1 17 3 100 | STO 0 64 2 17 5 101 | STO 0 65 1 13 1 102 | STO 0 66 2 12 1 103 | STO 0 67 1 13 0 104 | STO 0 68 2 11 2 105 | STO 0 69 1 15 1 106 | STO 0 70 2 15 10 107 | STO 0 71 1 18 3 108 | STO 0 72 2 18 5 109 | STO 0 73 1 9 9 110 | STO 0 74 2 15 12 111 | STO 0 75 1 9 13 112 | STO 0 76 2 8 15 113 | STO 0 77 1 8 13 114 | STO 0 78 2 6 13 115 | STO 0 79 1 7 13 116 | STO 0 80 2 6 14 117 | STO 0 81 1 6 12 118 | STO 0 82 2 5 16 119 | STO 0 83 1 5 12 120 | STO 0 84 2 9 17 121 | STO 0 85 1 10 17 122 | STO 0 86 2 7 17 123 | STO 0 87 1 11 13 124 | STO 0 88 2 14 17 125 | STO 0 89 1 5 13 126 | STO 0 90 2 0 13 127 | STO 0 91 1 0 14 128 | STO 0 92 2 0 12 129 | STO 0 93 1 1 15 130 | STO 0 94 2 3 12 131 | STO 0 95 1 4 12 132 | STO 0 96 2 3 10 133 | STO 0 97 1 7 11 134 | STO 0 98 2 11 10 135 | STO 0 99 1 9 10 136 | STO 0 100 2 13 9 137 | STO 0 101 1 9 18 138 | STO 0 102 2 8 17 139 | STO 0 103 1 7 7 140 | STO 0 104 2 11 6 141 | STO 0 105 1 8 4 142 | STO 0 106 2 8 3 143 | STO 0 107 1 11 4 144 | STO 0 108 2 12 4 145 | STO 0 109 1 10 6 146 | STO 0 110 2 11 7 147 | STO 0 111 1 10 7 148 | STO 0 112 2 11 5 149 | STO 0 113 1 11 8 150 | STO 0 114 2 12 8 151 | STO 0 115 1 11 9 152 | STO 0 116 2 12 9 153 | STO 0 117 1 10 10 154 | STO 0 118 2 11 11 155 | STO 0 119 1 10 8 156 | \GE 157 | -------------------------------------------------------------------------------- /test/gibTests.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert' 2 | import {gib} from '../src/modules/fileformats/index.js' 3 | 4 | describe('gib', () => { 5 | describe('parse', () => { 6 | it('should parse simple files', () => { 7 | let tree = gib.parseFile(`${__dirname}/gib/utf8.gib`)[0] 8 | 9 | assert.deepEqual(tree.root.data, { 10 | CA: ['UTF-8'], 11 | FF: ['4'], 12 | GM: ['1'], 13 | SZ: ['19'], 14 | KM: ['0'], 15 | PW: ['leejw977'], 16 | WR: ['10K'], 17 | PB: ['jy512'], 18 | BR: ['15K'], 19 | DT: ['2016-03-26'], 20 | HA: ['3'], 21 | AB: ['dp', 'pd', 'dd'] 22 | }) 23 | 24 | assert.deepEqual( 25 | [...tree.getSequence(tree.root.id)].map(node => node.data).slice(1, 5), 26 | [{W: ['pp']}, {B: ['nq']}, {W: ['qn']}, {B: ['no']}] 27 | ) 28 | }) 29 | 30 | it('should be able to detect encoding', () => { 31 | let tree = gib.parseFile(`${__dirname}/gib/gb2312.gib`)[0] 32 | assert.equal(tree.root.data.PB[0], '石下之臣') 33 | }) 34 | }) 35 | }) 36 | -------------------------------------------------------------------------------- /test/ngf/even.ngf: -------------------------------------------------------------------------------- 1 | Rated game 2 | 19 3 | LQC 9DP 4 | CYY 9DP 5 | www.cyberoro.com 6 | 0 7 | 0 8 | 7 9 | 20170316 [09:37] 10 | 5 11 | Black wins by 0.5! 12 | 333 13 | PMABBREER 14 | PMACWEEEE 15 | PMADBQRRQ 16 | PMAEWEQQE 17 | PMAFBGDDG 18 | PMAGWDGGD 19 | PMAHBMDDM 20 | PMAIWQPPQ 21 | PMAJBRPPR 22 | PMAKWROOR 23 | PMALBRQQR 24 | PMAMWQNNQ 25 | PMANBRMMR 26 | PMAOWSOOS 27 | PMAPBORRO 28 | PMAQWQJJQ 29 | PMARBPLLP 30 | PMASWPQQP 31 | PMATBPRRP 32 | PMAUWQLLQ 33 | PMAVBQMMQ 34 | PMAWWPMMP 35 | PMAXBQKKQ 36 | PMAYWPKKP 37 | PMAZBRLLR 38 | PMBAWOLLO 39 | PMBBBPJJP 40 | PMBCWQLLQ 41 | PMBDBRKKR 42 | PMBEWRJJR 43 | PMBFBPIIP 44 | PMBGWSKKS 45 | PMBHBSJJS 46 | PMBIWSIIS 47 | PMBJBRIIR 48 | PMBKWTJJT 49 | PMBLBQIIQ 50 | PMBMWSJJS 51 | PMBNBSHHS 52 | PMBOWSLLS 53 | PMBPBMFFM 54 | PMBQWRDDR 55 | PMBRBQDDQ 56 | PMBSWQEEQ 57 | PMBTBSEES 58 | PMBUWIDDI 59 | PMBVBECCE 60 | PMBWWDDDD 61 | PMBXBFEEF 62 | PMBYWKDDK 63 | PMBZBEFFE 64 | PMCAWDFFD 65 | PMCBBEDDE 66 | PMCCWDEED 67 | PMCDBLDDL 68 | PMCEWIFFI 69 | PMCFBGGGG 70 | PMCGWIHHI 71 | PMCHBDCCD 72 | PMCIWEGGE 73 | PMCJBHEEH 74 | PMCKWKFFK 75 | PMCLBGIIG 76 | PMCMWEJJE 77 | PMCNBDOOD 78 | PMCOWDPPD 79 | PMCPBEOOE 80 | PMCQWGRRG 81 | PMCRBDLLD 82 | PMCSWCKKC 83 | PMCTBLHHL 84 | PMCUWKCCK 85 | PMCVBLCCL 86 | PMCWWPLLP 87 | PMCXBKIIK 88 | PMCYWHCCH 89 | PMCZBGCCG 90 | PMDAWKHHK 91 | PMDBBJHHJ 92 | PMDCWJGGJ 93 | PMDDBKGGK 94 | PMDEWJIIJ 95 | PMDFBFQQF 96 | PMDGWFRRF 97 | PMDHBDRRD 98 | PMDIWEPPE 99 | PMDJBIIII 100 | PMDKWKHHK 101 | PMDLBERRE 102 | PMDMWFPPF 103 | PMDNBJHHJ 104 | PMDOWJJJJ 105 | PMDPBGQQG 106 | PMDQWHQQH 107 | PMDRBHHHH 108 | PMDSWKHHK 109 | PMDTBGPPG 110 | PMDUWFOOF 111 | PMDVBJHHJ 112 | PMDWWIGGI 113 | PMDXBHPPH 114 | PMDYWHRRH 115 | PMDZBIPPI 116 | PMEAWCQQC 117 | PMEBBFNNF 118 | PMECWGOOG 119 | PMEDBDJJD 120 | PMEEWDKKD 121 | PMEFBEKKE 122 | PMEGWCJJC 123 | PMEHBJEEJ 124 | PMEIWIEEI 125 | PMEJBKEEK 126 | PMEKWKHHK 127 | PMELBIQQI 128 | PMEMWIRRI 129 | PMENBJHHJ 130 | PMEOWIJJI 131 | PMEPBFJJF 132 | PMEQWEIIE 133 | PMERBHIIH 134 | PMESWKHHK 135 | PMETBJRRJ 136 | PMEUWJSSJ 137 | PMEVBJHHJ 138 | PMEWWPDDP 139 | PMEXBQCCQ 140 | PMEYWKHHK 141 | PMEZBKSSK 142 | PMFAWKRRK 143 | PMFBBJQQJ 144 | PMFCWISSI 145 | PMFDBJHHJ 146 | PMFEWRCCR 147 | PMFFBPCCP 148 | PMFGWKHHK 149 | PMFHBLSSL 150 | PMFIWFSSF 151 | PMFJBJHHJ 152 | PMFKWOCCO 153 | PMFLBPEEP 154 | PMFMWKHHK 155 | PMFNBLGGL 156 | PMFOWJHHJ 157 | PMFPBFLLF 158 | PMFQWSGGS 159 | PMFRBRGGR 160 | PMFSWTHHT 161 | PMFTBRHHR 162 | PMFUWCOOC 163 | PMFVBCNNC 164 | PMFWWBNNB 165 | PMFXBIMMI 166 | PMFYWLIIL 167 | PMFZBMIIM 168 | PMGAWLJJL 169 | PMGBBCMMC 170 | PMGCWGNNG 171 | PMGDBGMMG 172 | PMGEWENNE 173 | PMGFBDNND 174 | PMGGWFMMF 175 | PMGHBGLLG 176 | PMGIWINNI 177 | PMGJBJNNJ 178 | PMGKWHNNH 179 | PMGLBJOOJ 180 | PMGMWMPPM 181 | PMGNBCRRC 182 | PMGOWESSE 183 | PMGPBMJJM 184 | PMGQWLKKL 185 | PMGRBNQQN 186 | PMGSWQFFQ 187 | PMGTBODDO 188 | PMGUWHMMH 189 | PMGVBHLLH 190 | PMGWWILLI 191 | PMGXBJMMJ 192 | PMGYWHKKH 193 | PMGZBJLLJ 194 | PMHAWLRRL 195 | PMHBBMRRM 196 | PMHCWIKKI 197 | PMHDBNOON 198 | PMHEWEMME 199 | PMHFBELLE 200 | PMHGWGKKG 201 | PMHHBFKKF 202 | PMHIWGFFG 203 | PMHJBFFFF 204 | PMHKWFGGF 205 | PMHLBHFFH 206 | PMHMWHGGH 207 | PMHNBGEEG 208 | PMHOWCCCC 209 | PMHPBDSSD 210 | PMHQWCTTC 211 | PMHRBDTTD 212 | PMHSWBRRB 213 | PMHTBBSSB 214 | PMHUWBQQB 215 | PMHVBCPPC 216 | PMHWWBPPB 217 | PMHXBBMMB 218 | PMHYWDQQD 219 | PMHZBBOOB 220 | PMIAWFNNF 221 | PMIBBCPPC 222 | PMICWFIIF 223 | PMIDBGJJG 224 | PMIEWCOOC 225 | PMIFBCDDC 226 | PMIGWCEEC 227 | PMIHBCPPC 228 | PMIIWLQQL 229 | PMIJBLLLL 230 | PMIKWMLLM 231 | PMILBMKKM 232 | PMIMWLMML 233 | PMINBLPPL 234 | PMIOWCOOC 235 | PMIPBCBBC 236 | PMIQWBDDB 237 | PMIRBCPPC 238 | PMISWSFFS 239 | PMITBRFFR 240 | PMIUWCOOC 241 | PMIVBKLLK 242 | PMIWWKBBK 243 | PMIXBCPPC 244 | PMIYWLOOL 245 | PMIZBKPPK 246 | PMJAWCOOC 247 | PMJBBGBBG 248 | PMJCWDBBD 249 | PMJDBCPPC 250 | PMJEWMQQM 251 | PMJFBMOOM 252 | PMJGWCOOC 253 | PMJHBDMMD 254 | PMJIWBNNB 255 | PMJJBIOOI 256 | PMJKWETTE 257 | PMJLBBOOB 258 | PMJMWCSSC 259 | PMJNBCPPC 260 | PMJOWSDDS 261 | PMJPBTFFT 262 | PMJQWCOOC 263 | PMJRBEBBE 264 | PMJSWFCCF 265 | PMJTBCPPC 266 | PMJUWMSSM 267 | PMJVBNPPN 268 | PMJWWCOOC 269 | PMJXBBCCB 270 | PMJYWCDDC 271 | PMJZBCPPC 272 | PMKAWNRRN 273 | PMKBBCOOC 274 | PMKCWOSSO 275 | PMKDBKQQK 276 | PMKEWMRRM 277 | PMKFBMTTM 278 | PMKGWNSSN 279 | PMKHBPSSP 280 | PMKIWKTTK 281 | PMKJBNMMN 282 | PMKKWNLLN 283 | PMKLBMMMM 284 | PMKMWTRRT 285 | PMKNBSRRS 286 | PMKOWTQQT 287 | PMKPBTSST 288 | PMKQWSPPS 289 | PMKRBSQQS 290 | PMKSWTPPT 291 | PMKTBPOOP 292 | PMKUWFHHF 293 | PMKVBGHHG 294 | PMKWWPNNP 295 | PMKXBPPPP 296 | PMKYWOJJO 297 | PMKZBOIIO 298 | PMLAWQOOQ 299 | PMLBBJFFJ 300 | PMLCWLBBL 301 | PMLDBMBBM 302 | PMLEWNJJN 303 | PMLFBNIIN 304 | PMLGWJDDJ 305 | PMLHBLEEL 306 | PMLIWSSSS 307 | PMLJBRSSR 308 | PMLKWTTTT 309 | PMLLBQQQQ 310 | PMLMWOOOO 311 | PMLNBOPPO 312 | PMLOWONNO 313 | PMLPBNKKN 314 | PMLQWNNNN 315 | PMLRBMNNM 316 | PMLSWOKKO 317 | PMLTBHBBH 318 | PMLUWIBBI 319 | PMLVBHDDH 320 | PMLWWICCI 321 | PMLXBOTTO 322 | PMLYWLTTL 323 | PMLZBSMMS 324 | PMMAWTMMT 325 | PMMBBBKKB 326 | PMMCWBJJB 327 | PMMDBBLLB 328 | PMMEWNTTN 329 | PMMFBPTTP 330 | PMMGWSNNS 331 | PMMHBRTTR 332 | PMMIWBBBB 333 | PMMJBDBBD 334 | PMMKWBCCB 335 | PMMLBTGGT 336 | PMMMWTIIT 337 | PMMNBSTTS 338 | PMMOWTSST 339 | PMMPBKKKK 340 | PMMQWKJJK 341 | PMMRBHOOH 342 | PMMSWCLLC 343 | PMMTBHJJH 344 | PMMUWJKKJ 345 | PMMVBOMMO 346 | -------------------------------------------------------------------------------- /test/ngf/gb2312.ngf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SabakiHQ/Sabaki/2dd5882a65cdf7b800f9baaf1282d0712608cc25/test/ngf/gb2312.ngf -------------------------------------------------------------------------------- /test/ngf/handicap2.ngf: -------------------------------------------------------------------------------- 1 | Rated game 2 | 19 3 | ace550 7D* 4 | p81587 5D* 5 | www.cyberoro.com 6 | 2 7 | 0 8 | 0 9 | 20170316 [09:51] 10 | 5 11 | White wins by resign! 12 | 189 13 | PMABWQRRQ 14 | PMACBEEEE 15 | PMADWGRRG 16 | PMAEBIRRI 17 | PMAFWDOOD 18 | PMAGBGQQG 19 | PMAHWDRRD 20 | PMAIBFRRF 21 | PMAJWDQQD 22 | PMAKBRPPR 23 | PMALWQMMQ 24 | PMAMBPPPP 25 | PMANWNRRN 26 | PMAOBNPPN 27 | PMAPWRGGR 28 | PMAQBRHHR 29 | PMARWQGGQ 30 | PMASBRJJR 31 | PMATWPJJP 32 | PMAUBQKKQ 33 | PMAVWQOOQ 34 | PMAWBQPPQ 35 | PMAXWOOOO 36 | PMAYBPOOP 37 | PMAZWPNNP 38 | PMBABOPPO 39 | PMBBWOLLO 40 | PMBCBPIIP 41 | PMBDWQIIQ 42 | PMBEBQHHQ 43 | PMBFWPHHP 44 | PMBGBRIIR 45 | PMBHWOIIO 46 | PMBIBSMMS 47 | PMBJWSNNS 48 | PMBKBRMMR 49 | PMBLWRNNR 50 | PMBMBQLLQ 51 | PMBNWSKKS 52 | PMBOBSLLS 53 | PMBPWOEEO 54 | PMBQBODDO 55 | PMBRWPDDP 56 | PMBSBPEEP 57 | PMBTWNDDN 58 | PMBUBOCCO 59 | PMBVWPCCP 60 | PMBWBNCCN 61 | PMBXWRDDR 62 | PMBYBREER 63 | PMBZWSDDS 64 | PMCABOFFO 65 | PMCBWNEEN 66 | PMCCBNFFN 67 | PMCDWMCCM 68 | PMCEBPGGP 69 | PMCFWSFFS 70 | PMCGBSEES 71 | PMCHWTEET 72 | PMCIBOHHO 73 | PMCJWPIIP 74 | PMCKBPMMP 75 | PMCLWQNNQ 76 | PMCMBOMMO 77 | PMCNWONNO 78 | PMCOBPLLP 79 | PMCPWNMMN 80 | PMCQBNLLN 81 | PMCRWMNNM 82 | PMCSBMMMM 83 | PMCTWNNNN 84 | PMCUBMQQM 85 | PMCVWOKKO 86 | PMCWBNHHN 87 | PMCXWMLLM 88 | PMCYBSHHS 89 | PMCZWMRRM 90 | PMDABKQQK 91 | PMDBWLQQL 92 | PMDCBLPPL 93 | PMDDWLRRL 94 | PMDEBKOOK 95 | PMDFWNKKN 96 | PMDGBLFFL 97 | PMDHWLDDL 98 | PMDIBMIIM 99 | PMDJWJEEJ 100 | PMDKBSGGS 101 | PMDLWRFFR 102 | PMDMBTGGT 103 | PMDNWTFFT 104 | PMDOBJGGJ 105 | PMDPWQDDQ 106 | PMDQBPFFP 107 | PMDRWHFFH 108 | PMDSBHDDH 109 | PMDTWHHHH 110 | PMDUBIEEI 111 | PMDVWIFFI 112 | PMDWBJDDJ 113 | PMDXWJFFJ 114 | PMDYBMEEM 115 | PMDZWMDDM 116 | PMEABFDDF 117 | PMEBWCEEC 118 | PMECBDDDD 119 | PMEDWDGGD 120 | PMEEBFHHF 121 | PMEFWFGGF 122 | PMEGBGGGG 123 | PMEHWFFFF 124 | PMEIBDFFD 125 | PMEJWCFFC 126 | PMEKBGFFG 127 | PMELWGHHG 128 | PMEMBGEEG 129 | PMENWFIIF 130 | PMEOBHGGH 131 | PMEPWIGGI 132 | PMEQBKEEK 133 | PMERWJHHJ 134 | PMESBKDDK 135 | PMETWELLE 136 | PMEUBLEEL 137 | PMEVWKCCK 138 | PMEWBJCCJ 139 | PMEXWLBBL 140 | PMEYBEMME 141 | PMEZWENNE 142 | PMFABDMMD 143 | PMFBWFMMF 144 | PMFCBDLLD 145 | PMFDWDJJD 146 | PMFEBCOOC 147 | PMFFWCPPC 148 | PMFGBFNNF 149 | PMFHWFLLF 150 | PMFIBDKKD 151 | PMFJWCJJC 152 | PMFKBCNNC 153 | PMFLWFOOF 154 | PMFMBEKKE 155 | PMFNWGKKG 156 | PMFOBGNNG 157 | PMFPWGOOG 158 | PMFQBGJJG 159 | PMFRWFJJF 160 | PMFSBFKKF 161 | PMFTWGLLG 162 | PMFUBEJJE 163 | PMFVWEHHE 164 | PMFWBHNNH 165 | PMFXWHJJH 166 | PMFYBEPPE 167 | PMFZWDPPD 168 | PMGABEOOE 169 | PMGBWDNND 170 | PMGCBRSSR 171 | PMGDWQSSQ 172 | PMGEBRRRR 173 | PMGFWJSSJ 174 | PMGGBISSI 175 | PMGHWJRRJ 176 | PMGIBJQQJ 177 | PMGJWESSE 178 | PMGKBOSSO 179 | PMGLWORRO 180 | PMGMBPSSP 181 | PMGNWPRRP 182 | PMGOBQTTQ 183 | PMGPWPTTP 184 | PMGQBRTTR 185 | PMGRWNSSN 186 | PMGSBKRRK 187 | PMGTWKSSK 188 | PMGUBOTTO 189 | PMGVWNTTN 190 | PMGWBJTTJ 191 | PMGXWLTTL 192 | PMGYBPTTP 193 | PMGZWMSSM 194 | PMHABITTI 195 | PMHBWKTTK 196 | PMHCBCDDC 197 | PMHDWJNNJ 198 | PMHEBKNNK 199 | PMHFWJMMJ 200 | PMHGBJOOJ 201 | PMHHWHOOH 202 | -------------------------------------------------------------------------------- /test/ngfTests.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert' 2 | import {ngf} from '../src/modules/fileformats/index.js' 3 | 4 | describe('ngf', () => { 5 | describe('parse', () => { 6 | it('should parse simple files', () => { 7 | let tree = ngf.parseFile(`${__dirname}/ngf/even.ngf`)[0] 8 | 9 | assert.deepEqual(tree.root.data, { 10 | CA: ['UTF-8'], 11 | FF: ['4'], 12 | GM: ['1'], 13 | SZ: ['19'], 14 | KM: ['7.5'], 15 | PW: ['LQC'], 16 | WR: ['9p'], 17 | PB: ['CYY'], 18 | BR: ['9p'], 19 | DT: ['2017-03-16'], 20 | RE: ['B+0.5'] 21 | }) 22 | 23 | assert.deepEqual( 24 | [...tree.getSequence(tree.root.id)].map(node => node.data).slice(1, 5), 25 | [{B: ['qd']}, {W: ['dd']}, {B: ['pq']}, {W: ['dp']}] 26 | ) 27 | }) 28 | }) 29 | }) 30 | -------------------------------------------------------------------------------- /test/sgf/beginner_game.sgf: -------------------------------------------------------------------------------- 1 | ( 2 | ;GM[1]FF[4]CA[UTF-8]AP[Sabaki:0.33.4]KM[5.5]SZ[19]DT[2018-05-22]PB[Absolute Beginner]BR[30k]PW[Noob]WR[1d]GN[Teaching Game]EV[Go Club]AB[dp][pd][pp][dd][dj][pj][jd][jp][jj]HA[9] 3 | ;W[nc] 4 | ;B[pf] 5 | ;W[fd] 6 | ;B[df] 7 | ;W[jf] 8 | ;B[dc] 9 | ;W[ff] 10 | ;B[nf] 11 | ;W[me] 12 | ;B[mf] 13 | ;W[le] 14 | ;B[pc] 15 | ;W[qn] 16 | ;B[np] 17 | ;W[cn] 18 | ;B[fp] 19 | ;W[dh] 20 | ;B[jq] 21 | ) 22 | -------------------------------------------------------------------------------- /test/sgf/blank_game.sgf: -------------------------------------------------------------------------------- 1 | ( 2 | ;GM[1]FF[4]CA[UTF-8]AP[Sabaki:0.33.4]KM[5.5]SZ[19]DT[] 3 | ) 4 | -------------------------------------------------------------------------------- /test/sgf/pro_game.sgf: -------------------------------------------------------------------------------- 1 | (; 2 | EV[1st Kisei] 3 | RO[1-dan Final] 4 | PB[Maruyama Toyoji] 5 | BR[1p] 6 | PW[Ito Yoji] 7 | WR[1p] 8 | KM[5.5] 9 | RE[W+6.5] 10 | DT[1976-01-28] 11 | 12 | ;B[dp];W[qc];B[dd];W[pq];B[po];W[oo];B[qq];W[pn];B[pp];W[op] 13 | ;B[oq];W[pr];B[qr];W[ro];B[or];W[qp];B[ps];W[pq];B[pd];W[pc] 14 | ;B[od];W[re];B[pg];W[nb];B[ld];W[cj];B[lq];W[cg];B[ce];W[fc] 15 | ;B[hd];W[fe];B[ec];W[fb];B[hf];W[fg];B[bg];W[bf];B[cf];W[bh] 16 | ;B[dg];W[ch];B[be];W[ge];B[he];W[gh];B[hh];W[hi];B[ih];W[md] 17 | ;B[me];W[mc];B[le];W[pj];B[cm];W[ag];B[eb];W[dq];B[cq];W[eq] 18 | ;B[cr];W[ep];B[do];W[hq];B[qh];W[qi];B[ri];W[rj];B[pi];W[qj] 19 | ;B[oi];W[nk];B[mj];W[mk];B[lj];W[kr];B[kq];W[lr];B[mr];W[jq] 20 | ;B[jp];W[iq];B[rp];W[qo];B[mp];W[bl];B[bm];W[kc];B[jb];W[kb] 21 | ;B[bk];W[ck];B[cl];W[bj];B[al];W[jd];B[je];W[dr];B[mn];W[kd] 22 | ;B[ke];W[rg];B[nm];W[om];B[gi];W[io];B[hb];W[fi];B[gj];W[fj] 23 | ;B[eh];W[fh];B[gk];W[lo];B[mo];W[jk];B[lk];W[nj];B[ni];W[cp] 24 | ;B[co];W[il];B[ij];W[rh];B[jo];W[in];B[go];W[jn];B[kn];W[hk] 25 | ;B[gm];W[ip];B[hj];W[jj];B[ji];W[cs];B[bs];W[ds];B[br];W[rq] 26 | ;B[rr];W[sp];B[ml];W[nl];B[ae];W[el];B[ei];W[ej];B[di];W[ea] 27 | ;B[da];W[fa];B[cb];W[ln];B[km];W[ko];B[af];W[eg];B[dh];W[df] 28 | ;B[ef];W[dj];B[de];W[ci];B[df];W[dl];B[fk];W[ek];B[jl];W[kp] 29 | ;B[im];W[hm];B[ik];W[kl];B[hl];W[jm];B[il];W[ll];B[gp];W[gq] 30 | ;B[mm];W[lm];B[kk];W[np];B[nq];W[sr];B[rs];W[qg];B[ph];W[pf] 31 | ;B[of];W[pe];B[oe];W[qd];B[ka];W[lb];B[bg];W[ah];B[oc];W[ob] 32 | ;B[oj];W[ok];B[hn];W[ic];B[hc];W[ib];B[ia];W[jc];B[ja];W[la] 33 | ;B[ha];W[pp];B[pr];W[fp];B[jr];W[ir];B[ls];W[js];B[nd];W[fn] 34 | ;B[aj];W[ai];B[ak];W[dn];B[cn];W[gn];B[ho];W[gg];B[hg];W[id] 35 | ;B[ie];W[fl];B[ed];W[fd];B[bf]) 36 | -------------------------------------------------------------------------------- /test/sgf/shodan_game.sgf: -------------------------------------------------------------------------------- 1 | ( 2 | ;GM[1]FF[4]CA[UTF-8]AP[Sabaki:0.33.4]KM[6.5]SZ[19]DT[2000-01-01]PB[Zero]BR[1k]PW[Shodan]WR[1d]GN[A Challenge]EV[Tournament]RE[B+R] 3 | ;B[pd] 4 | ;W[dp] 5 | ;B[pp] 6 | ;W[dd] 7 | ;B[pj] 8 | ;W[nc] 9 | ;B[pf] 10 | ;W[pb] 11 | ;B[qc] 12 | ;W[kc] 13 | ;B[fq] 14 | ;W[dn] 15 | ;B[jp] 16 | ;W[qn] 17 | ;B[pm] 18 | ;W[pn] 19 | ;B[om] 20 | ;W[on] 21 | ;B[nm] 22 | ;W[np] 23 | ;B[oq] 24 | ;W[nq] 25 | ;B[nr] 26 | ;W[mr] 27 | ;B[qq] 28 | ;W[rp] 29 | ;B[qm] 30 | ;W[rm] 31 | ;B[rl] 32 | ;W[rn] 33 | ;B[rq] 34 | ;W[or] 35 | ;B[pr] 36 | ;W[ns] 37 | ;B[qp] 38 | ;W[sp] 39 | ;B[op] 40 | ;W[no] 41 | ;B[rr] 42 | ;W[hp] 43 | ;B[hq] 44 | ;W[iq] 45 | ;B[ip] 46 | ;W[gq] 47 | ;B[hr] 48 | ;W[gr] 49 | ;B[gp] 50 | ;W[ho] 51 | ;B[fr] 52 | ;W[jq] 53 | ;B[kq] 54 | ;W[ir] 55 | ;B[gs] 56 | ;W[kr] 57 | ;B[is] 58 | ;W[jr] 59 | ;B[in] 60 | ;W[gn] 61 | ;B[fn] 62 | ;W[fm] 63 | ;B[go] 64 | ;W[hn] 65 | ;B[en] 66 | ;W[em] 67 | ;B[gm] 68 | ;W[hm] 69 | ;B[gl] 70 | ;W[hl] 71 | ;B[gk] 72 | ;W[eo] 73 | ;B[fo] 74 | ;W[dl] 75 | ;B[dr] 76 | ;W[cq] 77 | ;B[hk] 78 | ;W[jn] 79 | ;B[il] 80 | ;W[io] 81 | ;B[jo] 82 | ;W[im] 83 | ;B[jm] 84 | ;W[in] 85 | ;B[kn] 86 | ) 87 | -------------------------------------------------------------------------------- /test/ugfTests.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert' 2 | import {ugf} from '../src/modules/fileformats/index.js' 3 | 4 | describe('ugf', () => { 5 | describe('parse', () => { 6 | it('should parse simple files', () => { 7 | let tree = ugf.parseFile(`${__dirname}/ugf/amateur.ugf`)[0] 8 | 9 | assert.deepEqual(tree.root.data, { 10 | CA: ['UTF-8'], 11 | CP: ['PANDANET INC.'], 12 | FF: ['4'], 13 | GM: ['1'], 14 | SZ: ['19'], 15 | KM: ['-5.50'], 16 | PW: ['YINNI'], 17 | WR: ['8d'], 18 | PB: ['kaziwami'], 19 | BR: ['7d'], 20 | DT: ['2019-03-08'], 21 | RE: ['B+7.50'] 22 | }) 23 | 24 | assert.deepEqual( 25 | [...tree.getSequence(tree.root.id)].map(node => node.data).slice(1, 5), 26 | [{B: ['qd']}, {W: ['dd']}, {B: ['pq']}, {W: ['dq']}] 27 | ) 28 | }) 29 | 30 | it('should parse reviews with comments and variations', () => { 31 | let tree = ugf.parseFile(`${__dirname}/ugf/review.ugi`)[0] 32 | 33 | assert.equal( 34 | tree.root.data.C, 35 | `apetresc 2k?: Let's begin and enjoy a great game. 36 | ken03110 2k : Hi! 37 | ` 38 | ) 39 | }) 40 | }) 41 | }) 42 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | module.exports = (env, argv) => ({ 4 | entry: './src/components/App.js', 5 | 6 | output: { 7 | filename: 'bundle.js', 8 | path: __dirname 9 | }, 10 | 11 | devtool: argv.mode === 'production' ? false : 'eval-cheap-module-source-map', 12 | target: 'electron-renderer', 13 | 14 | node: { 15 | __dirname: false 16 | }, 17 | 18 | resolve: { 19 | alias: { 20 | react: 'preact/compat', 21 | 'react-dom/test-utils': 'preact/test-utils', 22 | 'react-dom': 'preact/compat', 23 | 'react/jsx-runtime': 'preact/jsx-runtime' 24 | } 25 | }, 26 | 27 | externals: { 28 | '@sabaki/i18n': 'require("@sabaki/i18n")', 29 | 'cross-spawn': 'null', 30 | 'iconv-lite': 'require("iconv-lite")', 31 | moment: 'null' 32 | } 33 | }) 34 | --------------------------------------------------------------------------------