├── .env.local ├── .gitignore ├── LICENSE ├── README.md ├── doc ├── build.png └── study.png ├── jest.config.js ├── mongoscripts ├── README.md └── largestrepertoire.js ├── package.json ├── src ├── client │ ├── about.html │ ├── css │ │ ├── about.css │ │ ├── board.css │ │ ├── chessground.css │ │ ├── constants.css │ │ ├── main.css │ │ ├── pieces.css │ │ └── toastr.min.css │ ├── favicon.ico │ ├── img │ │ ├── board │ │ │ ├── blue.svg │ │ │ ├── brown.svg │ │ │ ├── gray.jpg │ │ │ ├── green.svg │ │ │ ├── leather.jpg │ │ │ ├── marble.jpg │ │ │ ├── purple.svg │ │ │ ├── wood1.jpg │ │ │ ├── wood2.jpg │ │ │ └── wood3.jpg │ │ ├── export.png │ │ ├── faviconbright.ico │ │ ├── faviconbright2.ico │ │ ├── favicondark.ico │ │ ├── import.png │ │ ├── left.png │ │ ├── logobright.png │ │ ├── logobright150.png │ │ ├── logodark.png │ │ ├── logodark150.png │ │ ├── pencil.png │ │ ├── pieces │ │ │ └── merida │ │ │ │ ├── bB.svg │ │ │ │ ├── bK.svg │ │ │ │ ├── bN.svg │ │ │ │ ├── bP.svg │ │ │ │ ├── bQ.svg │ │ │ │ ├── bR.svg │ │ │ │ ├── wB.svg │ │ │ │ ├── wK.svg │ │ │ │ ├── wN.svg │ │ │ │ ├── wP.svg │ │ │ │ ├── wQ.svg │ │ │ │ └── wR.svg │ │ ├── plus.png │ │ ├── soundoff.png │ │ ├── soundon.png │ │ ├── theme.png │ │ ├── trash.png │ │ └── x.png │ ├── js │ │ ├── annotation │ │ │ ├── annotationrenderer.ts │ │ │ ├── annotator.ts │ │ │ ├── noopannotationrenderer.ts │ │ │ └── nullannotator.ts │ │ ├── auth │ │ │ └── authmanager.ts │ │ ├── board │ │ │ ├── board.ts │ │ │ ├── boardhandler.ts │ │ │ ├── chessgroundboard.ts │ │ │ ├── chessgroundboardfactory.ts │ │ │ ├── delegatingboard.ts │ │ │ └── noopboard.ts │ │ ├── build │ │ │ ├── annotation │ │ │ │ ├── buildannotation.ts │ │ │ │ ├── defaultannotationrenderer.ts │ │ │ │ ├── defaultannotator.ts │ │ │ │ └── displaytype.ts │ │ │ ├── buildboardhandler.ts │ │ │ ├── buildmode.ts │ │ │ ├── colorchooser.ts │ │ │ ├── currentrepertoireexporter.ts │ │ │ ├── currentrepertoireupdater.ts │ │ │ ├── examplerepertoirehandler.ts │ │ │ ├── examplerepertoires.ts │ │ │ ├── import │ │ │ │ ├── converterstatus.ts │ │ │ │ ├── currentrepertoireimporter.ts │ │ │ │ ├── importdialog.ts │ │ │ │ ├── pgnimporter.ts │ │ │ │ ├── pgnimportprogress.ts │ │ │ │ ├── pgnparser.test.ts │ │ │ │ ├── pgnparser.ts │ │ │ │ ├── repertoireincrementalconverter.ts │ │ │ │ └── treemodelpopulator.ts │ │ │ ├── renameinput.ts │ │ │ └── treecontroller.ts │ │ ├── common │ │ │ ├── config.ts │ │ │ ├── debouncer.test.ts │ │ │ ├── debouncer.ts │ │ │ ├── listrefreshableview.ts │ │ │ ├── refreshableview.ts │ │ │ ├── toasts.ts │ │ │ ├── tooltips.ts │ │ │ └── viewinfo.ts │ │ ├── evaluate │ │ │ ├── annotation │ │ │ │ ├── statisticannotation.ts │ │ │ │ ├── statisticannotationrenderer.ts │ │ │ │ └── statisticannotator.ts │ │ │ ├── childmovedrawer.ts │ │ │ ├── evaluateboardhandler.ts │ │ │ ├── evaluatemode.ts │ │ │ ├── insights │ │ │ │ ├── insight.ts │ │ │ │ ├── insightcalculator.ts │ │ │ │ └── insightspanel.ts │ │ │ └── repertoirenamelabel.ts │ │ ├── footer │ │ │ └── footerlinks.ts │ │ ├── impressions │ │ │ ├── debouncingimpressionsender.ts │ │ │ └── impressionsender.ts │ │ ├── lib │ │ │ ├── auth0.min.js │ │ │ ├── chess.min.js │ │ │ ├── chessground.min.js │ │ │ ├── jquery.min.js │ │ │ ├── micromodal.min.js │ │ │ ├── pgnparser.js │ │ │ ├── tippy.all.min.js │ │ │ └── toastr.min.js │ │ ├── main.ts │ │ ├── mode │ │ │ ├── mode.ts │ │ │ ├── modemanager.test.ts │ │ │ ├── modemanager.ts │ │ │ ├── modetype.ts │ │ │ └── noopmode.ts │ │ ├── picker │ │ │ ├── confirmdeletedialog.ts │ │ │ ├── pickercontroller.ts │ │ │ ├── pickerfeature.ts │ │ │ ├── pickermodel.test.ts │ │ │ ├── pickermodel.ts │ │ │ └── pickerview.ts │ │ ├── preferences │ │ │ ├── preferenceloader.ts │ │ │ └── preferencesaver.ts │ │ ├── priveleged │ │ │ ├── privelegedcopydialog.ts │ │ │ └── privelegedfeature.ts │ │ ├── server │ │ │ ├── accesstokenserverwrapper.ts │ │ │ ├── delegatingserverwrapper.ts │ │ │ ├── evaluatedflagfetcher.ts │ │ │ ├── localstorageserverwrapper.test.ts │ │ │ ├── localstorageserverwrapper.ts │ │ │ └── serverwrapper.ts │ │ ├── sound │ │ │ ├── soundplayer.ts │ │ │ └── soundtoggler.ts │ │ ├── statistics │ │ │ ├── debouncingstatisticsrecorder.ts │ │ │ ├── delegatingstatisticsmodel.ts │ │ │ ├── serverstatisticsmodel.ts │ │ │ ├── statisticrecorder.ts │ │ │ ├── statisticsmodel.ts │ │ │ └── zerostatisticsmodel.ts │ │ ├── study │ │ │ ├── line.ts │ │ │ ├── lineemitter.ts │ │ │ ├── lineliststudier.ts │ │ │ ├── lineshuffler.ts │ │ │ ├── linestudier.ts │ │ │ ├── studyboardhandler.ts │ │ │ └── studymode.ts │ │ ├── theme │ │ │ ├── boardthemeinfo.ts │ │ │ ├── boardthemesetter.ts │ │ │ └── themepalette.ts │ │ └── tree │ │ │ ├── addmoveresult.ts │ │ │ ├── emptymessage.ts │ │ │ ├── fennormalizer.ts │ │ │ ├── fentopgnmap.ts │ │ │ ├── pgntonodemap.ts │ │ │ ├── treebutton.ts │ │ │ ├── treebuttons.ts │ │ │ ├── treemodel.ts │ │ │ ├── treenavigator.ts │ │ │ ├── treenode.ts │ │ │ ├── treenodehandler.ts │ │ │ └── treeview.ts │ ├── main.html │ └── ogg │ │ ├── capture.ogg │ │ ├── finishline.ogg │ │ ├── move.ogg │ │ └── wrongmove.ogg ├── flag │ ├── flags.ts │ └── rolloutstate.ts ├── protocol │ ├── actions.ts │ ├── boardtheme.ts │ ├── color.ts │ ├── evaluatedflags.ts │ ├── impression │ │ ├── extradata.test.ts │ │ ├── extradata.ts │ │ ├── impression.ts │ │ └── impressioncode.ts │ ├── move.ts │ ├── preference │ │ ├── preference.test.ts │ │ └── preference.ts │ ├── soundvalue.ts │ ├── statistic │ │ ├── cumulatedstatistic.ts │ │ ├── statistic.ts │ │ └── statistictype.ts │ └── storage.ts ├── server │ ├── action.ts │ ├── actions │ │ ├── createrepertoireaction.ts │ │ ├── deleterepertoireaction.ts │ │ ├── evaluateflagsaction.ts │ │ ├── getpreferenceaction.ts │ │ ├── loadcumulatedstatisticsaction.ts │ │ ├── loadrepertoireaction.ts │ │ ├── logimpressionsaction.ts │ │ ├── privelegedcopyaction.ts │ │ ├── recordstatisticsaction.ts │ │ ├── repertoiremetadataaction.ts │ │ ├── setpreferenceaction.ts │ │ └── updaterepertoireaction.ts │ ├── checkrequestresult.ts │ ├── databasewrapper.ts │ ├── endpointregistry.ts │ ├── flagevaluator.ts │ ├── main.ts │ ├── privelegedusers.ts │ └── server.ts ├── tools │ ├── generate-pgn-parser.sh │ └── grammar.peg └── util │ ├── assert.ts │ ├── datetime.ts │ └── random.ts ├── tsconfig.json ├── tslint.json ├── tstypes └── express-jwt-authz │ └── index.d.ts └── webpack.config.js /.env.local: -------------------------------------------------------------------------------- 1 | DATABASE_PATH=mongodb://127.0.0.1:27017 2 | ENABLE_LOCAL_FLAGS=1 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/* 3 | package-lock.json 4 | .env 5 | build/* -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Agustin O Venezuela III 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, 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, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # studyopenings 2 | 3 | > **UPDATE (Dec 31 2024):** After 5 years of serving studyopenings in production, I've decided to take down the site. I've financed the server costs entirely out-of-pocket and maintained the site entirely in my free time alongside a full-time job and, though I've enjoyed it until now, I no longer wish to pursue this. I wish studyopenings users the best of luck in their chess improvement journey. 4 | 5 | ![StudyOpenings - Build mode](https://raw.githubusercontent.com/jven/studyopenings/master/doc/build.png) 6 | 7 | ## What is this? 8 | A tool to help chess players memorize opening repertoires. 9 | 10 | The tool lets you: 11 | 12 | - _build_ repertoires by playing opening moves on a board and 13 | 14 | - _study_ repertoires by repeatedly recalling one side of the opening lines. 15 | 16 | ## Credits 17 | - Chessboard UI uses lichess' [Chessground](https://github.com/ornicar/chessground). 18 | - Chess game logic uses [chess.js](https://github.com/jhlywa/chess.js/). 19 | - PGN parser uses [PEG.js](https://pegjs.org/) and the grammar from [kevinludwig/pgn-parser](https://github.com/kevinludwig/pgn-parser). 20 | - Authentication uses [Auth0](https://auth0.com/). 21 | - Icons from [the Noun Project](https://thenounproject.com/). 22 | - Tooltips use [tippy.js](https://atomiks.github.io/tippyjs/). 23 | - Toasts use [toastr](https://github.com/CodeSeven/toastr). 24 | - Sounds from [lichess](https://github.com/ornicar/lila/tree/master/public/sound) and use [howler.js](https://howlerjs.com). 25 | - Feedback mechanism uses [Doorbell.io](https://doorbell.io). 26 | - Database uses [MongoDB](https://www.mongodb.com) and is hosted on [MongoDB Atlas](https://www.mongodb.com/cloud/atlas). 27 | - Server written in [Node.js](http://nodejs.org). 28 | - Client bundling uses [Webpack](https://webpack.js.org). 29 | - Unit tests use [Jest](https://jestjs.io) and [ts-jest](https://github.com/kulshekhar/ts-jest). 30 | 31 | ## Running locally 32 | 1. Clone the repository. 33 | 2. [Install MongoDB](https://docs.mongodb.com) if necessary. Then start a local MongoDB database instance: 34 | ```shell 35 | $ mongod --dbpath ~/data/db --port 27017 36 | [...] 37 | waiting for connections on port 27017 38 | ``` 39 | 3. Copy the `.env` file which points the application to your local database: 40 | ```shell 41 | studyopenings/ $ cp .env.local .env 42 | ``` 43 | 4. Run the server: 44 | ```shell 45 | studyopenings/ $ npm install 46 | studyopenings/ $ npm run start-dev 47 | [...] 48 | studyopenings is running! 49 | Listening on 5000. 50 | Using database path: mongodb://127.0.0.1:27017 51 | ``` 52 | 53 | 5. Go to http://localhost:5000. 54 | 55 | ## Running tests 56 | 57 | To run all the tests once: 58 | ```shell 59 | studyopenings/ $ npm install 60 | studyopenings/ $ npm run test 61 | [...] 62 | Ran all test suites. 63 | ``` 64 | 65 | To run the tests continuously as changes are made: 66 | 67 | ```shell 68 | studyopenings/ $ npm install 69 | studyopenings/ $ jest --watch 70 | ``` 71 | 72 | ## Author 73 | Justin Venezuela • jven@jvenezue.la • http://jvenezue.la 74 | -------------------------------------------------------------------------------- /doc/build.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jven/studyopenings/55ac1b6ad98589744440c4677b9664e56c402db6/doc/build.png -------------------------------------------------------------------------------- /doc/study.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jven/studyopenings/55ac1b6ad98589744440c4677b9664e56c402db6/doc/study.png -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'jsdom', 4 | }; -------------------------------------------------------------------------------- /mongoscripts/README.md: -------------------------------------------------------------------------------- 1 | # mongoscripts 2 | 3 | ## Running scripts 4 | 5 | For example, to run the `largestrepertoire.js` script locally: 6 | 7 | ```shell 8 | $ mongo mongoscripts/largestrepertoire.js 9 | [...] 10 | ObjectId("largestRepertoireId") 11 | ``` 12 | 13 | To run in production: 14 | 15 | ```shell 16 | $ mongo "mongodb+srv://path-to-database.mongodb.net/test" --username db-user mongoscripts/largestrepertoire.js 17 | [...] 18 | ObjectId("largestRepertoireId") 19 | ``` 20 | -------------------------------------------------------------------------------- /mongoscripts/largestrepertoire.js: -------------------------------------------------------------------------------- 1 | var db = db.getSiblingDB('studyopenings'); 2 | var maxObj = null; 3 | var maxSize = 0; 4 | db.repertoires.find().forEach(function(obj) { var size = Object.bsonsize(obj); if (size > maxSize) { maxSize = size; maxObj = obj; } } ); 5 | 6 | printjson(maxObj._id); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "studyopenings", 3 | "version": "1.0.0", 4 | "description": "An opening a day takes the blunders away!", 5 | "scripts": { 6 | "clean": "rm -rf build/*", 7 | "compile": "tsc", 8 | "copystatic": "mkdir -p build/client && cp -r src/client/css src/client/img src/client/ogg src/client/*.html src/client/*.ico build/client", 9 | "copyclientlib": "mkdir -p build/client/js && cp -r src/client/js/lib build/client/js", 10 | "fixlint": "tslint --fix 'src/**/*.ts'", 11 | "lint": "tslint 'src/**/*.ts'", 12 | "start": "npm run compile && (npm run webpack & (npm run copystatic && npm run copyclientlib && node build/server/main.js))", 13 | "start-dev": "npm run lint && npm run compile && npm run copystatic && npm run copyclientlib && npm run webpack-dev && node build/server/main.js", 14 | "test": "jest", 15 | "webpack": "npx webpack --mode=production", 16 | "webpack-dev": "npx webpack --mode=development" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "git+https://github.com/jven/studyopenings.git" 21 | }, 22 | "author": "", 23 | "license": "ISC", 24 | "bugs": { 25 | "url": "https://github.com/jven/studyopenings/issues" 26 | }, 27 | "homepage": "https://github.com/jven/studyopenings#readme", 28 | "dependencies": { 29 | "@types/auth0-js": "8.11.7", 30 | "@types/cors": "2.8.8", 31 | "@types/crypto-random-string": "1.0.0", 32 | "@types/dotenv": "^6.1.0", 33 | "@types/express": "4.16.0", 34 | "@types/express-jwt": "0.0.41", 35 | "@types/howler": "2.0.5", 36 | "@types/mongodb": "3.5.31", 37 | "@types/node": "14.11.2", 38 | "@types/toastr": "2.1.35", 39 | "auth0-js": "9.8.2", 40 | "body-parser": "1.18.3", 41 | "chessground": "7.9.2", 42 | "cors": "2.8.5", 43 | "crypto-random-string": "1.0.0", 44 | "dotenv": "6.2.0", 45 | "express": "4.15.2", 46 | "express-jwt": "5.3.1", 47 | "express-jwt-authz": "1.0.0", 48 | "howler": "2.2.1", 49 | "http": "0.0.0", 50 | "jwks-rsa": "1.11.0", 51 | "mongodb": "3.1.10", 52 | "typescript": "3.9.7", 53 | "webpack": "4.27.1", 54 | "webpack-cli": "3.1.2" 55 | }, 56 | "engines": { 57 | "node": "10.3.0" 58 | }, 59 | "devDependencies": { 60 | "@types/jest": "^23.3.10", 61 | "jest": "^23.6.0", 62 | "jest-cli": "^23.6.0", 63 | "pegjs": "^0.10.0", 64 | "ts-jest": "^23.10.5", 65 | "tslint": "^6.1.3" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/client/about.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Study openings! • About 8 | 9 | 10 | 11 | 12 | 13 |
14 | 15 | 18 |
19 |
20 |
21 |

What is StudyOpenings?

22 |

StudyOpenings is a free, open-sourced tool to help chess players memorize opening repertoires.

23 | 24 |

Where's the source code?

25 | https://github.com/jven/studyopenings 26 |

Looking to contribute? Send a pull request!

27 | 28 |

I want to donate!

29 |

Thanks so much! You can support me on Patreon here: https://www.patreon.com/studyopenings.

30 |

Of course, this is entirely optional. StudyOpenings.com will always be free and open-sourced and there will never be any "premium" features.

31 | 32 |

I have another question.

33 |

Send an e-mail to jven@jvenezue.la.

34 |
35 | 36 | -------------------------------------------------------------------------------- /src/client/css/about.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: var(--dark-gray-bg); 3 | background-image: var(--dark-gray-gradient); 4 | color: var(--light-gray-fg); 5 | font-family: 'Open Sans', sans-serif; 6 | } 7 | 8 | #title { 9 | float: left; 10 | font-family: 'Niramit', 'Open Sans', sans-serif; 11 | font-size: 26px; 12 | font-weight: 700; 13 | text-shadow: 3px 3px 2px rgba(0, 0, 0, 1); 14 | } 15 | 16 | #title > a { 17 | color: var(--light-gray-fg); 18 | text-decoration: none; 19 | } 20 | 21 | #headerContainer { 22 | margin: 0 auto; 23 | padding: 10px 0; 24 | max-width: 800px; 25 | width: 90%; 26 | } 27 | 28 | #logo { 29 | position: relative; 30 | width: 60px; 31 | } 32 | 33 | #header { 34 | border-bottom: var(--light-gray-border); 35 | float: right; 36 | font-size: 14px; 37 | height: 40px; 38 | text-align: right; 39 | width: calc(100% - 80px); 40 | } 41 | 42 | #content { 43 | background-color: var(--light-gray-bg); 44 | border: var(--very-dark-gray-border); 45 | font-size: 14px; 46 | margin: 0 auto; 47 | max-width: 600px; 48 | padding: 15px; 49 | width: 90%; 50 | } 51 | 52 | #content h1 { 53 | font-size: 20px; 54 | margin: 20px 0 10px 0; 55 | padding: 0; 56 | text-shadow: 0 2px 0 black; 57 | } 58 | 59 | #content p { 60 | margin: 10px 0; 61 | } 62 | 63 | #content a { 64 | color: var(--link-blue-fg); 65 | text-decoration: none; 66 | } 67 | -------------------------------------------------------------------------------- /src/client/css/board.css: -------------------------------------------------------------------------------- 1 | /** Blue */ 2 | 3 | #blueBoardThemeButton, 4 | .blueBoardTheme { 5 | background-image: url('/img/board/blue.svg'); 6 | } 7 | 8 | .blueBoardThemePreview { 9 | background-image: url('/img/board/blue.svg') !important; 10 | } 11 | 12 | /** Brown */ 13 | 14 | #brownBoardThemeButton, 15 | .brownBoardTheme { 16 | background-image: url('/img/board/brown.svg'); 17 | } 18 | 19 | .brownBoardThemePreview { 20 | background-image: url('/img/board/brown.svg') !important; 21 | } 22 | 23 | /** Gray */ 24 | 25 | #grayBoardThemeButton, 26 | .grayBoardTheme { 27 | background-image: url('/img/board/gray.jpg'); 28 | } 29 | 30 | .grayBoardThemePreview { 31 | background-image: url('/img/board/gray.jpg') !important; 32 | } 33 | 34 | /** Green */ 35 | 36 | #greenBoardThemeButton, 37 | .greenBoardTheme { 38 | background-image: url('/img/board/green.svg'); 39 | } 40 | 41 | .greenBoardThemePreview { 42 | background-image: url('/img/board/green.svg') !important; 43 | } 44 | 45 | /** Leather */ 46 | 47 | #leatherBoardThemeButton, 48 | .leatherBoardTheme { 49 | background-image: url('/img/board/leather.jpg'); 50 | } 51 | 52 | .leatherBoardThemePreview { 53 | background-image: url('/img/board/leather.jpg') !important; 54 | } 55 | 56 | /** Marble */ 57 | 58 | #marbleBoardThemeButton, 59 | .marbleBoardTheme { 60 | background-image: url('/img/board/marble.jpg'); 61 | } 62 | 63 | .marbleBoardThemePreview { 64 | background-image: url('/img/board/marble.jpg') !important; 65 | } 66 | 67 | /** Purple */ 68 | 69 | #purpleBoardThemeButton, 70 | .purpleBoardTheme { 71 | background-image: url('/img/board/purple.svg'); 72 | } 73 | 74 | .purpleBoardThemePreview { 75 | background-image: url('/img/board/purple.svg') !important; 76 | } 77 | 78 | /** Wood1 */ 79 | 80 | #wood1BoardThemeButton, 81 | .wood1BoardTheme { 82 | background-image: url('/img/board/wood1.jpg'); 83 | } 84 | 85 | .wood1BoardThemePreview { 86 | background-image: url('/img/board/wood1.jpg') !important; 87 | } 88 | 89 | /** Wood2 */ 90 | 91 | #wood2BoardThemeButton, 92 | .wood2BoardTheme { 93 | background-image: url('/img/board/wood2.jpg'); 94 | } 95 | 96 | .wood2BoardThemePreview { 97 | background-image: url('/img/board/wood2.jpg') !important; 98 | } 99 | 100 | /** Wood3 */ 101 | 102 | #wood3BoardThemeButton, 103 | .wood3BoardTheme { 104 | background-image: url('/img/board/wood3.jpg'); 105 | } 106 | 107 | .wood3BoardThemePreview { 108 | background-image: url('/img/board/wood3.jpg') !important; 109 | } 110 | -------------------------------------------------------------------------------- /src/client/css/chessground.css: -------------------------------------------------------------------------------- 1 | /* 2 | * Chessground base css properties. 3 | * 4 | * You need to include the css files in themes folder in order to have the 5 | * board and pieces displayed! 6 | */ 7 | 8 | .cg-wrap { 9 | width: 320px; 10 | height: 320px; 11 | position: relative; 12 | display: block; 13 | } 14 | cg-board { 15 | position: absolute; 16 | top: 0; 17 | left: 0; 18 | width: 100%; 19 | height: 100%; 20 | -webkit-user-select: none; 21 | -moz-user-select: none; 22 | -ms-user-select: none; 23 | user-select: none; 24 | line-height: 0; 25 | background-size: cover; 26 | cursor: pointer; 27 | } 28 | cg-board square { 29 | position: absolute; 30 | top: 0; 31 | left: 0; 32 | width: 12.5%; 33 | height: 12.5%; 34 | pointer-events: none; 35 | } 36 | cg-board square.move-dest { 37 | background: radial-gradient(rgba(20, 85, 30, 0.5) 22%, #208530 0, rgba(0, 0, 0, 0.3) 0, rgba(0, 0, 0, 0) 0); 38 | pointer-events: auto; 39 | } 40 | cg-board square.premove-dest { 41 | background: radial-gradient(rgba(20, 30, 85, 0.5) 22%, #203085 0, rgba(0, 0, 0, 0.3) 0, rgba(0, 0, 0, 0) 0); 42 | } 43 | cg-board square.oc.move-dest { 44 | background: radial-gradient(transparent 0%, transparent 80%, rgba(20, 85, 0, 0.3) 80%); 45 | } 46 | cg-board square.oc.premove-dest { 47 | background: radial-gradient(transparent 0%, transparent 80%, rgba(20, 30, 85, 0.2) 80%); 48 | } 49 | cg-board square.move-dest:hover { 50 | background: rgba(20, 85, 30, 0.3); 51 | } 52 | cg-board square.premove-dest:hover { 53 | background: rgba(20, 30, 85, 0.2); 54 | } 55 | cg-board square.last-move { 56 | will-change: transform; 57 | background-color: rgba(155, 199, 0, 0.41); 58 | } 59 | cg-board square.selected { 60 | background-color: rgba(20, 85, 30, 0.5); 61 | } 62 | cg-board square.check { 63 | background: radial-gradient(ellipse at center, rgba(255, 0, 0, 1) 0%, rgba(231, 0, 0, 1) 25%, rgba(169, 0, 0, 0) 89%, rgba(158, 0, 0, 0) 100%); 64 | } 65 | cg-board square.current-premove { 66 | background-color: rgba(20, 30, 85, 0.5); 67 | } 68 | .cg-wrap piece { 69 | position: absolute; 70 | top: 0; 71 | left: 0; 72 | width: 12.5%; 73 | height: 12.5%; 74 | background-size: cover; 75 | z-index: 2; 76 | will-change: transform; 77 | pointer-events: none; 78 | } 79 | cg-board piece.dragging { 80 | cursor: move; 81 | z-index: 9; 82 | } 83 | cg-board piece.anim { 84 | z-index: 8; 85 | } 86 | cg-board piece.fading { 87 | z-index: 1; 88 | opacity: 0.5; 89 | } 90 | .cg-wrap square.move-dest:hover { 91 | background-color: rgba(20, 85, 30, 0.3); 92 | } 93 | .cg-wrap piece.ghost { 94 | opacity: 0.3; 95 | } 96 | .cg-wrap svg { 97 | overflow: hidden; 98 | position: relative; 99 | top: 0px; 100 | left: 0px; 101 | width: 100%; 102 | height: 100%; 103 | pointer-events: none; 104 | z-index: 2; 105 | opacity: 0.6; 106 | } 107 | .cg-wrap svg image { 108 | opacity: 0.5; 109 | } 110 | .cg-wrap coords { 111 | position: absolute; 112 | display: flex; 113 | pointer-events: none; 114 | opacity: 0.8; 115 | font-size: 9px; 116 | } 117 | .cg-wrap coords.ranks { 118 | right: -15px; 119 | top: 0; 120 | flex-flow: column-reverse; 121 | height: 100%; 122 | width: 12px; 123 | } 124 | .cg-wrap coords.ranks.black { 125 | flex-flow: column; 126 | } 127 | .cg-wrap coords.files { 128 | bottom: -16px; 129 | left: 0; 130 | flex-flow: row; 131 | width: 100%; 132 | height: 16px; 133 | text-transform: uppercase; 134 | text-align: center; 135 | } 136 | .cg-wrap coords.files.black { 137 | flex-flow: row-reverse; 138 | } 139 | .cg-wrap coords coord { 140 | flex: 1 1 auto; 141 | } 142 | .cg-wrap coords.ranks coord { 143 | transform: translateY(39%); 144 | } 145 | -------------------------------------------------------------------------------- /src/client/css/constants.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --dark-gray-bg: rgb(30, 30, 30); 3 | --light-gray-bg: rgb(40, 40, 40); 4 | --very-light-gray-bg: rgb(100, 100, 100); 5 | --empty-study-bg: rgba(30, 30, 30, 0.8); 6 | --gray-hover-bg: rgb(85, 85, 85); 7 | --blue-hover-bg: rgba(56, 147, 232, 0.4); 8 | --blue-selected-bg: rgb(56, 147, 232); 9 | --modal-gray-bg: #2b2b2b; 10 | --delete-red-bg: #bd3b31; 11 | 12 | --dark-gray-box-shadow: 0 0 4px #dfdfdf inset; 13 | --very-dark-gray-border: solid 1px rgb(50, 50, 50); 14 | --dark-gray-border: solid 1px #8f8f8f; 15 | --light-gray-border: solid 1px #dfdfdf; 16 | --tree-border: solid 1px #555555; 17 | --modal-box-shadow: 0 0 95px 25px rgba(0, 0, 0, 0.6); 18 | 19 | --dark-gray-gradient: linear-gradient( 20 | to bottom, 21 | rgb(50, 50, 50), 22 | rgb(30, 30, 30) 23 | 116px); 24 | 25 | --light-gray-fg: #dfdfdf; 26 | --dark-gray-fg: #8f8f8f; 27 | --link-blue-fg: #3893e8; 28 | 29 | --wrong-move-flash: red; 30 | --right-move-flash: rgb(155, 199, 0); 31 | --finish-line-flash: #ffd700; 32 | 33 | --warning-selected-node-bg: rgb(255, 255, 204); 34 | 35 | --transposition-node-bg: rgba(120, 120, 120, 0.4); 36 | --transposition-hover-node-bg: rgba(120, 120, 120, 0.7); 37 | --transposition-selected-node-bg: rgb(120, 120, 120); 38 | 39 | --warning-tooltip-bg: #ffc; 40 | } -------------------------------------------------------------------------------- /src/client/css/pieces.css: -------------------------------------------------------------------------------- 1 | .cg-wrap piece.pawn.white { 2 | background-image: url('/img/pieces/merida/wP.svg'); 3 | } 4 | .cg-wrap piece.bishop.white { 5 | background-image: url('/img/pieces/merida/wB.svg'); 6 | } 7 | .cg-wrap piece.knight.white { 8 | background-image: url('/img/pieces/merida/wN.svg'); 9 | } 10 | .cg-wrap piece.rook.white { 11 | background-image: url('/img/pieces/merida/wR.svg'); 12 | } 13 | .cg-wrap piece.queen.white { 14 | background-image: url('/img/pieces/merida/wQ.svg'); 15 | } 16 | .cg-wrap piece.king.white { 17 | background-image: url('/img/pieces/merida/wK.svg'); 18 | } 19 | .cg-wrap piece.pawn.black { 20 | background-image: url('/img/pieces/merida/bP.svg'); 21 | } 22 | .cg-wrap piece.bishop.black { 23 | background-image: url('/img/pieces/merida/bB.svg'); 24 | } 25 | .cg-wrap piece.knight.black { 26 | background-image: url('/img/pieces/merida/bN.svg'); 27 | } 28 | .cg-wrap piece.rook.black { 29 | background-image: url('/img/pieces/merida/bR.svg'); 30 | } 31 | .cg-wrap piece.queen.black { 32 | background-image: url('/img/pieces/merida/bQ.svg'); 33 | } 34 | .cg-wrap piece.king.black { 35 | background-image: url('/img/pieces/merida/bK.svg'); 36 | } 37 | -------------------------------------------------------------------------------- /src/client/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jven/studyopenings/55ac1b6ad98589744440c4677b9664e56c402db6/src/client/favicon.ico -------------------------------------------------------------------------------- /src/client/img/board/blue.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/client/img/board/brown.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/client/img/board/gray.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jven/studyopenings/55ac1b6ad98589744440c4677b9664e56c402db6/src/client/img/board/gray.jpg -------------------------------------------------------------------------------- /src/client/img/board/green.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/client/img/board/leather.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jven/studyopenings/55ac1b6ad98589744440c4677b9664e56c402db6/src/client/img/board/leather.jpg -------------------------------------------------------------------------------- /src/client/img/board/marble.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jven/studyopenings/55ac1b6ad98589744440c4677b9664e56c402db6/src/client/img/board/marble.jpg -------------------------------------------------------------------------------- /src/client/img/board/purple.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/client/img/board/wood1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jven/studyopenings/55ac1b6ad98589744440c4677b9664e56c402db6/src/client/img/board/wood1.jpg -------------------------------------------------------------------------------- /src/client/img/board/wood2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jven/studyopenings/55ac1b6ad98589744440c4677b9664e56c402db6/src/client/img/board/wood2.jpg -------------------------------------------------------------------------------- /src/client/img/board/wood3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jven/studyopenings/55ac1b6ad98589744440c4677b9664e56c402db6/src/client/img/board/wood3.jpg -------------------------------------------------------------------------------- /src/client/img/export.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jven/studyopenings/55ac1b6ad98589744440c4677b9664e56c402db6/src/client/img/export.png -------------------------------------------------------------------------------- /src/client/img/faviconbright.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jven/studyopenings/55ac1b6ad98589744440c4677b9664e56c402db6/src/client/img/faviconbright.ico -------------------------------------------------------------------------------- /src/client/img/faviconbright2.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jven/studyopenings/55ac1b6ad98589744440c4677b9664e56c402db6/src/client/img/faviconbright2.ico -------------------------------------------------------------------------------- /src/client/img/favicondark.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jven/studyopenings/55ac1b6ad98589744440c4677b9664e56c402db6/src/client/img/favicondark.ico -------------------------------------------------------------------------------- /src/client/img/import.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jven/studyopenings/55ac1b6ad98589744440c4677b9664e56c402db6/src/client/img/import.png -------------------------------------------------------------------------------- /src/client/img/left.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jven/studyopenings/55ac1b6ad98589744440c4677b9664e56c402db6/src/client/img/left.png -------------------------------------------------------------------------------- /src/client/img/logobright.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jven/studyopenings/55ac1b6ad98589744440c4677b9664e56c402db6/src/client/img/logobright.png -------------------------------------------------------------------------------- /src/client/img/logobright150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jven/studyopenings/55ac1b6ad98589744440c4677b9664e56c402db6/src/client/img/logobright150.png -------------------------------------------------------------------------------- /src/client/img/logodark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jven/studyopenings/55ac1b6ad98589744440c4677b9664e56c402db6/src/client/img/logodark.png -------------------------------------------------------------------------------- /src/client/img/logodark150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jven/studyopenings/55ac1b6ad98589744440c4677b9664e56c402db6/src/client/img/logodark150.png -------------------------------------------------------------------------------- /src/client/img/pencil.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jven/studyopenings/55ac1b6ad98589744440c4677b9664e56c402db6/src/client/img/pencil.png -------------------------------------------------------------------------------- /src/client/img/pieces/merida/bB.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/client/img/pieces/merida/bN.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/client/img/pieces/merida/bP.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/client/img/pieces/merida/bQ.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/client/img/pieces/merida/bR.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/client/img/pieces/merida/wB.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/client/img/pieces/merida/wK.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/client/img/pieces/merida/wN.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/client/img/pieces/merida/wP.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/client/img/pieces/merida/wQ.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/client/img/pieces/merida/wR.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/client/img/plus.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jven/studyopenings/55ac1b6ad98589744440c4677b9664e56c402db6/src/client/img/plus.png -------------------------------------------------------------------------------- /src/client/img/soundoff.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jven/studyopenings/55ac1b6ad98589744440c4677b9664e56c402db6/src/client/img/soundoff.png -------------------------------------------------------------------------------- /src/client/img/soundon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jven/studyopenings/55ac1b6ad98589744440c4677b9664e56c402db6/src/client/img/soundon.png -------------------------------------------------------------------------------- /src/client/img/theme.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jven/studyopenings/55ac1b6ad98589744440c4677b9664e56c402db6/src/client/img/theme.png -------------------------------------------------------------------------------- /src/client/img/trash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jven/studyopenings/55ac1b6ad98589744440c4677b9664e56c402db6/src/client/img/trash.png -------------------------------------------------------------------------------- /src/client/img/x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jven/studyopenings/55ac1b6ad98589744440c4677b9664e56c402db6/src/client/img/x.png -------------------------------------------------------------------------------- /src/client/js/annotation/annotationrenderer.ts: -------------------------------------------------------------------------------- 1 | export interface AnnotationRenderer { 2 | renderAnnotation( 3 | annotation: ANNOTATION, treeNodeElement: HTMLElement): void; 4 | } 5 | -------------------------------------------------------------------------------- /src/client/js/annotation/annotator.ts: -------------------------------------------------------------------------------- 1 | import { Color } from '../../../protocol/color'; 2 | import { FenToPgnMap } from '../tree/fentopgnmap'; 3 | import { PgnToNodeMap } from '../tree/pgntonodemap'; 4 | import { TreeNode } from '../tree/treenode'; 5 | 6 | export interface Annotator { 7 | annotate( 8 | node: TreeNode, 9 | repertoireColor: Color, 10 | pgnToNode: PgnToNodeMap, 11 | fenToPgn: FenToPgnMap): Promise; 12 | } 13 | -------------------------------------------------------------------------------- /src/client/js/annotation/noopannotationrenderer.ts: -------------------------------------------------------------------------------- 1 | import { AnnotationRenderer } from './annotationrenderer'; 2 | 3 | export class NoOpAnnotationRenderer implements AnnotationRenderer { 4 | renderAnnotation(): void {} 5 | } 6 | -------------------------------------------------------------------------------- /src/client/js/annotation/nullannotator.ts: -------------------------------------------------------------------------------- 1 | import { Annotator } from './annotator'; 2 | 3 | export class NullAnnotator implements Annotator { 4 | annotate(): Promise { 5 | return Promise.resolve(null); 6 | } 7 | 8 | static INSTANCE: Annotator = new NullAnnotator(); 9 | } 10 | -------------------------------------------------------------------------------- /src/client/js/board/board.ts: -------------------------------------------------------------------------------- 1 | import { Color } from '../../../protocol/color'; 2 | 3 | export interface Board { 4 | redraw(): void; 5 | 6 | setStateFromChess(chess: any): void; 7 | 8 | setInitialPositionImmediately(): void; 9 | 10 | setOrientationForColor(color: Color): void; 11 | 12 | flashRightMove(): void; 13 | 14 | flashWrongMove(): void; 15 | 16 | flashFinishLine(): void; 17 | 18 | drawCircle(square: string, color: string): void; 19 | 20 | drawArrow(from: string, to: string, color: string): void; 21 | 22 | removeDrawings(): void; 23 | } 24 | -------------------------------------------------------------------------------- /src/client/js/board/boardhandler.ts: -------------------------------------------------------------------------------- 1 | export interface BoardHandler { 2 | /** 3 | * Handles the user dragging a piece from one square to another, before the 4 | * board position is updated. 5 | */ 6 | onMove(from: string, to: string): void; 7 | 8 | /** Handles the board position being updated. */ 9 | onChange(): void; 10 | 11 | /** Handles the user scrolling on the board. */ 12 | onScroll(e: WheelEvent): void; 13 | } 14 | -------------------------------------------------------------------------------- /src/client/js/board/chessgroundboardfactory.ts: -------------------------------------------------------------------------------- 1 | import { assert } from '../../../util/assert'; 2 | import { SoundPlayer } from '../sound/soundplayer'; 3 | import { BoardHandler } from './boardhandler'; 4 | import { ChessgroundBoard } from './chessgroundboard'; 5 | import { DelegatingBoard } from './delegatingboard'; 6 | 7 | export class ChessgroundBoardFactory { 8 | private soundPlayer_: SoundPlayer; 9 | private boardEls_: HTMLElement[]; 10 | 11 | constructor(soundPlayer: SoundPlayer) { 12 | this.soundPlayer_ = soundPlayer; 13 | this.boardEls_ = []; 14 | } 15 | 16 | createBoardAndSetDelegate( 17 | delegatingBoard: DelegatingBoard, 18 | boardId: string, 19 | handler: BoardHandler, 20 | viewOnly: boolean): void { 21 | const boardEl = assert(document.getElementById(boardId)); 22 | const chessgroundBoard = new ChessgroundBoard( 23 | boardEl, 24 | handler, 25 | this.soundPlayer_, 26 | viewOnly); 27 | 28 | delegatingBoard.setDelegate(chessgroundBoard); 29 | this.boardEls_.push(boardEl); 30 | } 31 | 32 | getBoardElements(): HTMLElement[] { 33 | return this.boardEls_; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/client/js/board/delegatingboard.ts: -------------------------------------------------------------------------------- 1 | import { Color } from '../../../protocol/color'; 2 | import { Board } from './board'; 3 | import { NoOpBoard } from './noopboard'; 4 | 5 | export class DelegatingBoard implements Board { 6 | private delegate_: Board; 7 | 8 | constructor() { 9 | this.delegate_ = new NoOpBoard(); 10 | } 11 | 12 | setDelegate(delegate: Board): void { 13 | this.delegate_ = delegate; 14 | } 15 | 16 | redraw(): void { 17 | this.delegate_.redraw(); 18 | } 19 | 20 | setStateFromChess(chess: any): void { 21 | this.delegate_.setStateFromChess(chess); 22 | } 23 | 24 | setInitialPositionImmediately(): void { 25 | this.delegate_.setInitialPositionImmediately(); 26 | } 27 | 28 | setOrientationForColor(color: Color): void { 29 | this.delegate_.setOrientationForColor(color); 30 | } 31 | 32 | flashRightMove(): void { 33 | this.delegate_.flashRightMove(); 34 | } 35 | 36 | flashWrongMove(): void { 37 | this.delegate_.flashWrongMove(); 38 | } 39 | 40 | flashFinishLine(): void { 41 | this.delegate_.flashFinishLine(); 42 | } 43 | 44 | drawCircle(square: string, color: string): void { 45 | this.delegate_.drawCircle(square, color); 46 | } 47 | 48 | drawArrow(from: string, to: string, color: string): void { 49 | this.delegate_.drawArrow(from, to, color); 50 | } 51 | 52 | removeDrawings(): void { 53 | this.delegate_.removeDrawings(); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/client/js/board/noopboard.ts: -------------------------------------------------------------------------------- 1 | import { Color } from '../../../protocol/color'; 2 | import { Board } from './board'; 3 | 4 | export class NoOpBoard implements Board { 5 | redraw(): void {} 6 | 7 | setStateFromChess(chess: any): void {} 8 | 9 | setInitialPositionImmediately(): void {} 10 | 11 | setOrientationForColor(color: Color): void {} 12 | 13 | flashRightMove(): void {} 14 | 15 | flashWrongMove(): void {} 16 | 17 | flashFinishLine(): void {} 18 | 19 | drawCircle(square: string, color: string): void {} 20 | 21 | drawArrow(from: string, to: string, color: string): void {} 22 | 23 | removeDrawings(): void {} 24 | } 25 | -------------------------------------------------------------------------------- /src/client/js/build/annotation/buildannotation.ts: -------------------------------------------------------------------------------- 1 | import { DisplayType } from './displaytype'; 2 | 3 | export interface BuildAnnotation { 4 | displayType: DisplayType, 5 | title: string, 6 | content: string 7 | } 8 | -------------------------------------------------------------------------------- /src/client/js/build/annotation/defaultannotationrenderer.ts: -------------------------------------------------------------------------------- 1 | import { assert } from '../../../../util/assert'; 2 | import { AnnotationRenderer } from '../../annotation/annotationrenderer'; 3 | import { BuildAnnotation } from './buildannotation'; 4 | import { DisplayType } from './displaytype'; 5 | 6 | declare let tippy: any; 7 | 8 | enum Classes { 9 | TRANSPOSITION_NODE = 'transpositionNode', 10 | WARNING_NODE = 'warningNode' 11 | } 12 | 13 | export class DefaultAnnotationRenderer 14 | implements AnnotationRenderer { 15 | renderAnnotation( 16 | annotation: BuildAnnotation | null, 17 | cell: HTMLElement): void { 18 | if (!annotation) { 19 | return; 20 | } 21 | if (annotation.displayType == DisplayType.WARNING) { 22 | // Indicate warnings. 23 | cell.classList.add(Classes.WARNING_NODE); 24 | const template = assert( 25 | document.getElementById('warningTooltipContentTemplate')); 26 | tippy(cell, { 27 | a11y: false, 28 | animateFill: false, 29 | animation: 'fade', 30 | content() { 31 | const content = document.createElement('div'); 32 | content.innerHTML = template.innerHTML; 33 | const contentList = 34 | assert(content.querySelector('.warningTooltipContent-list')); 35 | const newElement = document.createElement('li'); 36 | newElement.innerHTML = annotation.content; 37 | contentList.appendChild(newElement); 38 | return content; 39 | }, 40 | delay: 0, 41 | duration: 0, 42 | placement: 'bottom', 43 | theme: 'warningTooltip' 44 | }); 45 | } else if (annotation.displayType == DisplayType.INFORMATIONAL) { 46 | // Indicate transposition. 47 | cell.classList.add(Classes.TRANSPOSITION_NODE); 48 | const template = assert(document.getElementById( 49 | 'transpositionTooltipContentTemplate')); 50 | tippy(cell, { 51 | a11y: false, 52 | animateFill: false, 53 | animation: 'fade', 54 | content() { 55 | const content = document.createElement('div'); 56 | content.innerHTML = template.innerHTML; 57 | assert(content.querySelector('.transpositionTooltipContent-title')) 58 | .innerHTML = assert(annotation).title; 59 | assert(content.querySelector('.transpositionTooltipContent-body')) 60 | .innerHTML = assert(annotation).content; 61 | return content; 62 | }, 63 | delay: 0, 64 | duration: 0, 65 | placement: 'bottom', 66 | theme: 'transpositionTooltip' 67 | }); 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/client/js/build/annotation/displaytype.ts: -------------------------------------------------------------------------------- 1 | export enum DisplayType { 2 | WARNING, 3 | INFORMATIONAL 4 | } 5 | -------------------------------------------------------------------------------- /src/client/js/build/buildboardhandler.ts: -------------------------------------------------------------------------------- 1 | import { NullAnnotator } from '../annotation/nullannotator'; 2 | import { BoardHandler } from '../board/boardhandler'; 3 | import { Config } from '../common/config'; 4 | import { RefreshableView } from '../common/refreshableview'; 5 | import { Toasts } from '../common/toasts'; 6 | import { AddMoveFailureReason } from '../tree/addmoveresult'; 7 | import { TreeModel } from '../tree/treemodel'; 8 | import { TreeNavigator } from '../tree/treenavigator'; 9 | import { CurrentRepertoireUpdater } from './currentrepertoireupdater'; 10 | 11 | export class BuildBoardHandler implements BoardHandler { 12 | private treeModel_: TreeModel; 13 | private treeNavigator_: TreeNavigator; 14 | private modeView_: RefreshableView; 15 | private updater_: CurrentRepertoireUpdater; 16 | 17 | constructor( 18 | treeModel: TreeModel, 19 | treeNavigator: TreeNavigator, 20 | modeView: RefreshableView, 21 | updater: CurrentRepertoireUpdater) { 22 | this.treeModel_ = treeModel; 23 | this.treeNavigator_ = treeNavigator; 24 | this.modeView_ = modeView; 25 | this.updater_ = updater; 26 | } 27 | 28 | onMove(fromSquare: string, toSquare: string): void { 29 | let pgn = this.treeModel_.getSelectedViewInfo(NullAnnotator.INSTANCE).pgn; 30 | const result = this.treeModel_.addMove(pgn, {fromSquare, toSquare}); 31 | if (result.success && !result.failureReason) { 32 | this.updater_.updateCurrentRepertoire(); 33 | return; 34 | } 35 | 36 | switch (result.failureReason) { 37 | case AddMoveFailureReason.ILLEGAL_MOVE: 38 | Toasts.warning('Couldn\'t add move', 'That move is illegal.'); 39 | break; 40 | case AddMoveFailureReason.EXCEEDED_MAXIMUM_LINE_DEPTH: 41 | Toasts.warning( 42 | 'Couldn\'t add move', 43 | `Opening lines can\'t be longer than ` 44 | + `${Config.MAXIMUM_LINE_DEPTH_IN_PLY} ply.`); 45 | break; 46 | case AddMoveFailureReason.EXCEEDED_MAXIMUM_NUM_NODES: 47 | Toasts.warning( 48 | 'Couldn\'t add move', 49 | `Repertoires can\'t contain more than ` 50 | + `${Config.MAXIMUM_TREE_NODES_PER_REPERTOIRE} total moves.`); 51 | break; 52 | default: 53 | throw new Error(`Unknown failure reason: ${result.failureReason}`); 54 | } 55 | } 56 | 57 | onChange(): void { 58 | this.modeView_.refresh(); 59 | } 60 | 61 | onScroll(e: WheelEvent): void { 62 | this.treeNavigator_.selectFromWheelEvent(e); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/client/js/build/colorchooser.ts: -------------------------------------------------------------------------------- 1 | import { Color } from '../../../protocol/color'; 2 | import { ImpressionCode } from '../../../protocol/impression/impressioncode'; 3 | import { RefreshableView } from '../common/refreshableview'; 4 | import { ImpressionSender } from '../impressions/impressionsender'; 5 | import { TreeModel } from '../tree/treemodel'; 6 | import { CurrentRepertoireUpdater } from './currentrepertoireupdater'; 7 | 8 | enum Classes { 9 | SELECTED_COLOR = 'selectedColor' 10 | } 11 | 12 | export class ColorChooser implements RefreshableView { 13 | private whiteButton_: HTMLElement; 14 | private blackButton_: HTMLElement; 15 | private impressionSender_: ImpressionSender; 16 | private treeModel_: TreeModel; 17 | private modeView_: RefreshableView; 18 | private updater_: CurrentRepertoireUpdater; 19 | 20 | constructor( 21 | whiteButton: HTMLElement, 22 | blackButton: HTMLElement, 23 | impressionSender: ImpressionSender, 24 | treeModel: TreeModel, 25 | modeView: RefreshableView, 26 | updater: CurrentRepertoireUpdater) { 27 | this.whiteButton_ = whiteButton; 28 | this.blackButton_ = blackButton; 29 | this.impressionSender_ = impressionSender; 30 | this.treeModel_ = treeModel; 31 | this.modeView_ = modeView; 32 | this.updater_ = updater; 33 | 34 | whiteButton.onclick = () => this.handleClick_(Color.WHITE); 35 | blackButton.onclick = () => this.handleClick_(Color.BLACK); 36 | } 37 | 38 | refresh(): void { 39 | const color = this.treeModel_.getRepertoireColor(); 40 | this.whiteButton_.classList.toggle( 41 | Classes.SELECTED_COLOR, color == Color.WHITE); 42 | this.blackButton_.classList.toggle( 43 | Classes.SELECTED_COLOR, color == Color.BLACK); 44 | } 45 | 46 | private handleClick_(color: Color): void { 47 | this.impressionSender_.sendImpression( 48 | ImpressionCode.TREE_SET_REPERTOIRE_COLOR, {color}); 49 | this.treeModel_.setRepertoireColor(color); 50 | this.modeView_.refresh(); 51 | this.updater_.updateCurrentRepertoire(); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/client/js/build/currentrepertoireexporter.ts: -------------------------------------------------------------------------------- 1 | import { getUtcDate, getUtcTime } from '../../../util/datetime'; 2 | import { TreeModel } from '../tree/treemodel'; 3 | 4 | export class CurrentRepertoireExporter { 5 | private treeModel_: TreeModel; 6 | 7 | constructor(treeModel: TreeModel) { 8 | this.treeModel_ = treeModel; 9 | } 10 | 11 | exportCurrentRepertoire(): void { 12 | const now = new Date(); 13 | const linkEl = document.createElement('a'); 14 | linkEl.style.display = 'none'; 15 | linkEl.download = this.getExportFilename_(now); 16 | 17 | const contents = this.getExportContents_(now); 18 | linkEl.href = `data:application/x-chess-pgn,${contents}`; 19 | 20 | document.body.appendChild(linkEl); 21 | linkEl.click(); 22 | document.body.removeChild(linkEl); 23 | } 24 | 25 | private getExportContents_(now: Date): string { 26 | const tags = this.getExportTags_(now); 27 | let tagsString = ''; 28 | for (const key in tags) { 29 | tagsString += `[${key} "${tags[key]}"]\n`; 30 | } 31 | 32 | const moves = this.treeModel_.exportToPgn(); 33 | return encodeURIComponent(`${tagsString}\n${moves}`); 34 | } 35 | 36 | private getExportTags_(now: Date): {[key: string]: string} { 37 | return { 38 | 'Event': this.treeModel_.getRepertoireName(), 39 | 'Site': 'http://studyopenings.com', 40 | 'UTCDate': getUtcDate(now), 41 | 'UTCTime': getUtcTime(now), 42 | 'Result': '*' 43 | }; 44 | } 45 | 46 | private getExportFilename_(now: Date): string { 47 | const name = this.treeModel_.getRepertoireName(); 48 | const formattedName = name.toLocaleLowerCase() 49 | .trim() 50 | .replace(/[^a-z0-9\s]+/g, '') 51 | .replace(/\s+/g, '-'); 52 | const utcDate = getUtcDate(now); 53 | 54 | return `studyopenings_${formattedName}_${utcDate}.pgn`; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/client/js/build/currentrepertoireupdater.ts: -------------------------------------------------------------------------------- 1 | import { PickerController } from '../picker/pickercontroller'; 2 | import { ServerWrapper } from '../server/serverwrapper'; 3 | import { TreeModel } from '../tree/treemodel'; 4 | 5 | export class CurrentRepertoireUpdater { 6 | private server_: ServerWrapper; 7 | private pickerController_: PickerController; 8 | private treeModel_: TreeModel; 9 | 10 | constructor( 11 | server: ServerWrapper, 12 | pickerController: PickerController, 13 | treeModel: TreeModel) { 14 | this.server_ = server; 15 | this.pickerController_ = pickerController; 16 | this.treeModel_ = treeModel; 17 | } 18 | 19 | updateCurrentRepertoire(): Promise { 20 | const repertoireId = this.pickerController_.getSelectedMetadataId(); 21 | const repertoire = this.treeModel_.serializeAsRepertoire(); 22 | return this.server_.updateRepertoire(repertoireId, repertoire); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/client/js/build/examplerepertoirehandler.ts: -------------------------------------------------------------------------------- 1 | import { ImpressionCode } from '../../../protocol/impression/impressioncode'; 2 | import { RefreshableView } from '../common/refreshableview'; 3 | import { ImpressionSender } from '../impressions/impressionsender'; 4 | import { PickerController } from '../picker/pickercontroller'; 5 | import { TreeModel } from '../tree/treemodel'; 6 | import { CurrentRepertoireUpdater } from './currentrepertoireupdater'; 7 | import { ExampleRepertoires } from './examplerepertoires'; 8 | 9 | export class ExampleRepertoireHandler { 10 | private impressionSender_: ImpressionSender; 11 | private treeModel_: TreeModel; 12 | private modeView_: RefreshableView; 13 | private pickerController_: PickerController; 14 | private updater_: CurrentRepertoireUpdater; 15 | 16 | constructor( 17 | impressionSender: ImpressionSender, 18 | treeModel: TreeModel, 19 | modeView: RefreshableView, 20 | pickerController: PickerController, 21 | updater: CurrentRepertoireUpdater) { 22 | this.impressionSender_ = impressionSender; 23 | this.treeModel_ = treeModel; 24 | this.modeView_ = modeView; 25 | this.pickerController_ = pickerController; 26 | this.updater_ = updater; 27 | } 28 | 29 | handleButtonClicks(exampleRepertoireElement: HTMLElement): void { 30 | exampleRepertoireElement.onclick = this.handleClick_.bind(this); 31 | } 32 | 33 | private handleClick_(): void { 34 | this.impressionSender_.sendImpression( 35 | ImpressionCode.LOAD_EXAMPLE_REPERTOIRE); 36 | const exampleJson = JSON.parse(ExampleRepertoires.KINGS_GAMBIT); 37 | this.treeModel_.loadRepertoire(exampleJson); 38 | this.modeView_.refresh(); 39 | 40 | this.updater_.updateCurrentRepertoire() 41 | .then(() => this.pickerController_.updatePicker()); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/client/js/build/import/converterstatus.ts: -------------------------------------------------------------------------------- 1 | export class ConverterStatus { 2 | private wasAnyLongLineTruncated_: boolean; 3 | private wasMaximumNumNodesReached_: boolean; 4 | private label_: string; 5 | private errors_: string[]; 6 | 7 | constructor() { 8 | this.wasAnyLongLineTruncated_ = false; 9 | this.wasMaximumNumNodesReached_ = false; 10 | this.label_ = ''; 11 | this.errors_ = []; 12 | } 13 | 14 | getLabel(): string { 15 | return this.label_; 16 | } 17 | 18 | setLabel(label: string): void { 19 | this.label_ = label; 20 | } 21 | 22 | markLongLineTruncated(): void { 23 | this.wasAnyLongLineTruncated_ = true; 24 | } 25 | 26 | wasAnyLongLineTruncated(): boolean { 27 | return this.wasAnyLongLineTruncated_; 28 | } 29 | 30 | markMaximumNumNodesReached(): void { 31 | this.wasMaximumNumNodesReached_ = true; 32 | } 33 | 34 | wasMaximumNumNodesReached(): boolean { 35 | return this.wasMaximumNumNodesReached_; 36 | } 37 | 38 | addError(error: string): void { 39 | this.errors_.push(error); 40 | } 41 | 42 | getErrors(): string[] { 43 | return this.errors_; 44 | } 45 | } 46 | 47 | -------------------------------------------------------------------------------- /src/client/js/build/import/currentrepertoireimporter.ts: -------------------------------------------------------------------------------- 1 | import { RefreshableView } from '../../common/refreshableview'; 2 | import { PickerController } from '../../picker/pickercontroller'; 3 | import { TreeModel } from '../../tree/treemodel'; 4 | import { CurrentRepertoireUpdater } from '../currentrepertoireupdater'; 5 | import { ImportDialog } from './importdialog'; 6 | import { PgnImporter } from './pgnimporter'; 7 | import { PgnImportProgress } from './pgnimportprogress'; 8 | 9 | export class CurrentRepertoireImporter { 10 | private importDialog_: ImportDialog; 11 | private treeModel_: TreeModel; 12 | private modeView_: RefreshableView; 13 | private pickerController_: PickerController; 14 | private updater_: CurrentRepertoireUpdater; 15 | 16 | private currentProgress_: PgnImportProgress | null; 17 | 18 | constructor( 19 | importDialog: ImportDialog, 20 | treeModel: TreeModel, 21 | modeView: RefreshableView, 22 | pickerController: PickerController, 23 | updater: CurrentRepertoireUpdater) { 24 | this.importDialog_ = importDialog; 25 | this.treeModel_ = treeModel; 26 | this.modeView_ = modeView; 27 | this.pickerController_ = pickerController; 28 | this.updater_ = updater; 29 | 30 | this.currentProgress_ = null; 31 | } 32 | 33 | startPgnImport(pgn: string): void { 34 | if (this.currentProgress_) { 35 | throw new Error('An import is already in progress!'); 36 | } 37 | 38 | this.currentProgress_ = PgnImporter.startPgnImport(pgn); 39 | this.currentProgress_ 40 | .getCompletionPromise() 41 | .then(repertoire => { 42 | this.importDialog_.hide(); 43 | this.treeModel_.loadRepertoire(repertoire); 44 | this.modeView_.refresh(); 45 | 46 | this.updater_.updateCurrentRepertoire().then( 47 | () => this.pickerController_.updatePicker()); 48 | this.currentProgress_ = null; 49 | }) 50 | .catch(err => { 51 | this.importDialog_.hideProgress(); 52 | this.currentProgress_ = null; 53 | }); 54 | 55 | this.maybeUpdateProgressText_(); 56 | } 57 | 58 | cancelCurrentProgress(): void { 59 | if (this.currentProgress_) { 60 | this.importDialog_.hideProgress(); 61 | this.currentProgress_.cancel(); 62 | this.currentProgress_ = null; 63 | } 64 | } 65 | 66 | private maybeUpdateProgressText_(): void { 67 | if (!this.currentProgress_) { 68 | return; 69 | } 70 | 71 | this.importDialog_.showProgress(this.currentProgress_.getStatusString()); 72 | setTimeout(() => this.maybeUpdateProgressText_(), 500); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/client/js/build/import/importdialog.ts: -------------------------------------------------------------------------------- 1 | import { ImpressionCode } from '../../../../protocol/impression/impressioncode'; 2 | import { Toasts } from '../../common/toasts'; 3 | import { ImpressionSender } from '../../impressions/impressionsender'; 4 | import { CurrentRepertoireImporter } from './currentrepertoireimporter'; 5 | 6 | export class ImportDialog { 7 | private impressionSender_: ImpressionSender; 8 | private dialogEl_: HTMLElement; 9 | private textAreaEl_: HTMLTextAreaElement; 10 | private uploadEl_: HTMLInputElement; 11 | private okButtonEl_: HTMLElement; 12 | private progressEl_: HTMLElement; 13 | private importer_: CurrentRepertoireImporter | null; 14 | 15 | constructor( 16 | impressionSender: ImpressionSender, 17 | dialogEl: HTMLElement, 18 | textAreaEl: HTMLTextAreaElement, 19 | uploadEl: HTMLInputElement, 20 | okButtonEl: HTMLElement, 21 | cancelButtonEl: HTMLElement, 22 | progressEl: HTMLElement) { 23 | this.impressionSender_ = impressionSender; 24 | this.dialogEl_ = dialogEl; 25 | this.textAreaEl_ = textAreaEl; 26 | this.uploadEl_ = uploadEl; 27 | this.okButtonEl_ = okButtonEl; 28 | this.progressEl_ = progressEl; 29 | this.importer_ = null; 30 | 31 | textAreaEl.oninput = () => this.onTextAreaInput_(); 32 | uploadEl.onchange = () => this.onUpload_(); 33 | okButtonEl.onclick = () => this.onOkClick_(); 34 | cancelButtonEl.onclick = () => this.onCancelClick_(); 35 | } 36 | 37 | setImporter(importer: CurrentRepertoireImporter): void { 38 | this.importer_ = importer; 39 | } 40 | 41 | isVisible(): boolean { 42 | return !this.dialogEl_.classList.contains('hidden'); 43 | } 44 | 45 | show() { 46 | this.hideProgress(); 47 | this.dialogEl_.classList.remove('hidden'); 48 | // Initialize the state of the OK button. 49 | this.onTextAreaInput_(); 50 | } 51 | 52 | hide() { 53 | this.dialogEl_.classList.add('hidden'); 54 | } 55 | 56 | showProgress(progressText: string): void { 57 | this.textAreaEl_.setAttribute('disabled', 'disabled'); 58 | this.okButtonEl_.classList.add('disabled'); 59 | this.okButtonEl_.classList.remove('selectable'); 60 | this.progressEl_.classList.remove('hidden'); 61 | this.progressEl_.innerText = progressText; 62 | } 63 | 64 | hideProgress(): void { 65 | this.textAreaEl_.removeAttribute('disabled'); 66 | this.okButtonEl_.classList.remove('disabled'); 67 | this.okButtonEl_.classList.add('selectable'); 68 | this.progressEl_.classList.add('hidden'); 69 | } 70 | 71 | onKeyDown(e: KeyboardEvent): void { 72 | if (e.keyCode == 27) { 73 | this.hide(); // Esc 74 | } 75 | } 76 | 77 | private onTextAreaInput_(): void { 78 | const isTextAreaEmpty = !this.textAreaEl_.value; 79 | this.okButtonEl_.classList.toggle('disabled', isTextAreaEmpty); 80 | this.okButtonEl_.classList.toggle('selectable', !isTextAreaEmpty); 81 | } 82 | 83 | private onUpload_(): void { 84 | const files = this.uploadEl_.files; 85 | if (!files || !files.length) { 86 | return; 87 | } 88 | 89 | const fileToRead = files[0]; 90 | const fileReader = new FileReader(); 91 | fileReader.onload = (readEvent: any) => { 92 | this.textAreaEl_.value = readEvent.target.result; 93 | }; 94 | fileReader.onerror = () => { 95 | Toasts.error( 96 | 'Couldn\'t load PGN file', 97 | `There was a problem loading '${fileToRead.name}'. Make sure this is ` 98 | + 'a valid PGN file.'); 99 | }; 100 | fileReader.readAsText(fileToRead); 101 | } 102 | 103 | private onOkClick_(): void { 104 | if (!this.importer_) { 105 | throw new Error('No importer!'); 106 | } 107 | if (!this.okButtonEl_.classList.contains('disabled')) { 108 | const pgnToImport = this.textAreaEl_.value; 109 | this.impressionSender_.sendImpression( 110 | ImpressionCode.START_PGN_IMPORT, 111 | {importedPgn: pgnToImport}); 112 | this.importer_.startPgnImport(pgnToImport); 113 | } 114 | } 115 | 116 | private onCancelClick_(): void { 117 | if (!this.importer_) { 118 | throw new Error('No importer!'); 119 | } 120 | this.importer_.cancelCurrentProgress(); 121 | this.hide(); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/client/js/build/import/pgnimporter.ts: -------------------------------------------------------------------------------- 1 | import { Repertoire } from '../../../../protocol/storage'; 2 | import { Config } from '../../common/config'; 3 | import { Toasts } from '../../common/toasts'; 4 | import { ConverterStatus } from './converterstatus'; 5 | import { PgnImportProgress } from './pgnimportprogress'; 6 | import { RepertoireIncrementalConverter } from './repertoireincrementalconverter'; 7 | 8 | export class PgnImporter { 9 | static startPgnImport(pgn: string): PgnImportProgress { 10 | const status = new ConverterStatus(); 11 | const converter = new RepertoireIncrementalConverter(pgn, status); 12 | const progress = new FinishablePgnImportProgress(status); 13 | 14 | setTimeout(() => this.doModeWork_(progress, converter, status), 0); 15 | return progress; 16 | } 17 | 18 | private static doModeWork_( 19 | progress: FinishablePgnImportProgress, 20 | converter: RepertoireIncrementalConverter, 21 | status: ConverterStatus): void { 22 | if (progress.isComplete()) { 23 | return; 24 | } 25 | 26 | if (converter.isComplete()) { 27 | PgnImporter.maybeShowInfoToasts_(status); 28 | progress.markFinished(converter.getRepertoire()); 29 | return; 30 | } 31 | 32 | const errors = status.getErrors(); 33 | if (errors.length) { 34 | errors.forEach(e => Toasts.error('Error importing PGN.', e)); 35 | progress.cancel(); 36 | return; 37 | } 38 | 39 | converter.doIncrementalWork(); 40 | setTimeout(() => PgnImporter.doModeWork_(progress, converter, status), 0); 41 | } 42 | 43 | private static maybeShowInfoToasts_(status: ConverterStatus): void { 44 | if (status.wasAnyLongLineTruncated()) { 45 | Toasts.info( 46 | 'Some lines were shortened', 47 | `Opening lines can\'t be longer than ` 48 | + `${Config.MAXIMUM_LINE_DEPTH_IN_PLY} ply.`); 49 | } 50 | if (status.wasMaximumNumNodesReached()) { 51 | Toasts.info( 52 | 'Some moves were not imported', 53 | `Repertoires can\'t contain more than ` 54 | + `${Config.MAXIMUM_TREE_NODES_PER_REPERTOIRE} total moves.`); 55 | } 56 | } 57 | } 58 | 59 | class FinishablePgnImportProgress implements PgnImportProgress { 60 | private status_: ConverterStatus; 61 | private promise_: Promise; 62 | private resolveFn_: (repertoire: Repertoire) => void; 63 | private rejectFn_: () => void; 64 | private completed_: boolean; 65 | 66 | constructor(status: ConverterStatus) { 67 | this.status_ = status; 68 | this.resolveFn_ = () => {}; 69 | this.rejectFn_ = () => {}; 70 | this.promise_ = new Promise((resolve, reject) => { 71 | this.resolveFn_ = resolve; 72 | this.rejectFn_ = reject; 73 | }); 74 | this.completed_ = false; 75 | } 76 | 77 | getStatusString(): string { 78 | return this.status_.getLabel(); 79 | } 80 | 81 | getCompletionPromise(): Promise { 82 | return this.promise_; 83 | } 84 | 85 | isComplete(): boolean { 86 | return this.completed_; 87 | } 88 | 89 | cancel(): void { 90 | if (this.completed_) { 91 | throw new Error('Import has already completed.'); 92 | } 93 | 94 | this.rejectFn_(); 95 | this.completed_ = true; 96 | } 97 | 98 | markFinished(repertoire: Repertoire): void { 99 | if (this.completed_) { 100 | throw new Error('Import has already completed.'); 101 | } 102 | 103 | this.resolveFn_(repertoire); 104 | this.completed_ = true; 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/client/js/build/import/pgnimportprogress.ts: -------------------------------------------------------------------------------- 1 | import { Repertoire } from '../../../../protocol/storage'; 2 | 3 | export interface PgnImportProgress { 4 | getStatusString(): string; 5 | 6 | getCompletionPromise(): Promise; 7 | 8 | cancel(): void; 9 | } 10 | -------------------------------------------------------------------------------- /src/client/js/build/import/pgnparser.ts: -------------------------------------------------------------------------------- 1 | import * as pgnparser from '../../lib/pgnparser'; 2 | 3 | export interface ParsedVariation { 4 | moves: ParsedNode[] 5 | } 6 | 7 | export interface ParsedNode { 8 | move: string, 9 | ravs?: ParsedVariation[] 10 | } 11 | 12 | export class PgnParser { 13 | static parse(pgn: string): ParsedVariation[] { 14 | if (!pgn) { 15 | throw new Error('PGN is empty.'); 16 | } 17 | const result = pgnparser.parse(pgn); 18 | if (!result || !result.length) { 19 | throw new Error('Unknown parsing error.'); 20 | } 21 | return result; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/client/js/build/import/repertoireincrementalconverter.ts: -------------------------------------------------------------------------------- 1 | import { Repertoire } from '../../../../protocol/storage'; 2 | import { ConverterStatus } from './converterstatus'; 3 | import { ParsedVariation, PgnParser } from './pgnparser'; 4 | import { TreeModelPopulator } from './treemodelpopulator'; 5 | 6 | /** 7 | * A class which incrementally converts a PGN string into a Repertoire object. 8 | * 9 | * This conversion is done by constructing this Converter with the PGN string to 10 | * convert then repeatedly calling #doIncrementalWork until #isComplete returns 11 | * true. At that point, #getRepertoire will return the resulting repertoire. 12 | * 13 | * Since PGN parsing and conversion is expensive, this flow is intended to allow 14 | * the caller to only perform the conversion work when there is nothing else to 15 | * do (e.g. handling user input). 16 | */ 17 | export class RepertoireIncrementalConverter { 18 | private pgn_: string; 19 | private status_: ConverterStatus; 20 | private parsedVariations_: ParsedVariation[] | null; 21 | private populator_: TreeModelPopulator | null; 22 | private repertoire_: Repertoire | null; 23 | 24 | constructor(pgn: string, status: ConverterStatus) { 25 | this.pgn_ = pgn; 26 | this.status_ = status; 27 | this.parsedVariations_ = null; 28 | this.populator_ = null; 29 | this.repertoire_ = null; 30 | 31 | status.setLabel('Parsing PGN...'); 32 | } 33 | 34 | doIncrementalWork(): void { 35 | if (this.repertoire_) { 36 | throw new Error('Already done generating!'); 37 | } 38 | if (!this.parsedVariations_) { 39 | try { 40 | this.parsedVariations_ = PgnParser.parse(this.pgn_); 41 | } catch (e) { 42 | let message = e.message; 43 | if (e.location && e.location.start) { 44 | const l = e.location.start; 45 | message = `At line ${l.line}, column ${l.column}: ${message}`; 46 | } 47 | this.status_.addError(message); 48 | } 49 | return; 50 | } 51 | if (!this.populator_) { 52 | this.populator_ = new TreeModelPopulator( 53 | this.parsedVariations_, this.status_); 54 | return; 55 | } 56 | if (!this.populator_.isComplete()) { 57 | this.populator_.doIncrementalWork(); 58 | this.status_.setLabel(`Imported ${this.populator_.numPopulatedMoves()} / ` 59 | + `${this.populator_.numTotalMoves()} moves...`); 60 | return; 61 | } 62 | 63 | const treeModel = this.populator_.getPopulatedTreeModel(); 64 | this.repertoire_ = treeModel.serializeAsRepertoire(); 65 | } 66 | 67 | isComplete(): boolean { 68 | return !!this.repertoire_; 69 | } 70 | 71 | getRepertoire(): Repertoire { 72 | if (!this.repertoire_) { 73 | throw new Error('Not done generating yet!'); 74 | } 75 | 76 | return this.repertoire_; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/client/js/build/renameinput.ts: -------------------------------------------------------------------------------- 1 | import { Debouncer } from '../common/debouncer'; 2 | import { RefreshableView } from '../common/refreshableview'; 3 | import { PickerController } from '../picker/pickercontroller'; 4 | import { TreeModel } from '../tree/treemodel'; 5 | import { CurrentRepertoireUpdater } from './currentrepertoireupdater'; 6 | 7 | const UPDATE_DEBOUNCE_INTERVAL_MS_: number = 1000; 8 | 9 | export class RenameInput implements RefreshableView { 10 | private renameInputElement_: HTMLInputElement; 11 | private treeModel_: TreeModel; 12 | private pickerController_: PickerController; 13 | private updater_: CurrentRepertoireUpdater; 14 | private updateDebouncer_: Debouncer; 15 | 16 | constructor( 17 | renameInputElement: HTMLInputElement, 18 | treeModel: TreeModel, 19 | pickerController: PickerController, 20 | updater: CurrentRepertoireUpdater) { 21 | this.renameInputElement_ = renameInputElement; 22 | this.treeModel_ = treeModel; 23 | this.pickerController_ = pickerController; 24 | this.updater_ = updater; 25 | this.updateDebouncer_ = new Debouncer( 26 | () => this.update_(), UPDATE_DEBOUNCE_INTERVAL_MS_); 27 | 28 | this.renameInputElement_.oninput = () => this.onInputChange_(); 29 | } 30 | 31 | refresh(): void { 32 | this.renameInputElement_.value = this.treeModel_.getRepertoireName(); 33 | } 34 | 35 | isFocused(): boolean { 36 | return this.renameInputElement_ == document.activeElement; 37 | } 38 | 39 | private onInputChange_(): void { 40 | if (!this.renameInputElement_.value) { 41 | this.renameInputElement_.value = 'Untitled repertoire'; 42 | } 43 | 44 | this.treeModel_.setRepertoireName(this.renameInputElement_.value); 45 | this.updateDebouncer_.fire(); 46 | } 47 | 48 | private update_(): void { 49 | this.updater_.updateCurrentRepertoire() 50 | .then(() => this.pickerController_.updatePicker()); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/client/js/build/treecontroller.ts: -------------------------------------------------------------------------------- 1 | import { ImpressionCode } from '../../../protocol/impression/impressioncode'; 2 | import { RefreshableView } from '../common/refreshableview'; 3 | import { ImpressionSender } from '../impressions/impressionsender'; 4 | import { TreeModel } from '../tree/treemodel'; 5 | import { CurrentRepertoireExporter } from './currentrepertoireexporter'; 6 | import { CurrentRepertoireUpdater } from './currentrepertoireupdater'; 7 | 8 | export class TreeController { 9 | private impressionSender_: ImpressionSender; 10 | private treeModel_: TreeModel; 11 | private modeView_: RefreshableView; 12 | private updater_: CurrentRepertoireUpdater; 13 | private exporter_: CurrentRepertoireExporter; 14 | 15 | constructor( 16 | impressionSender: ImpressionSender, 17 | treeModel: TreeModel, 18 | modeView: RefreshableView, 19 | updater: CurrentRepertoireUpdater, 20 | exporter: CurrentRepertoireExporter) { 21 | this.impressionSender_ = impressionSender; 22 | this.treeModel_ = treeModel; 23 | this.modeView_ = modeView; 24 | this.updater_ = updater; 25 | this.exporter_ = exporter; 26 | } 27 | 28 | flipRepertoireColor(): void { 29 | this.impressionSender_.sendImpression( 30 | ImpressionCode.TREE_FLIP_REPERTOIRE_COLOR); 31 | this.treeModel_.flipRepertoireColor(); 32 | this.modeView_.refresh(); 33 | this.updater_.updateCurrentRepertoire(); 34 | } 35 | 36 | trash(): void { 37 | if (!this.treeModel_.canRemoveSelectedPgn()) { 38 | return; 39 | } 40 | 41 | this.impressionSender_.sendImpression(ImpressionCode.TREE_TRASH_SELECTED); 42 | this.treeModel_.removeSelectedPgn(); 43 | this.modeView_.refresh(); 44 | this.updater_.updateCurrentRepertoire(); 45 | } 46 | 47 | export(): void { 48 | this.impressionSender_.sendImpression(ImpressionCode.PGN_EXPORT); 49 | this.exporter_.exportCurrentRepertoire(); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/client/js/common/config.ts: -------------------------------------------------------------------------------- 1 | export const Config = { 2 | AUTH0_AUDIENCE: 'studyopenings-api', 3 | AUTH0_DOMAIN: 'studyopenings.auth0.com', 4 | AUTH0_CLIENT_ID: '4N3xZuND2puazbhvLdpLpp17Bv19Fn0g', 5 | 6 | OPPONENT_FIRST_MOVE_DELAY_MS: 600, 7 | OPPONENT_REPLY_DELAY_MS: 600, 8 | 9 | WRONG_MOVES_FOR_ANSWER: 2, 10 | WRONG_MOVES_FOR_HINT: 1, 11 | 12 | MAXIMUM_LINE_DEPTH_IN_PLY: 80, 13 | MAXIMUM_TREE_NODES_PER_REPERTOIRE: 5000, 14 | }; 15 | -------------------------------------------------------------------------------- /src/client/js/common/debouncer.test.ts: -------------------------------------------------------------------------------- 1 | import { Debouncer } from './debouncer'; 2 | 3 | let callbackFn: () => void; 4 | let debouncer: Debouncer; 5 | 6 | const DEBOUNCE_INTERVAL_MS = 10; 7 | 8 | beforeEach(() => { 9 | jest.useFakeTimers(); 10 | callbackFn = jest.fn(); 11 | debouncer = new Debouncer(callbackFn, DEBOUNCE_INTERVAL_MS); 12 | }); 13 | 14 | it('no fire should not call callback', () => { 15 | jest.advanceTimersByTime(DEBOUNCE_INTERVAL_MS * 2); 16 | expect(callbackFn).toBeCalledTimes(0); 17 | }); 18 | 19 | it('fire should call callback immediately and only once', () => { 20 | debouncer.fire(); 21 | expect(callbackFn).toBeCalledTimes(1); 22 | 23 | jest.advanceTimersByTime(DEBOUNCE_INTERVAL_MS * 2); 24 | expect(callbackFn).toBeCalledTimes(1); 25 | }); 26 | 27 | it('fire twice slowly should call twice immediately', () => { 28 | debouncer.fire(); 29 | jest.advanceTimersByTime(DEBOUNCE_INTERVAL_MS * 2); 30 | debouncer.fire(); 31 | expect(callbackFn).toBeCalledTimes(2); 32 | 33 | jest.advanceTimersByTime(DEBOUNCE_INTERVAL_MS * 2); 34 | expect(callbackFn).toBeCalledTimes(2); 35 | }); 36 | 37 | it('fire twice immediately should call once immediately, once delayed', () => { 38 | debouncer.fire(); 39 | debouncer.fire(); 40 | expect(callbackFn).toBeCalledTimes(1); 41 | 42 | jest.advanceTimersByTime(DEBOUNCE_INTERVAL_MS * 2); 43 | expect(callbackFn).toBeCalledTimes(2); 44 | 45 | jest.advanceTimersByTime(DEBOUNCE_INTERVAL_MS * 2); 46 | expect(callbackFn).toBeCalledTimes(2); 47 | }); 48 | 49 | it('fire twice quickly should call once immediately, once delayed', () => { 50 | debouncer.fire(); 51 | jest.advanceTimersByTime(DEBOUNCE_INTERVAL_MS - 1); 52 | debouncer.fire(); 53 | expect(callbackFn).toBeCalledTimes(1); 54 | 55 | jest.advanceTimersByTime(DEBOUNCE_INTERVAL_MS * 2); 56 | expect(callbackFn).toBeCalledTimes(2); 57 | 58 | jest.advanceTimersByTime(DEBOUNCE_INTERVAL_MS * 2); 59 | expect(callbackFn).toBeCalledTimes(2); 60 | }); 61 | 62 | it('fire many times quickly should call once immediately, once delayed', () => { 63 | const numQuickFires = 100; 64 | for (let i = 0; i < numQuickFires; i++) { 65 | debouncer.fire(); 66 | } 67 | expect(callbackFn).toBeCalledTimes(1); 68 | 69 | jest.advanceTimersByTime(DEBOUNCE_INTERVAL_MS * 2); 70 | expect(callbackFn).toBeCalledTimes(2); 71 | 72 | jest.advanceTimersByTime(DEBOUNCE_INTERVAL_MS * 2); 73 | expect(callbackFn).toBeCalledTimes(2); 74 | }); 75 | 76 | it('fire many times slowly should space out calls', () => { 77 | const numLoops = 100; 78 | for (let i = 0; i < numLoops; i++) { 79 | debouncer.fire(); 80 | jest.advanceTimersByTime(1); 81 | debouncer.fire(); 82 | jest.advanceTimersByTime(DEBOUNCE_INTERVAL_MS * 2); 83 | } 84 | 85 | expect(callbackFn).toBeCalledTimes(numLoops * 2); 86 | }); 87 | -------------------------------------------------------------------------------- /src/client/js/common/debouncer.ts: -------------------------------------------------------------------------------- 1 | export class Debouncer { 2 | private callbackFn_: () => void; 3 | private debounceIntervalMs_: number; 4 | private currentTimeout_: number | null; 5 | private firedDuringTimeout_: boolean; 6 | 7 | constructor(callbackFn: () => void, debounceIntervalMs: number) { 8 | this.callbackFn_ = callbackFn; 9 | this.debounceIntervalMs_ = debounceIntervalMs; 10 | this.currentTimeout_ = null; 11 | this.firedDuringTimeout_ = false; 12 | } 13 | 14 | fire(): void { 15 | if (this.currentTimeout_ != null) { 16 | this.firedDuringTimeout_ = true; 17 | return; 18 | } 19 | 20 | this.callbackFn_(); 21 | this.firedDuringTimeout_ = false; 22 | this.currentTimeout_ = window.setTimeout( 23 | () => this.maybeFireAfterTimeout_(), this.debounceIntervalMs_); 24 | } 25 | 26 | private maybeFireAfterTimeout_(): void { 27 | this.currentTimeout_ = null; 28 | if (!this.firedDuringTimeout_) { 29 | return; 30 | } 31 | 32 | this.firedDuringTimeout_ = false; 33 | this.fire(); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/client/js/common/listrefreshableview.ts: -------------------------------------------------------------------------------- 1 | import { RefreshableView } from './refreshableview'; 2 | 3 | export class ListRefreshableView implements RefreshableView { 4 | private views_: RefreshableView[]; 5 | 6 | constructor() { 7 | this.views_ = []; 8 | } 9 | 10 | addView(view: RefreshableView): void { 11 | this.views_.push(view); 12 | } 13 | 14 | refresh(): void { 15 | this.views_.forEach(v => v.refresh()); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/client/js/common/refreshableview.ts: -------------------------------------------------------------------------------- 1 | export interface RefreshableView { 2 | refresh(): void; 3 | } 4 | -------------------------------------------------------------------------------- /src/client/js/common/toasts.ts: -------------------------------------------------------------------------------- 1 | export class Toasts { 2 | static initialize() { 3 | toastr.options.showDuration = 300; 4 | toastr.options.hideDuration = 300; 5 | toastr.options.timeOut = 0; 6 | toastr.options.extendedTimeOut = 0; 7 | toastr.options.preventDuplicates = true; 8 | toastr.options.positionClass = 'toast-top-center'; 9 | } 10 | 11 | static error(title: string, message: string) { 12 | toastr.error(message, title); 13 | } 14 | 15 | static warning(title: string, message: string) { 16 | toastr.warning(message, title); 17 | } 18 | 19 | static info(title: string, message: string) { 20 | toastr.info(message, title); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/client/js/common/tooltips.ts: -------------------------------------------------------------------------------- 1 | declare var tippy: any; 2 | 3 | export class Tooltips { 4 | static initialize() { 5 | const tooltipElements = document.querySelectorAll('[data-tippy-content]'); 6 | tooltipElements.forEach(e => Tooltips.addTo_(e)); 7 | } 8 | 9 | private static addTo_(element: Element) { 10 | tippy(element, { 11 | a11y: false, 12 | arrow: true, 13 | delay: [10, 20], 14 | animation: 'shift-away' 15 | }); 16 | } 17 | 18 | static hideAll() { 19 | tippy.hideAllPoppers(); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/client/js/common/viewinfo.ts: -------------------------------------------------------------------------------- 1 | import { Color } from '../../../protocol/color'; 2 | import { Move } from '../../../protocol/move'; 3 | 4 | export interface ViewInfo { 5 | position: string, 6 | pgn: string, 7 | parentPgn: string | null, 8 | numLegalMoves: number, 9 | colorToMove: Color, 10 | lastMove: Move | null, 11 | lastMoveString: string, 12 | lastMoveVerboseString: string, 13 | lastMovePly: number, 14 | lastMoveNumber: number, 15 | lastMoveColor: Color, 16 | numChildren: number, 17 | childPgns: string[], 18 | childMoves: Move[], 19 | isSelected: boolean, 20 | annotationPromise: Promise 21 | } 22 | -------------------------------------------------------------------------------- /src/client/js/evaluate/annotation/statisticannotation.ts: -------------------------------------------------------------------------------- 1 | export interface StatisticAnnotation { 2 | rightMoveCount: number, 3 | wrongMoveCount: number 4 | } 5 | -------------------------------------------------------------------------------- /src/client/js/evaluate/annotation/statisticannotationrenderer.ts: -------------------------------------------------------------------------------- 1 | import { AnnotationRenderer } from '../../annotation/annotationrenderer'; 2 | import { StatisticAnnotation } from './statisticannotation'; 3 | 4 | export class StatisticAnnotationRenderer implements 5 | AnnotationRenderer { 6 | renderAnnotation( 7 | annotation: StatisticAnnotation, treeNodeElement: HTMLElement): void { 8 | const totalCount = annotation.rightMoveCount + annotation.wrongMoveCount; 9 | if (!totalCount) { 10 | return; 11 | } 12 | 13 | const rightRatio = annotation.rightMoveCount / totalCount; 14 | const rightRed = 155; 15 | const rightGreen = 199; 16 | const wrongRed = 255; 17 | const wrongGreen = 0; 18 | const red = Math.floor( 19 | rightRatio * rightRed + (1 - rightRatio) * wrongRed); 20 | const green = Math.floor( 21 | rightRatio * rightGreen + (1 - rightRatio) * wrongGreen); 22 | const color = `rgba(${red}, ${green}, 0, 0.8)`; 23 | 24 | treeNodeElement.style.backgroundColor = color; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/client/js/evaluate/annotation/statisticannotator.ts: -------------------------------------------------------------------------------- 1 | import { Color } from '../../../../protocol/color'; 2 | import { Annotator } from '../../annotation/annotator'; 3 | import { StatisticsModel } from '../../statistics/statisticsmodel'; 4 | import { FenToPgnMap } from '../../tree/fentopgnmap'; 5 | import { PgnToNodeMap } from '../../tree/pgntonodemap'; 6 | import { TreeNode } from '../../tree/treenode'; 7 | import { StatisticAnnotation } from './statisticannotation'; 8 | 9 | export class StatisticAnnotator implements Annotator { 10 | private statisticsModel_: StatisticsModel; 11 | 12 | constructor(statisticsModel: StatisticsModel) { 13 | this.statisticsModel_ = statisticsModel; 14 | } 15 | 16 | annotate( 17 | node: TreeNode, 18 | repertoireColor: Color, 19 | pgnToNode: PgnToNodeMap, 20 | fenToPgn: FenToPgnMap): Promise { 21 | return Promise 22 | .all([ 23 | this.statisticsModel_.getRightMoveCount(node.pgn), 24 | this.statisticsModel_.getWrongMoveCount(node.pgn) 25 | ]) 26 | .then(counts => { 27 | return { 28 | rightMoveCount: counts[0], 29 | wrongMoveCount: counts[1] 30 | }; 31 | }); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/client/js/evaluate/childmovedrawer.ts: -------------------------------------------------------------------------------- 1 | import { NullAnnotator } from '../annotation/nullannotator'; 2 | import { Board } from '../board/board'; 3 | import { RefreshableView } from '../common/refreshableview'; 4 | import { TreeModel } from '../tree/treemodel'; 5 | 6 | export class ChildMoveDrawer implements RefreshableView { 7 | private treeModel_: TreeModel; 8 | private board_: Board; 9 | 10 | constructor(treeModel: TreeModel, board: Board) { 11 | this.treeModel_ = treeModel; 12 | this.board_ = board; 13 | } 14 | 15 | refresh(): void { 16 | this.board_.removeDrawings(); 17 | const selectedViewInfo 18 | = this.treeModel_.getSelectedViewInfo(NullAnnotator.INSTANCE); 19 | selectedViewInfo.childMoves.forEach(m => 20 | this.board_.drawArrow(m.fromSquare, m.toSquare, 'green')); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/client/js/evaluate/evaluateboardhandler.ts: -------------------------------------------------------------------------------- 1 | import { BoardHandler } from '../board/boardhandler'; 2 | import { TreeNavigator } from '../tree/treenavigator'; 3 | 4 | export class EvaluateBoardHandler implements BoardHandler { 5 | private treeNavigator_: TreeNavigator; 6 | 7 | constructor(treeNavigator: TreeNavigator) { 8 | this.treeNavigator_ = treeNavigator; 9 | } 10 | 11 | onMove(from: string, to: string): void {} 12 | 13 | onChange(): void {} 14 | 15 | onScroll(e: WheelEvent): void { 16 | this.treeNavigator_.selectFromWheelEvent(e); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/client/js/evaluate/insights/insight.ts: -------------------------------------------------------------------------------- 1 | export interface Insight { 2 | title: string, 3 | value: Promise, 4 | color: string 5 | } 6 | -------------------------------------------------------------------------------- /src/client/js/evaluate/insights/insightcalculator.ts: -------------------------------------------------------------------------------- 1 | import { NullAnnotator } from '../../annotation/nullannotator'; 2 | import { StatisticsModel } from '../../statistics/statisticsmodel'; 3 | import { TreeModel } from '../../tree/treemodel'; 4 | import { Insight } from './insight'; 5 | 6 | enum InsightColor { 7 | GRAY = '#dfdfdf', 8 | YELLOW = '#ffd700', 9 | GREEN = 'rgb(155, 199, 0)', 10 | RED = 'red' 11 | } 12 | 13 | export class InsightCalculator { 14 | private treeModel_: TreeModel; 15 | private statisticsModel_: StatisticsModel; 16 | 17 | constructor( 18 | treeModel: TreeModel, 19 | statisticsModel: StatisticsModel) { 20 | this.treeModel_ = treeModel; 21 | this.statisticsModel_ = statisticsModel; 22 | } 23 | 24 | calculate(): (Insight | string)[] { 25 | let lineCount = 0; 26 | let positionCount = 0; 27 | this.treeModel_.traverseDepthFirst(viewInfo => { 28 | positionCount++; 29 | if (!viewInfo.numChildren) { 30 | lineCount++; 31 | } 32 | }, NullAnnotator.INSTANCE); 33 | 34 | return [ 35 | 'In this repertoire...', 36 | { 37 | title: 'Number of lines', 38 | value: Promise.resolve(lineCount), 39 | color: InsightColor.GRAY 40 | }, 41 | { 42 | title: 'Number of positions', 43 | value: Promise.resolve(positionCount), 44 | color: InsightColor.GRAY 45 | }, 46 | { 47 | title: 'Times you finished studying a line', 48 | value: this.statisticsModel_.getRepertoireFinishLineCount(), 49 | color: InsightColor.YELLOW 50 | }, 51 | { 52 | title: 'Times you played a correct move while studying', 53 | value: this.statisticsModel_.getRepertoireRightMoveCount(), 54 | color: InsightColor.GREEN 55 | }, 56 | { 57 | title: 'Times you played a wrong move while studying', 58 | value: this.statisticsModel_.getRepertoireWrongMoveCount(), 59 | color: InsightColor.RED 60 | }, 61 | 'For the selected position...', 62 | { 63 | title: 'Times you played the correct move', 64 | value: this.statisticsModel_.getRightMoveCount( 65 | this.treeModel_.getSelectedViewInfo(NullAnnotator.INSTANCE).pgn), 66 | color: InsightColor.GREEN 67 | }, 68 | { 69 | title: 'Times you played the wrong move', 70 | value: this.statisticsModel_.getWrongMoveCount( 71 | this.treeModel_.getSelectedViewInfo(NullAnnotator.INSTANCE).pgn), 72 | color: InsightColor.RED 73 | } 74 | ]; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/client/js/evaluate/insights/insightspanel.ts: -------------------------------------------------------------------------------- 1 | import { RefreshableView } from '../../common/refreshableview'; 2 | import { Insight } from './insight'; 3 | import { InsightCalculator } from './insightcalculator'; 4 | 5 | enum CssClass { 6 | INSIGHT = 'insight', 7 | INSIGHT_TITLE = 'insightTitle', 8 | INSIGHT_VALUE = 'insightValue', 9 | INSIGHT_LABEL = 'insightLabel' 10 | } 11 | 12 | export class InsightsPanel implements RefreshableView { 13 | private insightsEl_: HTMLElement; 14 | private calculator_: InsightCalculator; 15 | 16 | constructor( 17 | insightsEl: HTMLElement, 18 | calculator: InsightCalculator) { 19 | this.insightsEl_ = insightsEl; 20 | this.calculator_ = calculator; 21 | } 22 | 23 | refresh(): void { 24 | this.insightsEl_.innerHTML = ''; 25 | this.calculator_.calculate().forEach(e => this.handleElement_(e)); 26 | } 27 | 28 | private handleElement_(e: Insight | string): void { 29 | typeof e == 'string' 30 | ? this.handleInsightLabel_(e) 31 | : this.handleInsight_(e); 32 | } 33 | 34 | private handleInsight_(insight: Insight): void { 35 | const titleEl = document.createElement('div'); 36 | titleEl.classList.add(CssClass.INSIGHT_TITLE); 37 | titleEl.innerText = insight.title; 38 | titleEl.style.color = insight.color; 39 | titleEl.style.borderColor = insight.color; 40 | 41 | const valueEl = document.createElement('div'); 42 | valueEl.classList.add(CssClass.INSIGHT_VALUE); 43 | valueEl.innerText = '...'; 44 | valueEl.style.color = insight.color; 45 | insight.value.then(v => { valueEl.innerText = `${v}`; }); 46 | 47 | const insightEl = document.createElement('div'); 48 | insightEl.classList.add(CssClass.INSIGHT); 49 | insightEl.appendChild(titleEl); 50 | insightEl.appendChild(valueEl); 51 | 52 | this.insightsEl_.appendChild(insightEl); 53 | } 54 | 55 | private handleInsightLabel_(insightLabel: string): void { 56 | const labelEl = document.createElement('div'); 57 | labelEl.classList.add(CssClass.INSIGHT_LABEL); 58 | labelEl.innerText = insightLabel; 59 | 60 | this.insightsEl_.appendChild(labelEl); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/client/js/evaluate/repertoirenamelabel.ts: -------------------------------------------------------------------------------- 1 | import { RefreshableView } from '../common/refreshableview'; 2 | import { TreeModel } from '../tree/treemodel'; 3 | 4 | export class RepertoireNameLabel implements RefreshableView { 5 | private labelEl_: HTMLElement; 6 | private treeModel_: TreeModel; 7 | 8 | constructor(labelEl: HTMLElement, treeModel: TreeModel) { 9 | this.labelEl_ = labelEl; 10 | this.treeModel_ = treeModel; 11 | } 12 | 13 | refresh(): void { 14 | this.labelEl_.innerText = this.treeModel_.getRepertoireName(); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/client/js/footer/footerlinks.ts: -------------------------------------------------------------------------------- 1 | import { ImpressionCode } from '../../../protocol/impression/impressioncode'; 2 | import { ImpressionSender } from '../impressions/impressionsender'; 3 | 4 | export class FooterLinks { 5 | static logImpressionsForClicks( 6 | impressionSender: ImpressionSender, 7 | aboutLinkEl: HTMLElement, 8 | sourceCodeLinkEl: HTMLElement): void { 9 | aboutLinkEl.onclick = () => impressionSender.sendImpression( 10 | ImpressionCode.OPEN_ABOUT_PAGE); 11 | sourceCodeLinkEl.onclick = () => impressionSender.sendImpression( 12 | ImpressionCode.OPEN_SOURCE_CODE); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/client/js/impressions/debouncingimpressionsender.ts: -------------------------------------------------------------------------------- 1 | import { LogImpressionsRequest } from '../../../protocol/actions'; 2 | import { ExtraData } from '../../../protocol/impression/extradata'; 3 | import { Impression } from '../../../protocol/impression/impression'; 4 | import { ImpressionCode } from '../../../protocol/impression/impressioncode'; 5 | import { AuthManager } from '../auth/authmanager'; 6 | import { Debouncer } from '../common/debouncer'; 7 | import { ImpressionSender } from './impressionsender'; 8 | 9 | export class DebouncingImpressionSender implements ImpressionSender { 10 | private impressionSessionId_: string; 11 | private authManager_: AuthManager; 12 | private impressionsToSend_: Impression[]; 13 | private debouncer_: Debouncer; 14 | 15 | constructor( 16 | impressionSessionId: string, 17 | authManager: AuthManager, 18 | debounceIntervalMs: number) { 19 | this.impressionSessionId_ = impressionSessionId; 20 | this.authManager_ = authManager; 21 | this.impressionsToSend_ = []; 22 | this.debouncer_ = new Debouncer( 23 | () => this.sendImpressions_(), debounceIntervalMs); 24 | } 25 | 26 | sendImpression(impressionCode: ImpressionCode, extraData?: ExtraData): void { 27 | const sessionInfo = this.authManager_.getSessionInfo(); 28 | const user = sessionInfo ? sessionInfo.userId : '(anonymous)'; 29 | const impression: Impression = { 30 | impressionCode: impressionCode, 31 | user: user, 32 | timestampMs: Date.now(), 33 | sessionId: this.impressionSessionId_, 34 | userAgent: navigator.userAgent, 35 | extraData: extraData || {} 36 | }; 37 | 38 | this.impressionsToSend_.push(impression); 39 | this.debouncer_.fire(); 40 | } 41 | 42 | private sendImpressions_(): void { 43 | const options = { 44 | method: 'POST', 45 | headers: { 46 | 'Content-Type': 'application/json' 47 | }, 48 | body: JSON.stringify(this.makeRequest_()) 49 | }; 50 | 51 | // Errors are intentionally not handled: impressions are not critical so 52 | // failures are tolerated gracefully by the client. 53 | fetch('/impressions', options).catch(() => { }); 54 | 55 | this.impressionsToSend_ = []; 56 | } 57 | 58 | private makeRequest_(): LogImpressionsRequest { 59 | return {impressions: this.impressionsToSend_}; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/client/js/impressions/impressionsender.ts: -------------------------------------------------------------------------------- 1 | import { ExtraData } from '../../../protocol/impression/extradata'; 2 | import { ImpressionCode } from '../../../protocol/impression/impressioncode'; 3 | 4 | export interface ImpressionSender { 5 | sendImpression(impressionCode: ImpressionCode): void, 6 | sendImpression(impressionCode: ImpressionCode, extraData: ExtraData): void; 7 | } 8 | -------------------------------------------------------------------------------- /src/client/js/mode/mode.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A single mode of the application. 3 | * 4 | * The application has exactly one selected mode at any given time which handles 5 | * user input, receives events from the picker, etc.. At any point, the 6 | * application may exit the currently selected mode and enter a different mode, 7 | * by which process the newly entered mode becomes selected. 8 | */ 9 | export interface Mode { 10 | /** 11 | * Performs any logic necessary before the mode is entered and before the 12 | * currently selected mode is exited. 13 | * 14 | * Returns a promise which is fulfilled when the mode is ready to be selected. 15 | */ 16 | preEnter(): Promise; 17 | 18 | /** 19 | * Performs any logic necessary before entering another mode. 20 | * 21 | * Returns a promise which is fulfilled when the mode is ready to be 22 | * unselected. 23 | */ 24 | exit(): Promise; 25 | 26 | /** 27 | * Performs any logic necessary after the mode is entered and after the 28 | * previously selected mode is exited. 29 | * 30 | * Returns a promise which is fulfilled when the mode is finished being 31 | * selected. 32 | */ 33 | postEnter(): Promise; 34 | 35 | /** 36 | * Handles the given key event when the mode is selected. 37 | */ 38 | onKeyDown(e: KeyboardEvent): void; 39 | 40 | /** Notifies the mode that a new repertoire metadata has been selected. */ 41 | notifySelectedMetadata(): Promise; 42 | } 43 | -------------------------------------------------------------------------------- /src/client/js/mode/modemanager.test.ts: -------------------------------------------------------------------------------- 1 | import { Mode } from './mode'; 2 | import { ModeManager } from './modemanager'; 3 | 4 | const MockMode = jest.fn(() => ({ 5 | preEnter: jest.fn(() => Promise.resolve()), 6 | exit: jest.fn(() => Promise.resolve()), 7 | postEnter: jest.fn(() => Promise.resolve()), 8 | onKeyDown: jest.fn(), 9 | notifySelectedMetadata: jest.fn(() => Promise.resolve()) 10 | })); 11 | 12 | describe('register', () => { 13 | test('register twice throws', () => { 14 | const modeManager = new ModeManager(); 15 | modeManager.registerMode('mode', new MockMode()); 16 | expect(() => modeManager.registerMode('mode', new MockMode())) 17 | .toThrow(); 18 | }); 19 | 20 | test('register does not select', () => { 21 | const modeManager = new ModeManager(); 22 | const mode = new MockMode(); 23 | modeManager.registerMode('mode', mode); 24 | expect(mode.preEnter).toHaveBeenCalledTimes(0); 25 | expect(mode.exit).toHaveBeenCalledTimes(0); 26 | expect(mode.postEnter).toHaveBeenCalledTimes(0); 27 | }); 28 | }); 29 | 30 | describe('select', () => { 31 | test('get selected without selecting throws', () => { 32 | const modeManager = new ModeManager(); 33 | modeManager.registerMode('mode', new MockMode()); 34 | expect(() => modeManager.getSelectedMode()).toThrow(); 35 | }); 36 | 37 | test('select unregistered mode throws', () => { 38 | const modeManager = new ModeManager(); 39 | modeManager.registerMode('mode1', new MockMode()); 40 | expect(() => modeManager.selectModeType('mode2')) 41 | .toThrow(); 42 | }); 43 | 44 | test('select single mode', () => { 45 | const modeManager = new ModeManager(); 46 | const mode = new MockMode(); 47 | modeManager.registerMode('mode', mode); 48 | return modeManager.selectModeType('mode') 49 | .then(() => { 50 | expect(modeManager.getSelectedMode()).toBe(mode); 51 | 52 | expect(mode.preEnter).toHaveBeenCalledTimes(1); 53 | expect(mode.exit).toHaveBeenCalledTimes(0); 54 | expect(mode.postEnter).toHaveBeenCalledTimes(1); 55 | }); 56 | }); 57 | 58 | test('switch modes', () => { 59 | const modeManager = new ModeManager(); 60 | const mode1 = new MockMode(); 61 | const mode2 = new MockMode(); 62 | modeManager 63 | .registerMode('mode1', mode1) 64 | .registerMode('mode2', mode2); 65 | return modeManager.selectModeType('mode1') 66 | .then(() => modeManager.selectModeType('mode2')) 67 | .then(() => { 68 | expect(modeManager.getSelectedMode()).toBe(mode2); 69 | 70 | expect(mode1.preEnter).toHaveBeenCalledTimes(1); 71 | expect(mode1.exit).toHaveBeenCalledTimes(1); 72 | expect(mode1.postEnter).toHaveBeenCalledTimes(1); 73 | 74 | expect(mode2.preEnter).toHaveBeenCalledTimes(1); 75 | expect(mode2.exit).toHaveBeenCalledTimes(0); 76 | expect(mode2.postEnter).toHaveBeenCalledTimes(1); 77 | }); 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /src/client/js/mode/modemanager.ts: -------------------------------------------------------------------------------- 1 | import { Mode } from './mode'; 2 | 3 | export class ModeManager { 4 | private modes_: Map; 5 | private selectedModeType_: string | null; 6 | 7 | constructor() { 8 | this.modes_ = new Map(); 9 | this.selectedModeType_ = null; 10 | } 11 | 12 | registerMode(modeType: string, mode: Mode): ModeManager { 13 | if (this.modes_.has(modeType)) { 14 | throw new Error('Mode type registered twice!'); 15 | } 16 | this.modes_.set(modeType, mode); 17 | return this; 18 | } 19 | 20 | selectModeType(modeType: string): Promise { 21 | if (modeType == this.selectedModeType_) { 22 | // This mode is already selected. 23 | return Promise.resolve(); 24 | } 25 | 26 | const newMode = this.mode_(modeType); 27 | return newMode.preEnter() 28 | .then(() => this.selectedModeType_ 29 | ? this.mode_(this.selectedModeType_).exit() 30 | : Promise.resolve()) 31 | .then(() => newMode.postEnter()) 32 | .then(() => { 33 | this.selectedModeType_ = modeType; 34 | }); 35 | } 36 | 37 | getSelectedMode(): Mode { 38 | if (!this.selectedModeType_) { 39 | throw new Error('No mode selected yet.'); 40 | } 41 | 42 | return this.mode_(this.selectedModeType_); 43 | } 44 | 45 | private mode_(modeType: string): Mode { 46 | const mode = this.modes_.get(modeType); 47 | if (!mode) { 48 | throw new Error('Unregistered mode type: ' + modeType); 49 | } 50 | return mode; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/client/js/mode/modetype.ts: -------------------------------------------------------------------------------- 1 | export enum ModeType { 2 | INITIAL = 'initialMode', 3 | BUILD = 'buildMode', 4 | STUDY = 'studyMode', 5 | EVALUATE = 'evaluateMode' 6 | } 7 | -------------------------------------------------------------------------------- /src/client/js/mode/noopmode.ts: -------------------------------------------------------------------------------- 1 | import { Mode } from './mode'; 2 | 3 | export class NoOpMode implements Mode { 4 | preEnter(): Promise { 5 | return Promise.resolve(); 6 | } 7 | 8 | exit(): Promise { 9 | return Promise.resolve(); 10 | } 11 | 12 | postEnter(): Promise { 13 | return Promise.resolve(); 14 | } 15 | onKeyDown(e: KeyboardEvent): void {} 16 | 17 | notifySelectedMetadata(): Promise { 18 | return Promise.resolve(); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/client/js/picker/confirmdeletedialog.ts: -------------------------------------------------------------------------------- 1 | import { PickerController } from './pickercontroller'; 2 | 3 | export class ConfirmDeleteDialog { 4 | private pickerController_: PickerController; 5 | private dialogEl_: HTMLElement; 6 | private dialogNameEl_: HTMLElement; 7 | private okButton_: HTMLElement; 8 | private repertoireIdToDelete_: string; 9 | 10 | constructor( 11 | pickerController: PickerController, 12 | dialogEl: HTMLElement, 13 | dialogNameEl: HTMLElement, 14 | okButton: HTMLElement, 15 | cancelButton: HTMLElement) { 16 | this.pickerController_ = pickerController; 17 | this.dialogEl_ = dialogEl; 18 | this.dialogNameEl_ = dialogNameEl; 19 | this.okButton_ = okButton; 20 | this.repertoireIdToDelete_ = ''; 21 | 22 | cancelButton.onclick = () => this.hide_(); 23 | } 24 | 25 | isVisible(): boolean { 26 | return !this.dialogEl_.classList.contains('hidden'); 27 | } 28 | 29 | showForRepertoire( 30 | repertoireId: string, 31 | repertoireName: string): void { 32 | this.dialogNameEl_.innerText = repertoireName; 33 | this.dialogEl_.classList.remove('hidden'); 34 | this.repertoireIdToDelete_ = repertoireId; 35 | this.okButton_.onclick = () => this.onOkClick_(); 36 | } 37 | 38 | onKeyDown(e: KeyboardEvent): void { 39 | if (e.keyCode == 27) { 40 | this.hide_(); // Esc 41 | } 42 | } 43 | 44 | private onOkClick_(): void { 45 | this.pickerController_.deleteMetadataId(this.repertoireIdToDelete_) 46 | .then(() => this.hide_()); 47 | } 48 | 49 | private hide_(): void { 50 | this.dialogEl_.classList.add('hidden'); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/client/js/picker/pickercontroller.ts: -------------------------------------------------------------------------------- 1 | import { ImpressionCode } from '../../../protocol/impression/impressioncode'; 2 | import { Metadata } from '../../../protocol/storage'; 3 | import { ImpressionSender } from '../impressions/impressionsender'; 4 | import { ModeManager } from '../mode/modemanager'; 5 | import { PickerModel } from '../picker/pickermodel'; 6 | import { PickerView } from '../picker/pickerview'; 7 | import { ServerWrapper } from '../server/serverwrapper'; 8 | 9 | export class PickerController { 10 | private impressionSender_: ImpressionSender; 11 | private server_: ServerWrapper; 12 | private modeManager_: ModeManager; 13 | private model_: PickerModel | null; 14 | private view_: PickerView | null; 15 | 16 | constructor( 17 | impressionSender: ImpressionSender, 18 | server: ServerWrapper, 19 | modeManager: ModeManager) { 20 | this.impressionSender_ = impressionSender; 21 | this.server_ = server; 22 | this.modeManager_ = modeManager; 23 | this.model_ = null; 24 | this.view_ = null; 25 | } 26 | 27 | initialize(model: PickerModel, view: PickerView) { 28 | this.model_ = model; 29 | this.view_ = view; 30 | } 31 | 32 | addMetadata(): Promise { 33 | this.impressionSender_.sendImpression(ImpressionCode.PICKER_NEW_REPERTOIRE); 34 | return this.server_.createRepertoire().then(newRepertoireId => { 35 | this.updatePicker().then(() => { 36 | this.selectMetadataId(newRepertoireId); 37 | this.modeManager_.getSelectedMode().notifySelectedMetadata(); 38 | }); 39 | }); 40 | } 41 | 42 | selectMetadataId(metadataId: string): void { 43 | if (!this.model_ || !this.view_) { 44 | throw new Error('Not initialized yet.'); 45 | } 46 | if (this.model_.getSelectedMetadata().id == metadataId) { 47 | // No-op if the clicked metadata is already selected. 48 | return; 49 | } 50 | this.impressionSender_.sendImpression( 51 | ImpressionCode.PICKER_SELECT_REPERTOIRE); 52 | this.model_.selectMetadataId(metadataId); 53 | this.view_.refresh(); 54 | this.modeManager_.getSelectedMode().notifySelectedMetadata(); 55 | } 56 | 57 | deleteMetadataId(metadataId: string): Promise { 58 | if (!this.model_ || !this.view_) { 59 | throw new Error('Not initialized yet.'); 60 | } 61 | 62 | this.impressionSender_.sendImpression( 63 | ImpressionCode.PICKER_DELETE_REPERTOIRE); 64 | const notifyMode = metadataId == this.getSelectedMetadataId(); 65 | return this.server_.deleteRepertoire(metadataId) 66 | .then(() => this.updatePicker()) 67 | .then(() => { 68 | if (notifyMode) { 69 | this.modeManager_.getSelectedMode().notifySelectedMetadata(); 70 | } 71 | }); 72 | } 73 | 74 | isModelEmpty(): boolean { 75 | if (!this.model_) { 76 | throw new Error('Not initialized yet.'); 77 | } 78 | return this.model_.isEmpty(); 79 | } 80 | 81 | getSelectedMetadataId(): string { 82 | if (!this.model_) { 83 | throw new Error('Not initialized yet.'); 84 | } 85 | return this.model_.getSelectedMetadata().id; 86 | } 87 | 88 | updatePicker(): Promise { 89 | if (!this.model_ || !this.view_) { 90 | throw new Error('Not initialized yet.'); 91 | } 92 | 93 | const lastSelectedMetadataId = this.model_.isEmpty() 94 | ? null 95 | : this.model_.getSelectedMetadata().id; 96 | return this.server_.getAllRepertoireMetadata() 97 | // If there are no repertoires, create a new one first. 98 | .then(metadataList => !metadataList.length 99 | ? this.addMetadata() 100 | .then(() => this.server_.getAllRepertoireMetadata()) 101 | : Promise.resolve(metadataList)) 102 | .then(metadataList => this.populatePicker_( 103 | lastSelectedMetadataId, metadataList)); 104 | } 105 | 106 | private populatePicker_( 107 | lastSelectedMetadataId: string | null, 108 | metadataList: Metadata[]): void { 109 | if (!this.model_ || !this.view_) { 110 | throw new Error('Not initialized yet.'); 111 | } 112 | 113 | this.model_.setMetadataList(metadataList, lastSelectedMetadataId); 114 | this.view_.refresh(); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/client/js/picker/pickerfeature.ts: -------------------------------------------------------------------------------- 1 | import { EvaluatedFlags } from '../../../protocol/evaluatedflags'; 2 | import { assert } from '../../../util/assert'; 3 | import { ConfirmDeleteDialog } from './confirmdeletedialog'; 4 | import { PickerController } from './pickercontroller'; 5 | import { PickerModel } from './pickermodel'; 6 | import { PickerView } from './pickerview'; 7 | 8 | export class PickerFeature { 9 | static install( 10 | flags: EvaluatedFlags, 11 | controller: PickerController, 12 | confirmDeleteDialog: ConfirmDeleteDialog): void { 13 | const pickerElement = assert(document.getElementById('picker')); 14 | const addMetadataElement = assert(document.getElementById('addMetadata')); 15 | 16 | const model = new PickerModel(); 17 | const view = new PickerView( 18 | flags, 19 | model, 20 | controller, 21 | confirmDeleteDialog, 22 | pickerElement, 23 | addMetadataElement); 24 | 25 | controller.initialize(model, view); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/client/js/picker/pickermodel.test.ts: -------------------------------------------------------------------------------- 1 | import { PickerModel } from './pickermodel'; 2 | 3 | describe('errors', () => { 4 | test('getSelected without set', () => { 5 | const model = new PickerModel(); 6 | expect(() => model.getSelectedMetadata()).toThrow(); 7 | expect(() => model.getSelectedIndex()).toThrow(); 8 | }); 9 | }); 10 | 11 | describe('set', () => { 12 | test('set without id', () => { 13 | const model = new PickerModel(); 14 | const metadata = [ 15 | {id: 'id1', name: 'name1'}, 16 | {id: 'id2', name: 'name2'}, 17 | {id: 'id3', name: 'name3'} 18 | ]; 19 | model.setMetadataList(metadata, null /* selectMetadataId */); 20 | 21 | expect(model.getSelectedIndex()).toBe(0); 22 | expect(model.getSelectedMetadata()).toBe(metadata[0]); 23 | }); 24 | 25 | test('set with id', () => { 26 | const model = new PickerModel(); 27 | const metadata = [ 28 | {id: 'id1', name: 'name1'}, 29 | {id: 'id2', name: 'name2'}, 30 | {id: 'id3', name: 'name3'} 31 | ]; 32 | model.setMetadataList(metadata, 'id2'); 33 | 34 | expect(model.getSelectedIndex()).toBe(1); 35 | expect(model.getSelectedMetadata()).toBe(metadata[1]); 36 | }); 37 | 38 | test('set with unknown id', () => { 39 | const model = new PickerModel(); 40 | const metadata = [ 41 | {id: 'id1', name: 'name1'}, 42 | {id: 'id2', name: 'name2'}, 43 | {id: 'id3', name: 'name3'} 44 | ]; 45 | model.setMetadataList(metadata, 'id4'); 46 | 47 | expect(model.getSelectedIndex()).toBe(0); 48 | expect(model.getSelectedMetadata()).toBe(metadata[0]); 49 | }); 50 | 51 | test('set twice with id', () => { 52 | const model = new PickerModel(); 53 | const metadata1 = [ 54 | {id: 'id1', name: 'name1'}, 55 | {id: 'id2', name: 'name2'}, 56 | {id: 'id3', name: 'name3'} 57 | ]; 58 | model.setMetadataList(metadata1, null); 59 | const metadata2 = [ 60 | {id: 'id4', name: 'name4'}, 61 | {id: 'id5', name: 'name5'}, 62 | {id: 'id6', name: 'name6'} 63 | ]; 64 | model.setMetadataList(metadata2, 'id6'); 65 | 66 | expect(model.getSelectedIndex()).toBe(2); 67 | expect(model.getSelectedMetadata()).toBe(metadata2[2]); 68 | }); 69 | }); 70 | 71 | describe('isEmpty', () => { 72 | test('empty', () => { 73 | const model = new PickerModel(); 74 | model.setMetadataList([], null); 75 | expect(model.isEmpty()).toBe(true); 76 | }); 77 | 78 | test('not empty', () => { 79 | const model = new PickerModel(); 80 | model.setMetadataList([{id: 'id1', name: 'name1'}], null); 81 | expect(model.isEmpty()).toBe(false); 82 | }); 83 | }); 84 | -------------------------------------------------------------------------------- /src/client/js/picker/pickermodel.ts: -------------------------------------------------------------------------------- 1 | import { Metadata } from '../../../protocol/storage'; 2 | 3 | export class PickerModel { 4 | private metadataList_: Metadata[]; 5 | private selectedIndex_: number; 6 | 7 | constructor() { 8 | this.metadataList_ = []; 9 | this.selectedIndex_ = -1; 10 | } 11 | 12 | isEmpty(): boolean { 13 | return !this.metadataList_.length; 14 | } 15 | 16 | setMetadataList( 17 | metadataList: Metadata[], 18 | selectedMetadataId: string | null): void { 19 | this.metadataList_ = metadataList; 20 | this.selectMetadataId(selectedMetadataId); 21 | } 22 | 23 | selectMetadataId(metadataId: string | null): void { 24 | if (metadataId) { 25 | const metadataIndex 26 | = this.metadataList_.findIndex(m => m.id == metadataId); 27 | if (metadataIndex >= 0) { 28 | this.selectedIndex_ = metadataIndex; 29 | return; 30 | } 31 | } 32 | this.selectedIndex_ = 0; 33 | } 34 | 35 | getMetadataList(): Metadata[] { 36 | return this.metadataList_; 37 | } 38 | 39 | getSelectedMetadata(): Metadata { 40 | return this.metadataList_[this.getSelectedIndex()]; 41 | } 42 | 43 | getSelectedIndex(): number { 44 | if (this.selectedIndex_ == -1) { 45 | throw new Error('No metadata selected yet.'); 46 | } 47 | return this.selectedIndex_; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/client/js/picker/pickerview.ts: -------------------------------------------------------------------------------- 1 | import { FlagName } from '../../../flag/flags'; 2 | import { EvaluatedFlags } from '../../../protocol/evaluatedflags'; 3 | import { Metadata } from '../../../protocol/storage'; 4 | import { ConfirmDeleteDialog } from './confirmdeletedialog'; 5 | import { PickerController } from './pickercontroller'; 6 | import { PickerModel } from './pickermodel'; 7 | 8 | const MAX_REPERTOIRES_PER_USER = 20; 9 | 10 | enum CssClasses { 11 | DELETE_BUTTON = 'deleteButton', 12 | HOVER_BUTTON = 'hoverButton', 13 | METADATA = 'metadata', 14 | SELECTED_METADATA = 'selected' 15 | } 16 | 17 | export class PickerView { 18 | private flags_: EvaluatedFlags; 19 | private pickerModel_: PickerModel; 20 | private pickerController_: PickerController; 21 | private confirmDeleteDialog_: ConfirmDeleteDialog; 22 | private pickerElement_: HTMLElement; 23 | private addMetadataElement_: HTMLElement; 24 | 25 | constructor( 26 | flags: EvaluatedFlags, 27 | pickerModel: PickerModel, 28 | pickerController: PickerController, 29 | confirmDeleteDialog: ConfirmDeleteDialog, 30 | pickerElement: HTMLElement, 31 | addMetadataElement: HTMLElement) { 32 | this.flags_ = flags; 33 | this.pickerModel_ = pickerModel; 34 | this.pickerController_ = pickerController; 35 | this.confirmDeleteDialog_ = confirmDeleteDialog; 36 | this.pickerElement_ = pickerElement; 37 | this.addMetadataElement_ = addMetadataElement; 38 | 39 | this.addMetadataElement_.onclick 40 | = () => this.pickerController_.addMetadata(); 41 | } 42 | 43 | refresh() { 44 | // Remove all metadata children of the picker. 45 | const metadataChildren = document.querySelectorAll( 46 | '#picker > div.metadata'); 47 | for (let i = 0; i < metadataChildren.length; i++) { 48 | this.pickerElement_.removeChild(metadataChildren.item(i)); 49 | } 50 | 51 | // Insert the new metadata children before the add metadata button. 52 | const metadataList = this.pickerModel_.getMetadataList(); 53 | const selectedIndex = this.pickerModel_.getSelectedIndex(); 54 | for (let j = 0; j < metadataList.length; j++) { 55 | const newChild = this.createMetadataElement_( 56 | metadataList[j], j == selectedIndex /* isSelected */); 57 | this.pickerElement_.insertBefore(newChild, this.addMetadataElement_); 58 | } 59 | 60 | // Hide the add metadata button if the user has the maximum number of 61 | // repertoires. 62 | const hideAddMetadataButton = 63 | metadataList.length >= MAX_REPERTOIRES_PER_USER; 64 | this.addMetadataElement_.classList.toggle('hidden', hideAddMetadataButton); 65 | } 66 | 67 | private createMetadataElement_( 68 | metadata: Metadata, isSelected: boolean): HTMLElement { 69 | const newElement = document.createElement('div'); 70 | newElement.classList.add(CssClasses.METADATA); 71 | if (isSelected) { 72 | newElement.classList.add(CssClasses.SELECTED_METADATA); 73 | } 74 | 75 | const label = document.createElement('div'); 76 | label.classList.add('metadataName'); 77 | label.innerText = metadata.name; 78 | 79 | const deleteButton = document.createElement('div'); 80 | deleteButton.onclick = (e) => this.handleDeleteButton_( 81 | e, metadata.id, metadata.name); 82 | 83 | deleteButton.classList.add( 84 | CssClasses.HOVER_BUTTON, CssClasses.DELETE_BUTTON); 85 | 86 | newElement.append(label, deleteButton); 87 | 88 | newElement.onclick = () => 89 | this.pickerController_.selectMetadataId(metadata.id); 90 | return newElement; 91 | } 92 | 93 | private handleDeleteButton_( 94 | e: MouseEvent, metadataId: string, metadataName: string): void { 95 | if (!this.flags_[FlagName.ENABLE_PICKER_DELETE_CONFIRM]) { 96 | this.pickerController_.deleteMetadataId(metadataId); 97 | } else { 98 | this.confirmDeleteDialog_.showForRepertoire(metadataId, metadataName); 99 | } 100 | 101 | // The click should not propagate to the parent metadata element since doing 102 | // so would cause the repertoire being deleted to also be loaded. 103 | e.stopPropagation(); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/client/js/preferences/preferenceloader.ts: -------------------------------------------------------------------------------- 1 | import { BoardTheme } from '../../../protocol/boardtheme'; 2 | import { Preference } from '../../../protocol/preference/preference'; 3 | import { SoundValue } from '../../../protocol/soundvalue'; 4 | import { ServerWrapper } from '../server/serverwrapper'; 5 | import { SoundToggler } from '../sound/soundtoggler'; 6 | import { BoardThemeSetter } from '../theme/boardthemesetter'; 7 | 8 | export class PreferenceLoader { 9 | private server_: ServerWrapper; 10 | private boardThemeSetter_: BoardThemeSetter; 11 | private soundToggler_: SoundToggler; 12 | 13 | constructor( 14 | server: ServerWrapper, 15 | boardThemeSetter: BoardThemeSetter, 16 | soundToggler: SoundToggler) { 17 | this.server_ = server; 18 | this.boardThemeSetter_ = boardThemeSetter; 19 | this.soundToggler_ = soundToggler; 20 | } 21 | 22 | load(): void { 23 | this.server_.getPreference() 24 | .then(preference => { 25 | this.setBoardTheme_(preference); 26 | this.setSoundValue_(preference); 27 | }); 28 | } 29 | 30 | private setBoardTheme_(preference: Preference) { 31 | this.boardThemeSetter_.set(preference.boardTheme || BoardTheme.BLUE); 32 | } 33 | 34 | private setSoundValue_(preference: Preference) { 35 | const enabled = preference.soundValue 36 | ? preference.soundValue == SoundValue.ON 37 | : true; 38 | this.soundToggler_.setSoundsEnabled(enabled); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/client/js/preferences/preferencesaver.ts: -------------------------------------------------------------------------------- 1 | import { Preference } from '../../../protocol/preference/preference'; 2 | import { ServerWrapper } from '../server/serverwrapper'; 3 | 4 | export class PreferenceSaver { 5 | private server_: ServerWrapper; 6 | 7 | constructor(server: ServerWrapper) { 8 | this.server_ = server; 9 | } 10 | 11 | save(preference: Preference): void { 12 | this.server_.setPreference(preference); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/client/js/priveleged/privelegedcopydialog.ts: -------------------------------------------------------------------------------- 1 | import { PickerController } from '../picker/pickercontroller'; 2 | import { ServerWrapper } from '../server/serverwrapper'; 3 | 4 | export class PrivelegedCopyDialog { 5 | private server_: ServerWrapper; 6 | private pickerController_: PickerController; 7 | private dialogEl_: HTMLElement; 8 | private inputEl_: HTMLInputElement; 9 | 10 | constructor( 11 | server: ServerWrapper, 12 | pickerController: PickerController, 13 | dialogEl: HTMLElement, 14 | inputEl: HTMLInputElement) { 15 | this.server_ = server; 16 | this.pickerController_ = pickerController; 17 | this.dialogEl_ = dialogEl; 18 | this.inputEl_ = inputEl; 19 | } 20 | 21 | bindButtons( 22 | okButton: HTMLElement, 23 | cancelButton: HTMLElement): void { 24 | okButton.onclick = () => this.onOkClick_(); 25 | cancelButton.onclick = () => this.hide(); 26 | } 27 | 28 | show(): void { 29 | this.dialogEl_.classList.remove('hidden'); 30 | } 31 | 32 | hide(): void { 33 | this.dialogEl_.classList.add('hidden'); 34 | } 35 | 36 | isVisible(): boolean { 37 | return !this.dialogEl_.classList.contains('hidden'); 38 | } 39 | 40 | onKeyDown(e: KeyboardEvent): void { 41 | if (e.keyCode == 13) { 42 | this.onOkClick_(); // Enter 43 | } else if (e.keyCode == 27) { 44 | this.hide(); // Esc 45 | } 46 | } 47 | 48 | private onOkClick_(): void { 49 | this.server_.copyRepertoireAsPrivelegedUser(this.inputEl_.value).then( 50 | () => { 51 | this.hide(); 52 | this.pickerController_.updatePicker(); 53 | }); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/client/js/priveleged/privelegedfeature.ts: -------------------------------------------------------------------------------- 1 | import { assert } from '../../../util/assert'; 2 | import { PrivelegedCopyDialog } from './privelegedcopydialog'; 3 | 4 | export class PrivelegedFeature { 5 | static install( 6 | privelegedCopyDialog: PrivelegedCopyDialog) { 7 | privelegedCopyDialog.bindButtons( 8 | assert(document.getElementById('privelegedCopyOk')), 9 | assert(document.getElementById('privelegedCopyCancel'))); 10 | 11 | const copyButton = assert(document.getElementById('privelegedCopyButton')); 12 | copyButton.classList.remove('hidden'); 13 | copyButton.onclick = () => privelegedCopyDialog.show(); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/client/js/server/delegatingserverwrapper.ts: -------------------------------------------------------------------------------- 1 | import { Preference } from '../../../protocol/preference/preference'; 2 | import { CumulatedStatistic } from '../../../protocol/statistic/cumulatedstatistic'; 3 | import { Statistic } from '../../../protocol/statistic/statistic'; 4 | import { Metadata, Repertoire } from '../../../protocol/storage'; 5 | import { ServerWrapper } from './serverwrapper'; 6 | 7 | export class DelegatingServerWrapper implements ServerWrapper { 8 | private delegate_: ServerWrapper; 9 | 10 | constructor(delegate: ServerWrapper) { 11 | this.delegate_ = delegate; 12 | } 13 | 14 | setDelegate(delegate: ServerWrapper) { 15 | this.delegate_ = delegate; 16 | } 17 | 18 | getAllRepertoireMetadata(): Promise { 19 | return this.delegate_.getAllRepertoireMetadata(); 20 | } 21 | 22 | loadRepertoire(repertoireId: string): Promise { 23 | return this.delegate_.loadRepertoire(repertoireId); 24 | } 25 | 26 | updateRepertoire( 27 | repertoireId: string, repertoire: Repertoire): Promise { 28 | return this.delegate_.updateRepertoire(repertoireId, repertoire); 29 | } 30 | 31 | createRepertoire(): Promise { 32 | return this.delegate_.createRepertoire(); 33 | } 34 | 35 | deleteRepertoire(repertoireId: string): Promise { 36 | return this.delegate_.deleteRepertoire(repertoireId); 37 | } 38 | 39 | setPreference(preference: Preference): Promise { 40 | return this.delegate_.setPreference(preference); 41 | } 42 | 43 | getPreference(): Promise { 44 | return this.delegate_.getPreference(); 45 | } 46 | 47 | recordStatistics(statisticList: Statistic[]): Promise { 48 | return this.delegate_.recordStatistics(statisticList); 49 | } 50 | 51 | loadCumulatedStatistics(repertoireId: string): Promise { 52 | return this.delegate_.loadCumulatedStatistics(repertoireId); 53 | } 54 | 55 | copyRepertoireAsPrivelegedUser(repertoireId: string): Promise { 56 | return this.delegate_.copyRepertoireAsPrivelegedUser(repertoireId); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/client/js/server/evaluatedflagfetcher.ts: -------------------------------------------------------------------------------- 1 | import { EvaluateFlagsResponse } from '../../../protocol/actions'; 2 | import { EvaluatedFlags } from '../../../protocol/evaluatedflags'; 3 | import { Toasts } from '../common/toasts'; 4 | 5 | export class EvaluatedFlagFetcher { 6 | static fetchEvaluatedFlags(): Promise { 7 | const options = { 8 | method: 'POST', 9 | headers: { 10 | 'Content-Type': 'application/json', 11 | } 12 | }; 13 | return fetch('/flags', options) 14 | .then(res => (res.json() as unknown) as EvaluateFlagsResponse) 15 | .then(evaluatedFlagsResponse => evaluatedFlagsResponse.evaluatedFlags) 16 | .catch(err => { 17 | EvaluatedFlagFetcher.showError_(); 18 | throw err; 19 | }); 20 | } 21 | 22 | private static showError_(): void { 23 | Toasts.error( 24 | 'Something went wrong.', 25 | 'There was a problem reaching the server. Please refresh the page and ' 26 | + 'try again.'); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/client/js/server/serverwrapper.ts: -------------------------------------------------------------------------------- 1 | import { Preference } from '../../../protocol/preference/preference'; 2 | import { CumulatedStatistic } from '../../../protocol/statistic/cumulatedstatistic'; 3 | import { Statistic } from '../../../protocol/statistic/statistic'; 4 | import { Metadata, Repertoire } from '../../../protocol/storage'; 5 | 6 | /** 7 | * An interface for classes that send requests to the server and receive 8 | * responses on behalf of the rest of the application. 9 | * 10 | * Implementations may not actually communicate with the server (e.g. in the 11 | * case of users that are not logged in). 12 | */ 13 | export interface ServerWrapper { 14 | /** 15 | * Returns a promise of the list of the metadata for all repertoires owned by 16 | * the current user. 17 | */ 18 | getAllRepertoireMetadata(): Promise; 19 | 20 | /** 21 | * Loads the repertoire with the given ID, returning a promise of the loaded 22 | * repertoire. 23 | */ 24 | loadRepertoire(repertoireId: string): Promise; 25 | 26 | /** Updates the existing repertoire with the given ID. */ 27 | updateRepertoire( 28 | repertoireId: string, 29 | repertoire: Repertoire): Promise; 30 | 31 | /** 32 | * Creates a new repertoire and returns a promise of the ID of the newly 33 | * created repertoire. 34 | */ 35 | createRepertoire(): Promise; 36 | 37 | /** Deletes the repertoire with the given ID. */ 38 | deleteRepertoire(repertoireId: string): Promise; 39 | 40 | /** Sets the given preference. */ 41 | setPreference(preference: Preference): Promise; 42 | 43 | /** Gets the preference. */ 44 | getPreference(): Promise; 45 | 46 | /** Records the given statistics. */ 47 | recordStatistics(statisticList: Statistic[]): Promise; 48 | 49 | /** Loads the cumulated statistics for the given repertoire. */ 50 | loadCumulatedStatistics(repertoireId: string): Promise; 51 | 52 | /** Copies the given repertoire as a priveleged user. */ 53 | copyRepertoireAsPrivelegedUser(repertoireId: string): Promise; 54 | } 55 | -------------------------------------------------------------------------------- /src/client/js/sound/soundplayer.ts: -------------------------------------------------------------------------------- 1 | import { Howl } from 'howler'; 2 | import { SoundToggler } from './soundtoggler'; 3 | 4 | export class SoundPlayer { 5 | private soundToggler_: SoundToggler; 6 | 7 | constructor(soundToggler: SoundToggler) { 8 | this.soundToggler_ = soundToggler; 9 | } 10 | 11 | playMove(): void { 12 | this.play_('move', 0.4); 13 | } 14 | 15 | playCapture(): void { 16 | this.play_('capture', 0.4); 17 | } 18 | 19 | playWrongMove(): void { 20 | this.play_('wrongmove', 1); 21 | } 22 | 23 | playFinishLine(): void { 24 | this.play_('finishline', 1); 25 | } 26 | 27 | private play_(soundName: string, volume: number): void { 28 | if (!this.soundToggler_.areSoundsEnabled()) { 29 | return; 30 | } 31 | 32 | new Howl({ 33 | src: [`ogg/${soundName}.ogg`], 34 | volume: volume, 35 | autoplay: true 36 | }); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/client/js/sound/soundtoggler.ts: -------------------------------------------------------------------------------- 1 | import { ImpressionCode } from '../../../protocol/impression/impressioncode'; 2 | import { SoundValue } from '../../../protocol/soundvalue'; 3 | import { ImpressionSender } from '../impressions/impressionsender'; 4 | import { PreferenceSaver } from '../preferences/preferencesaver'; 5 | 6 | export class SoundToggler { 7 | private impressionSender_: ImpressionSender; 8 | private preferenceSaver_: PreferenceSaver; 9 | private soundOnEl_: HTMLElement; 10 | private soundOffEl_: HTMLElement; 11 | private soundsEnabled_: boolean; 12 | 13 | constructor( 14 | impressionSender: ImpressionSender, 15 | preferenceSaver: PreferenceSaver, 16 | soundTogglerEl: HTMLElement, 17 | soundOnEl: HTMLElement, 18 | soundOffEl: HTMLElement) { 19 | this.impressionSender_ = impressionSender; 20 | this.preferenceSaver_ = preferenceSaver; 21 | this.soundOnEl_ = soundOnEl; 22 | this.soundOffEl_ = soundOffEl; 23 | this.soundsEnabled_ = true; 24 | 25 | this.refreshEls_(); 26 | 27 | soundTogglerEl.onclick = () => this.toggle(); 28 | } 29 | 30 | areSoundsEnabled(): boolean { 31 | return this.soundsEnabled_; 32 | } 33 | 34 | setSoundsEnabled(soundsEnabled: boolean): void { 35 | this.setSoundsEnabledAndMaybeSetPreference_( 36 | soundsEnabled, false /* setPreference */); 37 | } 38 | 39 | toggle(): void { 40 | this.setSoundsEnabledAndMaybeSetPreference_( 41 | !this.soundsEnabled_, true /* setPreference */); 42 | } 43 | 44 | private setSoundsEnabledAndMaybeSetPreference_( 45 | soundsEnabled: boolean, setPreference: boolean): void { 46 | this.soundsEnabled_ = soundsEnabled; 47 | this.refreshEls_(); 48 | 49 | if (setPreference) { 50 | const soundValue = soundsEnabled ? SoundValue.ON : SoundValue.OFF; 51 | this.impressionSender_.sendImpression( 52 | ImpressionCode.TOGGLED_SOUNDS, {soundValue}); 53 | this.preferenceSaver_.save({soundValue}); 54 | } 55 | } 56 | 57 | private refreshEls_(): void { 58 | this.soundOnEl_.classList.toggle('hidden', !this.soundsEnabled_); 59 | this.soundOffEl_.classList.toggle('hidden', this.soundsEnabled_); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/client/js/statistics/debouncingstatisticsrecorder.ts: -------------------------------------------------------------------------------- 1 | import { Statistic } from '../../../protocol/statistic/statistic'; 2 | import { StatisticType } from '../../../protocol/statistic/statistictype'; 3 | import { Debouncer } from '../common/debouncer'; 4 | import { PickerController } from '../picker/pickercontroller'; 5 | import { ServerWrapper } from '../server/serverwrapper'; 6 | import { StatisticRecorder } from './statisticrecorder'; 7 | 8 | export class DebouncingStatisticRecorder implements StatisticRecorder { 9 | private pickerController_: PickerController; 10 | private server_: ServerWrapper; 11 | private debouncer_: Debouncer; 12 | private statisticsToSend_: Statistic[]; 13 | 14 | constructor( 15 | pickerController: PickerController, 16 | server: ServerWrapper, 17 | debounceIntervalMs: number) { 18 | this.pickerController_ = pickerController; 19 | this.server_ = server; 20 | this.debouncer_ = new Debouncer( 21 | () => this.sendToServer_(), debounceIntervalMs); 22 | 23 | this.statisticsToSend_ = []; 24 | } 25 | 26 | recordRightMove(pgn: string): void { 27 | this.recordStatistic_(pgn, StatisticType.RIGHT_MOVE); 28 | } 29 | 30 | recordWrongMove(pgn: string): void { 31 | this.recordStatistic_(pgn, StatisticType.WRONG_MOVE); 32 | } 33 | 34 | recordFinishLine(pgn: string): void { 35 | this.recordStatistic_(pgn, StatisticType.FINISH_LINE); 36 | } 37 | 38 | private recordStatistic_(pgn: string, statisticType: StatisticType): void { 39 | const repertoireId = this.pickerController_.getSelectedMetadataId(); 40 | this.statisticsToSend_.push({repertoireId, pgn, statisticType}); 41 | this.debouncer_.fire(); 42 | } 43 | 44 | private sendToServer_(): void { 45 | this.server_.recordStatistics(this.statisticsToSend_); 46 | this.statisticsToSend_ = []; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/client/js/statistics/delegatingstatisticsmodel.ts: -------------------------------------------------------------------------------- 1 | import { StatisticsModel } from './statisticsmodel'; 2 | import { ZeroStatisticsModel } from './zerostatisticsmodel'; 3 | 4 | export class DelegatingStatisticsModel implements StatisticsModel { 5 | private delegate_: StatisticsModel; 6 | 7 | constructor() { 8 | this.delegate_ = new ZeroStatisticsModel(); 9 | } 10 | 11 | setDelegate(delegate: StatisticsModel): void { 12 | this.delegate_ = delegate; 13 | } 14 | 15 | getRepertoireFinishLineCount(): Promise { 16 | return this.delegate_.getRepertoireFinishLineCount(); 17 | } 18 | 19 | getRepertoireRightMoveCount(): Promise { 20 | return this.delegate_.getRepertoireRightMoveCount(); 21 | } 22 | 23 | getRepertoireWrongMoveCount(): Promise { 24 | return this.delegate_.getRepertoireWrongMoveCount(); 25 | } 26 | 27 | getRightMoveCount(pgn: string): Promise { 28 | return this.delegate_.getRightMoveCount(pgn); 29 | } 30 | 31 | getWrongMoveCount(pgn: string): Promise { 32 | return this.delegate_.getWrongMoveCount(pgn); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/client/js/statistics/serverstatisticsmodel.ts: -------------------------------------------------------------------------------- 1 | import { CumulatedStatistic } from '../../../protocol/statistic/cumulatedstatistic'; 2 | import { ServerWrapper } from '../server/serverwrapper'; 3 | import { StatisticsModel } from './statisticsmodel'; 4 | 5 | export class ServerStatisticsModel implements StatisticsModel { 6 | private repertoireFinishLineCount_: Promise; 7 | private repertoireRightMoveCount_: Promise; 8 | private repertoireWrongMoveCount_: Promise; 9 | private rightMoveCounts_: Promise>; 10 | private wrongMoveCounts_: Promise>; 11 | 12 | constructor( 13 | server: ServerWrapper, 14 | repertoireId: string) { 15 | const cumulatedStatistics = server.loadCumulatedStatistics(repertoireId); 16 | const finishLineCounts = this.mapFromCumulatedStatistics_( 17 | cumulatedStatistics, cs => cs.finishLineCount); 18 | this.rightMoveCounts_ = this.mapFromCumulatedStatistics_( 19 | cumulatedStatistics, cs => cs.rightMoveCount); 20 | this.wrongMoveCounts_ = this.mapFromCumulatedStatistics_( 21 | cumulatedStatistics, cs => cs.wrongMoveCount); 22 | 23 | this.repertoireFinishLineCount_ = this.sumValues_(finishLineCounts); 24 | this.repertoireRightMoveCount_ = this.sumValues_(this.rightMoveCounts_); 25 | this.repertoireWrongMoveCount_ = this.sumValues_(this.wrongMoveCounts_); 26 | } 27 | 28 | private mapFromCumulatedStatistics_( 29 | cumulatedStatistics: Promise, 30 | mapFn: (cs: CumulatedStatistic) => T): Promise> { 31 | return cumulatedStatistics 32 | .then(csList => { 33 | const map = new Map(); 34 | csList.forEach(cs => map.set(cs.pgn, mapFn(cs))); 35 | return map; 36 | }); 37 | } 38 | 39 | private sumValues_(m: Promise>): Promise { 40 | return m.then(map => Array.from(map.values()).reduce((a, b) => a + b, 0)); 41 | } 42 | 43 | getRepertoireFinishLineCount(): Promise { 44 | return this.repertoireFinishLineCount_; 45 | } 46 | 47 | getRepertoireRightMoveCount(): Promise { 48 | return this.repertoireRightMoveCount_; 49 | } 50 | 51 | getRepertoireWrongMoveCount(): Promise { 52 | return this.repertoireWrongMoveCount_; 53 | } 54 | 55 | getRightMoveCount(pgn: string): Promise { 56 | return this.rightMoveCounts_.then(map => map.get(pgn) || 0); 57 | } 58 | 59 | getWrongMoveCount(pgn: string): Promise { 60 | return this.wrongMoveCounts_.then(map => map.get(pgn) || 0); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/client/js/statistics/statisticrecorder.ts: -------------------------------------------------------------------------------- 1 | export interface StatisticRecorder { 2 | recordRightMove(pgn: string): void; 3 | 4 | recordWrongMove(pgn: string): void; 5 | 6 | recordFinishLine(pgn: string): void; 7 | } 8 | -------------------------------------------------------------------------------- /src/client/js/statistics/statisticsmodel.ts: -------------------------------------------------------------------------------- 1 | export interface StatisticsModel { 2 | getRepertoireFinishLineCount(): Promise; 3 | 4 | getRepertoireRightMoveCount(): Promise; 5 | 6 | getRepertoireWrongMoveCount(): Promise; 7 | 8 | getRightMoveCount(pgn: string): Promise; 9 | 10 | getWrongMoveCount(pgn: string): Promise; 11 | } 12 | -------------------------------------------------------------------------------- /src/client/js/statistics/zerostatisticsmodel.ts: -------------------------------------------------------------------------------- 1 | import { StatisticsModel } from './statisticsmodel'; 2 | 3 | export class ZeroStatisticsModel implements StatisticsModel { 4 | getRepertoireFinishLineCount(): Promise { 5 | return Promise.resolve(0); 6 | } 7 | 8 | getRepertoireRightMoveCount(): Promise { 9 | return Promise.resolve(0); 10 | } 11 | 12 | getRepertoireWrongMoveCount(): Promise { 13 | return Promise.resolve(0); 14 | } 15 | 16 | getRightMoveCount(): Promise { 17 | return Promise.resolve(0); 18 | } 19 | 20 | getWrongMoveCount(): Promise { 21 | return Promise.resolve(0); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/client/js/study/line.ts: -------------------------------------------------------------------------------- 1 | import { Color } from '../../../protocol/color'; 2 | import { Move } from '../../../protocol/move'; 3 | 4 | declare var Chess: any; 5 | 6 | export class Line { 7 | public startPosition: string; 8 | public opponentFirstMove: Move | null; 9 | public moves: Move[]; 10 | public color: Color; 11 | 12 | constructor( 13 | startPosition: string, 14 | opponentFirstMove: Move | null, 15 | moves: Move[], 16 | color: Color) { 17 | this.startPosition = startPosition; 18 | this.opponentFirstMove = opponentFirstMove; 19 | this.moves = moves; 20 | this.color = color; 21 | } 22 | 23 | static fromPgnForInitialPosition(pgn: string, color: Color) { 24 | let chess = new Chess(); 25 | if (!chess.load_pgn(pgn)) { 26 | throw new Error('Invalid PGN: ' + pgn); 27 | } 28 | 29 | let opponentFirstMove = null; 30 | let history: {from: string, to: string}[] = chess.history({verbose: true}); 31 | let moves = history.map(m => { 32 | return { 33 | fromSquare: m.from, 34 | toSquare: m.to 35 | }; 36 | }); 37 | if (color == Color.BLACK) { 38 | opponentFirstMove = moves.splice(0, 1)[0]; 39 | } 40 | return new Line( 41 | 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1', 42 | opponentFirstMove, 43 | moves, 44 | color); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/client/js/study/lineemitter.ts: -------------------------------------------------------------------------------- 1 | import { NullAnnotator } from '../annotation/nullannotator'; 2 | import { TreeModel } from '../tree/treemodel'; 3 | import { Line } from './line'; 4 | 5 | export class LineEmitter { 6 | static emitForModel(treeModel: TreeModel): Line[] { 7 | const lines: Line[] = []; 8 | 9 | treeModel.traverseDepthFirst(viewInfo => { 10 | const isLeafNode = !viewInfo.numChildren; 11 | if (isLeafNode) { 12 | const line = Line.fromPgnForInitialPosition( 13 | viewInfo.pgn, treeModel.getRepertoireColor()); 14 | lines.push(line); 15 | } 16 | }, NullAnnotator.INSTANCE); 17 | 18 | return lines; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/client/js/study/lineliststudier.ts: -------------------------------------------------------------------------------- 1 | import { Line } from './line'; 2 | import { LineShuffler } from './lineshuffler'; 3 | import { LineStudier } from './linestudier'; 4 | 5 | const SHUFFLE_TIME_MS = 700; 6 | const NEXT_LINE_DELAY_MS = 1200; 7 | 8 | export class LineListStudier { 9 | private lineStudier_: LineStudier; 10 | private messageEl_: HTMLElement; 11 | private currentTimeout_: NodeJS.Timeout | null; 12 | 13 | constructor(lineStudier: LineStudier, messageEl: HTMLElement) { 14 | this.lineStudier_ = lineStudier; 15 | this.messageEl_ = messageEl; 16 | this.currentTimeout_ = null; 17 | } 18 | 19 | cancelStudy() { 20 | if (this.currentTimeout_ != null) { 21 | clearTimeout(this.currentTimeout_); 22 | this.currentTimeout_ = null; 23 | } 24 | } 25 | 26 | study(lines: Line[]) { 27 | if (!lines.length) { 28 | throw new Error('Need at least one line to study.'); 29 | } 30 | 31 | this.cancelStudy(); 32 | this.studyLine_(lines, lines.length); 33 | } 34 | 35 | private studyLine_(shuffledLines: Line[], lineIndex: number): void { 36 | if (lineIndex >= shuffledLines.length) { 37 | this.messageEl_.innerText = `Shuffling ${shuffledLines.length} lines...`; 38 | this.messageEl_.classList.remove('hidden'); 39 | 40 | this.currentTimeout_ = setTimeout( 41 | () => this.studyLine_(LineShuffler.shuffle(shuffledLines), 0), 42 | SHUFFLE_TIME_MS); 43 | return; 44 | } 45 | 46 | this.messageEl_.innerText = `${lineIndex} / ${shuffledLines.length} lines ` 47 | + `studied`; 48 | this.lineStudier_.study(shuffledLines[lineIndex]).then(success => { 49 | if (success) { 50 | this.messageEl_.innerText = `${lineIndex + 1} / ` 51 | + `${shuffledLines.length} lines studied`; 52 | this.currentTimeout_ = setTimeout( 53 | () => this.studyLine_(shuffledLines, lineIndex + 1), 54 | NEXT_LINE_DELAY_MS); 55 | } 56 | }); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/client/js/study/lineshuffler.ts: -------------------------------------------------------------------------------- 1 | import { Line } from './line'; 2 | 3 | export class LineShuffler { 4 | static shuffle(lines: Line[]): Line[] { 5 | const ans: Line[] = []; 6 | for (let i = 0; i < lines.length; i++) { 7 | ans[i] = lines[i]; 8 | } 9 | for (let i = 0; i < lines.length; i++) { 10 | const swapIndex = i + Math.floor(Math.random() * (lines.length - i)); 11 | const dummy = ans[i]; 12 | ans[i] = ans[swapIndex]; 13 | ans[swapIndex] = dummy; 14 | } 15 | 16 | return ans; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/client/js/study/studyboardhandler.ts: -------------------------------------------------------------------------------- 1 | import { BoardHandler } from '../board/boardhandler'; 2 | import { LineStudier } from './linestudier'; 3 | 4 | export class StudyBoardHandler implements BoardHandler { 5 | private lineStudier_: LineStudier; 6 | 7 | constructor(lineStudier: LineStudier) { 8 | this.lineStudier_ = lineStudier; 9 | } 10 | 11 | onMove(fromSquare: string, toSquare: string): void { 12 | this.lineStudier_.tryMove({fromSquare, toSquare}); 13 | } 14 | 15 | onChange(): void {} 16 | 17 | onScroll(): void {} 18 | } 19 | -------------------------------------------------------------------------------- /src/client/js/theme/boardthemeinfo.ts: -------------------------------------------------------------------------------- 1 | import { BoardTheme } from '../../../protocol/boardtheme'; 2 | import { assert } from '../../../util/assert'; 3 | 4 | export interface BoardThemeInfo { 5 | buttonEl: HTMLElement, 6 | setCssClass: string, 7 | previewCssClass: string 8 | } 9 | 10 | export type BoardThemeInfoMap = Map; 11 | 12 | export function allThemes(): BoardThemeInfoMap { 13 | const ans = new Map(); 14 | defaultTheme_(ans, BoardTheme.BLUE, 'blue'); 15 | defaultTheme_(ans, BoardTheme.BROWN, 'brown'); 16 | defaultTheme_(ans, BoardTheme.GRAY, 'gray'); 17 | defaultTheme_(ans, BoardTheme.GREEN, 'green'); 18 | defaultTheme_(ans, BoardTheme.LEATHER, 'leather'); 19 | defaultTheme_(ans, BoardTheme.MARBLE, 'marble'); 20 | defaultTheme_(ans, BoardTheme.PURPLE, 'purple'); 21 | defaultTheme_(ans, BoardTheme.WOOD_1, 'wood1'); 22 | defaultTheme_(ans, BoardTheme.WOOD_2, 'wood2'); 23 | defaultTheme_(ans, BoardTheme.WOOD_3, 'wood3'); 24 | 25 | return ans; 26 | } 27 | 28 | 29 | function defaultTheme_( 30 | map: Map, 31 | boardTheme: BoardTheme, 32 | themeName: string): void { 33 | theme_( 34 | map, 35 | boardTheme, 36 | `${themeName}BoardThemeButton`, 37 | `${themeName}BoardTheme`, 38 | `${themeName}BoardThemePreview`); 39 | } 40 | 41 | 42 | function theme_( 43 | map: Map, 44 | boardTheme: BoardTheme, 45 | buttonElId: string, 46 | setCssClass: string, 47 | previewCssClass: string): void { 48 | map.set( 49 | boardTheme, 50 | { 51 | buttonEl: assert(document.getElementById(buttonElId)), 52 | setCssClass: setCssClass, 53 | previewCssClass: previewCssClass 54 | }); 55 | } 56 | -------------------------------------------------------------------------------- /src/client/js/theme/boardthemesetter.ts: -------------------------------------------------------------------------------- 1 | import { BoardTheme } from '../../../protocol/boardtheme'; 2 | import { ChessgroundBoardFactory } from '../board/chessgroundboardfactory'; 3 | import { BoardThemeInfo, BoardThemeInfoMap } from './boardthemeinfo'; 4 | 5 | export class BoardThemeSetter { 6 | private chessgroundBoardFactory_: ChessgroundBoardFactory; 7 | private boardThemeInfoMap_: BoardThemeInfoMap; 8 | 9 | constructor( 10 | chessgroundBoardFactory: ChessgroundBoardFactory, 11 | boardThemeInfoMap: BoardThemeInfoMap) { 12 | this.chessgroundBoardFactory_ = chessgroundBoardFactory; 13 | this.boardThemeInfoMap_ = boardThemeInfoMap; 14 | } 15 | 16 | set(newBoardTheme: BoardTheme): void { 17 | this.toggleCssForBoards_( 18 | info => info.setCssClass, 19 | boardTheme => boardTheme == newBoardTheme); 20 | 21 | this.boardThemeInfoMap_.forEach((info, boardTheme) => { 22 | info.buttonEl.classList.toggle( 23 | 'selectedBoardTheme', boardTheme == newBoardTheme); 24 | }); 25 | } 26 | 27 | preview(newBoardTheme: BoardTheme): void { 28 | this.toggleCssForBoards_( 29 | info => info.previewCssClass, 30 | boardTheme => boardTheme == newBoardTheme); 31 | } 32 | 33 | endPreview(): void { 34 | this.toggleCssForBoards_( 35 | info => info.previewCssClass, 36 | () => false); 37 | } 38 | 39 | private toggleCssForBoards_( 40 | cssClassFn: (info: BoardThemeInfo) => string, 41 | filterFn: (boardTheme: BoardTheme) => boolean): void { 42 | this.chessgroundBoardFactory_.getBoardElements().forEach(boardEl => { 43 | this.boardThemeInfoMap_.forEach((info, boardTheme) => { 44 | boardEl.classList.toggle(cssClassFn(info), filterFn(boardTheme)); 45 | }); 46 | }); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/client/js/theme/themepalette.ts: -------------------------------------------------------------------------------- 1 | import { BoardTheme } from '../../../protocol/boardtheme'; 2 | import { ImpressionCode } from '../../../protocol/impression/impressioncode'; 3 | import { ImpressionSender } from '../impressions/impressionsender'; 4 | import { PreferenceSaver } from '../preferences/preferencesaver'; 5 | import { BoardThemeInfoMap } from './boardthemeinfo'; 6 | import { BoardThemeSetter } from './boardthemesetter'; 7 | 8 | declare var tippy: any; 9 | 10 | export class ThemePalette { 11 | private impressionSender_: ImpressionSender; 12 | private boardThemeSetter_: BoardThemeSetter; 13 | private preferenceSaver_: PreferenceSaver; 14 | 15 | constructor( 16 | impressionSender: ImpressionSender, 17 | boardThemeSetter: BoardThemeSetter, 18 | preferenceSaver: PreferenceSaver) { 19 | this.impressionSender_ = impressionSender; 20 | this.boardThemeSetter_ = boardThemeSetter; 21 | this.preferenceSaver_ = preferenceSaver; 22 | } 23 | 24 | initializePalette( 25 | themePaletteEl: HTMLElement, 26 | themePaletteTooltipContentEl: HTMLElement, 27 | boardThemeInfoMap: BoardThemeInfoMap): void { 28 | boardThemeInfoMap.forEach( 29 | (info, boardTheme) => this.bindTheme_(info.buttonEl, boardTheme)); 30 | 31 | tippy( 32 | themePaletteEl, 33 | { 34 | a11y: false, 35 | content: themePaletteTooltipContentEl, 36 | delay: 0, 37 | duration: 0, 38 | interactive: true, 39 | theme: 'themePaletteTooltip', 40 | trigger: 'mouseenter click' 41 | }); 42 | themePaletteTooltipContentEl.classList.remove('hidden'); 43 | } 44 | 45 | private bindTheme_(buttonEl: HTMLElement, boardTheme: BoardTheme): void { 46 | buttonEl.onclick = () => { 47 | this.impressionSender_.sendImpression( 48 | ImpressionCode.SET_BOARD_THEME, 49 | {boardTheme}); 50 | this.boardThemeSetter_.set(boardTheme); 51 | this.preferenceSaver_.save({boardTheme}); 52 | }; 53 | 54 | buttonEl.onmouseenter = () => { 55 | this.boardThemeSetter_.preview(boardTheme); 56 | }; 57 | 58 | buttonEl.onmouseleave = () => { 59 | this.boardThemeSetter_.endPreview(); 60 | }; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/client/js/tree/addmoveresult.ts: -------------------------------------------------------------------------------- 1 | export interface AddMoveResult { 2 | success: boolean, 3 | failureReason: AddMoveFailureReason | null 4 | } 5 | 6 | export enum AddMoveFailureReason { 7 | ILLEGAL_MOVE = 1, 8 | EXCEEDED_MAXIMUM_LINE_DEPTH = 2, 9 | EXCEEDED_MAXIMUM_NUM_NODES = 3 10 | } 11 | -------------------------------------------------------------------------------- /src/client/js/tree/emptymessage.ts: -------------------------------------------------------------------------------- 1 | import { RefreshableView } from '../common/refreshableview'; 2 | import { TreeModel } from './treemodel'; 3 | 4 | export class EmptyMessage implements RefreshableView { 5 | private treeModel_: TreeModel; 6 | private emptyEl_: HTMLElement; 7 | 8 | constructor(treeModel: TreeModel, emptyEl: HTMLElement) { 9 | this.treeModel_ = treeModel; 10 | this.emptyEl_ = emptyEl; 11 | } 12 | 13 | refresh(): void { 14 | const hideEmptyMessage = !this.treeModel_.isEmpty(); 15 | this.emptyEl_.classList.toggle('hidden', hideEmptyMessage); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/client/js/tree/fennormalizer.ts: -------------------------------------------------------------------------------- 1 | export class FenNormalizer { 2 | static normalize(fen: string, numLegalMoves: number): string { 3 | // Remove the half move counts and en passant tokens at the end. To treat en 4 | // passant positions as different, we append the number of legal moves. 5 | return fen.split(' ').slice(0, 3).join(' ') + numLegalMoves; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/client/js/tree/fentopgnmap.ts: -------------------------------------------------------------------------------- 1 | export interface FenToPgnMap { 2 | [fen: string]: string[] 3 | } 4 | -------------------------------------------------------------------------------- /src/client/js/tree/pgntonodemap.ts: -------------------------------------------------------------------------------- 1 | import { TreeNode } from './treenode'; 2 | 3 | export interface PgnToNodeMap { 4 | [pgn: string]: TreeNode 5 | } 6 | -------------------------------------------------------------------------------- /src/client/js/tree/treebutton.ts: -------------------------------------------------------------------------------- 1 | export interface TreeButton { 2 | buttonEl: HTMLElement, 3 | handleClick: () => void, 4 | isEnabled: () => boolean 5 | } 6 | -------------------------------------------------------------------------------- /src/client/js/tree/treebuttons.ts: -------------------------------------------------------------------------------- 1 | import { RefreshableView } from '../common/refreshableview'; 2 | import { TreeButton } from './treebutton'; 3 | import { TreeModel } from './treemodel'; 4 | import { TreeNavigator } from './treenavigator'; 5 | 6 | export class TreeButtons implements RefreshableView { 7 | private buttonsEl_: HTMLElement; 8 | private treeModel_: TreeModel; 9 | private buttons_: TreeButton[]; 10 | 11 | constructor( 12 | buttonsEl: HTMLElement, 13 | treeModel: TreeModel) { 14 | this.buttonsEl_ = buttonsEl; 15 | this.treeModel_ = treeModel; 16 | this.buttons_ = []; 17 | } 18 | 19 | refresh(): void { 20 | // Show/hide the button container as necessary. 21 | let isModelEmpty = this.treeModel_.isEmpty(); 22 | this.buttonsEl_.classList.toggle('hidden', isModelEmpty); 23 | 24 | // Show/hide the individual buttons. 25 | this.buttons_.forEach(b => { 26 | const enabled = b.isEnabled(); 27 | b.buttonEl.classList.toggle('disabled', !enabled); 28 | b.buttonEl.classList.toggle('selectable', enabled); 29 | }); 30 | } 31 | 32 | addButton(treeButton: TreeButton): TreeButtons { 33 | treeButton.buttonEl.onclick = () => treeButton.handleClick(); 34 | this.buttons_.push(treeButton); 35 | return this; 36 | } 37 | 38 | addNavigationButtons( 39 | leftButtonEl: HTMLElement, 40 | rightButtonEl: HTMLElement, 41 | treeNavigator: TreeNavigator): TreeButtons { 42 | return this 43 | .addButton({ 44 | buttonEl: leftButtonEl, 45 | handleClick: () => treeNavigator.selectLeft(), 46 | isEnabled: () => this.treeModel_.hasPreviousPgn() 47 | }) 48 | .addButton({ 49 | buttonEl: rightButtonEl, 50 | handleClick: () => treeNavigator.selectRight(), 51 | isEnabled: () => this.treeModel_.hasNextPgn() 52 | }); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/client/js/tree/treenavigator.ts: -------------------------------------------------------------------------------- 1 | import { ImpressionCode } from '../../../protocol/impression/impressioncode'; 2 | import { RefreshableView } from '../common/refreshableview'; 3 | import { ImpressionSender } from '../impressions/impressionsender'; 4 | import { TreeModel } from './treemodel'; 5 | 6 | export class TreeNavigator { 7 | private impressionSender_: ImpressionSender; 8 | private treeModel_: TreeModel; 9 | private modeView_: RefreshableView; 10 | 11 | constructor( 12 | impressionSender: ImpressionSender, 13 | treeModel: TreeModel, 14 | modeView: RefreshableView) { 15 | this.impressionSender_ = impressionSender; 16 | this.treeModel_ = treeModel; 17 | this.modeView_ = modeView; 18 | } 19 | 20 | selectLeft(): void { 21 | if (this.treeModel_.hasPreviousPgn()) { 22 | this.impressionSender_.sendImpression(ImpressionCode.TREE_SELECT_LEFT); 23 | this.treeModel_.selectPreviousPgn(); 24 | this.modeView_.refresh(); 25 | } 26 | } 27 | 28 | selectRight(): void { 29 | if (this.treeModel_.hasNextPgn()) { 30 | this.impressionSender_.sendImpression(ImpressionCode.TREE_SELECT_RIGHT); 31 | this.treeModel_.selectNextPgn(); 32 | this.modeView_.refresh(); 33 | } 34 | } 35 | 36 | selectDown(): void { 37 | if (this.treeModel_.hasNextSiblingPgn()) { 38 | this.impressionSender_.sendImpression(ImpressionCode.TREE_SELECT_DOWN); 39 | this.treeModel_.selectNextSiblingPgn(); 40 | this.modeView_.refresh(); 41 | } 42 | } 43 | 44 | selectUp(): void { 45 | if (this.treeModel_.hasPreviousSiblingPgn()) { 46 | this.impressionSender_.sendImpression(ImpressionCode.TREE_SELECT_UP); 47 | this.treeModel_.selectPreviousSiblingPgn(); 48 | this.modeView_.refresh(); 49 | } 50 | } 51 | 52 | selectFromWheelEvent(e: WheelEvent): void { 53 | if (e.deltaY < 0) { 54 | this.selectLeft(); 55 | } else if (e.deltaY > 0) { 56 | this.selectRight(); 57 | } 58 | e.preventDefault(); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/client/js/tree/treenodehandler.ts: -------------------------------------------------------------------------------- 1 | import { ImpressionCode } from '../../../protocol/impression/impressioncode'; 2 | import { RefreshableView } from '../common/refreshableview'; 3 | import { ImpressionSender } from '../impressions/impressionsender'; 4 | import { TreeModel } from './treemodel'; 5 | 6 | export class TreeNodeHandler { 7 | private impressionSender_: ImpressionSender; 8 | private treeModel_: TreeModel; 9 | private modeView_: RefreshableView; 10 | 11 | constructor( 12 | impressionSender: ImpressionSender, 13 | treeModel: TreeModel, 14 | modeView: RefreshableView) { 15 | this.impressionSender_ = impressionSender; 16 | this.treeModel_ = treeModel; 17 | this.modeView_ = modeView; 18 | } 19 | 20 | onClick(pgn: string) { 21 | this.impressionSender_.sendImpression(ImpressionCode.TREE_SELECT_NODE); 22 | this.treeModel_.selectPgn(pgn); 23 | this.modeView_.refresh(); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/client/ogg/capture.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jven/studyopenings/55ac1b6ad98589744440c4677b9664e56c402db6/src/client/ogg/capture.ogg -------------------------------------------------------------------------------- /src/client/ogg/finishline.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jven/studyopenings/55ac1b6ad98589744440c4677b9664e56c402db6/src/client/ogg/finishline.ogg -------------------------------------------------------------------------------- /src/client/ogg/move.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jven/studyopenings/55ac1b6ad98589744440c4677b9664e56c402db6/src/client/ogg/move.ogg -------------------------------------------------------------------------------- /src/client/ogg/wrongmove.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jven/studyopenings/55ac1b6ad98589744440c4677b9664e56c402db6/src/client/ogg/wrongmove.ogg -------------------------------------------------------------------------------- /src/flag/flags.ts: -------------------------------------------------------------------------------- 1 | import { RolloutState } from './rolloutstate'; 2 | 3 | export enum FlagName { 4 | ENABLE_EVALUATE_MODE = 'enable_evaluate_mode', 5 | ENABLE_PICKER_DELETE_CONFIRM = 'enable_picker_delete_confirm' 6 | } 7 | 8 | export const FLAG_MAP: Map = new Map(); 9 | FLAG_MAP 10 | .set( 11 | FlagName.ENABLE_EVALUATE_MODE, 12 | RolloutState.ENABLED_EVERYWHERE) 13 | .set( 14 | FlagName.ENABLE_PICKER_DELETE_CONFIRM, 15 | RolloutState.ENABLED_EVERYWHERE); 16 | -------------------------------------------------------------------------------- /src/flag/rolloutstate.ts: -------------------------------------------------------------------------------- 1 | export enum RolloutState { 2 | LOCAL_ONLY = 1, 3 | ENABLED_EVERYWHERE = 2 4 | } 5 | -------------------------------------------------------------------------------- /src/protocol/actions.ts: -------------------------------------------------------------------------------- 1 | import { EvaluatedFlags } from './evaluatedflags'; 2 | import { Impression } from './impression/impression'; 3 | import { Preference } from './preference/preference'; 4 | import { CumulatedStatistic } from './statistic/cumulatedstatistic'; 5 | import { Statistic } from './statistic/statistic'; 6 | import { Metadata, Repertoire } from './storage'; 7 | 8 | export interface CreateRepertoireRequest {} 9 | 10 | export interface CreateRepertoireResponse { 11 | newRepertoireId: string 12 | } 13 | 14 | export interface DeleteRepertoireRequest { 15 | repertoireId: string 16 | } 17 | 18 | export interface DeleteRepertoireResponse {} 19 | 20 | export interface EvaluateFlagsRequest {} 21 | 22 | export interface EvaluateFlagsResponse { 23 | evaluatedFlags: EvaluatedFlags 24 | } 25 | 26 | export interface GetPreferenceRequest {} 27 | 28 | export interface GetPreferenceResponse { 29 | preference: Preference 30 | } 31 | 32 | export interface LogImpressionsRequest { 33 | impressions: Impression[] 34 | } 35 | 36 | export interface LogImpressionsResponse {} 37 | 38 | export interface LoadRepertoireRequest { 39 | repertoireId: string 40 | } 41 | 42 | export interface LoadRepertoireResponse { 43 | repertoire: Repertoire 44 | } 45 | 46 | export interface LoadCumulatedStatisticsRequest { 47 | repertoireId: string 48 | } 49 | 50 | export interface LoadCumulatedStatisticsResponse { 51 | cumulatedStatisticList: CumulatedStatistic[] 52 | } 53 | 54 | export interface MetadataRequest {} 55 | 56 | export interface MetadataResponse { 57 | metadataList: Metadata[] 58 | } 59 | 60 | export interface PrivelegedCopyRequest { 61 | repertoireId: string 62 | } 63 | 64 | export interface PrivelegedCopyResponse {} 65 | 66 | export interface RecordStatisticsRequest { 67 | statisticList: Statistic[] 68 | } 69 | 70 | export interface RecordStatisticsResponse {} 71 | 72 | export interface SetPreferenceRequest { 73 | preference: Preference 74 | } 75 | 76 | export interface SetPreferenceResponse {} 77 | 78 | export interface UpdateRepertoireRequest { 79 | repertoireId: string, 80 | repertoire: Repertoire 81 | } 82 | 83 | export interface UpdateRepertoireResponse {} 84 | -------------------------------------------------------------------------------- /src/protocol/boardtheme.ts: -------------------------------------------------------------------------------- 1 | export enum BoardTheme { 2 | BLUE = 'bl', 3 | BROWN = 'br', 4 | GRAY = 'gy', 5 | GREEN = 'ge', 6 | LEATHER = 'le', 7 | MARBLE = 'ma', 8 | PURPLE = 'pu', 9 | WOOD_1 = 'w1', 10 | WOOD_2 = 'w2', 11 | WOOD_3 = 'w3' 12 | } 13 | -------------------------------------------------------------------------------- /src/protocol/color.ts: -------------------------------------------------------------------------------- 1 | export enum Color { 2 | WHITE = 'w', 3 | BLACK = 'b' 4 | } 5 | -------------------------------------------------------------------------------- /src/protocol/evaluatedflags.ts: -------------------------------------------------------------------------------- 1 | export interface EvaluatedFlags {[flagName: string]: boolean} 2 | -------------------------------------------------------------------------------- /src/protocol/impression/extradata.test.ts: -------------------------------------------------------------------------------- 1 | import { ExtraData } from './extradata'; 2 | 3 | it('all fields in extra data must be optional', () => { 4 | assertIsExtraData({}); 5 | }); 6 | 7 | function assertIsExtraData(extraData: ExtraData) { 8 | return true; 9 | } 10 | -------------------------------------------------------------------------------- /src/protocol/impression/extradata.ts: -------------------------------------------------------------------------------- 1 | import { BoardTheme } from '../boardtheme'; 2 | import { Color } from '../color'; 3 | import { SoundValue } from '../soundvalue'; 4 | 5 | export interface ExtraData { 6 | importedPgn?: string, 7 | boardTheme?: BoardTheme, 8 | soundValue?: SoundValue, 9 | color?: Color 10 | } 11 | -------------------------------------------------------------------------------- /src/protocol/impression/impression.ts: -------------------------------------------------------------------------------- 1 | import { ExtraData } from './extradata'; 2 | import { ImpressionCode } from './impressioncode'; 3 | 4 | export interface Impression { 5 | impressionCode: ImpressionCode, 6 | user: string, 7 | timestampMs: number, 8 | sessionId: string, 9 | userAgent: string, 10 | extraData: ExtraData 11 | } 12 | -------------------------------------------------------------------------------- /src/protocol/impression/impressioncode.ts: -------------------------------------------------------------------------------- 1 | export enum ImpressionCode { 2 | START_PGN_IMPORT = 1, 3 | PGN_EXPORT = 2, 4 | TREE_SELECT_LEFT = 3, 5 | TREE_SELECT_RIGHT = 4, 6 | TREE_SELECT_UP = 5, 7 | TREE_SELECT_DOWN = 6, 8 | TREE_TRASH_SELECTED = 7, 9 | TREE_FLIP_REPERTOIRE_COLOR = 8, 10 | ENTER_BUILD_MODE = 9, 11 | ENTER_STUDY_MODE = 10, 12 | INITIAL_LOAD_COMPLETE = 11, 13 | TREE_SELECT_NODE = 12, 14 | STUDY_CORRECT_MOVE = 13, 15 | STUDY_WRONG_MOVE = 14, 16 | STUDY_FINISH_LINE = 15, 17 | PICKER_NEW_REPERTOIRE = 16, 18 | PICKER_SELECT_REPERTOIRE = 17, 19 | PICKER_DELETE_REPERTOIRE = 18, 20 | OPEN_ABOUT_PAGE = 19, 21 | OPEN_SOURCE_CODE = 20, 22 | LOAD_EXAMPLE_REPERTOIRE = 21, 23 | SET_BOARD_THEME = 22, 24 | TOGGLED_SOUNDS = 23, 25 | TREE_SET_REPERTOIRE_COLOR = 24, 26 | ENTER_EVALUATE_MODE = 25 27 | } 28 | -------------------------------------------------------------------------------- /src/protocol/move.ts: -------------------------------------------------------------------------------- 1 | export interface Move { 2 | fromSquare: string, 3 | toSquare: string 4 | } 5 | -------------------------------------------------------------------------------- /src/protocol/preference/preference.test.ts: -------------------------------------------------------------------------------- 1 | import { Preference } from './preference'; 2 | 3 | it('all fields in preference must be optional', () => { 4 | assertIsExtraData({}); 5 | }); 6 | 7 | function assertIsExtraData(preference: Preference) { 8 | return true; 9 | } 10 | -------------------------------------------------------------------------------- /src/protocol/preference/preference.ts: -------------------------------------------------------------------------------- 1 | import { BoardTheme } from '../boardtheme'; 2 | import { SoundValue } from '../soundvalue'; 3 | 4 | export interface Preference { 5 | boardTheme?: BoardTheme, 6 | soundValue?: SoundValue 7 | } 8 | 9 | export function mergePreferences( 10 | source: Preference, other: Preference): Preference { 11 | const result = source; 12 | if (other.boardTheme) { 13 | result.boardTheme = other.boardTheme; 14 | } 15 | if (other.soundValue) { 16 | result.soundValue = other.soundValue; 17 | } 18 | 19 | return result; 20 | } 21 | -------------------------------------------------------------------------------- /src/protocol/soundvalue.ts: -------------------------------------------------------------------------------- 1 | export enum SoundValue { 2 | ON = 'on', 3 | OFF = 'off' 4 | } 5 | -------------------------------------------------------------------------------- /src/protocol/statistic/cumulatedstatistic.ts: -------------------------------------------------------------------------------- 1 | export interface CumulatedStatistic { 2 | pgn: string, 3 | rightMoveCount: number, 4 | wrongMoveCount: number, 5 | finishLineCount: number 6 | } 7 | -------------------------------------------------------------------------------- /src/protocol/statistic/statistic.ts: -------------------------------------------------------------------------------- 1 | import { StatisticType } from './statistictype'; 2 | 3 | export interface Statistic { 4 | repertoireId: string, 5 | pgn: string, 6 | statisticType: StatisticType 7 | } 8 | -------------------------------------------------------------------------------- /src/protocol/statistic/statistictype.ts: -------------------------------------------------------------------------------- 1 | export enum StatisticType { 2 | RIGHT_MOVE = 1, 3 | WRONG_MOVE = 2, 4 | FINISH_LINE = 3 5 | } 6 | -------------------------------------------------------------------------------- /src/protocol/storage.ts: -------------------------------------------------------------------------------- 1 | import { Color } from './color'; 2 | 3 | export interface Metadata { 4 | id: string, 5 | name: string 6 | } 7 | 8 | export interface RepertoireNode { 9 | pgn: string, 10 | fen: string, 11 | nlm: number, // numLegalMoves 12 | ctm: string, // colorToMove 13 | lmf: string, // lastMoveFromSquare 14 | lmt: string, // lastMoveToSquare 15 | lms: string, // lastMoveString 16 | c: RepertoireNode[] // children 17 | } 18 | 19 | export interface Repertoire { 20 | name: string, 21 | color: Color, 22 | root: RepertoireNode | null 23 | } 24 | -------------------------------------------------------------------------------- /src/server/action.ts: -------------------------------------------------------------------------------- 1 | import { CheckRequestResult } from './checkrequestresult'; 2 | 3 | /** 4 | * An interface for actions exposed by the server. Conceptually, these are 5 | * POST endpoints exposed by the server. 6 | * 7 | * Each action takes a request type and returns a response type. These types are 8 | * unique to this action and are declared in the protocol folder as they are 9 | * shared with the client. 10 | */ 11 | export interface Action { 12 | checkRequest(request: REQUEST, user: string | null): 13 | Promise; 14 | 15 | /** 16 | * Does the action with the given request for the given user. The user is null 17 | * if the user is anonymous. 18 | * 19 | * Returns a promise of the response. 20 | */ 21 | do(request: REQUEST, user: string | null): Promise; 22 | } 23 | -------------------------------------------------------------------------------- /src/server/actions/createrepertoireaction.ts: -------------------------------------------------------------------------------- 1 | import { CreateRepertoireRequest, CreateRepertoireResponse } from '../../protocol/actions'; 2 | import { assert } from '../../util/assert'; 3 | import { Action } from '../action'; 4 | import { CheckRequestResult } from '../checkrequestresult'; 5 | import { DatabaseWrapper } from '../databasewrapper'; 6 | 7 | const MAX_REPERTOIRES_PER_USER = 20; 8 | 9 | export class CreateRepertoireAction 10 | implements Action { 11 | private database_: DatabaseWrapper; 12 | 13 | constructor(database: DatabaseWrapper) { 14 | this.database_ = database; 15 | } 16 | 17 | checkRequest(request: CreateRepertoireRequest, user: string | null): 18 | Promise { 19 | return this.database_.getMetadataListForOwner(assert(user)) 20 | .then(metadataList => { 21 | return metadataList.length >= MAX_REPERTOIRES_PER_USER 22 | ? { 23 | success: false, 24 | failureMessage: `Cannot have more than ` 25 | + `${MAX_REPERTOIRES_PER_USER} repertoires per user.` 26 | } 27 | : { success: true }; 28 | }); 29 | } 30 | 31 | do(request: CreateRepertoireRequest, user: string | null): 32 | Promise { 33 | return this.database_ 34 | .createNewRepertoire(assert(user)) 35 | .then(newRepertoireId => { 36 | return {newRepertoireId}; 37 | }); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/server/actions/deleterepertoireaction.ts: -------------------------------------------------------------------------------- 1 | import { DeleteRepertoireRequest, DeleteRepertoireResponse } from '../../protocol/actions'; 2 | import { assert } from '../../util/assert'; 3 | import { Action } from '../action'; 4 | import { CheckRequestResult } from '../checkrequestresult'; 5 | import { DatabaseWrapper } from '../databasewrapper'; 6 | 7 | export class DeleteRepertoireAction 8 | implements Action { 9 | private database_: DatabaseWrapper; 10 | 11 | constructor(database: DatabaseWrapper) { 12 | this.database_ = database; 13 | } 14 | 15 | checkRequest(): Promise { 16 | return Promise.resolve({ success: true }); 17 | } 18 | 19 | do(request: DeleteRepertoireRequest, user: string | null): 20 | Promise { 21 | return this.database_ 22 | .deleteRepertoire(request.repertoireId, assert(user)) 23 | .then(() => { return {}; }); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/server/actions/evaluateflagsaction.ts: -------------------------------------------------------------------------------- 1 | import { EvaluateFlagsRequest, EvaluateFlagsResponse } from '../../protocol/actions'; 2 | import { Action } from '../action'; 3 | import { CheckRequestResult } from '../checkrequestresult'; 4 | import { FlagEvaluator } from '../flagevaluator'; 5 | 6 | export class EvaluateFlagsAction 7 | implements Action { 8 | checkRequest(): Promise { 9 | return Promise.resolve({ success: true }); 10 | } 11 | 12 | do(): Promise { 13 | return Promise.resolve({ 14 | evaluatedFlags: FlagEvaluator.evaluateAllFlags() 15 | }); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/server/actions/getpreferenceaction.ts: -------------------------------------------------------------------------------- 1 | import { GetPreferenceRequest, GetPreferenceResponse } from '../../protocol/actions'; 2 | import { assert } from '../../util/assert'; 3 | import { Action } from '../action'; 4 | import { CheckRequestResult } from '../checkrequestresult'; 5 | import { DatabaseWrapper } from '../databasewrapper'; 6 | 7 | export class GetPreferenceAction 8 | implements Action { 9 | private databaseWrapper_: DatabaseWrapper; 10 | 11 | constructor(databaseWrapper: DatabaseWrapper) { 12 | this.databaseWrapper_ = databaseWrapper; 13 | } 14 | 15 | checkRequest(): Promise { 16 | return Promise.resolve({ success: true }); 17 | } 18 | 19 | do(request: GetPreferenceRequest, user: string | null): 20 | Promise { 21 | return this.databaseWrapper_.getPreferenceForUser(assert(user)) 22 | .then(preference => { 23 | return {preference}; 24 | }); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/server/actions/loadcumulatedstatisticsaction.ts: -------------------------------------------------------------------------------- 1 | import { LoadCumulatedStatisticsRequest, LoadCumulatedStatisticsResponse } from '../../protocol/actions'; 2 | import { assert } from '../../util/assert'; 3 | import { Action } from '../action'; 4 | import { CheckRequestResult } from '../checkrequestresult'; 5 | import { DatabaseWrapper } from '../databasewrapper'; 6 | 7 | export class LoadCumulatedStatisticsAction implements 8 | Action { 9 | private database_: DatabaseWrapper; 10 | 11 | constructor(database: DatabaseWrapper) { 12 | this.database_ = database; 13 | } 14 | 15 | checkRequest(): Promise { 16 | return Promise.resolve({ success: true }); 17 | } 18 | 19 | do(request: LoadCumulatedStatisticsRequest, user: string | null): 20 | Promise { 21 | return this.database_ 22 | .loadCumulatedStatistics(request.repertoireId, assert(user)) 23 | .then(cumulatedStatisticList => { return { cumulatedStatisticList }; }); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/server/actions/loadrepertoireaction.ts: -------------------------------------------------------------------------------- 1 | import { LoadRepertoireRequest, LoadRepertoireResponse } from '../../protocol/actions'; 2 | import { assert } from '../../util/assert'; 3 | import { Action } from '../action'; 4 | import { CheckRequestResult } from '../checkrequestresult'; 5 | import { DatabaseWrapper } from '../databasewrapper'; 6 | 7 | export class LoadRepertoireAction 8 | implements Action { 9 | private database_: DatabaseWrapper; 10 | 11 | constructor(database: DatabaseWrapper) { 12 | this.database_ = database; 13 | } 14 | 15 | checkRequest(): Promise { 16 | return Promise.resolve({ success: true }); 17 | } 18 | 19 | do(request: LoadRepertoireRequest, user: string | null): 20 | Promise { 21 | return this.database_ 22 | .getRepertoireForOwner(request.repertoireId, assert(user)) 23 | .then(repertoire => { return {repertoire}; }); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/server/actions/logimpressionsaction.ts: -------------------------------------------------------------------------------- 1 | import { LogImpressionsRequest, LogImpressionsResponse } from '../../protocol/actions'; 2 | import { Action } from '../action'; 3 | import { CheckRequestResult } from '../checkrequestresult'; 4 | import { DatabaseWrapper } from '../databasewrapper'; 5 | 6 | export class LogImpressionsAction 7 | implements Action { 8 | private database_: DatabaseWrapper; 9 | 10 | constructor(database: DatabaseWrapper) { 11 | this.database_ = database; 12 | } 13 | 14 | checkRequest(request: LogImpressionsRequest): Promise { 15 | if (!request.impressions.length) { 16 | return Promise.resolve({ 17 | success: false, 18 | failureMessage: 'Must provide at least one impression.' 19 | }); 20 | } 21 | 22 | return Promise.resolve({ success: true }); 23 | } 24 | 25 | do(request: LogImpressionsRequest): Promise { 26 | return this.database_ 27 | .addImpressions(request.impressions) 28 | .then(() => { 29 | return {}; 30 | }); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/server/actions/privelegedcopyaction.ts: -------------------------------------------------------------------------------- 1 | import { PrivelegedCopyRequest, PrivelegedCopyResponse } from '../../protocol/actions'; 2 | import { assert } from '../../util/assert'; 3 | import { Action } from '../action'; 4 | import { CheckRequestResult } from '../checkrequestresult'; 5 | import { DatabaseWrapper } from '../databasewrapper'; 6 | 7 | export class PrivelegedCopyAction 8 | implements Action { 9 | private database_: DatabaseWrapper; 10 | 11 | constructor(database: DatabaseWrapper) { 12 | this.database_ = database; 13 | } 14 | 15 | checkRequest(): Promise { 16 | return Promise.resolve({ success: true }); 17 | } 18 | 19 | do(request: PrivelegedCopyRequest, user: string | null): 20 | Promise { 21 | return this.database_ 22 | .copyRepertoireForPrivelegedUser(request.repertoireId, assert(user)) 23 | .then(() => { 24 | return {}; 25 | }); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/server/actions/recordstatisticsaction.ts: -------------------------------------------------------------------------------- 1 | import { RecordStatisticsRequest, RecordStatisticsResponse } from '../../protocol/actions'; 2 | import { assert } from '../../util/assert'; 3 | import { Action } from '../action'; 4 | import { CheckRequestResult } from '../checkrequestresult'; 5 | import { DatabaseWrapper } from '../databasewrapper'; 6 | 7 | export class RecordStatisticsAction 8 | implements Action { 9 | private database_: DatabaseWrapper; 10 | 11 | constructor(database: DatabaseWrapper) { 12 | this.database_ = database; 13 | } 14 | 15 | checkRequest(request: RecordStatisticsRequest, user: string | null): 16 | Promise { 17 | if (!request.statisticList.length) { 18 | return Promise.resolve({ 19 | success: false, 20 | failureMessage: 'Must provide at least one statistic.' 21 | }); 22 | } 23 | 24 | return Promise.resolve({ success: true }); 25 | } 26 | 27 | do(request: RecordStatisticsRequest, user: string | null): 28 | Promise { 29 | return this.database_ 30 | .recordStatistics(assert(user), request.statisticList) 31 | .then(() => { 32 | return {}; 33 | }); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/server/actions/repertoiremetadataaction.ts: -------------------------------------------------------------------------------- 1 | import { MetadataRequest, MetadataResponse } from '../../protocol/actions'; 2 | import { assert } from '../../util/assert'; 3 | import { Action } from '../action'; 4 | import { CheckRequestResult } from '../checkrequestresult'; 5 | import { DatabaseWrapper } from '../databasewrapper'; 6 | 7 | export class RepertoireMetadataAction implements 8 | Action { 9 | private database_: DatabaseWrapper; 10 | 11 | constructor(database: DatabaseWrapper) { 12 | this.database_ = database; 13 | } 14 | 15 | checkRequest(): Promise { 16 | return Promise.resolve({ success: true }); 17 | } 18 | 19 | do(request: MetadataRequest, user: string | null): Promise { 20 | return this.database_ 21 | .getMetadataListForOwner(assert(user)) 22 | .then(metadataList => { return { metadataList }; }); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/server/actions/setpreferenceaction.ts: -------------------------------------------------------------------------------- 1 | import { SetPreferenceRequest, SetPreferenceResponse } from '../../protocol/actions'; 2 | import { assert } from '../../util/assert'; 3 | import { Action } from '../action'; 4 | import { CheckRequestResult } from '../checkrequestresult'; 5 | import { DatabaseWrapper } from '../databasewrapper'; 6 | 7 | export class SetPreferenceAction 8 | implements Action { 9 | private databaseWrapper_: DatabaseWrapper; 10 | 11 | constructor(databaseWrapper: DatabaseWrapper) { 12 | this.databaseWrapper_ = databaseWrapper; 13 | } 14 | 15 | checkRequest(): Promise { 16 | return Promise.resolve({ success: true }); 17 | } 18 | 19 | do(request: SetPreferenceRequest, user: string | null): 20 | Promise { 21 | return this.databaseWrapper_ 22 | .setPreferenceForUser(request.preference, assert(user)) 23 | .then(() => { 24 | return {}; 25 | }); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/server/actions/updaterepertoireaction.ts: -------------------------------------------------------------------------------- 1 | import { UpdateRepertoireRequest, UpdateRepertoireResponse } from '../../protocol/actions'; 2 | import { assert } from '../../util/assert'; 3 | import { Action } from '../action'; 4 | import { CheckRequestResult } from '../checkrequestresult'; 5 | import { DatabaseWrapper } from '../databasewrapper'; 6 | 7 | const MAX_REPERTOIRE_NAME_LENGTH = 200; 8 | 9 | export class UpdateRepertoireAction implements 10 | Action { 11 | private database_: DatabaseWrapper; 12 | 13 | constructor(database: DatabaseWrapper) { 14 | this.database_ = database; 15 | } 16 | 17 | checkRequest(request: UpdateRepertoireRequest): Promise { 18 | if (request.repertoire.name.length > MAX_REPERTOIRE_NAME_LENGTH) { 19 | return Promise.resolve({ 20 | success: false, 21 | failureMessage: `Repertoire name must not exceed ` 22 | + `${MAX_REPERTOIRE_NAME_LENGTH} characters.` 23 | }); 24 | } 25 | 26 | return Promise.resolve({ success: true }); 27 | } 28 | 29 | do(request: UpdateRepertoireRequest, user: string | null): 30 | Promise { 31 | return this.database_ 32 | .updateRepertoire( 33 | request.repertoireId, 34 | request.repertoire, 35 | assert(user)) 36 | .then(() => { return {}; }); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/server/checkrequestresult.ts: -------------------------------------------------------------------------------- 1 | export interface CheckRequestResult { 2 | success: boolean, 3 | failureMessage?: string 4 | } 5 | -------------------------------------------------------------------------------- /src/server/flagevaluator.ts: -------------------------------------------------------------------------------- 1 | import { FLAG_MAP } from '../flag/flags'; 2 | import { RolloutState } from '../flag/rolloutstate'; 3 | import { EvaluatedFlags } from '../protocol/evaluatedflags'; 4 | 5 | export class FlagEvaluator { 6 | static evaluateAllFlags(): EvaluatedFlags { 7 | const allFlags: EvaluatedFlags = {}; 8 | FLAG_MAP.forEach((rolloutState, flagName) => { 9 | allFlags[flagName] = FlagEvaluator.isRolloutStateEnabled_(rolloutState); 10 | }); 11 | 12 | return allFlags; 13 | } 14 | 15 | private static isRolloutStateEnabled_( 16 | rolloutState: RolloutState): boolean { 17 | switch (rolloutState) { 18 | case RolloutState.LOCAL_ONLY: 19 | return !!process.env.ENABLE_LOCAL_FLAGS; 20 | case RolloutState.ENABLED_EVERYWHERE: 21 | return true; 22 | default: 23 | throw new Error('Unknown rollout state: ' + rolloutState); 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/server/main.ts: -------------------------------------------------------------------------------- 1 | import * as dotenv from 'dotenv'; 2 | import { Server } from './server'; 3 | 4 | (() => { 5 | dotenv.config(); 6 | const port = process.env.PORT || '5000'; 7 | const databasePath = process.env.DATABASE_PATH; 8 | if (!databasePath) { 9 | throw new Error('Database path not provided!'); 10 | } 11 | 12 | new Server().run(port, databasePath); 13 | })(); 14 | -------------------------------------------------------------------------------- /src/server/privelegedusers.ts: -------------------------------------------------------------------------------- 1 | const PRIVELEGED_USERS = new Set(); 2 | // jven@jvenezue.la 3 | PRIVELEGED_USERS.add('auth0|5bf577320833cd785c67a1da'); 4 | 5 | export const isPrivelegedUser = function(user: string): boolean { 6 | return PRIVELEGED_USERS.has(user); 7 | }; 8 | -------------------------------------------------------------------------------- /src/server/server.ts: -------------------------------------------------------------------------------- 1 | import * as createApplication from 'express'; 2 | import { createServer, Server as HttpServer } from 'http'; 3 | import { CreateRepertoireAction } from './actions/createrepertoireaction'; 4 | import { DeleteRepertoireAction } from './actions/deleterepertoireaction'; 5 | import { EvaluateFlagsAction } from './actions/evaluateflagsaction'; 6 | import { GetPreferenceAction } from './actions/getpreferenceaction'; 7 | import { LoadCumulatedStatisticsAction } from './actions/loadcumulatedstatisticsaction'; 8 | import { LoadRepertoireAction } from './actions/loadrepertoireaction'; 9 | import { LogImpressionsAction } from './actions/logimpressionsaction'; 10 | import { PrivelegedCopyAction } from './actions/privelegedcopyaction'; 11 | import { RecordStatisticsAction } from './actions/recordstatisticsaction'; 12 | import { RepertoireMetadataAction } from './actions/repertoiremetadataaction'; 13 | import { SetPreferenceAction } from './actions/setpreferenceaction'; 14 | import { UpdateRepertoireAction } from './actions/updaterepertoireaction'; 15 | import { DatabaseWrapper } from './databasewrapper'; 16 | import { EndpointRegistry } from './endpointregistry'; 17 | 18 | export class Server { 19 | private httpServer_: HttpServer; 20 | private databaseWrapper_: DatabaseWrapper; 21 | 22 | constructor() { 23 | const app = createApplication(); 24 | this.httpServer_ = createServer(app); 25 | this.databaseWrapper_ = new DatabaseWrapper(); 26 | 27 | new EndpointRegistry(app, '1mb') 28 | .registerStaticFolder('../client') 29 | .registerStaticFile('/', '../client/main.html') 30 | .registerStaticFile('/about', '../client/about.html') 31 | .registerLoggedInAction( 32 | '/metadata', 33 | new RepertoireMetadataAction(this.databaseWrapper_), 34 | ['read:repertoires']) 35 | .registerLoggedInAction( 36 | '/loadrepertoire', 37 | new LoadRepertoireAction(this.databaseWrapper_), 38 | ['read:repertoires']) 39 | .registerLoggedInAction( 40 | '/updaterepertoire', 41 | new UpdateRepertoireAction(this.databaseWrapper_), 42 | ['write:repertoires']) 43 | .registerLoggedInAction( 44 | '/createrepertoire', 45 | new CreateRepertoireAction(this.databaseWrapper_), 46 | ['write:repertoires']) 47 | .registerLoggedInAction( 48 | '/deleterepertoire', 49 | new DeleteRepertoireAction(this.databaseWrapper_), 50 | ['write:repertoires']) 51 | .registerLoggedInAction( 52 | '/setpreference', 53 | new SetPreferenceAction(this.databaseWrapper_), 54 | ['write:repertoires']) 55 | .registerLoggedInAction( 56 | '/getpreference', 57 | new GetPreferenceAction(this.databaseWrapper_), 58 | ['read:repertoires']) 59 | .registerLoggedInAction( 60 | '/recordstatistics', 61 | new RecordStatisticsAction(this.databaseWrapper_), 62 | ['write:repertoires']) 63 | .registerLoggedInAction( 64 | '/loadcumulatedstatistics', 65 | new LoadCumulatedStatisticsAction(this.databaseWrapper_), 66 | ['read:repertoires']) 67 | .registerPrivelegedAction( 68 | '/privelegedcopy', 69 | new PrivelegedCopyAction(this.databaseWrapper_), 70 | ['read:repertoires']) 71 | .registerAnonymousAction( 72 | '/flags', 73 | new EvaluateFlagsAction()) 74 | .registerAnonymousAction( 75 | '/impressions', 76 | new LogImpressionsAction(this.databaseWrapper_)); 77 | } 78 | 79 | run(port: string, databasePath: string): void { 80 | this.httpServer_.listen(port, () => { 81 | console.log(`StudyOpenings is running!\nListening on port ${port}...`); 82 | this.databaseWrapper_.connect(databasePath); 83 | }); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/tools/generate-pgn-parser.sh: -------------------------------------------------------------------------------- 1 | DIR="$( cd "$( dirname "$0" )" && pwd )" 2 | 3 | if [[ ! -f $DIR/grammar.peg ]] ; then 4 | echo '"grammar.peg" not found.' 5 | exit 6 | fi 7 | 8 | pegjs -o $DIR/../client/js/lib/pgnparser.js $DIR/grammar.peg 9 | echo "Generated '$DIR/client/js/lib/pgnparser.js'." 10 | -------------------------------------------------------------------------------- /src/tools/grammar.peg: -------------------------------------------------------------------------------- 1 | { 2 | function flatten(a, acc = []) { 3 | for (var i = 0; i < a.length; i++) { 4 | if (Array.isArray(a[i])) { 5 | flatten(a[i], acc); 6 | } else { 7 | acc.push(a[i]); 8 | } 9 | } 10 | return acc; 11 | } 12 | function make_header(hn,hv) { 13 | var m = {}; 14 | m[hn] = hv; 15 | return m; 16 | } 17 | function make_move(move_number, move, nags, ravs, comments) { 18 | var m = {}; 19 | if (move_number) m.move_number = move_number; 20 | if (move) m.move = move; 21 | if (nags && nags.length) m.nags = nags; 22 | if (ravs && ravs.length) m.ravs = ravs; 23 | if (comments && comments.length) m.comments = comments; 24 | return m; 25 | } 26 | function make_rav(moves, result) { 27 | return { 28 | moves, 29 | result 30 | }; 31 | } 32 | function make_game(h, c, m, r) { 33 | h = h || []; 34 | return { 35 | headers: h.reduce((acc, x) => Object.assign(acc, x), {}), 36 | comments: c || [], 37 | moves: m || [], 38 | result: r 39 | }; 40 | } 41 | } 42 | 43 | start = gs:(game newline*)* EOF {return gs.map(function(g) { return g[0]})} 44 | 45 | game = 46 | h:headers? 47 | c:(whitespace* comment)* 48 | whitespace* 49 | mr:( 50 | r:result {return [null, r]} / 51 | m:movetext whitespace+ r:result {return [m, r]} / 52 | m:movetext {return [m, null]} 53 | ) 54 | whitespace* {return make_game(h, c, mr[0], mr[1])} 55 | 56 | EOF = !. 57 | double_quote = '"' 58 | string = double_quote str:[^"]* double_quote {return str.join('')} 59 | integer = a:[1-9] b:[0-9]* {return parseInt(a+b.join(''), 10)} 60 | symbol = chars:[A-Za-z0-9_-]+ {return chars.join('')} 61 | ws = ' ' / '\f' / '\t' 62 | whitespace = ws / newline 63 | newline = '\n' 64 | 65 | header = '[' hn:symbol ws+ hv:string ']' whitespace* { return make_header(hn,hv) } 66 | headers = hs:header+ {return hs} 67 | 68 | piece = [NKQRB] 69 | rank = [a-h] 70 | file = [1-8] 71 | check = "+" 72 | checkmate = "#" 73 | capture = "x" 74 | period = "." 75 | result = "1-0" / "0-1" / "*" / "1/2-1/2" 76 | move_number = mn:integer period? (period period)? {return mn} 77 | square = r:rank f:file {return r+f} 78 | promotion = "=" [QRBN] 79 | nag = chars:("$" integer) {return chars.join('')} 80 | nag_alts = "!!" / "??" / "!?" / "?!" / "!" / "?" 81 | continuation = period period period 82 | 83 | comment_chars = [^{}] 84 | comment = "{" cc:comment_chars* "}" {return cc.join('');} 85 | 86 | pawn_half_move = (r:rank c:capture)? square promotion? 87 | piece_half_move = piece capture? square 88 | piece_disambiguation_half_move = piece (rank / file) capture? square 89 | castle_half_move = ("O-O-O" / "O-O") 90 | 91 | half_move = m:(continuation? 92 | (castle_half_move / 93 | piece_disambiguation_half_move / 94 | piece_half_move / 95 | pawn_half_move) 96 | (check / checkmate)? 97 | nag_alts?) {return flatten(m).join('');} 98 | 99 | move = mn:move_number? 100 | whitespace* 101 | m:half_move 102 | nags:(whitespace+ n:nag {return n})* 103 | com:(whitespace* c2:comment {return c2})* 104 | ravs:(whitespace+ r:rav {return r})* 105 | {return make_move(mn, m, nags, ravs, com)} 106 | 107 | movetext = first:move rest:(whitespace+ move)* {return first ? [first].concat(rest.map(function(m) {return m[1]})) : []} 108 | 109 | rav = "(" 110 | whitespace* 111 | m:movetext 112 | whitespace* 113 | r:result? 114 | whitespace* 115 | ")" {return make_rav(m, r)} 116 | -------------------------------------------------------------------------------- /src/util/assert.ts: -------------------------------------------------------------------------------- 1 | export function assert(x: T | null): T; 2 | export function assert(x: T | undefined): T { 3 | if (!x) { 4 | throw new Error('Expected non-null and non-undefined.'); 5 | } 6 | return x; 7 | } 8 | -------------------------------------------------------------------------------- /src/util/datetime.ts: -------------------------------------------------------------------------------- 1 | export function getUtcDate(now: Date): string { 2 | const year = zeroFill_(now.getUTCFullYear(), 4); 3 | const month = zeroFill_(now.getUTCMonth() + 1, 2); 4 | const day = zeroFill_(now.getUTCDate(), 2); 5 | return `${year}.${month}.${day}`; 6 | } 7 | 8 | export function getUtcTime(now: Date): string { 9 | const hour = zeroFill_(now.getUTCHours(), 2); 10 | const minutes = zeroFill_(now.getUTCMinutes(), 2); 11 | const seconds = zeroFill_(now.getUTCSeconds(), 2); 12 | return `${hour}:${minutes}:${seconds}`; 13 | } 14 | 15 | function zeroFill_(n: number, numDigits: number): string { 16 | let ans = n.toString(); 17 | for (let i = 0; i < numDigits - ans.length; i++) { 18 | ans = '0' + ans; 19 | } 20 | return ans; 21 | } 22 | -------------------------------------------------------------------------------- /src/util/random.ts: -------------------------------------------------------------------------------- 1 | import cryptoRandomString = require('crypto-random-string'); 2 | 3 | export function getRandomString(length: number): string { 4 | return cryptoRandomString(length); 5 | } 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Compile unmigrated JavaScript files. 4 | "allowJs": true, 5 | "lib": ["dom", "es6"], 6 | "noUnusedLocals": true, 7 | "outDir": "./build", 8 | "removeComments": true, 9 | "strict": true, 10 | "typeRoots": ["node_modules/@types", "tstypes"] 11 | }, 12 | "include": [ 13 | "src/**/*" 14 | ], 15 | "exclude": [ 16 | "src/client/js/lib/**/*", 17 | "**/*.test.ts", 18 | ] 19 | } -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "ban-types": [ 4 | true, 5 | ["Boolean", "Use boolean instead."], 6 | ["Number", "Use number instead."], 7 | ["Object", "Use {} instead."], 8 | ["String", "Use object instead."] 9 | ], 10 | "class-name": true, 11 | "comment-format": [true, "check-space"], 12 | "comment-type": [true, "singleline", "multiline", "doc"], 13 | "curly": true, 14 | "eofline": true, 15 | "file-name-casing": [true, "kebab-case"], 16 | "import-spacing": true, 17 | "indent": [true, "spaces", 2], 18 | "interface-over-type-literal": true, 19 | "max-line-length": [ 20 | true, 21 | { 22 | "limit": 80, 23 | "ignore-pattern": "^import" 24 | } 25 | ], 26 | "no-debugger": true, 27 | "no-duplicate-super": true, 28 | "no-duplicate-switch-case": true, 29 | "no-duplicate-variable": true, 30 | "no-irregular-whitespace": true, 31 | "no-parameter-properties": true, 32 | "no-trailing-whitespace": true, 33 | "no-var-keyword": true, 34 | "no-var-requires": true, 35 | "one-line": [ 36 | true, 37 | "check-catch", 38 | "check-finally", 39 | "check-else", 40 | "check-open-brace", 41 | "check-whitespace" 42 | ], 43 | "ordered-imports": true, 44 | "quotemark": [true, "single"], 45 | "semicolon": [true, "always", "ignore-interfaces"], 46 | "space-before-function-paren": [true, "never"], 47 | "whitespace": [ 48 | true, 49 | "check-branch", 50 | "check-decl", 51 | "check-operator", 52 | "check-module", 53 | "check-separator", 54 | "check-type", 55 | "check-typecase", 56 | "check-type-operator", 57 | "check-preblock" 58 | ] 59 | } 60 | } -------------------------------------------------------------------------------- /tstypes/express-jwt-authz/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'express-jwt-authz' { 2 | import { RequestHandler } from 'express'; 3 | 4 | export = jwt; 5 | 6 | function jwt(scopes: string[]): RequestHandler; 7 | } -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | entry: './build/client/js/main.js', 5 | output: { 6 | filename: 'bundle.js', 7 | path: path.resolve(__dirname, 'build/client/js') 8 | } 9 | }; --------------------------------------------------------------------------------