├── .env.DEV ├── .gitignore ├── LICENSE ├── README.md ├── activity_code.js ├── attempt_result.js ├── auth.js ├── cluster ├── cluster.js └── constraint.js ├── competition.js ├── docs └── scripts.md ├── events.js ├── extension.js ├── functions ├── array.js ├── boolean.js ├── cluster.js ├── display.js ├── events.js ├── functions.js ├── groups.js ├── help.js ├── math.js ├── persons.js ├── sheets.js ├── staff.js ├── stream.js ├── table.js ├── time.js ├── tuple.js ├── udf.js ├── util.js └── wcif.js ├── group.js ├── groups ├── assign.js └── scorers.js ├── lib.js ├── main.js ├── package-lock.json ├── package.json ├── parser ├── driver.js ├── grammar.pegjs └── parser.js ├── perf.js ├── pug_functions.js ├── specification.md ├── staff ├── assign.js └── scorers.js ├── static ├── cubing-icons.css └── favicon.ico ├── views ├── cluster.pug ├── dispatch.pug ├── error.pug ├── grammar_error.pug ├── groups.pug ├── help.pug ├── index.pug ├── script.css ├── script.js ├── script.pug ├── spreadsheet.pug ├── staff.pug ├── table.pug └── udfs.pug └── wcif_data └── .keep /.env.DEV: -------------------------------------------------------------------------------- 1 | # Node environment for using a server and WCA website running locally. 2 | 3 | NODE_ENV=development 4 | SCHEME='http' 5 | HOST='localhost' 6 | PORT=3030 7 | WCA_HOST='http://localhost:3000' 8 | USE_CDN= 9 | API_KEY='example-application-id' 10 | API_SECRET='example-secret' 11 | COOKIE_SECRET='b8f6959746b617d9c9e90ce4a4b6e0' 12 | WCIF_DATA_FOLDER=wcif_data 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | google-credentials.json 2 | .env.* 3 | !.env.DEV 4 | .wcif_cache 5 | wcif_data 6 | 7 | # Logs 8 | logs 9 | *.log 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | lerna-debug.log* 14 | 15 | # Diagnostic reports (https://nodejs.org/api/report.html) 16 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 17 | 18 | # Runtime data 19 | pids 20 | *.pid 21 | *.seed 22 | *.pid.lock 23 | 24 | # Directory for instrumented libs generated by jscoverage/JSCover 25 | lib-cov 26 | 27 | # Coverage directory used by tools like istanbul 28 | coverage 29 | *.lcov 30 | 31 | # nyc test coverage 32 | .nyc_output 33 | 34 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 35 | .grunt 36 | 37 | # Bower dependency directory (https://bower.io/) 38 | bower_components 39 | 40 | # node-waf configuration 41 | .lock-wscript 42 | 43 | # Compiled binary addons (https://nodejs.org/api/addons.html) 44 | build/Release 45 | 46 | # Dependency directories 47 | node_modules/ 48 | jspm_packages/ 49 | 50 | # TypeScript v1 declaration files 51 | typings/ 52 | 53 | # TypeScript cache 54 | *.tsbuildinfo 55 | 56 | # Optional npm cache directory 57 | .npm 58 | 59 | # Optional eslint cache 60 | .eslintcache 61 | 62 | # Microbundle cache 63 | .rpt2_cache/ 64 | .rts2_cache_cjs/ 65 | .rts2_cache_es/ 66 | .rts2_cache_umd/ 67 | 68 | # Optional REPL history 69 | .node_repl_history 70 | 71 | # Output of 'npm pack' 72 | *.tgz 73 | 74 | # Yarn Integrity file 75 | .yarn-integrity 76 | 77 | # dotenv environment variables file 78 | .env 79 | .env.test 80 | 81 | # parcel-bundler cache (https://parceljs.org/) 82 | .cache 83 | 84 | # Next.js build output 85 | .next 86 | 87 | # Nuxt.js build / generate output 88 | .nuxt 89 | dist 90 | 91 | # Gatsby files 92 | .cache/ 93 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 94 | # https://nextjs.org/blog/next-9-1#public-directory-support 95 | # public 96 | 97 | # vuepress build output 98 | .vuepress/dist 99 | 100 | # Serverless directories 101 | .serverless/ 102 | 103 | # FuseBox cache 104 | .fusebox/ 105 | 106 | # DynamoDB Local files 107 | .dynamodb/ 108 | 109 | # TernJS port file 110 | .tern-port 111 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 timreyn 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 | # CompScript 2 | 3 | CompScript is a tool for assigning groups and staff assignments for WCA Rubik's Cube competitions. Other similar tools include [Groupifier](https://github.com/jonatanklosko/groupifier), [AGE](https://github.com/Goosly/AGE), and [Delegate Dashboard](https://github.com/coder13/delegateDashboard). Those tools are intended to be user-friendly, automated systems that give you a few configuration options, let you click a button to generate all assignments, then allow for fine-tuning. This is a power-user tool -- it is designed for competitions where the organizer would consider spending multiple hours assigning groups, due to complicated constraints like multiple stages, dedicated staff, a live stream, and a desire to have a large amount of control over how groups should be assigned. Competitions with up to ~200 competitors and only one stage are likely served better by another tool. 4 | 5 | CompScript is designed primarily for CubingUSA Nationals and other large championships. Requirements for other competitions may not be prioritized. 6 | 7 | ## Running 8 | 9 | Node must be installed on your machine. 10 | 11 | ``` 12 | $ npm install 13 | $ npm run dev-server 14 | ``` 15 | 16 | Running the development server will use uses a dev WCA environment running on the same machine. If you would like to use the production WCA site, you need to: 17 | 18 | 1. Make an OAuth application [here](https://www.worldcubeassociation.org/oauth/applications). For "Scopes", use `public manage_competitions`; for "Callback Urls" use `http://localhost:3033/auth/oauth_response`. 19 | 2. Make a copy of the `.env.DEV` file, such as `.env.PROD`. This file should not be committed; `.gitignore` should automatically ignore it. 20 | 3. Replace `WCA_HOST`, `API_KEY`, and `API_SECRET` with the production values. You should also consider changing the `COOKIE_SECRET` to a new value, and to change `PORT` to 3033 to distinguish from the dev version. 21 | 4. Run with `$ ENV=PROD node main.js`, using the file suffix you used above. 22 | 23 | ## Scripts 24 | 25 | You can enter scripts in the script box, using a custom language called CompScript. See `docs/scripts.md` for full documentation. 26 | 27 | Some examples: 28 | 29 | The Luke psych sheet 30 | ``` 31 | Table( 32 | Sort(Persons(And(Registered(), (FirstName() == "Luke"))), 33 | PersonalBest(_333, "average")), 34 | [Column("Name", Name()), 35 | Column("WCA ID", WcaId(), WcaLink()), 36 | Column("Average", PersonalBest(_333)), 37 | Column("Single", PersonalBest(_333, "single")), 38 | Column("psych sheet ranking", PsychSheetPosition(_333))]) 39 | ``` 40 | 41 | Defining a custom function 42 | ``` 43 | Define( 44 | "SumOfRankings", 45 | (PsychSheetPosition({1, Event}, "average") + PsychSheetPosition({1, Event}, "single"))) 46 | ``` 47 | which can then be called by: 48 | ``` 49 | SumOfRankings(_333) 50 | ``` 51 | 52 | ## Google Sheets integration 53 | 54 | We use the google-spreadsheets NPM package to read from Google Sheets. Please refer to [this page](https://theoephraim.github.io/node-google-spreadsheet/#/guides/authentication) for how to create a Service Account with Google Sheets access. Move the generated JSON file to `google-credentials.json` in the top-level project directory, and make sure to grant the service account access to the spreadsheet you'd like it to read. 55 | 56 | Do not share the service account credentials with anyone who should not have access to the spreadsheets you'd like to read. 57 | -------------------------------------------------------------------------------- /activity_code.js: -------------------------------------------------------------------------------- 1 | const events = require('./events') 2 | 3 | class ActivityCode { 4 | constructor(eventId, roundNumber, groupNumber, attemptNumber) { 5 | this.eventId = eventId 6 | this.roundNumber = roundNumber 7 | this.groupNumber = groupNumber 8 | this.attemptNumber = attemptNumber 9 | } 10 | 11 | round(roundNumber) { 12 | return new ActivityCode(this.eventId, roundNumber, this.groupNumber, this.attemptNumber) 13 | } 14 | 15 | group(groupNumber) { 16 | return new ActivityCode(this.eventId, this.roundNumber, groupNumber, this.attemptNumber) 17 | } 18 | 19 | attempt(attemptNumber) { 20 | return new ActivityCode(this.eventId, this.roundNumber, this.groupNumber, attemptNumber) 21 | } 22 | 23 | toString() { 24 | var out = [events.idToName[this.eventId]] 25 | if (this.roundNumber) { 26 | out.push('Round ' + this.roundNumber) 27 | } 28 | if (this.groupNumber || this.groupNumber === 0) { 29 | out.push('Group ' + this.groupNumber) 30 | } 31 | if (this.attemptNumber) { 32 | out.push('Attempt ' + this.attemptNumber) 33 | } 34 | return out.join(' ') 35 | } 36 | 37 | toStringShort() { 38 | if (this.attemptNumber) { 39 | return 'A' + this.attemptNumber 40 | } else { 41 | return 'R' + this.roundNumber 42 | } 43 | } 44 | 45 | id() { 46 | var out = [this.eventId] 47 | if (this.roundNumber) { 48 | out.push('r' + this.roundNumber) 49 | } 50 | if (this.groupNumber || this.groupNumber === 0) { 51 | out.push('g' + this.groupNumber) 52 | } 53 | if (this.attemptNumber) { 54 | out.push('a' + this.attemptNumber) 55 | } 56 | return out.join('-') 57 | } 58 | 59 | isActivity() { 60 | return true 61 | } 62 | 63 | contains(other) { 64 | if (this.eventId !== other.eventId) { 65 | return false 66 | } 67 | if (this.roundNumber !== null && this.roundNumber !== other.roundNumber) { 68 | return false 69 | } 70 | if (this.groupNumber !== null && this.groupNumber !== other.groupNumber) { 71 | return false 72 | } 73 | if (this.activityNumber !== null && this.activityNumber !== other.activityNumber) { 74 | return false 75 | } 76 | return true 77 | } 78 | } 79 | 80 | class OtherActivity { 81 | constructor(code) { 82 | this.code = code 83 | } 84 | 85 | toString() { 86 | return this.code 87 | } 88 | 89 | isActivity() { 90 | return false 91 | } 92 | } 93 | 94 | function parse(code) { 95 | var codeSplit = code.split('-') 96 | if (codeSplit[0] == 'other') { 97 | return new OtherActivity(codeSplit[1]) 98 | } 99 | var eventId = codeSplit[0] 100 | if (!events.idToName[eventId]) { 101 | console.log('Invalid ActivityCode ' + code) 102 | return null 103 | } 104 | var roundNumber = null 105 | var groupNumber = null 106 | var attemptNumber = null 107 | for (var i = 1; i < codeSplit.length; i++) { 108 | if (codeSplit[i].startsWith('r')) { 109 | roundNumber = +codeSplit[i].slice(1) 110 | } else if (codeSplit[i].startsWith('g')) { 111 | groupNumber = +codeSplit[i].slice(1) 112 | } else if (codeSplit[i].startsWith('a')) { 113 | attemptNumber = +codeSplit[i].slice(1) 114 | } else { 115 | console.log('Invalid ActivityCode ' + code) 116 | return null 117 | } 118 | } 119 | return new ActivityCode(eventId, roundNumber, groupNumber, attemptNumber) 120 | } 121 | 122 | module.exports = { 123 | parse: parse, 124 | ActivityCode: ActivityCode 125 | } 126 | -------------------------------------------------------------------------------- /attempt_result.js: -------------------------------------------------------------------------------- 1 | function parseTime(time) { 2 | var centiseconds = time % 100 3 | time = (time - centiseconds) / 100 4 | var seconds = time % 60 5 | time = (time - seconds) / 60 6 | var minutes = time % 60 7 | var hours = (time - minutes) / 60 8 | return { 9 | hours: hours, 10 | minutes: minutes, 11 | seconds: seconds, 12 | centiseconds: centiseconds, 13 | } 14 | } 15 | 16 | function pad(num) { 17 | if (num < 10) { 18 | return '0' + num.toString() 19 | } else { 20 | return num.toString() 21 | } 22 | } 23 | 24 | function formatTime(time) { 25 | var parsed = parseTime(time) 26 | if (parsed.hours > 0) { 27 | return parsed.hours + ':' + pad(parsed.minutes) + ':' + pad(parsed.seconds) 28 | } 29 | if (parsed.minutes >= 10) { 30 | return parsed.minutes + ':' + pad(parsed.seconds) 31 | } 32 | if (parsed.minutes > 0) { 33 | return parsed.minutes + ':' + pad(parsed.seconds) + '.' + pad(parsed.centiseconds) 34 | } 35 | return parsed.seconds + '.' + pad(parsed.centiseconds) 36 | } 37 | 38 | class AttemptResult { 39 | constructor(value, eventId) { 40 | this.value = value 41 | this.eventId = eventId 42 | } 43 | 44 | toString() { 45 | if (this.value == -1) { 46 | return 'DNF' 47 | } 48 | if (this.value == -2) { 49 | return 'DNS' 50 | } 51 | if (this.value == 0) { 52 | return '' 53 | } 54 | if (this.eventId == '333fm') { 55 | if (this.value < 1000) { 56 | return this.value.toString() 57 | } else { 58 | return (this.value / 100).toFixed(2) 59 | } 60 | } 61 | if (this.eventId == '333mbf') { 62 | var missed = this.value % 100 63 | var res = (this.value - missed) / 100 64 | var timeInSeconds = res % 1e5 65 | res = (res - timeInSeconds) / 1e5 66 | var delta = 99 - res 67 | var solved = missed + delta 68 | var attempted = solved + missed 69 | if (timeInSeconds == 0) { 70 | return delta + ' points' 71 | } else { 72 | return solved + '/' + attempted + ' ' + formatTime(timeInSeconds * 100) 73 | } 74 | } 75 | return formatTime(this.value) 76 | } 77 | 78 | valueOf() { 79 | if (this.value == -1) { 80 | return Number.MAX_SAFE_INTEGER 81 | } 82 | if (this.value == 0) { 83 | return Number.MAX_SAFE_INTEGER - 1 84 | } 85 | return this.value 86 | } 87 | } 88 | 89 | function parseString(str) { 90 | if (str == 'DNF') { 91 | return new AttemptResult(-1, '333') 92 | } 93 | if (str == 'DNS') { 94 | return new AttemptResult(-2, '333') 95 | } 96 | if (str.endsWith('p')) { 97 | return new AttemptResult((99 - +(str.replace('p', ''))) * 1e7, '333mbf') 98 | } 99 | if (str.endsWith('m')) { 100 | if (str.indexOf('.') == -1) { 101 | return new AttemptResult(+(str.replace('m', '')) * 100, '333fm') 102 | } else { 103 | return new AttemptResult(+(str.replace('m', '')), '333fm') 104 | } 105 | } 106 | if (str.endsWith('s')) { 107 | str = str.replace('s', '') 108 | value = 0 109 | if (str.indexOf(':') != -1) { 110 | value += 6000 * +(str.substr(0, str.indexOf(':'))) 111 | str = str.substr(str.indexOf(':') + 1) 112 | } 113 | if (str.indexOf(':') != -1) { 114 | value = value * 60 + 6000 * +(str.substr(0, str.indexOf(':'))) 115 | str = str.substr(str.indexOf(':') + 1) 116 | } 117 | value += 100 * +str 118 | return new AttemptResult(value, '333') 119 | } 120 | return new AttemptResult(+str, '333') 121 | } 122 | 123 | module.exports = { 124 | AttemptResult: AttemptResult, 125 | parseString: parseString, 126 | } 127 | -------------------------------------------------------------------------------- /auth.js: -------------------------------------------------------------------------------- 1 | const express = require('express') 2 | const { Issuer, custom } = require('openid-client') 3 | const fs = require('fs') 4 | const fse = require('fs-extra') 5 | 6 | fse.ensureDirSync('.wcif_cache/' + process.env.ENV) 7 | 8 | custom.setHttpOptionsDefaults({ 9 | timeout: 600000, 10 | }); 11 | 12 | var router = express.Router() 13 | 14 | function cachePath(competitionId) { 15 | return '.wcif_cache/' + process.env.ENV + '/' + competitionId 16 | } 17 | 18 | const wca = new Issuer({ 19 | issuer: 'worldcubeassociation', 20 | authorization_endpoint: `${process.env.WCA_HOST}/oauth/authorize`, 21 | token_endpoint: `${process.env.WCA_HOST}/oauth/token`, 22 | userinfo_endpoint: `${process.env.WCA_HOST}/api/v0/me` 23 | }) 24 | 25 | const redirect_uri = `${process.env.SCHEME}://${process.env.HOST}:${process.env.PORT}/auth/oauth_response` 26 | 27 | const client = new wca.Client({ 28 | client_id: process.env.API_KEY, 29 | client_secret: process.env.API_SECRET, 30 | response_type: 'code', 31 | redirect_uri: redirect_uri 32 | }) 33 | 34 | async function getWcif(competitionId, req, res) { 35 | var shouldFetch = false 36 | try { 37 | var stat = fs.statSync(cachePath(competitionId)) 38 | if (stat.mtimeMs < Date.now() - 6 * 60 * 60 * 1000) { 39 | shouldFetch = true 40 | } 41 | } catch (e) { 42 | shouldFetch = true 43 | } 44 | if (shouldFetch) { 45 | console.log('fetching WCIF') 46 | var wcif = await getWcaApi('/api/v0/competitions/' + competitionId + '/wcif', req, res) 47 | if (wcif.error) { 48 | wcif = await getWcaApi('/api/v0/competitions/' + competitionId + '/wcif/public', req, res) 49 | } 50 | fs.writeFileSync(cachePath(competitionId), JSON.stringify(wcif)) 51 | console.log('fetched WCIF') 52 | return wcif 53 | } else { 54 | console.log('reading cached WCIF') 55 | var wcif = fs.readFileSync(cachePath(competitionId)) 56 | return JSON.parse(wcif) 57 | } 58 | } 59 | 60 | function wcaUrl(path) { 61 | var host = process.env.WCA_HOST 62 | if (process.env.USE_CDN) { 63 | host = (path.startsWith('api/v0') || path.startsWith('/api/v0')) ? process.env.WCA_CDN_HOST : process.env.WCA_HOST 64 | path = path.replace('api/v0', '') 65 | } 66 | return `${host}/${path}` 67 | } 68 | 69 | async function getWcaApi(resourceUrl, req, res) { 70 | if (!req.session.refreshToken) { 71 | return Promise.resolve(null) 72 | } 73 | var tokenSet = await client.refresh(req.session.refreshToken) 74 | req.session.refreshToken = tokenSet.refresh_token 75 | var url = wcaUrl(resourceUrl) 76 | console.log(`starting call ${url}`) 77 | var out = await client.requestResource(url, tokenSet.access_token) 78 | console.log('ending call') 79 | return JSON.parse(out.body.toString()); 80 | } 81 | 82 | async function patchWcif(obj, keys, req, res) { 83 | var tokenSet = await client.refresh(req.session.refreshToken) 84 | req.session.refreshToken = tokenSet.refresh_token 85 | var toPatch = {} 86 | keys.forEach((key) => { 87 | toPatch[key] = obj[key] 88 | }) 89 | var out = 90 | await client.requestResource( 91 | wcaUrl(`/api/v0/competitions/${obj.id}/wcif`), 92 | tokenSet.access_token, 93 | {method: 'PATCH', 94 | body: JSON.stringify(toPatch), 95 | headers: {'Content-Type': 'application/json'}}) 96 | if (out.statusCode !== 200) { 97 | throw new Error(out.body.toString()) 98 | } 99 | // This automatically clears the cache. 100 | try { 101 | fs.unlinkSync(cachePath(obj.id)) 102 | } catch(e) { 103 | console.log(e) 104 | } 105 | return JSON.parse(out.body.toString()); 106 | } 107 | 108 | async function patchWcifWithRetries(obj, keys, req, res) { 109 | var i = 0 110 | while (i < 10) { 111 | try { 112 | return await patchWcif(obj, keys, req, res) 113 | } catch (e) { 114 | if (i == 9) { 115 | throw e 116 | } 117 | if (e.code == 'ECONNRESET') { 118 | i += 1 119 | continue 120 | } 121 | throw e 122 | } 123 | } 124 | } 125 | 126 | router.get('/login', function(req, res) { 127 | const uri = client.authorizationUrl({ 128 | scope: 'public manage_competitions' 129 | }) 130 | if (req.get('Referer')) { 131 | req.session.redirect = req.get('Referer') 132 | } 133 | 134 | res.redirect(uri) 135 | }) 136 | 137 | router.get('/oauth_response', async function(req, res) { 138 | const params = client.callbackParams(req) 139 | try { 140 | var tokenSet = await client.oauthCallback(redirect_uri, params); 141 | req.session.refreshToken = tokenSet.refresh_token; 142 | var url = req.session.redirect 143 | if (url) { 144 | req.session.redirect = null; 145 | res.redirect(url) 146 | } else { 147 | res.redirect('/') 148 | } 149 | } catch (e) { 150 | req.session.refreshToken = null; 151 | res.redirect('/') 152 | } 153 | }) 154 | 155 | router.get('/logout', function(req, res) { 156 | res.clearCookie('userId') 157 | req.session.refreshToken = null; 158 | res.redirect('/') 159 | }) 160 | 161 | async function redirectIfNotLoggedIn(req, res, next) { 162 | console.log(req.method + ' ' + req.path); 163 | if (req.path == '/auth/oauth_response') { 164 | next() 165 | return 166 | } 167 | if (req.session.refreshToken) { 168 | try { 169 | var me = await getWcaApi('/api/v0/me', req, res) 170 | next() 171 | return 172 | } catch (e) { 173 | console.log('error, redirecting') 174 | console.log(e) 175 | // Refresh token doesn't work, go to login flow. 176 | } 177 | } 178 | if (req.body.script) { 179 | req.session.script = req.body.script 180 | } 181 | const uri = client.authorizationUrl({ 182 | scope: 'public manage_competitions' 183 | }) 184 | req.session.statusMessage = 'Refreshed oauth token' 185 | req.session.redirect = req.originalUrl; 186 | res.redirect(uri); 187 | } 188 | 189 | module.exports = { 190 | router: router, 191 | getWcaApi: getWcaApi, 192 | getWcif: getWcif, 193 | redirectIfNotLoggedIn: redirectIfNotLoggedIn, 194 | patchWcif: patchWcif, 195 | patchWcifWithRetries: patchWcifWithRetries, 196 | cachePath: cachePath, 197 | } 198 | -------------------------------------------------------------------------------- /cluster/cluster.js: -------------------------------------------------------------------------------- 1 | const extension = require('./../extension') 2 | 3 | function Cluster(name, numClusters, persons, preCluster, constraints) { 4 | var clusters = [...Array(numClusters).keys()].map((x) => x + 1) 5 | var result 6 | var unassignedGroups = {} 7 | persons.forEach((person) => { 8 | var preClusterValue = preCluster({ Person: person }) 9 | var key 10 | if (!!preClusterValue) { 11 | key = 'CLUSTER-' + preClusterValue 12 | } else { 13 | key = 'PERSON-' + person.wcaUserId 14 | } 15 | if (unassignedGroups[key] === undefined) { 16 | unassignedGroups[key] = [] 17 | } 18 | unassignedGroups[key].push(person) 19 | constraints.forEach((constraint) => { 20 | constraint.loadPerson(person, key) 21 | }) 22 | }) 23 | var assignments = {} 24 | clusters.forEach((cluster) => { 25 | assignments[cluster] = [] 26 | }) 27 | var out = { 28 | name: name, 29 | constraints: constraints.map((constraint) => constraint.name), 30 | clusters: {}, 31 | } 32 | while (Object.keys(unassignedGroups).length > 0) { 33 | clusters.sort((clusterA, clusterB) => assignments[clusterA].length - assignments[clusterB].length) 34 | var clusterToAssign = clusters[0] 35 | var bestKey = null 36 | var bestScore = Number.NEGATIVE_INFINITY 37 | for (const [groupKey, group] of Object.entries(unassignedGroups)) { 38 | var groupScore = group.length 39 | constraints.forEach((constraint) => { 40 | clusters.forEach((clusterToScore) => { 41 | var score = constraint.score(assignments, clusterToScore, groupKey) 42 | if (clusterToScore === clusterToAssign) { 43 | groupScore += score 44 | } else { 45 | groupScore -= score / (clusters.length - 1) 46 | } 47 | }) 48 | }) 49 | if (groupScore > bestScore) { 50 | bestKey = groupKey 51 | bestScore = groupScore 52 | } 53 | } 54 | unassignedGroups[bestKey].forEach((person) => { 55 | assignments[clusterToAssign].push(person) 56 | }) 57 | delete unassignedGroups[bestKey] 58 | } 59 | for (const [cluster, persons] of Object.entries(assignments)) { 60 | out.clusters[cluster] = { 61 | id: cluster, 62 | constraints: {}, 63 | persons: [], 64 | } 65 | constraints.forEach((constraint) => { 66 | out.clusters[cluster].constraints[constraint.name] = 0 67 | }) 68 | for (const person of persons) { 69 | var personOut = { 70 | person: person, 71 | constraints: {}, 72 | } 73 | constraints.forEach((constraint) => { 74 | var val = constraint.valueFor(person) 75 | personOut.constraints[constraint.name] = val 76 | out.clusters[cluster].constraints[constraint.name] += val 77 | }) 78 | var ext = extension.getOrInsertExtension(person, 'Person') 79 | if (!ext.properties) { 80 | ext.properties = {} 81 | } 82 | ext.properties[name] = +cluster 83 | out.clusters[cluster].persons.push(personOut) 84 | } 85 | } 86 | return out 87 | } 88 | 89 | module.exports = { 90 | Cluster: Cluster, 91 | } 92 | -------------------------------------------------------------------------------- /cluster/constraint.js: -------------------------------------------------------------------------------- 1 | class BalanceConstraint { 2 | constructor(name, value, weight) { 3 | this.name = name 4 | this.value = value 5 | this.weight = weight 6 | this.personScores = {} 7 | this.personGroups = {} 8 | this.totalScore = 0 9 | } 10 | 11 | loadPerson(person, key) { 12 | if (!this.personGroups[key]) { 13 | this.personGroups[key] = [] 14 | } 15 | this.personGroups[key].push(person) 16 | var value = this.value({Person: person}) 17 | this.personScores[person.wcaUserId] = value 18 | this.totalScore += +value 19 | } 20 | 21 | valueFor(person) { 22 | return this.personScores[person.wcaUserId] 23 | } 24 | 25 | score(assignments, cluster, key) { 26 | if (this.totalScore == 0) { 27 | return 0 28 | } 29 | var expectedTotal = this.totalScore / Object.keys(assignments).length 30 | var totals = {} 31 | for (const [clusterName, persons] of Object.entries(assignments)) { 32 | var clusterTotal = 0 33 | for (const person of persons) { 34 | clusterTotal += this.personScores[person.wcaUserId] 35 | } 36 | totals[clusterName] = clusterTotal 37 | } 38 | for (const person of this.personGroups[key]) { 39 | totals[cluster] += this.personScores[person.wcaUserId] 40 | } 41 | var totalAssigned = 0 42 | for (const total of Object.values(totals)) { 43 | totalAssigned += total 44 | } 45 | var remainder = this.totalScore - totalAssigned 46 | var score = 0 47 | for (const [clusterName, total] of Object.entries(totals)) { 48 | var needed = expectedTotal - total 49 | if (remainder > 0) { 50 | score -= (Math.pow((needed / remainder) - (1 / Object.keys(assignments).length), 2)) 51 | 52 | } else { 53 | score -= (Math.pow((total / this.totalScore) - (1 / Object.keys(assignments).length), 2)) 54 | } 55 | } 56 | return score * this.weight 57 | } 58 | } 59 | 60 | class LimitConstraint { 61 | constructor(name, value, min, weight) { 62 | this.name = name 63 | this.value = value 64 | this.weight = weight 65 | this.min = min 66 | this.personScores = {} 67 | this.personGroups = {} 68 | this.totalScore = 0 69 | } 70 | 71 | loadPerson(person, key) { 72 | if (!this.personGroups[key]) { 73 | this.personGroups[key] = [] 74 | } 75 | this.personGroups[key].push(person) 76 | var value = this.value({Person: person}) 77 | this.personScores[person.wcaUserId] = value 78 | this.totalScore += +value 79 | } 80 | 81 | valueFor(person) { 82 | return this.personScores[person.wcaUserId] 83 | } 84 | 85 | score(assignments, cluster, key) { 86 | var expectedTotal = this.totalScore / Object.keys(assignments).length 87 | var totals = {} 88 | for (const [clusterName, persons] of Object.entries(assignments)) { 89 | var clusterTotal = 0 90 | for (const person of persons) { 91 | clusterTotal += this.personScores[person.wcaUserId] 92 | } 93 | totals[clusterName] = clusterTotal 94 | } 95 | for (const person of this.personGroups[key]) { 96 | totals[cluster] += this.personScores[person.wcaUserId] 97 | } 98 | var totalAssigned = 0 99 | var totalNeeded = 0 100 | for (const total of Object.values(totals)) { 101 | totalAssigned += total 102 | if (total < this.min) { 103 | totalNeeded += (this.min - total) 104 | } 105 | } 106 | var remainder = this.totalScore - totalAssigned 107 | if (totalNeeded === 0) { 108 | return 1 109 | } else if (totalNeeded > remainder) { 110 | return -1000 111 | } 112 | return Math.pow(1 - totalNeeded / remainder, 2) * this.weight 113 | } 114 | } 115 | 116 | class SpecificAssignmentScore { 117 | constructor(name, personProperty, clusterProperty, score) { 118 | this.name = name 119 | this.personProperty = personProperty 120 | this.clusterProperty = clusterProperty 121 | this.scoreValue = score 122 | this.personCache = {} 123 | this.clusterCache = {} 124 | this.personGroups = {} 125 | } 126 | 127 | loadPerson(person, key) { 128 | if (!this.personGroups[key]) { 129 | this.personGroups[key] = [] 130 | } 131 | this.personGroups[key].push(person) 132 | this.getPerson(person) 133 | } 134 | 135 | getPerson(person) { 136 | if (!(person.wcaUserId in this.personCache)) { 137 | this.personCache[person.wcaUserId] = this.personProperty({Person: person}) 138 | } 139 | return this.personCache[person.wcaUserId] 140 | } 141 | 142 | getCluster(cluster) { 143 | if (!(cluster in this.clusterCache)) { 144 | this.clusterCache[cluster] = this.clusterProperty({Number: cluster}) 145 | } 146 | return this.clusterCache[cluster] 147 | } 148 | 149 | valueFor(person) { 150 | return this.getPerson(person) 151 | } 152 | 153 | score(assignments, cluster, key) { 154 | if (!this.getCluster(cluster)) { 155 | return 0 156 | } 157 | out = 0 158 | for (const person of this.personGroups[key]) { 159 | if (this.getPerson(person)) { 160 | out += this.scoreValue 161 | } 162 | } 163 | return out 164 | } 165 | } 166 | 167 | module.exports = { 168 | BalanceConstraint: BalanceConstraint, 169 | LimitConstraint: LimitConstraint, 170 | SpecificAssignmentScore: SpecificAssignmentScore, 171 | } 172 | -------------------------------------------------------------------------------- /competition.js: -------------------------------------------------------------------------------- 1 | const express = require('express') 2 | const fs = require('fs') 3 | const { DateTime } = require('luxon') 4 | const url = require('url') 5 | const compiler = require('c-preprocessor') 6 | 7 | const auth = require('./auth') 8 | const activityCode = require('./activity_code') 9 | const events = require('./events') 10 | const extension = require('./extension') 11 | const perf = require('./perf') 12 | const pugFunctions = require('./pug_functions') 13 | const functions = require('./functions/functions') 14 | const driver = require('./parser/driver') 15 | const parser = require('./parser/parser') 16 | 17 | var router = express.Router() 18 | 19 | function listFiles() { 20 | if (!process.env.SCRIPT_BASE) { 21 | return [] 22 | } 23 | out = [] 24 | dirs = ['.'] 25 | i = 0; 26 | while (dirs.length && i < 100) { 27 | dir = dirs.pop() 28 | files = fs.readdirSync(process.env.SCRIPT_BASE + '/' + dir) 29 | for (const f of files) { 30 | if (f.endsWith('.cs')) { 31 | out.push((dir + '/' + f).substring(2)) 32 | } else { 33 | stats = fs.lstatSync(process.env.SCRIPT_BASE + '/' + dir + '/' + f); 34 | if (stats.isDirectory()) { 35 | dirs.push(dir + '/' + f) 36 | } 37 | } 38 | } 39 | } 40 | return out 41 | } 42 | 43 | router.use('/:competitionId', async (req, res, next) => { 44 | req.logger = new perf.PerfLogger() 45 | try { 46 | req.logger.start('fetch') 47 | req.competition = await auth.getWcif(req.params.competitionId, req, res) 48 | req.logger.stop('fetch') 49 | next() 50 | } catch (e) { 51 | console.log(e) 52 | res.redirect('/') 53 | } 54 | }) 55 | 56 | router.get('/:competitionId', async (req, res) => { 57 | var script = '' 58 | var filename = '' 59 | if (req.query.script) { 60 | req.session.script = req.query.script 61 | req.session.filename = req.query.filename 62 | res.redirect(url.parse(req.originalUrl).pathname) 63 | return 64 | } 65 | if (req.session.script) { 66 | script = req.session.script 67 | filename = req.session.filename 68 | delete req.session.script 69 | delete req.session.filename 70 | } 71 | await runScript(req, res, script || req.query.script, filename || req.query.filename, true, req.query.clearCache) 72 | }) 73 | 74 | router.post('/:competitionId', async (req, res) => { 75 | await runScript(req, res, req.body.script, req.body.filename, req.body.dryrun, req.body.clearCache) 76 | }) 77 | 78 | async function runScript(req, res, script, filename, dryrun, clearCache) { 79 | var logger = req.logger 80 | var params = { 81 | comp: req.competition, 82 | fn: pugFunctions, 83 | script: script, 84 | outputs: [], 85 | dryrun: dryrun, 86 | dryrunWarning: false, 87 | clearCache: clearCache, 88 | files: listFiles(), 89 | selectedFile: filename, 90 | } 91 | if (filename) { 92 | script = `#include "${filename}" 93 | ${script}` 94 | } 95 | if (clearCache) { 96 | fs.unlinkSync(auth.cachePath(req.params.competitionId)) 97 | } 98 | if (script) { 99 | compiler.compile(script, { 100 | basePath: process.env.SCRIPT_BASE + '/', 101 | newLine: '\r\n', 102 | }, async (err, newScript) => { 103 | if (err) { 104 | params.outputs = [{type: 'Error', data: err}] 105 | res.render('script', params) 106 | return 107 | } 108 | newScript = newScript.trim() 109 | var ctx = { 110 | competition: req.competition, 111 | command: newScript, 112 | allFunctions: functions.allFunctions, 113 | dryrun: dryrun, 114 | logger, 115 | udfs: {}, 116 | } 117 | try { 118 | ctx.logger.start('parse') 119 | var scriptResult = await parser.parse(newScript, req, res, ctx, false) 120 | ctx.logger.stop('parse') 121 | params.outputs = scriptResult.outputs 122 | if (scriptResult.mutations.length) { 123 | if (dryrun) { 124 | params.dryrunWarning = true 125 | } else { 126 | ctx.logger.start('patch') 127 | await auth.patchWcifWithRetries(ctx.competition, scriptResult.mutations, req, res) 128 | ctx.logger.stop('patch') 129 | } 130 | } 131 | } catch (e) { 132 | params.outputs.splice(0, 0, {type: 'Exception', data: e.stack }) 133 | console.log(e) 134 | } 135 | logger.start('render') 136 | res.render('script', params) 137 | logger.stop('render') 138 | logger.log() 139 | }) 140 | } else { 141 | logger.start('render') 142 | res.render('script', params) 143 | logger.stop('render') 144 | logger.log() 145 | } 146 | 147 | } 148 | 149 | module.exports = { 150 | router: router 151 | } 152 | -------------------------------------------------------------------------------- /docs/scripts.md: -------------------------------------------------------------------------------- 1 | # CompScript 2 | 3 | CompScript is a custom language that allows running various commands and queries over a competition's WCIF data. It is likely that there are bugs or improvements that can be made. Please file issues on [GitHub](https://github.com/timreyn/natshelper) and I'm happy to take a look. 4 | 5 | ## Intro 6 | 7 | The CompScript interpreter can be found at (for example) . You may enter one or more CompScript commands into the box and hit "Submit". 8 | 9 | A sample command is 10 | ``` 11 | Persons((FirstName() == "William")) 12 | ``` 13 | which returns a list of all competitors whose first name is William. There are many functions available; you may view all of them by running 14 | ``` 15 | ListFunctions() 16 | ``` 17 | and you may view the documentation for one function by running 18 | ``` 19 | Help("Persons") 20 | ``` 21 | Whitespace is generally ignored, and comments (beginning with python-style `#`) are ignored as well 22 | 23 | ## Literals 24 | 25 | The CompScript parser can understand various expressions, such as: 26 | ``` 27 | 12.345 # Number 28 | "Rubik's Cube" # String 29 | true # Boolean 30 | _333 # Event 31 | _333-r1 # Round 32 | 22.95s # AttemptResult 33 | 20m # Fewest Moves result 34 | 60p # Multi Blind number of points 35 | DNF # DNF 36 | [1, 2, 3] # Array 37 | 2005REYN01 # Person 38 | 2023-01-01 # Date 39 | 2023-02-03T10:23 # DateTime (ISO-8601 format, using competition time zone) 40 | ``` 41 | 42 | The full grammar is at `parser/grammar.pegjs`. 43 | 44 | ## Files 45 | 46 | In addition to the interpreter box, you can write CompScript scripts in any directory on your local computer. These files can be accessed by setting the environment variable `SCRIPT_BASE`. 47 | 48 | CompScript files are parsed using a [C-style preprocessor](https://github.com/ParksProjets/C-Preprocessor), so you can do things like including other files: 49 | 50 | ``` 51 | #include "lib/utilities.cs" 52 | ``` 53 | 54 | For an example, the CubingUSA Nationals 2023 scripts are available [here](https://github.com/cubingusa/nats-scripts). 55 | 56 | ## Functions and Types 57 | 58 | CompScript has many functions. Each of these take zero or more arguments, and has a return type. There may be multiple overloads for one function. For example, `Add()` has overloads that take `Number` and `String`. 59 | 60 | Arguments are assumed to be in the order listed in the function, with some exceptions: 61 | 62 | - If a parameter is provided with a name, it may appear out of order. For example `Subtract(val2=3, val1=4)` returns 1. 63 | - If an argument is `repeated`, all remaining unnamed parameters provided are assumed to be part of that argument. 64 | - If an argument has a `defaultValue`, its value may be omitted. 65 | 66 | Functions can have generic types. For example, one of the overloads of `Add()` takes `Array<$T>` arguments and returns an `Array<$T>`. The type-deduction logic for generics is best-effort; complicated functional types may not be deduced correctly. 67 | 68 | Some arguments are marked as `canBeExternal`. This means that, if their value is not provided, the return value is instead a function that taking that as a parameter. For example, `FirstName()` takes a `Person` as an argument, but this can be external. `FirstName(2007BARR01)` would return a `String` ("Kian"), while `FirstName` would return a `String(Person)`. Functional arguments can be passed through any other function, as long as eventually the functional argument is provided. 69 | 70 | For example, `RegisteredEvents()` takes a `Person` argument and returns an `Array`. Without providing a `Person`, the return value would be `Array(Person)`. The expression `Length(RegisteredEvents())` would have type `Number(Person)`. Finally, this can all be passed to `Map`: 71 | 72 | ``` 73 | Map( 74 | [2005REYN01, 2008CLEM01, 2011WELC01], 75 | Length(RegisteredEvents()) 76 | ) 77 | ``` 78 | 79 | would return `[16, 16, 17]` (for CubingUSA Nationals 2023. Other competitions may vary). 80 | 81 | Simply issuing the command `Length(RegisteredEvents())` would return an error, however, as nothing is ever provided as an argument to `RegisteredEvents`. 82 | 83 | Some arguments may have multiple external arguments, for example `PsychSheetPosition()`. 84 | 85 | In order to avoid null-checking every argument, by default if any arguments are `null` then the function simply returns `null` as well. However, individual arguments may be marked as `nullable` to prevent this propagation. 86 | 87 | ## User-Defined Functions 88 | 89 | You may implement your own function either in Javascript (see the `functions/` folder), or as a combination of other built-in functions. For example, if you find yourself calling `Length(RegisteredEvents())` frequently, you can define a `NumEvents()` function: 90 | 91 | ``` 92 | Define( 93 | "NumEvents", 94 | Length(RegisteredEvents()) 95 | ) 96 | ``` 97 | 98 | If you would like to provide an argument to your user-defined function, you can do so with `{}`: 99 | 100 | ``` 101 | Define( 102 | "FirstNames", 103 | Map({1, Array}, FirstName()) 104 | ) 105 | ``` 106 | 107 | This says that the first argument should be of type `Array`. 108 | 109 | UDFs are only available for the duration of a request. 110 | 111 | ## Binary operators 112 | 113 | Some functions such as `Add` may be invoked using a binary operator (e.g. `+`). See `BinaryOperation` in `parser/grammar.pegjs` for the full list. Currently this requires the arguments to be surrounded by parentheses (e.g. `(2 + 3)`); improvements to the grammar to remove this requirement are welcome. 114 | 115 | ## Mutations 116 | 117 | Many functions, such as assigning groups, assigning properties to people, and defining functions, are saved on the competition WCIF. Functions may declare that they modify a top-level WCIF field, such as `events`, `schedule`, `persons`, or `extensions`. If so, when the command is complete, a PATCH request will be issued to the WCA website for that field. As a safety measure, there is a "dry-run" field which defaults to true, which allows for testing the command before modifying any 118 | data on the website. 119 | 120 | ## Rendering 121 | 122 | The final output type decides which renderer should be used. See `views/dispatch.pug` for all available renderers. Some special types: 123 | 124 | * `Array<$T>` renders each item successively 125 | * `Multi` is like an `Array`, where the type of each object does not have to match. Rendering is similar to `Array` 126 | 127 | Some complicated functions like `AssignGroups` define their own output type, since this is easier than rendering via a combination of primitives like `Header` and `Table`. However, such functions can invoke `dispatch` for some sub-outputs in order to avoid reinventing every wheel. 128 | -------------------------------------------------------------------------------- /events.js: -------------------------------------------------------------------------------- 1 | const idToName = { 2 | '333': '3x3x3 Cube', 3 | '222': '2x2x2 Cube', 4 | '444': '4x4x4 Cube', 5 | '555': '5x5x5 Cube', 6 | '666': '6x6x6 Cube', 7 | '777': '7x7x7 Cube', 8 | '333bf': '3x3x3 Blindfolded', 9 | '333fm': '3x3x3 Fewest Moves', 10 | '333oh': '3x3x3 One-Handed', 11 | 'clock': 'Clock', 12 | 'minx': 'Megaminx', 13 | 'pyram': 'Pyraminx', 14 | 'skewb': 'Skewb', 15 | 'sq1': 'Square-1', 16 | '444bf': '4x4x4 Blindfolded', 17 | '555bf': '5x5x5 Blindfolded', 18 | '333mbf': '3x3x3 Multi-Blind', 19 | } 20 | 21 | module.exports = { 22 | idToName: idToName, 23 | } 24 | -------------------------------------------------------------------------------- /extension.js: -------------------------------------------------------------------------------- 1 | function getExtension(obj, type, namespace='org.cubingusa.natshelper.v1') { 2 | type = namespace + '.' + type 3 | if (!obj.extensions) { 4 | obj.extensions = [] 5 | } 6 | var matching = obj.extensions.filter((ext) => ext.id == type) 7 | if (matching.length > 0) { 8 | return matching[0].data 9 | } 10 | return null 11 | } 12 | 13 | function getOrInsertExtension(obj, type, namespace='org.cubingusa.natshelper.v1') { 14 | type = namespace + '.' + type 15 | if (!obj.extensions) { 16 | obj.extensions = [] 17 | } 18 | var matching = obj.extensions.filter((ext) => ext.id == type) 19 | if (matching.length > 0) { 20 | return matching[0].data 21 | } 22 | var extension = { 23 | id: type, 24 | specUrl: 'https://github.com/cubingusa/natshelper/blob/main/specification.md', 25 | data: {} 26 | } 27 | obj.extensions.push(extension) 28 | return extension.data 29 | } 30 | 31 | function getExtensionsWithPrefix(obj, type, prefix, namespace='org.cubingusa.natshelper.v1') { 32 | if (!obj.extensions) { 33 | obj.extensions = [] 34 | } 35 | return obj.extensions.filter((ext) => ext.id.startsWith(namespace + '.' + type + '.' + prefix)) 36 | } 37 | 38 | module.exports = { 39 | getExtension: getExtension, 40 | getOrInsertExtension: getOrInsertExtension, 41 | getExtensionsWithPrefix: getExtensionsWithPrefix, 42 | } 43 | -------------------------------------------------------------------------------- /functions/array.js: -------------------------------------------------------------------------------- 1 | const MakeArray = { 2 | name: 'MakeArray', 3 | genericParams: ['T'], 4 | docs: 'Makes an array containing the provided elements. Can be invoked as a literal expression via [vals].', 5 | args: [ 6 | { 7 | name: 'vals', 8 | type: '$T', 9 | repeated: true, 10 | nullable: true, 11 | }, 12 | ], 13 | outputType: 'Array<$T>', 14 | implementation: (vals) => { 15 | return vals 16 | }, 17 | } 18 | 19 | const At = { 20 | name: 'At', 21 | genericParams: ['T'], 22 | docs: 'Selects the 0-indexed element from the provided array.', 23 | args: [ 24 | { 25 | name: 'array', 26 | type: 'Array<$T>', 27 | }, 28 | { 29 | name: 'index', 30 | type: 'Number', 31 | } 32 | ], 33 | outputType: '$T', 34 | implementation: (array, index) => { 35 | if (array.length <= index) { 36 | return null 37 | } 38 | return array[index] 39 | } 40 | } 41 | 42 | const MakeEmptyArray = { 43 | name: 'MakeEmptyArray', 44 | docs: 'Constructs an array containing zero elements. Can be invoked as a literal expression via [].', 45 | args: [], 46 | outputType: 'Array', 47 | implementation: () => [], 48 | } 49 | 50 | const In = { 51 | name: 'In', 52 | genericParams: ['T'], 53 | docs: 'Returns whether the provided element is in the given array.', 54 | args: [ 55 | { 56 | name: 'value', 57 | type: '$T', 58 | nullable: true, 59 | canBeExternal: true, 60 | }, 61 | { 62 | name: 'array', 63 | type: 'Array<$T>', 64 | }, 65 | ], 66 | outputType: 'Boolean', 67 | implementation: (value, array) => { 68 | return array.includes(value) 69 | }, 70 | } 71 | 72 | const InActivityCode = function(type) { 73 | return { 74 | name: 'In', 75 | docs: 'Returns whether the provided element is in the given array, overloaded for activity codes.', 76 | args: [ 77 | { 78 | name: 'value', 79 | type: type, 80 | nullable: true, 81 | canBeExternal: true, 82 | }, 83 | { 84 | name: 'array', 85 | type: 'Array<' + type + '>', 86 | }, 87 | ], 88 | outputType: 'Boolean', 89 | implementation: (value, array) => { 90 | return array.some((val) => val.id() === value.id()) 91 | }, 92 | } 93 | } 94 | 95 | const InDateTime = { 96 | name: 'In', 97 | docs: 'In, overloaded for DateTime.', 98 | args: [ 99 | { 100 | name: 'value', 101 | type: 'DateTime', 102 | nullable: true, 103 | canBeExternal: true, 104 | }, 105 | { 106 | name: 'array', 107 | type: 'Array', 108 | }, 109 | ], 110 | outputType: 'Boolean', 111 | implementation: (value, array) => { 112 | return array.some((val) => val.toSeconds() === value.toSeconds()) 113 | }, 114 | } 115 | 116 | const Length = { 117 | name: 'Length', 118 | genericParams: ['T'], 119 | docs: 'Returns the length of the provided array.', 120 | args: [ 121 | { 122 | name: 'array', 123 | type: 'Array<$T>', 124 | }, 125 | ], 126 | outputType: 'Number', 127 | implementation: (array) => array.length, 128 | } 129 | 130 | const Map = { 131 | name: 'Map', 132 | genericParams: ['T', 'U'], 133 | docs: 'Transforms the provided array using the provided function.', 134 | args: [ 135 | { 136 | name: 'array', 137 | type: 'Array<$T>', 138 | }, 139 | { 140 | name: 'operation', 141 | type: '$U($T)', 142 | lazy: true, 143 | }, 144 | ], 145 | outputType: 'Array<$U>', 146 | usesGenericTypes: true, 147 | implementation: (generics, array, operation) => { 148 | return array.map((x) => operation({[generics.T]: x})) 149 | }, 150 | } 151 | 152 | const Filter = { 153 | name: 'Filter', 154 | genericParams: ['T'], 155 | docs: 'Filters an array to those satisfying a property.', 156 | args: [ 157 | { 158 | name: 'array', 159 | type: 'Array<$T>', 160 | }, 161 | { 162 | name: 'condition', 163 | type: 'Boolean($T)', 164 | lazy: true, 165 | }, 166 | ], 167 | outputType: 'Array<$T>', 168 | usesGenericTypes: true, 169 | implementation: (generics, array, condition) => { 170 | return array.filter((x) => condition({[generics.T]: x})) 171 | }, 172 | } 173 | 174 | const Flatten = { 175 | name: 'Flatten', 176 | genericParams: ['T'], 177 | docs: 'Flattens an array of arrays into a single array.', 178 | args: [ 179 | { 180 | name: 'args', 181 | type: 'Array>', 182 | } 183 | ], 184 | outputType: 'Array<$T>', 185 | implementation: (args) => { 186 | return args.flat() 187 | } 188 | } 189 | 190 | const Concat = { 191 | name: 'Concat', 192 | genericParams: ['T'], 193 | docs: 'Concatenates multiple arrays into a single array.', 194 | args: [ 195 | { 196 | name: 'args', 197 | type: 'Array<$T>', 198 | repeated: true, 199 | } 200 | ], 201 | outputType: 'Array<$T>', 202 | implementation: (args) => { 203 | return args.flat() 204 | } 205 | } 206 | 207 | const Sort = { 208 | name: 'Sort', 209 | genericParams: ['ValType', 'SortType'], 210 | args: [ 211 | { 212 | name: 'vals', 213 | type: 'Array<$ValType>', 214 | }, 215 | { 216 | name: 'sortFns', 217 | type: '$SortType($ValType)', 218 | lazy: true, 219 | repeated: true, 220 | } 221 | ], 222 | outputType: 'Array<$ValType>', 223 | usesGenericTypes: true, 224 | implementation: (generics, vals, sortFns) => { 225 | return vals.sort((valA, valB) => { 226 | for (const sortFn of sortFns) { 227 | var sortA = sortFn({[generics.ValType]: valA}) 228 | var sortB = sortFn({[generics.ValType]: valB}) 229 | if (sortA < sortB) { 230 | return -1 231 | } 232 | if (sortA > sortB) { 233 | return 1 234 | } 235 | } 236 | return 0 237 | }) 238 | } 239 | } 240 | 241 | const RandomChoice = { 242 | name: 'RandomChoice', 243 | genericParams: 'T', 244 | args: [ 245 | { 246 | name: 'array', 247 | type: 'Array<$T>' 248 | }, 249 | ], 250 | outputType: '$T', 251 | implementation: (array) => { 252 | if (array.length == 0) { 253 | return null 254 | } 255 | var idx = Math.floor(Math.random() * array.length) 256 | return array[idx] 257 | } 258 | } 259 | 260 | const Slice = { 261 | name: 'Slice', 262 | genericParams: 'T', 263 | args: [ 264 | { 265 | name: 'array', 266 | type: 'Array<$T>', 267 | }, 268 | { 269 | name: 'start', 270 | type: 'Number', 271 | }, 272 | { 273 | name: 'end', 274 | type: 'Number', 275 | }, 276 | ], 277 | outputType: 'Array<$T>', 278 | implementation: (array, start, end) => array.slice(start, end), 279 | } 280 | 281 | module.exports = { 282 | functions: [MakeArray, MakeEmptyArray, At, In, InActivityCode('Event'), InActivityCode('Round'), InDateTime, 283 | Length, Map, Filter, Flatten, Concat, Sort, RandomChoice, Slice], 284 | } 285 | -------------------------------------------------------------------------------- /functions/boolean.js: -------------------------------------------------------------------------------- 1 | const Or = { 2 | name: 'Or', 3 | docs: 'Returns true if any of the provided arguments are true.', 4 | args: [ 5 | { 6 | name: 'param', 7 | type: 'Boolean', 8 | repeated: true, 9 | }, 10 | ], 11 | outputType: 'Boolean', 12 | implementation: (params) => { 13 | for (var i = 0; i < params.length; i++) { 14 | if (params[i]) { 15 | return true 16 | } 17 | } 18 | return false 19 | }, 20 | } 21 | 22 | const And = { 23 | name: 'And', 24 | docs: 'Returns true if all of the provided arguments are true.', 25 | args: [ 26 | { 27 | name: 'param', 28 | type: 'Boolean', 29 | repeated: true, 30 | }, 31 | ], 32 | outputType: 'Boolean', 33 | implementation: (params) => { 34 | for (var i = 0; i < params.length; i++) { 35 | if (!params[i]) { 36 | return false 37 | } 38 | } 39 | return true 40 | }, 41 | } 42 | 43 | const Not = { 44 | name: 'Not', 45 | docs: 'Returns true if the provided argument is false.', 46 | args: [ 47 | { 48 | name: 'param', 49 | type: 'Boolean', 50 | }, 51 | ], 52 | outputType: 'Boolean', 53 | implementation: (param) => { 54 | return !param 55 | }, 56 | } 57 | 58 | module.exports = { 59 | functions: [And, Or, Not], 60 | } 61 | -------------------------------------------------------------------------------- /functions/cluster.js: -------------------------------------------------------------------------------- 1 | const cluster = require('./../cluster/cluster') 2 | const constraint = require('./../cluster/constraint') 3 | 4 | const Cluster = { 5 | name: 'Cluster', 6 | docs: 'Arranges the provided Persons into clusters, and sets a property on each person to indicate which cluster they are in.', 7 | args: [ 8 | { 9 | name: 'name', 10 | type: 'String', 11 | docs: 'The name of the property where the result should be stored.', 12 | }, 13 | { 14 | name: 'numClusters', 15 | type: 'Number', 16 | docs: 'The number of clusters to create.', 17 | }, 18 | { 19 | name: 'persons', 20 | type: 'Array', 21 | docs: 'The people to be clustered.', 22 | }, 23 | { 24 | name: 'preCluster', 25 | type: 'String(Person)', 26 | lazy: true, 27 | docs: 'People with the same value for this function will be assigned to the same cluster.', 28 | }, 29 | { 30 | name: 'constraints', 31 | type: 'Array', 32 | docs: 'Constraints that should be applied to the clustering.', 33 | }, 34 | ], 35 | outputType: 'ClusteringResult', 36 | mutations: ['persons'], 37 | implementation: (name, numClusters, persons, preCluster, constraints) => { 38 | return cluster.Cluster(name, numClusters, persons, preCluster, constraints) 39 | } 40 | } 41 | 42 | function BalanceConstraint(argType) { 43 | return { 44 | name: 'BalanceConstraint', 45 | docs: 'A clustering constraint which balances the number of people in each Cluster with a given property, or the total of a given property.', 46 | args: [ 47 | { 48 | name: 'name', 49 | type: 'String', 50 | docs: 'The name of the constraint', 51 | }, 52 | { 53 | name: 'value', 54 | type: argType + '(Person)', 55 | lazy: true, 56 | docs: 'The value of the constraint to be evaluated for each person', 57 | }, 58 | { 59 | name: 'weight', 60 | type: 'Number', 61 | docs: 'The weighting value to assign to this cluster', 62 | }, 63 | ], 64 | outputType: 'Constraint', 65 | implementation: (name, value, weight) => { 66 | return new constraint.BalanceConstraint(name, value, weight) 67 | } 68 | } 69 | } 70 | 71 | function LimitConstraint(argType) { 72 | return { 73 | name: 'LimitConstraint', 74 | docs: 'A constraint that limits the sum of a given property across all people in a cluster.', 75 | args: [ 76 | { 77 | name: 'name', 78 | type: 'String', 79 | docs: 'The name of the constraint', 80 | }, 81 | { 82 | name: 'value', 83 | type: argType + '(Person)', 84 | lazy: true, 85 | docs: 'The value of the constraint to be evaluated for each person', 86 | }, 87 | { 88 | name: 'min', 89 | type: 'Number', 90 | docs: 'The minimum value per cluster', 91 | }, 92 | { 93 | name: 'weight', 94 | type: 'Number', 95 | docs: 'The weighting value to assign to this cluster', 96 | }, 97 | ], 98 | outputType: 'Constraint', 99 | implementation: (name, value, min, weight) => { 100 | return new constraint.LimitConstraint(name, value, min, weight) 101 | } 102 | } 103 | } 104 | 105 | const SpecificAssignmentScore = { 106 | name: 'SpecificAssignmentScore', 107 | args: [ 108 | { 109 | name: 'name', 110 | type: 'String', 111 | }, 112 | { 113 | name: 'personProperty', 114 | type: 'Boolean(Person)', 115 | lazy: true, 116 | }, 117 | { 118 | name: 'clusterProperty', 119 | type: 'Boolean(Number)', 120 | lazy: true, 121 | }, 122 | { 123 | name: 'score', 124 | type: 'Number', 125 | }, 126 | ], 127 | outputType: 'Constraint', 128 | implementation: (name, personProperty, clusterProperty, score) => { 129 | return new constraint.SpecificAssignmentScore(name, personProperty, clusterProperty, score) 130 | } 131 | } 132 | 133 | module.exports = { 134 | functions: [Cluster, 135 | BalanceConstraint('Number'), BalanceConstraint('Boolean'), 136 | LimitConstraint('Number'), LimitConstraint('Boolean'), 137 | SpecificAssignmentScore] 138 | } 139 | -------------------------------------------------------------------------------- /functions/display.js: -------------------------------------------------------------------------------- 1 | const extension = require('./../extension') 2 | 3 | const All = function(argCount) { 4 | const genericParams = [...Array(argCount).keys()].map((x) => 'T' + x) 5 | return { 6 | name: 'All', 7 | docs: 'Renders multiple items, possibly of different types', 8 | genericParams: genericParams, 9 | args: genericParams.map((param) => { 10 | return { 11 | name: param, 12 | type: '$' + param, 13 | } 14 | }), 15 | outputType: 'Multi', 16 | usesGenericTypes: true, 17 | implementation: (generics, ...args) => { 18 | return args.map((arg, idx) => { 19 | return { 20 | type: generics['T' + idx], 21 | data: arg, 22 | } 23 | }) 24 | } 25 | } 26 | } 27 | 28 | const Header = { 29 | name: 'Header', 30 | docs: 'Renders a header', 31 | args: [ 32 | { 33 | name: 'value', 34 | type: 'String', 35 | }, 36 | ], 37 | outputType: 'Header', 38 | implementation: (value) => value, 39 | } 40 | 41 | const NoPageBreak = { 42 | name: 'NoPageBreak', 43 | docs: 'Renders without a page break', 44 | genericParams: ['T'], 45 | args: [ 46 | { 47 | name: 'arg', 48 | type: '$T', 49 | }, 50 | ], 51 | outputType: 'NoPageBreak', 52 | usesGenericTypes: true, 53 | implementation: (generics, value) => { 54 | return [ 55 | { 56 | type: generics.T, 57 | data: value, 58 | } 59 | ] 60 | } 61 | } 62 | 63 | module.exports = { 64 | functions: [All(0), All(1), All(2), All(3), All(4), All(5), All(6), All(7), All(8), All(9), 65 | Header, NoPageBreak], 66 | } 67 | -------------------------------------------------------------------------------- /functions/events.js: -------------------------------------------------------------------------------- 1 | const activityCode = require('./../activity_code') 2 | const attemptResult = require('./../attempt_result') 3 | const lib = require('./../lib') 4 | 5 | const Events = { 6 | name: 'Events', 7 | docs: 'Returns a list of all events in a competition', 8 | args: [], 9 | outputType: 'Array', 10 | usesContext: true, 11 | implementation: (ctx) => { 12 | return ctx.competition.events.map((evt) => activityCode.parse(evt.id)) 13 | } 14 | } 15 | 16 | const Rounds = { 17 | name: 'Rounds', 18 | docs: 'Returns a list of all rounds in a competition', 19 | args: [], 20 | outputType: 'Array', 21 | usesContext: true, 22 | implementation: (ctx) => { 23 | return ctx.competition.events.map((evt) => evt.rounds.map((rd) => activityCode.parse(rd.id))).flat() 24 | } 25 | } 26 | 27 | const EventId = { 28 | name: 'EventId', 29 | docs: 'Returns the string event ID for an event', 30 | args: [ 31 | { 32 | name: 'event', 33 | type: 'Event', 34 | canBeExternal: true, 35 | } 36 | ], 37 | outputType: 'String', 38 | implementation: (evt) => evt.id() 39 | } 40 | 41 | const RoundId = { 42 | name: 'RoundId', 43 | docs: 'Returns the ID for a round', 44 | args: [ 45 | { 46 | name: 'round', 47 | type: 'Round', 48 | canBeExternal: true, 49 | } 50 | ], 51 | outputType: 'String', 52 | implementation: (rd) => rd.id() 53 | } 54 | 55 | const CompetingIn_Event = { 56 | name: 'CompetingIn', 57 | docs: 'Returns true if the specified person is competing in the specified event', 58 | args: [ 59 | { 60 | name: 'event', 61 | type: 'Event', 62 | canBeExternal: true, 63 | }, 64 | { 65 | name: 'person', 66 | type: 'Person', 67 | canBeExternal: true, 68 | } 69 | ], 70 | outputType: 'Boolean', 71 | implementation: (event, person) => { 72 | return person.registration && person.registration.status == 'accepted' && person.registration.eventIds.includes(event.eventId) 73 | }, 74 | } 75 | 76 | const CompetingIn_Round = { 77 | name: 'CompetingInRound', 78 | docs: 'Returns true if the specified person is competing in the specified round', 79 | args: [ 80 | { 81 | name: 'round', 82 | type: 'Round', 83 | canBeExternal: true, 84 | }, 85 | { 86 | name: 'person', 87 | type: 'Person', 88 | canBeExternal: true, 89 | } 90 | ], 91 | outputType: 'Boolean', 92 | usesContext: true, 93 | implementation: (ctx, round, person) => { 94 | var rd = lib.getWcifRound(ctx.competition, round) 95 | return rd.results.filter((res) => res.personId == person.registrantId).length > 0 96 | }, 97 | } 98 | 99 | const PositionInRound = { 100 | name: 'PositionInRound', 101 | args: [ 102 | { 103 | name: 'round', 104 | type: 'Round', 105 | canBeExternal: true, 106 | }, 107 | { 108 | name: 'person', 109 | type: 'Person', 110 | canBeExternal: true, 111 | } 112 | ], 113 | outputType: 'Number', 114 | usesContext: true, 115 | implementation: (ctx, round, person) => { 116 | var rd = lib.getWcifRound(ctx.competition, round) 117 | var res = rd.results.filter((res) => res.personId == person.registrantId) 118 | if (res.length > 0) { 119 | return res[0].ranking 120 | } else { 121 | return null 122 | } 123 | }, 124 | } 125 | 126 | // TODO: Add CompetingIn(Group) 127 | 128 | const RegisteredEvents = { 129 | name: 'RegisteredEvents', 130 | docs: 'Returns an array of events that the person is registered for', 131 | args: [ 132 | { 133 | name: 'person', 134 | type: 'Person', 135 | canBeExternal: true, 136 | } 137 | ], 138 | outputType: 'Array', 139 | implementation: (person) => { 140 | if (!person.registration) return [] 141 | if (person.registration.status !== 'accepted') return [] 142 | return person.registration.eventIds.map((eventId) => activityCode.parse(eventId)) 143 | }, 144 | } 145 | 146 | const PersonalBest = { 147 | name: 'PersonalBest', 148 | docs: 'Returns the personal best for an event', 149 | args: [ 150 | { 151 | name: 'event', 152 | type: 'Event', 153 | canBeExternal: true, 154 | }, 155 | { 156 | name: 'person', 157 | type: 'Person', 158 | canBeExternal: true, 159 | }, 160 | { 161 | name: 'type', 162 | type: 'String', // 'single', 'average', or 'default' 163 | defaultValue: 'default', 164 | }, 165 | ], 166 | outputType: 'AttemptResult', 167 | implementation: (evt, person, type) => lib.personalBest(person, evt, type), 168 | } 169 | 170 | const Result = { 171 | name: 'Result', 172 | args: [ 173 | { 174 | name: 'person', 175 | type: 'Person', 176 | }, 177 | { 178 | name: 'round', 179 | type: 'Round', 180 | }, 181 | { 182 | name: 'number', 183 | type: 'Number', 184 | }, 185 | ], 186 | outputType: 'AttemptResult', 187 | usesContext: true, 188 | implementation: (ctx, person, round, number) => { 189 | for (const evt of ctx.competition.events) { 190 | for (const rd of evt.rounds) { 191 | if (rd.id !== round.id()) { 192 | continue 193 | } 194 | for (const result of rd.results) { 195 | if (result.personId !== person.registrantId) { 196 | continue 197 | } 198 | if (result.attempts.length < number) { 199 | return null 200 | } 201 | return new attemptResult.AttemptResult(result.attempts[number - 1].result, round.eventId) 202 | } 203 | } 204 | } 205 | return null 206 | } 207 | } 208 | 209 | 210 | const PsychSheetPosition = { 211 | name: 'PsychSheetPosition', 212 | docs: 'Returns this person\'s position on the psych sheet for an event', 213 | args: [ 214 | { 215 | name: 'event', 216 | type: 'Event', 217 | canBeExternal: true, 218 | }, 219 | { 220 | name: 'type', 221 | type: 'String', 222 | defaultValue: 'default', 223 | }, 224 | { 225 | name: 'person', 226 | type: 'Person', 227 | canBeExternal: true, 228 | } 229 | ], 230 | outputType: 'Number', 231 | usesContext: true, 232 | implementation: (ctx, evt, type, person) => { 233 | if (person.registration == null || 234 | person.registration.status !== 'accepted' || 235 | !person.registration.eventIds.includes(evt.eventId)) { 236 | return null 237 | } 238 | var pb = lib.personalBest(person, evt, type) 239 | var singlePb = lib.personalBest(person, evt, 'single') 240 | return ctx.competition.persons.filter((otherPerson) => { 241 | if (!otherPerson.registration || otherPerson.registration.status !== 'accepted') { 242 | return false 243 | } 244 | if (!otherPerson.registration.eventIds.includes(evt.eventId)) { 245 | return false 246 | } 247 | var otherPb = lib.personalBest(otherPerson, evt, type) 248 | if (otherPb === null) { 249 | return false 250 | } 251 | if (pb === null) { 252 | return true 253 | } 254 | if (pb.value > otherPb.value) { 255 | return true 256 | } else if (pb.value < otherPb.value) { 257 | return false 258 | } else { 259 | var otherSinglePb = lib.personalBest(otherPerson, evt, 'single') 260 | return singlePb.value > otherSinglePb.value 261 | } 262 | }).length + 1 263 | } 264 | } 265 | 266 | const RoundPosition = { 267 | name: 'RoundPosition', 268 | docs: 'Returns this person\'s placement in a round that has already happened', 269 | args: [ 270 | { 271 | name: 'round', 272 | type: 'Round', 273 | }, 274 | { 275 | name: 'person', 276 | type: 'Person', 277 | canBeExternal: true, 278 | } 279 | ], 280 | outputType: 'Number', 281 | usesContext: true, 282 | implementation: (ctx, round, person) => { 283 | var allResults = lib.getWcifRound(ctx.competition, round).results 284 | var res = allResults.filter((res) => res.personId == person.registrantId) 285 | if (res.length && res[0].ranking) { 286 | return res[0].ranking 287 | } 288 | return null 289 | } 290 | } 291 | 292 | const AddResults = { 293 | name: 'AddResults', 294 | docs: 'Add fake results for the given persons in the given round', 295 | args: [ 296 | { 297 | name: 'round', 298 | type: 'Round', 299 | }, 300 | { 301 | name: 'persons', 302 | type: 'Array', 303 | }, 304 | { 305 | name: 'result', 306 | type: 'AttemptResult(Person)', 307 | lazy: true, 308 | defaultValue: new attemptResult.AttemptResult(0, '333'), 309 | }, 310 | ], 311 | outputType: 'String', 312 | usesContext: true, 313 | mutations: ['events'], 314 | implementation: (ctx, round, persons, result) => { 315 | var rd = lib.getWcifRound(ctx.competition, round) 316 | var attempts = ((rd) => { 317 | switch (rd.format) { 318 | case '1': 319 | return 1 320 | case '2': 321 | return 2 322 | case '3': 323 | return 3 324 | case 'm': 325 | return 3 326 | case 'a': 327 | return 5 328 | } 329 | })(rd) 330 | if (rd.results.length > 0) { 331 | return `There are already results for ${round.id()}, not adding fake results.` 332 | } 333 | rd.results = persons.map((person) => { 334 | var res = result({'Person': person}) 335 | if (res.value != 0) { 336 | return { 337 | personId: person.registrantId, 338 | attempts: [...Array(attempts)].map((x) => { return { result: res.value } }), 339 | best: res.value, 340 | average: res.value, 341 | ranking: null, 342 | } 343 | } else { 344 | return { 345 | personId: person.registrantId, 346 | attempts: [...Array(attempts)].map((x) => null), 347 | ranking: null, 348 | best: 0, 349 | average: 0, 350 | } 351 | } 352 | }).sort((p1, p2) => { 353 | if (p1.average <= 0) return -1 354 | if (p2.average <= 0) return 1 355 | return p1.average - p2.average 356 | }).map((res, idx) => { 357 | if (res.average > 0) { 358 | res.ranking = idx 359 | } 360 | return res 361 | }) 362 | return 'Added ' + rd.results.length + ' results for ' + round.id() 363 | } 364 | } 365 | 366 | const IsFinal = { 367 | name: 'IsFinal', 368 | docs: 'Returns true if the provided round is a final', 369 | args: [ 370 | { 371 | name: 'round', 372 | type: 'Round', 373 | canBeExternal: true, 374 | } 375 | ], 376 | outputType: 'Boolean', 377 | usesContext: true, 378 | implementation: (ctx, round) => { 379 | var matchingEvt = ctx.competition.events.filter((evt) => evt.id === round.eventId) 380 | if (matchingEvt.length !== 1) { 381 | return false 382 | } 383 | return matchingEvt[0].rounds.length === round.roundNumber 384 | }, 385 | } 386 | 387 | const RoundNumber = { 388 | name: 'RoundNumber', 389 | docs: 'Returns the number of a round', 390 | args: [ 391 | { 392 | name: 'round', 393 | type: 'Round', 394 | } 395 | ], 396 | outputType: 'Number', 397 | implementation: round => round.roundNumber, 398 | } 399 | 400 | const RoundForEvent = { 401 | name: 'RoundForEvent', 402 | docs: 'Returns a round for the specified event.', 403 | args: [ 404 | { 405 | name: 'number', 406 | type: 'Number', 407 | }, 408 | { 409 | name: 'event', 410 | type: 'Event', 411 | canBeExternal: true, 412 | } 413 | ], 414 | outputType: 'Round', 415 | implementation: (number, event) => event.round(number), 416 | } 417 | 418 | const EventForRound = { 419 | name: 'EventForRound', 420 | docs: 'Returns the event for the round.', 421 | args: [ 422 | { 423 | name: 'round', 424 | type: 'Round', 425 | canBeExternal: true, 426 | } 427 | ], 428 | outputType: 'Event', 429 | implementation: (round) => round.round(null), 430 | } 431 | 432 | const PreviousRound = { 433 | name: 'PreviousRound', 434 | args: [ 435 | { 436 | name: 'round', 437 | type: 'Round', 438 | } 439 | ], 440 | outputType: 'Round', 441 | implementation: (round) => round.round(round.roundNumber - 1), 442 | } 443 | 444 | const NumberInRound = { 445 | name: 'NumberInRound', 446 | args: [ 447 | { 448 | name: 'round', 449 | type: 'Round', 450 | } 451 | ], 452 | outputType: 'Number', 453 | usesContext: true, 454 | implementation: (ctx, round) => { 455 | if (round.roundNumber == 1) { 456 | return lib.getWcifRound(ctx.competition, round).results.length 457 | } else { 458 | var prev = lib.getWcifRound(ctx.competition, round.round(round.roundNumber - 1)) 459 | var adv = prev.advancementCondition 460 | if (adv.type !== "ranking") { 461 | // Unsupported. 462 | return 0; 463 | } 464 | return adv.level 465 | } 466 | } 467 | } 468 | 469 | module.exports = { 470 | functions: [Events, Rounds, EventId, RoundId, CompetingIn_Event, CompetingIn_Round, PositionInRound, 471 | RegisteredEvents, PersonalBest, Result, 472 | PsychSheetPosition, RoundPosition, AddResults, 473 | IsFinal, RoundNumber, RoundForEvent, EventForRound, PreviousRound, NumberInRound], 474 | } 475 | -------------------------------------------------------------------------------- /functions/functions.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | allFunctions: 3 | [].concat( 4 | require('./array').functions, 5 | require('./boolean').functions, 6 | require('./cluster').functions, 7 | require('./display').functions, 8 | require('./events').functions, 9 | require('./groups').functions, 10 | require('./help').functions, 11 | require('./math').functions, 12 | require('./persons').functions, 13 | require('./sheets').functions, 14 | require('./staff').functions, 15 | require('./stream').functions, 16 | require('./table').functions, 17 | require('./time').functions, 18 | require('./tuple').functions, 19 | require('./udf').functions, 20 | require('./util').functions, 21 | require('./wcif').functions, 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /functions/help.js: -------------------------------------------------------------------------------- 1 | const ListFunctions = { 2 | name: 'ListFunctions', 3 | docs: 'Provide a list of all functions', 4 | args: [], 5 | outputType: 'ListFunctionsOutput', 6 | usesContext: true, 7 | implementation: (ctx) => { 8 | return [...new Set(ctx.allFunctions.map((fn) => fn.name))] 9 | } 10 | } 11 | 12 | const Help = { 13 | name: 'Help', 14 | docs: 'Provide documentation about a single function', 15 | args: [ 16 | { 17 | name: 'functionName', 18 | type: 'String', 19 | } 20 | ], 21 | outputType: 'FunctionHelp', 22 | usesContext: true, 23 | implementation: (ctx, functionName) => { 24 | return ctx.allFunctions.filter((fn) => fn.name === functionName) 25 | } 26 | } 27 | 28 | module.exports = { 29 | functions: [ListFunctions, Help] 30 | } 31 | -------------------------------------------------------------------------------- /functions/math.js: -------------------------------------------------------------------------------- 1 | const GreaterThan = { 2 | name: 'GreaterThan', 3 | docs: 'Return true if val1 > val2 (maybe invoked with ">")', 4 | genericParams: ['T'], 5 | args: [ 6 | { 7 | name: 'val1', 8 | type: '$T', 9 | nullable: true, 10 | }, 11 | { 12 | name: 'val2', 13 | type: '$T', 14 | nullable: true, 15 | }, 16 | ], 17 | outputType: 'Boolean', 18 | implementation: (val1, val2) => { 19 | if (val1 === null || val2 === null) { 20 | return false 21 | } 22 | return val1 > val2 23 | } 24 | } 25 | 26 | const GreaterThanOrEqualTo = { 27 | name: 'GreaterThanOrEqualTo', 28 | docs: 'Return true if val1 >= val2 (maybe invoked with ">=")', 29 | genericParams: ['T'], 30 | args: [ 31 | { 32 | name: 'val1', 33 | type: '$T', 34 | nullable: true, 35 | }, 36 | { 37 | name: 'val2', 38 | type: '$T', 39 | nullable: true, 40 | }, 41 | ], 42 | outputType: 'Boolean', 43 | implementation: (val1, val2) => { 44 | if (val1 === null || val2 === null) { 45 | return false 46 | } 47 | return val1 >= val2 48 | } 49 | } 50 | 51 | const EqualTo = { 52 | name: 'EqualTo', 53 | docs: 'Return true if val1 == val2 (maybe invoked with "==")', 54 | genericParams: ['T'], 55 | args: [ 56 | { 57 | name: 'val1', 58 | type: '$T', 59 | nullable: true, 60 | }, 61 | { 62 | name: 'val2', 63 | type: '$T', 64 | nullable: true, 65 | }, 66 | ], 67 | outputType: 'Boolean', 68 | implementation: (val1, val2) => val1 === val2, 69 | } 70 | 71 | const EqualTo_Date = { 72 | name: 'EqualTo', 73 | docs: 'Override of EqualTo for Date objects', 74 | args: [ 75 | { 76 | name: 'val1', 77 | type: 'Date', 78 | nullable: true, 79 | }, 80 | { 81 | name: 'val2', 82 | type: 'Date', 83 | nullable: true, 84 | }, 85 | ], 86 | outputType: 'Boolean', 87 | implementation: (date1, date2) => { 88 | if (date1 === null || date2 === null) { 89 | return false 90 | } 91 | return date1.year === date2.year && date1.month === date2.month && date1.day === date2.day 92 | } 93 | } 94 | 95 | const Add = { 96 | name: 'Add', 97 | docs: 'Adds two numbers (may be invoked with "+")', 98 | args: [ 99 | { 100 | name: 'val1', 101 | type: 'Number', 102 | }, 103 | { 104 | name: 'val2', 105 | type: 'Number', 106 | }, 107 | ], 108 | outputType: 'Number', 109 | implementation: (val1, val2) => val1 + val2, 110 | } 111 | 112 | const ConcatStrings = { 113 | name: 'Add', 114 | docs: 'Concatenates two strings (may be invoked with "+")', 115 | args: [ 116 | { 117 | name: 'val1', 118 | type: 'String', 119 | nullable: true, 120 | }, 121 | { 122 | name: 'val2', 123 | type: 'String', 124 | nullable: true, 125 | }, 126 | ], 127 | outputType: 'String', 128 | implementation: (val1, val2) => val1 + val2, 129 | } 130 | 131 | const ConcatArrays = { 132 | name: 'Add', 133 | genericParams: ['T'], 134 | docs: 'Concatenates two arrays (may be invoked with "+")', 135 | args: [ 136 | { 137 | name: 'array1', 138 | type: 'Array<$T>', 139 | }, 140 | { 141 | name: 'array2', 142 | type: 'Array<$T>', 143 | } 144 | ], 145 | outputType: 'Array<$T>', 146 | implementation: (array1, array2) => array1.concat(array2), 147 | } 148 | 149 | const Subtract = { 150 | name: 'Subtract', 151 | docs: 'Subtracts two numbers (may be invoked with "-")', 152 | args: [ 153 | { 154 | name: 'val1', 155 | type: 'Number', 156 | }, 157 | { 158 | name: 'val2', 159 | type: 'Number', 160 | }, 161 | ], 162 | outputType: 'Number', 163 | implementation: (val1, val2) => val1 - val2, 164 | } 165 | 166 | const Multiply = { 167 | name: 'Multiply', 168 | docs: 'Multiplies two numbers (may be invoked with "*")', 169 | args: [ 170 | { 171 | name: 'val1', 172 | type: 'Number', 173 | }, 174 | { 175 | name: 'val2', 176 | type: 'Number', 177 | }, 178 | ], 179 | outputType: 'Number', 180 | implementation: (val1, val2) => val1 * val2, 181 | } 182 | 183 | const Divide = { 184 | name: 'Divide', 185 | docs: 'Divides two numbers (may be invoked with "/")', 186 | args: [ 187 | { 188 | name: 'val1', 189 | type: 'Number', 190 | }, 191 | { 192 | name: 'val2', 193 | type: 'Number', 194 | }, 195 | ], 196 | outputType: 'Number', 197 | implementation: (val1, val2) => val1 / val2, 198 | } 199 | 200 | const If = { 201 | name: 'If', 202 | docs: 'If the condition is true, return the first value, else the second value', 203 | genericParams: ['T'], 204 | args: [ 205 | { 206 | name: 'condition', 207 | type: 'Boolean', 208 | }, 209 | { 210 | name: 'ifTrue', 211 | type: '$T', 212 | nullable: true, 213 | lazy: true, 214 | }, 215 | { 216 | name: 'ifFalse', 217 | type: '$T', 218 | nullable: true, 219 | lazy: true, 220 | }, 221 | ], 222 | outputType: '$T', 223 | implementation: (condition, ifTrue, ifFalse) => { 224 | return condition ? ifTrue() : ifFalse() 225 | } 226 | } 227 | 228 | const Switch = { 229 | name: 'Switch', 230 | docs: 'Returns the first matching value', 231 | genericParams: ['T', 'U'], 232 | args: [ 233 | { 234 | name: 'value', 235 | type: '$T', 236 | canBeExternal: true, 237 | }, 238 | { 239 | name: 'options', 240 | type: 'Array>', 241 | }, 242 | { 243 | name: 'defaultValue', 244 | type: '$U', 245 | defaultValue: null, 246 | nullable: true, 247 | }, 248 | ], 249 | outputType: '$U', 250 | implementation: (value, options, defaultValue) => { 251 | for (const option of options) { 252 | if (option[0] === value) { 253 | return option[1] 254 | } 255 | } 256 | return defaultValue 257 | } 258 | } 259 | 260 | const Switch_Events = { 261 | name: 'Switch', 262 | docs: 'Returns the first matching value', 263 | genericParams: ['U'], 264 | args: [ 265 | { 266 | name: 'value', 267 | type: 'Event', 268 | canBeExternal: true, 269 | }, 270 | { 271 | name: 'options', 272 | type: 'Array>', 273 | }, 274 | { 275 | name: 'defaultValue', 276 | type: '$U', 277 | defaultValue: null, 278 | nullable: true, 279 | }, 280 | ], 281 | outputType: '$U', 282 | implementation: (value, options, defaultValue) => { 283 | for (const option of options) { 284 | if (option[0].id() === value.id()) { 285 | return option[1] 286 | } 287 | } 288 | return defaultValue 289 | } 290 | } 291 | 292 | const Even = { 293 | name: 'Even', 294 | docs: 'Returns true if the number is even', 295 | args: [ 296 | { 297 | name: 'val', 298 | type: 'Number', 299 | nullable: true, 300 | }, 301 | ], 302 | outputType: 'Boolean', 303 | implementation: (val) => val !== null && val % 2 == 0, 304 | } 305 | 306 | const Odd = { 307 | name: 'Odd', 308 | docs: 'Returns true if the number is odd', 309 | args: [ 310 | { 311 | name: 'val', 312 | type: 'Number', 313 | nullable: true, 314 | }, 315 | ], 316 | outputType: 'Boolean', 317 | implementation: (val) => val !== null && val % 2 == 1, 318 | } 319 | 320 | const Mod = { 321 | name: 'Mod', 322 | args: [ 323 | { 324 | name: 'val', 325 | type: 'Number', 326 | }, 327 | { 328 | name: 'modulus', 329 | type: 'Number', 330 | } 331 | ], 332 | outputType: 'Number', 333 | implementation: (val, modulus) => { 334 | while (val < 0) { 335 | val += modulus 336 | } 337 | return val % modulus 338 | } 339 | } 340 | 341 | const Min = { 342 | name: 'Min', 343 | genericParams: 'T', 344 | args: [ 345 | { 346 | name: 'vals', 347 | type: 'Array<$T>', 348 | } 349 | ], 350 | outputType: '$T', 351 | implementation: (vals) => { 352 | if (vals.length == 0) { 353 | return null 354 | } 355 | var out = vals[0] 356 | for (const val of vals) { 357 | if (val < out) { 358 | out = val 359 | } 360 | } 361 | return out 362 | } 363 | } 364 | 365 | const Max = { 366 | name: 'Max', 367 | genericParams: 'T', 368 | args: [ 369 | { 370 | name: 'vals', 371 | type: 'Array<$T>', 372 | } 373 | ], 374 | outputType: '$T', 375 | implementation: (vals) => { 376 | if (vals.length == 0) { 377 | return null 378 | } 379 | var out = vals[0] 380 | for (const val of vals) { 381 | if (val > out) { 382 | out = val 383 | } 384 | } 385 | return out 386 | } 387 | } 388 | 389 | module.exports = { 390 | functions: [GreaterThan, GreaterThanOrEqualTo, 391 | EqualTo, EqualTo_Date, If, Switch, Switch_Events, Add, ConcatStrings, ConcatArrays, Subtract, 392 | Multiply, Divide, Even, Odd, Mod, Min, Max], 393 | } 394 | -------------------------------------------------------------------------------- /functions/persons.js: -------------------------------------------------------------------------------- 1 | const extension = require('./../extension') 2 | const { DateTime } = require('luxon') 3 | 4 | const Name = { 5 | name: 'Name', 6 | docs: 'Returns the person\'s name', 7 | args: [ 8 | { 9 | name: 'person', 10 | type: 'Person', 11 | canBeExternal: true 12 | } 13 | ], 14 | outputType: 'String', 15 | implementation: (person) => person.name 16 | } 17 | 18 | const Birthdate = { 19 | name: 'Birthdate', 20 | args: [ 21 | { 22 | name: 'person', 23 | type: 'Person', 24 | canBeExternal: true 25 | } 26 | ], 27 | outputType: 'Date', 28 | implementation: (person) => DateTime.fromISO(person.birthdate) 29 | } 30 | 31 | const Age = { 32 | name: 'Age', 33 | docs: 'Returns the person\'s age on the first day of the competition, rounded down', 34 | args: [ 35 | { 36 | name: 'person', 37 | type: 'Person', 38 | canBeExternal: true 39 | } 40 | ], 41 | outputType: 'Number', 42 | usesContext: true, 43 | implementation: (ctx, person) => { 44 | const birthdate = DateTime.fromISO(person.birthdate) 45 | const start = DateTime.fromISO(ctx.competition.schedule.startDate) 46 | return start.diff(birthdate, ['years', 'months', 'days']).years 47 | } 48 | } 49 | 50 | const WcaId = { 51 | name: 'WcaId', 52 | docs: 'Returns the person\'s WCA ID', 53 | args: [ 54 | { 55 | name: 'person', 56 | type: 'Person', 57 | canBeExternal: true 58 | } 59 | ], 60 | outputType: 'String', 61 | implementation: (person) => { 62 | if (person.wcaId === undefined) { 63 | return null 64 | } 65 | return person.wcaId 66 | } 67 | } 68 | 69 | const WcaLink = { 70 | name: 'WcaLink', 71 | docs: 'Returns a link to the person\'s WCA profile', 72 | args: [ 73 | { 74 | name: 'person', 75 | type: 'Person', 76 | canBeExternal: true 77 | } 78 | ], 79 | outputType: 'String', 80 | implementation: (person) => { 81 | if (person.wcaId === undefined) { 82 | return null 83 | } 84 | return 'https://wca.link/' + person.wcaId 85 | } 86 | } 87 | 88 | const CompetitionGroupsLink = { 89 | name: 'CompetitionGroups', 90 | docs: 'Returns a link to competitiongroups.com for the person', 91 | args: [ 92 | { 93 | name: 'person', 94 | type: 'Person', 95 | canBeExternal: true, 96 | } 97 | ], 98 | outputType: 'String', 99 | usesContext: true, 100 | implementation: (ctx, person) => { 101 | return `https://www.competitiongroups.com/competitions/${ctx.competition.id}/persons/${person.registrantId}` 102 | } 103 | } 104 | 105 | const Registered = { 106 | name: 'Registered', 107 | docs: 'Returns true if the person is registered for the competition', 108 | args: [ 109 | { 110 | name: 'person', 111 | type: 'Person', 112 | canBeExternal: true 113 | } 114 | ], 115 | outputType: 'Boolean', 116 | implementation: (person) => person.registration && person.registration.status == 'accepted' 117 | } 118 | 119 | const WcaIdYear = { 120 | name: 'WcaIdYear', 121 | docs: 'Returns the year component of the person\'s WCA ID', 122 | args: [ 123 | { 124 | name: 'person', 125 | type: 'Person', 126 | canBeExternal: true 127 | } 128 | ], 129 | outputType: 'Number', 130 | implementation: (person) => { 131 | if (!person.wcaId) return null 132 | return +person.wcaId.substring(0, 4) 133 | } 134 | } 135 | 136 | const Country = { 137 | name: 'Country', 138 | docs: 'Returns the person\'s country', 139 | args: [ 140 | { 141 | name: 'person', 142 | type: 'Person', 143 | canBeExternal: true 144 | } 145 | ], 146 | outputType: 'String', 147 | implementation: (person) => person.countryIso2, 148 | } 149 | 150 | const Email = { 151 | name: 'Email', 152 | docs: 'Returns the person\'s email', 153 | args: [ 154 | { 155 | name: 'person', 156 | type: 'Person', 157 | canBeExternal: true 158 | } 159 | ], 160 | outputType: 'String', 161 | implementation: (person) => person.email, 162 | } 163 | 164 | const FirstName = { 165 | name: 'FirstName', 166 | docs: 'Returns the person\'s first name', 167 | args: [ 168 | { 169 | name: 'person', 170 | type: 'Person', 171 | canBeExternal: true 172 | } 173 | ], 174 | outputType: 'String', 175 | implementation: (person) => person.name.split(' ').at(0), 176 | } 177 | 178 | const LastName = { 179 | name: 'LastName', 180 | docs: 'Returns the person\'s last name', 181 | args: [ 182 | { 183 | name: 'person', 184 | type: 'Person', 185 | canBeExternal: true 186 | } 187 | ], 188 | outputType: 'String', 189 | implementation: (person) => person.name.split(' ').at(-1), 190 | } 191 | 192 | const Property = (type) => { 193 | var defaultValue = ((type) => { 194 | switch (type) { 195 | case 'String': 196 | return '' 197 | case 'Boolean': 198 | return false 199 | case 'Number': 200 | return 0 201 | case 'Array': 202 | return [] 203 | } 204 | })(type) 205 | var name = type === 'Array' ? 'ArrayProperty' : type + 'Property' 206 | return { 207 | name: name, 208 | docs: 'Gets a property attached to the person\'s WCIF', 209 | args: [ 210 | { 211 | name: 'name', 212 | type: 'String', 213 | }, 214 | { 215 | name: 'person', 216 | type: 'Person', 217 | canBeExternal: true, 218 | }, 219 | { 220 | name: 'defaultValue', 221 | type: type, 222 | defaultValue: defaultValue, 223 | } 224 | ], 225 | outputType: type, 226 | implementation: (name, person, defaultValue) => { 227 | const ext = extension.getExtension(person, 'Person') 228 | if (ext !== null && ext.properties && name in ext.properties) { 229 | return ext.properties[name] 230 | } 231 | return defaultValue 232 | } 233 | } 234 | } 235 | 236 | const HasProperty = { 237 | name: 'HasProperty', 238 | docs: 'Returns true if the person has this property set', 239 | args: [ 240 | { 241 | name: 'property', 242 | type: 'String', 243 | }, 244 | { 245 | name: 'person', 246 | type: 'Person', 247 | canBeExternal: true, 248 | } 249 | ], 250 | outputType: 'Boolean', 251 | implementation: (property, person) => { 252 | const ext = extension.getExtension(person, 'Person') 253 | if (!ext || !ext.properties) { 254 | return false 255 | } 256 | return property in ext.properties 257 | } 258 | } 259 | 260 | const SetProperty = { 261 | name: 'SetProperty', 262 | docs: 'Sets the given property on the provided people', 263 | genericParams: ['T'], 264 | args: [ 265 | { 266 | name: 'persons', 267 | type: 'Array', 268 | }, 269 | { 270 | name: 'property', 271 | type: 'String', 272 | }, 273 | { 274 | name: 'value', 275 | type: '$T(Person)', 276 | lazy: true, 277 | }, 278 | ], 279 | outputType: 'String', 280 | mutations: ['persons'], 281 | implementation: (persons, property, value) => { 282 | persons.forEach((person) => { 283 | const ext = extension.getOrInsertExtension(person, 'Person') 284 | if (!ext.properties) { 285 | ext.properties = {} 286 | } 287 | ext.properties[property] = value({Person: person}) 288 | }) 289 | return 'Set ' + property + ' for ' + persons.length.toString() + ' persons.' 290 | } 291 | } 292 | 293 | const DeleteProperty = { 294 | name: 'DeleteProperty', 295 | docs: 'Deletes the given property on the provided people', 296 | args: [ 297 | { 298 | name: 'persons', 299 | type: 'Array', 300 | }, 301 | { 302 | name: 'property', 303 | type: 'String', 304 | } 305 | ], 306 | outputType: 'String', 307 | mutations: ['persons'], 308 | implementation: (persons, property) => { 309 | persons.forEach((person) => { 310 | const ext = extension.getExtension(person, 'Person') 311 | if (ext && ext.properties && ext.properties[property] !== undefined) { 312 | delete ext.properties[property] 313 | } 314 | }) 315 | return 'Deleted ' + property + ' for ' + persons.length.toString() + ' persons.' 316 | } 317 | } 318 | 319 | const AddPerson = { 320 | name: 'AddPerson', 321 | docs: 'Adds the given person as a - non-competing - staff member to the WCIF, if it is not present. The person is first added with basic data (and a possibly fake name) to the WCIF. The real data will be fetched when PATCH-ing the competition WCIF, and thus creating a non-competing registration on the WCA website', 322 | args: [ 323 | { 324 | name: 'wcaUserId', 325 | type: 'Number', 326 | docs: 'The user id of the person on the WCA website.', 327 | }, 328 | { 329 | name: 'name', 330 | type: 'String', 331 | defaultValue: 'Fake name for ', 332 | docs: 'The name to use until the registration is created on the WCA website', 333 | }, 334 | ], 335 | usesContext: true, 336 | outputType: 'String', 337 | mutations: ['persons'], 338 | implementation: (ctx, wcaUserId, name) => { 339 | // Given 'AddPerson' is primarly aimed at PATCH-ing the WCA website, 340 | // we fill the persons array with very basic data, iff they do not 341 | // exist already in the persons array. 342 | const existingPerson = 343 | ctx.competition.persons.filter(p => p.wcaUserId == wcaUserId)[0]; 344 | if (existingPerson) { 345 | if (existingPerson.registration) { 346 | return `Person with userId ${wcaUserId} (${existingPerson.name}) already exists.` 347 | } else { 348 | existingPerson.registration = { 349 | eventIds: [], 350 | isCompeting: false, 351 | } 352 | return `Added registration to person ${wcaUserId} (${existingPerson.name})` 353 | } 354 | } 355 | ctx.competition.persons.push({ 356 | assignments: [], 357 | name: name.replace('', wcaUserId), 358 | wcaUserId: wcaUserId, 359 | personalBests: [], 360 | roles: [], 361 | registration: { 362 | eventIds: [], 363 | isCompeting: false, 364 | } 365 | }) 366 | return 'Added person with userId ' + wcaUserId 367 | } 368 | } 369 | 370 | const Persons = { 371 | name: 'Persons', 372 | docs: 'Returns all persons matching a property', 373 | args: [ 374 | { 375 | name: 'filter', 376 | type: 'Boolean(Person)', 377 | lazy: true, 378 | }, 379 | ], 380 | usesContext: true, 381 | outputType: 'Array', 382 | implementation: (ctx, filter) => { 383 | return ctx.competition.persons.filter((person) => filter({Person: person})) 384 | } 385 | } 386 | 387 | const AddRole = { 388 | name: 'AddRole', 389 | docs: 'Adds the provided Role to the given people', 390 | args: [ 391 | { 392 | name: 'persons', 393 | type: 'Array', 394 | }, 395 | { 396 | name: 'role', 397 | type: 'String', 398 | }, 399 | ], 400 | outputType: 'String', 401 | mutations: ['persons'], 402 | implementation: (persons, role) => { 403 | persons.forEach((person) => { 404 | if (!person.roles.includes(role)) { 405 | person.roles.push(role) 406 | } 407 | }) 408 | return 'Added ' + role + ' to ' + persons.length + ' people.' 409 | } 410 | } 411 | 412 | const DeleteRole = { 413 | name: 'DeleteRole', 414 | docs: 'Deletes the provided Role from the given people', 415 | args: [ 416 | { 417 | name: 'persons', 418 | type: 'Array', 419 | }, 420 | { 421 | name: 'role', 422 | type: 'String', 423 | }, 424 | ], 425 | outputType: 'String', 426 | mutations: ['persons'], 427 | implementation: (persons, role) => { 428 | persons.forEach((person) => { 429 | person.roles = person.roles.filter(existingRole => existingRole !== role) 430 | }) 431 | return 'Removed ' + role + ' from ' + persons.length + ' people.' 432 | } 433 | } 434 | 435 | const HasRole = { 436 | name: 'HasRole', 437 | docs: 'Returns whether the given person has the given role', 438 | args: [ 439 | { 440 | name: 'person', 441 | type: 'Person', 442 | canBeExternal: true, 443 | }, 444 | { 445 | name: 'role', 446 | type: 'String', 447 | }, 448 | ], 449 | outputType: 'Boolean', 450 | implementation: (person, role) => { 451 | return (person.roles || []).includes(role) 452 | } 453 | } 454 | 455 | const RegistrationStatus = { 456 | name: 'RegistrationStatus', 457 | docs: 'Returns the registration.status field in WCIF.', 458 | args: [ 459 | { 460 | name: 'person', 461 | type: 'Person', 462 | canBeExternal: true, 463 | } 464 | ], 465 | outputType: 'String', 466 | implementation: (person) => person.registration.status, 467 | } 468 | 469 | const ClearAssignments = { 470 | name: 'ClearAssignments', 471 | docs: 'Clears assignments.', 472 | args: [ 473 | { 474 | name: 'persons', 475 | type: 'Array', 476 | }, 477 | { 478 | name: 'clearStaff', 479 | type: 'Boolean', 480 | }, 481 | { 482 | name: 'clearGroups', 483 | type: 'Boolean', 484 | }, 485 | ], 486 | mutations: ['persons'], 487 | outputType: 'String', 488 | implementation: (persons, clearStaff, clearGroups) => { 489 | persons.forEach((person) => { 490 | person.assignments = person.assignments.filter((assignment) => { 491 | if (clearGroups && assignment.assignmentCode === 'competitor') { 492 | return false 493 | } 494 | if (clearStaff && assignment.assignmentCode.startsWith('staff-')) { 495 | return false 496 | } 497 | return true 498 | }) 499 | }) 500 | return 'ok' 501 | } 502 | } 503 | 504 | const HasResults = { 505 | name: 'HasResults', 506 | docs: 'Returns true if the person appears in the results', 507 | args: [ 508 | { 509 | name: 'person', 510 | type: 'Person', 511 | canBeExternal: true, 512 | }, 513 | ], 514 | outputType: 'Boolean', 515 | usesContext: true, 516 | implementation: (ctx, person) => { 517 | return ctx.competition.events.map((event) => event.rounds[0].results).flat().some((result) => result.personId === person.registrantId && result.attempts.length > 0) 518 | } 519 | } 520 | 521 | const IsPossibleNoShow = { 522 | name: 'IsPossibleNoShow', 523 | docs: 'Returns true if the competitor has not competed and has missed at least one event', 524 | args: [ 525 | { 526 | name: 'person', 527 | type: 'Person', 528 | canBeExternal: true, 529 | } 530 | ], 531 | outputType: 'Boolean', 532 | usesContext: true, 533 | implementation: (ctx, person) => { 534 | return person.registration && person.registration.status === 'accepted' && 535 | !ctx.competition.events.map((event) => event.rounds[0].results).flat().some((result) => result.personId === person.registrantId && result.attempts.length > 0) && 536 | ctx.competition.events.some((event) => !event.rounds[0].results.map((result) => result.personId).includes(person.registrantId) && person.registration && person.registration.eventIds.includes(event.id)) 537 | } 538 | } 539 | 540 | const Gender = { 541 | name: 'Gender', 542 | args: [ 543 | { 544 | name: 'person', 545 | type: 'Person', 546 | canBeExternal: true, 547 | }, 548 | ], 549 | outputType: 'String', 550 | implementation: (person) => person.gender, 551 | } 552 | 553 | module.exports = { 554 | functions: 555 | [Name, Birthdate, Age, WcaId, WcaLink, CompetitionGroupsLink, Registered, WcaIdYear, Email, Country, FirstName, LastName, 556 | Property('Boolean'), Property('String'), Property('Number'), Property('Array'), 557 | SetProperty, DeleteProperty, HasProperty, AddPerson, Persons, 558 | AddRole, DeleteRole, HasRole, RegistrationStatus, 559 | ClearAssignments, HasResults, IsPossibleNoShow, Gender], 560 | } 561 | -------------------------------------------------------------------------------- /functions/sheets.js: -------------------------------------------------------------------------------- 1 | const { GoogleSpreadsheet } = require('google-spreadsheet') 2 | const { JWT } = require('google-auth-library') 3 | 4 | const extension = require('./../extension') 5 | 6 | class Header { 7 | constructor(value) { 8 | this.value = value 9 | this.headerType = value.substring(0, value.indexOf(':')) 10 | if (this.headerType === 'ignore') { 11 | return 12 | } 13 | var suffix = value.substring(value.indexOf(':') + 1).split(':') 14 | this.type = suffix[0] 15 | this.name = suffix[1] 16 | } 17 | 18 | parse(value) { 19 | let val = value || '' 20 | if (this.type == 'number') { 21 | return +val 22 | } 23 | if (this.type == 'list') { 24 | return val.split(',').map(s => s.trim()) 25 | } 26 | return val.trim() 27 | } 28 | 29 | get(row) { 30 | return this.parse(row.get(this.value)) 31 | } 32 | 33 | getIdentifier(person) { 34 | return this.parse(person[this.name]) 35 | } 36 | } 37 | 38 | readSpreadsheetImpl = async function(competition, spreadsheetId, offset, sheetTitle) { 39 | out = { warnings: [], loaded: 0 } 40 | const creds = require('./../google-credentials.json') 41 | const serviceAccountAuth = new JWT({ 42 | email: creds.client_email, 43 | key: creds.private_key, 44 | scopes: ['https://www.googleapis.com/auth/spreadsheets.readonly'], 45 | }) 46 | const doc = new GoogleSpreadsheet(spreadsheetId, serviceAccountAuth) 47 | await doc.loadInfo() 48 | const sheet = sheetTitle == '' ? doc.sheetsByIndex[0] : doc.sheetsByTitle[sheetTitle] 49 | await sheet.loadHeaderRow(1) 50 | var headers = sheet.headerValues.map((val) => new Header(val)) 51 | 52 | // Clear existing properties. 53 | competition.persons.forEach((person) => { 54 | var ext = extension.getOrInsertExtension(person, 'Person') 55 | if (!ext.properties) { 56 | return 57 | } 58 | headers.forEach((header) => { 59 | if (header.headerType == 'property' && header.name in ext.properties) { 60 | delete ext.properties[header.name] 61 | } 62 | }) 63 | }) 64 | 65 | const rows = await sheet.getRows({ offset }) 66 | rows.forEach((row) => { 67 | // First use the identifiers provided to find the person. 68 | var bestMatch = 0 69 | var countBestMatch = 0 70 | var matchingPerson = null 71 | var bestIdentifier = '' 72 | competition.persons.forEach((person) => { 73 | var matching = 0 74 | headers.forEach((header) => { 75 | if (header.headerType !== 'identifier') { 76 | return 77 | } 78 | var identifierVal = header.get(row) 79 | if (!identifierVal) { 80 | return 81 | } 82 | identifierVal = identifierVal instanceof String ? identifierVal.toUpperCase() : identifierVal 83 | 84 | if (bestIdentifier === '' || header.name == 'name') { 85 | bestIdentifier = identifierVal 86 | } 87 | var personIdentifierVal = header.getIdentifier(person) 88 | personIdentifierVal = personIdentifierVal instanceof String ? personIdentifierVal.toUpperCase() : personIdentifierVal 89 | if (personIdentifierVal === identifierVal) { 90 | matching++ 91 | if (header.name == 'wcaId') { 92 | matching++ 93 | } 94 | } 95 | }) 96 | if (matching > bestMatch) { 97 | bestMatch = matching 98 | countBestMatch = 1 99 | matchingPerson = person 100 | } else if (matching == bestMatch) { 101 | countBestMatch++ 102 | } 103 | }) 104 | if (countBestMatch > 1 || matchingPerson === null) { 105 | out.warnings.push('Ambiguous row for ' + bestIdentifier) 106 | return 107 | } 108 | var ext = extension.getOrInsertExtension(matchingPerson, 'Person') 109 | if (!ext.properties) { 110 | ext.properties = {} 111 | } 112 | headers.forEach((header) => { 113 | if (header.headerType !== 'property') { 114 | return 115 | } 116 | ext.properties[header.name] = header.get(row) 117 | }) 118 | out.loaded += 1 119 | }) 120 | return out 121 | } 122 | 123 | // ReadSpreadsheet.implementation returns a Promise, which is resolved in 124 | // competition.js. 125 | const ReadSpreadsheet = { 126 | name: 'ReadSpreadsheet', 127 | docs: 'Reads data from the provided Google Sheet', 128 | args: [ 129 | { 130 | name: 'spreadsheetId', 131 | type: 'String', 132 | }, 133 | { 134 | name: 'offset', 135 | type: 'Number', 136 | docs: 'Skip the first `offset` rows of the spreadsheet.', 137 | defaultValue: 0, 138 | }, 139 | { 140 | name: 'sheetTitle', 141 | type: 'String', 142 | docs: 'Select the sheet with this name.', 143 | defaultValue: '', 144 | } 145 | ], 146 | outputType: 'ReadSpreadsheetResult', 147 | usesContext: true, 148 | mutations: ['persons'], 149 | implementation: (ctx, spreadsheetId, offset, sheetTitle) => { 150 | return readSpreadsheetImpl(ctx.competition, spreadsheetId, offset, sheetTitle) 151 | } 152 | } 153 | 154 | module.exports = { 155 | functions: [ReadSpreadsheet], 156 | } 157 | -------------------------------------------------------------------------------- /functions/staff.js: -------------------------------------------------------------------------------- 1 | const assign = require('./../staff/assign') 2 | const scorers = require('./../staff/scorers') 3 | const extension = require('./../extension') 4 | const lib = require('./../lib') 5 | 6 | const AssignStaff = { 7 | name: 'AssignStaff', 8 | args: [ 9 | { 10 | name: 'round', 11 | type: 'Round', 12 | }, 13 | { 14 | name: 'groupFilter', 15 | type: 'Boolean(Group)', 16 | lazy: true, 17 | }, 18 | { 19 | name: 'persons', 20 | type: 'Array', 21 | }, 22 | { 23 | name: 'jobs', 24 | type: 'Array', 25 | }, 26 | { 27 | name: 'scorers', 28 | type: 'Array', 29 | }, 30 | { 31 | name: 'overwrite', 32 | type: 'Boolean', 33 | defaultValue: false, 34 | }, 35 | { 36 | name: 'avoidConflicts', 37 | type: 'Boolean', 38 | defaultValue: true, 39 | }, 40 | { 41 | name: 'unavailable', 42 | type: 'Array(Person)', 43 | lazy: true, 44 | defaultValue: [], 45 | } 46 | ], 47 | outputType: 'StaffAssignmentResult', 48 | usesContext: true, 49 | mutations: ['persons'], 50 | implementation: (ctx, round, groupFilter, persons, jobs, scorers, overwrite, avoidConflicts, unavailable) => { 51 | return assign.Assign(ctx, round, groupFilter, persons, jobs, scorers, overwrite || ctx.dryrun, avoidConflicts, unavailable) 52 | } 53 | } 54 | 55 | const AssignMisc = { 56 | name: 'AssignMisc', 57 | args: [ 58 | { 59 | name: 'activityId', 60 | type: 'Number', 61 | }, 62 | { 63 | name: 'persons', 64 | type: 'Array', 65 | }, 66 | { 67 | name: 'jobs', 68 | type: 'Array', 69 | }, 70 | { 71 | name: 'scorers', 72 | type: 'Array', 73 | }, 74 | { 75 | name: 'overwrite', 76 | type: 'Boolean', 77 | defaultValue: false, 78 | }, 79 | { 80 | name: 'avoidConflicts', 81 | type: 'Boolean', 82 | defaultValue: true, 83 | }, 84 | ], 85 | outputType: 'StaffAssignmentResult', 86 | usesContext: true, 87 | mutations: ['persons'], 88 | implementation: (ctx, activityId, persons, jobs, scorers, overwrite, avoidConflicts) => { 89 | return assign.AssignMisc(ctx, activityId, persons, jobs, scorers, overwrite || ctx.dryrun, avoidConflicts) 90 | } 91 | } 92 | 93 | const Job = { 94 | name: 'Job', 95 | args: [ 96 | { 97 | name: 'name', 98 | type: 'String', 99 | }, 100 | { 101 | name: 'count', 102 | type: 'Number', 103 | }, 104 | { 105 | name: 'assignStations', 106 | type: 'Boolean', 107 | defaultValue: false, 108 | }, 109 | { 110 | name: 'eligibility', 111 | type: 'Boolean(Person)', 112 | lazy: true, 113 | defaultValue: true, 114 | }, 115 | ], 116 | outputType: 'AssignmentJob', 117 | implementation: (name, count, assignStations, eligibility) => { 118 | return assign.Job(name, count, assignStations, eligibility) 119 | }, 120 | } 121 | 122 | const JobCountScorer = { 123 | name: 'JobCountScorer', 124 | args:[ 125 | { 126 | name: 'weight', 127 | type: 'Number', 128 | }, 129 | ], 130 | outputType: 'AssignmentScorer', 131 | implementation: (weight) => { 132 | return new scorers.JobCountScorer(weight) 133 | }, 134 | } 135 | 136 | const PriorAssignmentScorer = { 137 | name: 'PriorAssignmentScorer', 138 | args: [ 139 | { 140 | name: 'staffingWeight', 141 | type: 'Number', 142 | description: 'Weight added per hour previously spent staffing.', 143 | }, 144 | { 145 | name: 'competingWeight', 146 | type: 'Number', 147 | description: 'Weight added per hour previously spent competing.', 148 | }, 149 | { 150 | name: 'startTime', 151 | type: 'DateTime', 152 | defaultValue: null, 153 | nullable: true, 154 | }, 155 | ], 156 | outputType: 'AssignmentScorer', 157 | usesContext: true, 158 | implementation: (ctx, staffingWeight, competingWeight, startTime) => { 159 | return new scorers.PriorAssignmentScorer(ctx.competition, staffingWeight, competingWeight, startTime) 160 | } 161 | } 162 | 163 | const PreferenceScorer = { 164 | name: 'PreferenceScorer', 165 | args: [ 166 | { 167 | name: 'weight', 168 | type: 'Number', 169 | }, 170 | { 171 | name: 'prefix', 172 | type: 'String', 173 | }, 174 | { 175 | name: 'prior', 176 | type: 'Number', 177 | }, 178 | { 179 | name: 'allJobs', 180 | type: 'Array', 181 | } 182 | ], 183 | outputType: 'AssignmentScorer', 184 | implementation: (weight, prefix, prior, allJobs) => { 185 | return new scorers.PreferenceScorer(weight, prefix, prior, allJobs) 186 | }, 187 | } 188 | 189 | const SameJobScorer = { 190 | name: 'SameJobScorer', 191 | args: [ 192 | { 193 | name: 'center', 194 | type: 'Number', 195 | }, 196 | { 197 | name: 'posWeight', 198 | type: 'Number', 199 | }, 200 | { 201 | name: 'negWeight', 202 | type: 'Number', 203 | }, 204 | { 205 | name: 'jobs', 206 | type: 'Array', 207 | nullable: true, 208 | defaultValue: null, 209 | } 210 | ], 211 | outputType: 'AssignmentScorer', 212 | usesContext: true, 213 | implementation: (ctx, center, posWeight, negWeight, jobs) => { 214 | return new scorers.PrecedingAssignmentsScorer( 215 | ctx.competition, center, posWeight, negWeight, 216 | (assignment, job) => assignment.assignmentCode === 'staff-' + job, jobs) 217 | }, 218 | } 219 | 220 | const ConsecutiveJobScorer = { 221 | name: 'ConsecutiveJobScorer', 222 | args: [ 223 | { 224 | name: 'center', 225 | type: 'Number', 226 | }, 227 | { 228 | name: 'posWeight', 229 | type: 'Number', 230 | }, 231 | { 232 | name: 'negWeight', 233 | type: 'Number', 234 | }, 235 | { 236 | name: 'jobs', 237 | type: 'Array', 238 | nullable: true, 239 | defaultValue: null, 240 | } 241 | ], 242 | outputType: 'AssignmentScorer', 243 | usesContext: true, 244 | implementation: (ctx, center, posWeight, negWeight, jobs) => { 245 | return new scorers.PrecedingAssignmentsScorer( 246 | ctx.competition, center, posWeight, negWeight, 247 | (assignment, job) => assignment.assignmentCode !== 'competitor', jobs) 248 | }, 249 | } 250 | 251 | const MismatchedStationScorer = { 252 | name: 'MismatchedStationScorer', 253 | args: [ 254 | { 255 | name: 'weight', 256 | type: 'Number', 257 | }, 258 | ], 259 | outputType: 'AssignmentScorer', 260 | usesContext: true, 261 | implementation: (ctx, weight) => { 262 | return new scorers.MismatchedStationScorer(ctx.competition, weight) 263 | }, 264 | } 265 | 266 | const SolvingSpeedScorer = { 267 | name: 'SolvingSpeedScorer', 268 | args: [ 269 | { 270 | name: 'event', 271 | type: 'Event', 272 | }, 273 | { 274 | name: 'maxTime', 275 | type: 'AttemptResult', 276 | }, 277 | { 278 | name: 'weight', 279 | type: 'Number', 280 | }, 281 | { 282 | name: 'jobs', 283 | type: 'Array', 284 | } 285 | ], 286 | outputType: 'AssignmentScorer', 287 | implementation: (event, maxTime, weight, jobs) => { 288 | return new scorers.SolvingSpeedScorer(event, maxTime, weight, jobs) 289 | } 290 | } 291 | 292 | const GroupScorer = { 293 | name: 'GroupScorer', 294 | args: [ 295 | { 296 | name: 'condition', 297 | type: 'Boolean(Person, Group)', 298 | lazy: true, 299 | }, 300 | { 301 | name: 'weight', 302 | type: 'Number', 303 | } 304 | ], 305 | outputType: 'AssignmentScorer', 306 | implementation: (condition, weight) => { 307 | return new scorers.GroupScorer(condition, weight) 308 | } 309 | } 310 | 311 | const FollowingGroupScorer = { 312 | name: 'FollowingGroupScorer', 313 | args: [ 314 | { 315 | name: 'weight', 316 | type: 'Number', 317 | } 318 | ], 319 | usesContext: true, 320 | outputType: 'AssignmentScorer', 321 | implementation: (ctx, weight) => { 322 | return new scorers.FollowingGroupScorer(ctx.competition, weight) 323 | } 324 | } 325 | 326 | const PersonPropertyScorer = { 327 | name: 'PersonPropertyScorer', 328 | args: [ 329 | { 330 | name: 'filter', 331 | type: 'Boolean(Person, Group)', 332 | lazy: true, 333 | }, 334 | { 335 | name: 'weight', 336 | type: 'Number', 337 | } 338 | ], 339 | outputType: 'AssignmentScorer', 340 | implementation: (filter, weight) => { 341 | return new scorers.PersonPropertyScorer(filter, weight) 342 | } 343 | } 344 | 345 | const ComputedWeightScorer = { 346 | name: 'ComputedWeightScorer', 347 | args: [ 348 | { 349 | name: 'weightFn', 350 | type: 'Number(Person)', 351 | lazy: true, 352 | }, 353 | { 354 | name: 'jobs', 355 | type: 'Array', 356 | } 357 | ], 358 | outputType: 'AssignmentScorer', 359 | implementation: (weightFn, jobs) => { 360 | return new scorers.ComputedWeightScorer(weightFn, jobs) 361 | } 362 | } 363 | 364 | const ConditionalScorer = { 365 | name: 'ConditionalScorer', 366 | args: [ 367 | { 368 | name: 'personCondition', 369 | type: 'Boolean(Person)', 370 | lazy: true, 371 | }, 372 | { 373 | name: 'groupCondition', 374 | type: 'Boolean(Group)', 375 | lazy: true, 376 | }, 377 | { 378 | name: 'jobCondition', 379 | type: 'Boolean(String)', 380 | lazy: true, 381 | }, 382 | { 383 | name: 'stationCondition', 384 | type: 'Boolean(Number)', 385 | lazy: true, 386 | }, 387 | { 388 | name: 'score', 389 | type: 'Number', 390 | }, 391 | ], 392 | outputType: 'AssignmentScorer', 393 | implementation: (personCondition, groupCondition, jobCondition, stationCondition, score) => { 394 | return new scorers.ConditionalScorer(personCondition, groupCondition, jobCondition, stationCondition, score) 395 | } 396 | } 397 | 398 | const UnavailableBetween = { 399 | name: 'UnavailableBetween', 400 | docs: 'Indicates that the staff member is unavailable at the given time', 401 | args: [ 402 | { 403 | name: 'start', 404 | type: 'DateTime', 405 | }, 406 | { 407 | name: 'end', 408 | type: 'DateTime', 409 | }, 410 | ], 411 | outputType: 'StaffUnavailability', 412 | implementation: (start, end) => { 413 | return (activity) => activity.endTime > start && end > activity.startTime 414 | } 415 | } 416 | 417 | const UnavailableForDate = { 418 | name: 'UnavailableForDate', 419 | docs: 'Indicates that the staff member is unavailable on the given date', 420 | args: [ 421 | { 422 | name: 'date', 423 | type: 'Date', 424 | }, 425 | ], 426 | outputType: 'StaffUnavailability', 427 | implementation: (date) => { 428 | return (activity) => activity.startTime.year === date.year && activity.startTime.month === date.month && activity.startTime.day === date.day 429 | } 430 | } 431 | 432 | const DuringTimes = { 433 | name: 'DuringTimes', 434 | docs: 'Indicates the staff member is unavailable during groups that start in the provided times', 435 | args: [ 436 | { 437 | name: 'times', 438 | type: 'Array', 439 | }, 440 | ], 441 | outputType: 'StaffUnavailability', 442 | implementation: (times) => { 443 | return (activity) => times.some((time) => +time === +activity.startTime) 444 | } 445 | } 446 | 447 | const BeforeTimes = { 448 | name: 'BeforeTimes', 449 | docs: 'Indicates the staff member is unavailable during groups that end in the provided times', 450 | args: [ 451 | { 452 | name: 'times', 453 | type: 'Array', 454 | }, 455 | ], 456 | outputType: 'StaffUnavailability', 457 | implementation: (times) => { 458 | return (activity) => times.some((time) => +time === +activity.endTime) 459 | } 460 | } 461 | 462 | const NumJobs = { 463 | name: 'NumJobs', 464 | docs: 'The number of jobs for a given person. If type is not provided, all jobs are included.', 465 | args: [ 466 | { 467 | name: 'person', 468 | type: 'Person', 469 | canBeExternal: true, 470 | }, 471 | { 472 | name: 'type', 473 | type: 'String', 474 | defaultValue: null, 475 | nullable: true, 476 | } 477 | ], 478 | outputType: 'Number', 479 | implementation: (person, type) => { 480 | return person.assignments.filter((assignment) => { 481 | if (type !== null) { 482 | return assignment.assignmentCode === 'staff-' + type 483 | } else { 484 | return assignment.assignmentCode.startsWith('staff-') 485 | } 486 | }).length 487 | } 488 | } 489 | 490 | const NumJobsInRound = { 491 | name: 'NumJobsInRound', 492 | docs: 'The number of jobs for a given person. If type is not provided, all jobs are included.', 493 | args: [ 494 | { 495 | name: 'person', 496 | type: 'Person', 497 | canBeExternal: true, 498 | }, 499 | { 500 | name: 'round', 501 | type: 'Round', 502 | }, 503 | { 504 | name: 'type', 505 | type: 'String', 506 | defaultValue: null, 507 | nullable: true, 508 | } 509 | ], 510 | outputType: 'Number', 511 | usesContext: true, 512 | implementation: (ctx, person, round, type) => { 513 | const activityIds = lib.allActivitiesForRoundId(ctx.competition, round.id()).map((activity) => activity.wcif.id) 514 | return person.assignments.filter((assignment) => { 515 | if (!activityIds.includes(assignment.activityId)) { 516 | return false 517 | } 518 | if (type !== null) { 519 | return assignment.assignmentCode === 'staff-' + type 520 | } else { 521 | return assignment.assignmentCode.startsWith('staff-') 522 | } 523 | }).length 524 | } 525 | } 526 | const LengthOfJobs = { 527 | name: 'LengthOfJobs', 528 | docs: 'The number of hours a given person spends working. If type is not provided, all jobs are included.', 529 | args: [ 530 | { 531 | name: 'person', 532 | type: 'Person', 533 | canBeExternal: true, 534 | }, 535 | { 536 | name: 'type', 537 | type: 'String', 538 | defaultValue: null, 539 | nullable: true, 540 | } 541 | ], 542 | outputType: 'Number', 543 | usesContext: true, 544 | implementation: (ctx, person, type) => { 545 | return person.assignments.filter((assignment) => { 546 | if (type !== null) { 547 | return assignment.assignmentCode === 'staff-' + type 548 | } else { 549 | return assignment.assignmentCode.startsWith('staff-') 550 | } 551 | }).map((assignment) => { 552 | var group = lib.groupForActivityId(ctx.competition, assignment.activityId) 553 | return group.endTime.diff(group.startTime, 'hours').hours 554 | }).reduce((total, current) => total + current, 0) 555 | } 556 | } 557 | 558 | module.exports = { 559 | functions: [AssignStaff, AssignMisc, Job, 560 | JobCountScorer, PriorAssignmentScorer, PreferenceScorer, 561 | SameJobScorer, ConsecutiveJobScorer, MismatchedStationScorer, 562 | SolvingSpeedScorer, GroupScorer, FollowingGroupScorer, 563 | PersonPropertyScorer, ComputedWeightScorer, ConditionalScorer, 564 | UnavailableBetween, UnavailableForDate, BeforeTimes, DuringTimes, 565 | NumJobs, NumJobsInRound, LengthOfJobs], 566 | } 567 | -------------------------------------------------------------------------------- /functions/stream.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | 3 | const { DateTime } = require('luxon') 4 | 5 | const activityCode = require('./../activity_code') 6 | const extension = require('./../extension') 7 | const group = require('./../group') 8 | 9 | const DeleteFeaturedCompetitorsByTime = { 10 | name: 'DeleteFeaturedCompetitorsByTime', 11 | args: [ 12 | { 13 | name: 'startTime', 14 | type: 'DateTime', 15 | }, 16 | { 17 | name: 'endTime', 18 | type: 'DateTime', 19 | }, 20 | ], 21 | outputType: 'Array', 22 | mutations: ['schedule'], 23 | usesContext: true, 24 | implementation: (ctx, startTime, endTime) => { 25 | const out = []; 26 | ctx.competition.schedule.venues[0].rooms.forEach((room) => { 27 | room.activities.forEach((activity) => { 28 | activity.childActivities.forEach((childActivity) => { 29 | var g = new group.Group(childActivity, room, ctx.competition) 30 | if (g.startTime > startTime && g.endTime < endTime) { 31 | ext = extension.getExtension(childActivity, 'ActivityConfig', 'groupifier') 32 | if (ext && ext.featuredCompetitorWcaUserIds.length) { 33 | ext.featuredCompetitorWcaUserIds = []; 34 | out.push("Removed ids from activity " + g.activityCode + " " + g.name()) 35 | } 36 | } 37 | }) 38 | }) 39 | }) 40 | return out; 41 | } 42 | } 43 | 44 | const AddFeaturedCompetitors = { 45 | name: 'AddFeaturedCompetitors', 46 | args: [ 47 | { 48 | name: 'round', 49 | type: 'Round', 50 | }, 51 | { 52 | name: 'persons', 53 | type: 'Array', 54 | }, 55 | ], 56 | outputType: 'Array', 57 | mutations: ['schedule'], 58 | usesContext: true, 59 | implementation: (ctx, round, persons) => { 60 | const out = []; 61 | ctx.competition.schedule.venues[0].rooms.forEach((room) => { 62 | room.activities.forEach((activity) => { 63 | activity.childActivities.forEach((childActivity) => { 64 | const code = activityCode.parse(childActivity.activityCode) 65 | if (round.contains(code)) { 66 | ext = extension.getOrInsertExtension(childActivity, 'ActivityConfig', 'groupifier') 67 | if (ext.featuredCompetitorWcaUserIds === undefined) { 68 | ext.featuredCompetitorWcaUserIds = [] 69 | } 70 | persons.forEach((person) => { 71 | if (person.assignments.some((a) => a.activityId == childActivity.id && a.assignmentCode == "competitor")) { 72 | ext.featuredCompetitorWcaUserIds.push(person.wcaUserId) 73 | out.push("Added featured " + person.name + " to " + code.toString()) 74 | } 75 | }) 76 | } 77 | }) 78 | }) 79 | }) 80 | return out 81 | } 82 | } 83 | 84 | const DeleteFeaturedCompetitors = { 85 | name: 'DeleteFeaturedCompetitors', 86 | args: [ 87 | { 88 | name: 'persons', 89 | type: 'Array', 90 | }, 91 | ], 92 | outputType: 'Array', 93 | mutations: ['schedule'], 94 | usesContext: true, 95 | implementation: (ctx, persons) => { 96 | const out = []; 97 | ctx.competition.schedule.venues[0].rooms.forEach((room) => { 98 | room.activities.forEach((activity) => { 99 | activity.childActivities.forEach((childActivity) => { 100 | ext = extension.getExtension(childActivity, 'ActivityConfig', 'groupifier') 101 | if (ext && ext.featuredCompetitorWcaUserIds !== undefined) { 102 | const len = ext.featuredCompetitorWcaUserIds.length 103 | ext.featuredCompetitorWcaUserIds = ext.featuredCompetitorWcaUserIds.filter((x) => !persons.map((p) => p.wcaUserId).includes(x)) 104 | if (len > ext.featuredCompetitorWcaUserIds.length) { 105 | out.push("Removed " + (len - ext.featuredCompetitorWcaUserIds.length) + " competitors from " + childActivity.name) 106 | } 107 | } 108 | }) 109 | }) 110 | }) 111 | return out 112 | } 113 | } 114 | 115 | module.exports = { 116 | functions: [DeleteFeaturedCompetitorsByTime, AddFeaturedCompetitors, DeleteFeaturedCompetitors], 117 | } 118 | -------------------------------------------------------------------------------- /functions/table.js: -------------------------------------------------------------------------------- 1 | const Table = { 2 | name: 'Table', 3 | genericParams: ['ArgType'], 4 | args: [ 5 | { 6 | name: 'keys', 7 | type: 'Array<$ArgType>', 8 | }, 9 | { 10 | name: 'columns', 11 | type: 'Array($ArgType)', 12 | lazy: true, 13 | }, 14 | ], 15 | outputType: 'Table', 16 | usesGenericTypes: true, 17 | implementation: (generics, keys, columns) => { 18 | var rows = keys 19 | rows = rows.map((val) => { 20 | return columns({[generics.ArgType]: val}) 21 | }) 22 | return { 23 | headers: rows.length ? rows[0].map((col) => col.title) : 0, 24 | rows: rows 25 | } 26 | }, 27 | } 28 | 29 | const Column = { 30 | name: 'Column', 31 | genericParams: ['T'], 32 | args: [ 33 | { 34 | name: 'title', 35 | type: 'String', 36 | }, 37 | { 38 | name: 'value', 39 | type: '$T', 40 | nullable: true, 41 | }, 42 | { 43 | name: 'link', 44 | type: 'String', 45 | defaultValue: null, 46 | nullable: true, 47 | }, 48 | ], 49 | outputType: 'Column', 50 | implementation: (title, value, link) => { 51 | return {title: title, value: value, link: link} 52 | }, 53 | } 54 | 55 | module.exports = { 56 | functions: [Table, Column], 57 | } 58 | -------------------------------------------------------------------------------- /functions/time.js: -------------------------------------------------------------------------------- 1 | const { DateTime } = require('luxon') 2 | 3 | const Time = { 4 | name: 'Time', 5 | args: [ 6 | { 7 | name: 'time', 8 | type: 'DateTime', 9 | }, 10 | ], 11 | outputType: 'String', 12 | implementation: (time) => { 13 | if (time === null) { 14 | return '' 15 | } 16 | return time.toLocaleString(DateTime.TIME_SIMPLE) 17 | } 18 | } 19 | 20 | const Hour = { 21 | name: 'Hour', 22 | args: [ 23 | { 24 | name: 'time', 25 | type: 'DateTime', 26 | }, 27 | ], 28 | outputType: 'Number', 29 | implementation: (time) => time.hour, 30 | } 31 | 32 | const Day = { 33 | name: 'Day', 34 | args: [ 35 | { 36 | name: 'date', 37 | type: 'Date', 38 | } 39 | ], 40 | outputType: 'Number', 41 | implementation: (date) => date.day, 42 | } 43 | 44 | const Date = { 45 | name: 'Date', 46 | args: [ 47 | { 48 | name: 'time', 49 | type: 'DateTime', 50 | } 51 | ], 52 | outputType: 'Date', 53 | implementation: (time) => time.set({hour: 0, minute: 0, second: 0, millisecond: 0}), 54 | } 55 | 56 | const Weekday = { 57 | name: 'Weekday', 58 | args: [ 59 | { 60 | name: 'arg', 61 | type: 'Date', 62 | }, 63 | ], 64 | outputType: 'String', 65 | implementation: (arg) => arg.toFormat('cccc'), 66 | } 67 | 68 | const Midnight = { 69 | name: 'Midnight', 70 | args: [ 71 | { 72 | name: 'date', 73 | type: 'Date', 74 | } 75 | ], 76 | outputType: 'DateTime', 77 | implementation: (date) => date, 78 | } 79 | 80 | const FormatTime = { 81 | name: 'FormatTime', 82 | args: [ 83 | { 84 | name: 'time', 85 | type: 'DateTime', 86 | } 87 | ], 88 | outputType: 'String', 89 | implementation: (time) => time.toLocaleString(DateTime.TIME_SIMPLE) 90 | } 91 | 92 | module.exports = { 93 | functions: [Time, Hour, Day, Date, Weekday, Midnight, FormatTime], 94 | } 95 | -------------------------------------------------------------------------------- /functions/tuple.js: -------------------------------------------------------------------------------- 1 | const extension = require('./../extension') 2 | 3 | const Tuple = function(argCount) { 4 | const genericArgs = [...Array(argCount).keys()].map((x) => 'T' + x) 5 | const outputType = 'Tuple<' + genericArgs.map((x) => '$' + x).join(', ') + '>' 6 | return { 7 | name: 'Tuple', 8 | genericParams: genericArgs, 9 | args: genericArgs.map(arg => { 10 | return { 11 | name: 'arg_' + arg, 12 | type: '$' + arg, 13 | } 14 | }), 15 | outputType: outputType, 16 | implementation: (...args) => { 17 | return args 18 | }, 19 | } 20 | } 21 | 22 | const Get = function(name, argCount, selected) { 23 | const genericArgs = [...Array(argCount).keys()].map((x) => 'T' + x) 24 | const tupleType = 'Tuple<' + genericArgs.map((x) => '$' + x).join(', ') + '>' 25 | return { 26 | name: name, 27 | genericParams: genericArgs, 28 | args: [ 29 | { 30 | name: 'tuple', 31 | type: tupleType, 32 | canBeExternal: true, 33 | } 34 | ], 35 | outputType: '$T' + (selected - 1), 36 | implementation: (args) => { 37 | return args[selected - 1] 38 | }, 39 | } 40 | } 41 | 42 | module.exports = { 43 | functions: [ 44 | Tuple(1), Get('First', 1, 1), 45 | Tuple(2), Get('First', 2, 1), Get('Second', 2, 2), 46 | ] 47 | } 48 | -------------------------------------------------------------------------------- /functions/udf.js: -------------------------------------------------------------------------------- 1 | const Define = function(argCount) { 2 | const genericArgs = [...Array(argCount).keys()].map((x) => 'U' + x) 3 | const implementationType = '$T(' + genericArgs.map((x) => '$' + x).join(', ') + ')' 4 | return { 5 | name: 'Define', 6 | genericParams: ['T'].concat(genericArgs), 7 | args: [ 8 | { 9 | name: 'name', 10 | type: 'String', 11 | }, 12 | { 13 | name: 'implementation', 14 | type: implementationType, 15 | serialized: true, 16 | }, 17 | { 18 | name: 'public', 19 | type: 'Boolean', 20 | defaultValue: false, 21 | } 22 | ], 23 | outputType: 'Void', 24 | usesContext: true, 25 | implementation: (ctx, name, implementation, public) => { 26 | ctx.udfs[name] = { 27 | impl: implementation, 28 | name: name, 29 | public: public 30 | } 31 | }, 32 | } 33 | } 34 | 35 | const ListScripts = { 36 | name: 'ListScripts', 37 | args: [], 38 | outputType: 'ListScriptsOutput', 39 | usesContext: true, 40 | implementation: (ctx) => { 41 | return Object.entries(ctx.udfs).map((entry) => entry[1]).filter((udf) => { 42 | return udf.public 43 | }) 44 | } 45 | } 46 | 47 | module.exports = { 48 | functions: [Define(0), Define(1), ListScripts] 49 | } 50 | -------------------------------------------------------------------------------- /functions/wcif.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const fse = require('fs-extra') 3 | 4 | const ClearWCIF = { 5 | name: 'ClearWCIF', 6 | docs: 'Remove all childActivities keeping only the main schedule, remove all assignments, cleanup roles, cleanup NatsHelper extension data.', 7 | args: [ 8 | { 9 | name: 'clearExternalExtensions', 10 | type: 'Boolean', 11 | docs: 'Also cleanup external tools extensions.', 12 | defaultValue: false, 13 | }, 14 | ], 15 | outputType: 'Void', 16 | usesContext: true, 17 | mutations: ['schedule', 'persons'], 18 | implementation: (ctx, clearExternalExtensions) => { 19 | const cleanupExtensions = (object) => { 20 | if (clearExternalExtensions) { 21 | object.extensions = [] 22 | } else { 23 | object.extensions = object.extensions.filter(({ id }) => !id.startsWith("org.cubingusa.natshelper")) 24 | } 25 | } 26 | cleanupExtensions(ctx.competition) 27 | ctx.competition.persons.forEach((person) => { 28 | // Cleanup roles which are user-defined. 29 | const immutableRoles = ['delegate', 'organizer', 'trainee-delegate'] 30 | person.roles = person.roles.filter((r) => immutableRoles.includes(r)) 31 | person.assignments = [] 32 | cleanupExtensions(person) 33 | }) 34 | ctx.competition.schedule.venues.forEach((venue) => { 35 | venue.rooms.forEach((room) => { 36 | cleanupExtensions(room) 37 | room.activities.forEach((activity) => { 38 | cleanupExtensions(activity) 39 | activity.childActivities = [] 40 | }) 41 | }) 42 | }) 43 | } 44 | } 45 | 46 | const ImportWCIF = { 47 | name: 'ImportWCIF', 48 | docs: 'Import the WCIF from a json file', 49 | args: [ 50 | { 51 | name: 'filename', 52 | type: 'String', 53 | docs: 'WCIF filename (relative to WCIF_DATA_FOLDER/competitionId)', 54 | }, 55 | ], 56 | outputType: 'String', 57 | usesContext: true, 58 | implementation: (ctx, filename) => { 59 | var fullPath = `${process.env.WCIF_DATA_FOLDER || '.'}/${ctx.competition.id}/${filename}` 60 | var wcif = JSON.parse(fs.readFileSync(fullPath)) 61 | if (wcif.id !== ctx.competition.id) { 62 | throw new Error(`The WCIF is not for the correct competition (expected "${ctx.competition.id}", but got "${wcif.id})"`) 63 | } 64 | ctx.competition = wcif 65 | return `Imported WCIF from '${fullPath}'.` 66 | } 67 | } 68 | 69 | const ExportWCIF = { 70 | name: 'ExportWCIF', 71 | docs: 'Export the WCIF to a json file', 72 | args: [ 73 | { 74 | name: 'filename', 75 | type: 'String', 76 | docs: 'WCIF filename (emitted in WCIF_DATA_FOLDER/competitionId)', 77 | defaultValue: 'wcif.json', 78 | }, 79 | ], 80 | outputType: 'String', 81 | usesContext: true, 82 | implementation: (ctx, filename) => { 83 | var folder = `${process.env.WCIF_DATA_FOLDER || '.'}/${ctx.competition.id}`; 84 | var fullPath = `${folder}/${filename}` 85 | fse.ensureDirSync(folder) 86 | fs.writeFileSync( 87 | fullPath, 88 | JSON.stringify(ctx.competition, null, 2) 89 | ) 90 | return `WCIF saved to '${fullPath}'.` 91 | } 92 | } 93 | 94 | module.exports = { 95 | functions: [ClearWCIF, ExportWCIF, ImportWCIF], 96 | } 97 | -------------------------------------------------------------------------------- /group.js: -------------------------------------------------------------------------------- 1 | const activityCode = require('./activity_code') 2 | const { DateTime } = require('luxon') 3 | 4 | class Group { 5 | constructor(activity, room, competition) { 6 | this.wcif = activity 7 | this.activityCode = activityCode.parse(activity.activityCode) 8 | this.room = room 9 | this.startTime = DateTime.fromISO(activity.startTime).setZone(competition.schedule.venues[0].timezone) 10 | this.endTime = DateTime.fromISO(activity.endTime).setZone(competition.schedule.venues[0].timezone) 11 | } 12 | 13 | name() { 14 | if (this.activityCode.isActivity()) { 15 | return this.wcif.name 16 | // This is not ideal; when displaying room names we'd like to 17 | // keep only the meaningful part (eg: "Red" when it's "Red Stage", which 18 | // is usually the first part of the name in English, but may be some other 19 | // parts in other languages. This is a compromise where we strip out 20 | // 'Stage' or 'Room' suffix, which should be enough in "regular" major 21 | // competitions setup while not messing up room names in non English setups. 22 | // return this.room.name.replace(/Room|Stage/g, '').trim() + ' ' + this.activityCode.groupNumber 23 | } else { 24 | return this.activityCode.toString() 25 | } 26 | } 27 | 28 | fullName() { 29 | return this.activityCode.toString() 30 | } 31 | } 32 | 33 | module.exports = { 34 | Group: Group, 35 | } 36 | -------------------------------------------------------------------------------- /groups/assign.js: -------------------------------------------------------------------------------- 1 | const solver = require('javascript-lp-solver') 2 | const activityCode = require('./../activity_code') 3 | const extension = require('./../extension') 4 | const lib = require('./../lib') 5 | 6 | function Assign(competition, round, assignmentSets, scorers, stationRules, attemptNumber, override) { 7 | var groups = lib.groupsForRoundCode(competition, round) 8 | groups.sort((g1, g2) => g1.activityCode.groupNumber - g2.activityCode.groupNumber) 9 | if (attemptNumber !== null) { 10 | groups = groups.filter((group) => group.activityCode.attemptNumber === attemptNumber) 11 | } 12 | var activityIds = groups.map((group) => group.wcif.id) 13 | 14 | if (competition.persons.map((person) => person.assignments).flat() 15 | .some((assignment) => activityIds.includes(assignment.activityId))) { 16 | if (!override) { 17 | return { 18 | round: round, 19 | groups: groups, 20 | warnings: ['Groups are already saved. Not overwriting unless overwrite=true is added.'], 21 | assignments: {}, 22 | } 23 | } else { 24 | competition.persons.forEach((person) => { 25 | person.assignments = person.assignments.filter( 26 | (assignment) => !activityIds.includes(assignment.activityId)) 27 | }) 28 | } 29 | } 30 | 31 | var personIds = lib.getWcifRound(competition, round) 32 | .results.map((res) => res.personId) 33 | 34 | var people = 35 | competition.persons.filter((person) => personIds.includes(person.registrantId)) 36 | .sort((p1, p2) => { 37 | var pb1 = lib.personalBest(p1, round) 38 | var pb2 = lib.personalBest(p2, round) 39 | if (pb1 === null) { 40 | return 1 41 | } 42 | if (pb2 === null) { 43 | return -1 44 | } 45 | return pb1.value - pb2.value 46 | }) 47 | 48 | var assignmentsByPerson = {} 49 | var assignmentsByGroup = {} 50 | var conflictingActivitiesByGroup = {} 51 | groups.forEach((group) => { 52 | assignmentsByGroup[group.wcif.id] = [] 53 | conflictingActivitiesByGroup[group.wcif.id] = [] 54 | lib.allGroups(competition).forEach((otherGroup) => { 55 | if (group.startTime < otherGroup.endTime && otherGroup.startTime < group.endTime) { 56 | conflictingActivitiesByGroup[group.wcif.id].push(otherGroup.wcif.id) 57 | } 58 | }) 59 | 60 | var ext = extension.getOrInsertExtension(group.wcif, 'ActivityConfig', 'groupifier') 61 | ext.featuredCompetitorWcaUserIds = [] 62 | }) 63 | var groupSizeLimit = people.length / groups.length 64 | warnings = [] 65 | assignmentSets.forEach((set) => { 66 | console.log('assigning ' + set.name) 67 | var eligibleGroups = groups.filter((group) => set.groupFilter({Group: group})) 68 | var eligiblePeople = people.filter((person) => set.personFilter({Person: person})) 69 | if (eligibleGroups.length == 0) { 70 | warnings.push({ 71 | type: 'NO_ELIGIBLE_GROUPS', 72 | category: set.name, 73 | }) 74 | return 75 | } 76 | var queue = [] 77 | var currentByPerson = {} 78 | var currentByGroup = {} 79 | // wcaUserId -> group id 80 | var preAssignedByPerson = {} 81 | // group id -> count 82 | var preAssignedByGroup = {} 83 | var preAssignedTotal = 0 84 | eligibleGroups.forEach((group) => { 85 | currentByGroup[group.wcif.id] = [] 86 | preAssignedByGroup[group.wcif.id] = 0 87 | }) 88 | eligiblePeople.forEach((person) => { 89 | if (person.wcaUserId in assignmentsByPerson) { 90 | var assignment = assignmentsByPerson[person.wcaUserId] 91 | var group = assignment.group 92 | if (group.wcif.id in currentByGroup) { 93 | queue.push({person: person, idx: queue.length}) 94 | preAssignedByPerson[person.wcaUserId] = group.wcif.id 95 | preAssignedByGroup[group.wcif.id] += 1 96 | preAssignedTotal += 1 97 | } 98 | } else { 99 | queue.push({person: person, idx: queue.length}) 100 | } 101 | }) 102 | var totalToAssign = queue.length 103 | var previousLength = -1; 104 | while (queue.length > preAssignedTotal) { 105 | var potentialInfinite = queue.length === previousLength; 106 | previousLength = queue.length; 107 | console.log(queue.length + ' left') 108 | // Don't assign any more to groups with enough people pre-assigned. 109 | var groupsToUse = eligibleGroups.filter((group) => currentByGroup[group.wcif.id].length + preAssignedByGroup[group.wcif.id] <= groupSizeLimit) 110 | // but if that filters out all groups, it means the math is wrong and we need to allow more space. 111 | if (groupsToUse.length === 0) { 112 | groupSizeLimit++ 113 | continue 114 | } 115 | // Remove anyone from the queue who's pre-assigned to a full group. 116 | queue = queue.filter((queueItem, idx) => { 117 | var preAssigned = preAssignedByPerson[queueItem.person.wcaUserId] 118 | var toKeep = preAssigned === undefined || groupsToUse.map((group) => group.wcif.id).includes(preAssigned) 119 | if (!toKeep) { 120 | preAssignedTotal-- 121 | } 122 | return toKeep 123 | }) 124 | 125 | var model = constructModel(queue.slice(0, 100), groupsToUse, scorers, assignmentsByGroup, currentByGroup, preAssignedByPerson, conflictingActivitiesByGroup) 126 | var solution = solver.Solve(model) 127 | var newlyAssigned = [] 128 | var indicesToErase = [] 129 | queue.forEach((queueItem, idx) => { 130 | groupsToUse.forEach((group) => { 131 | var key = queueItem.person.wcaUserId.toString() + '-g' + group.wcif.id 132 | if (key in solution && solution[key] == 1) { 133 | newlyAssigned.push({person: queueItem.person, group: group}) 134 | indicesToErase.push(idx) 135 | if (set.featured) { 136 | var ext = extension.getOrInsertExtension(group.wcif, 'ActivityConfig', 'groupifier') 137 | ext.featuredCompetitorWcaUserIds.push(queueItem.person.wcaUserId) 138 | } 139 | } 140 | }) 141 | }) 142 | if (!solution.feasible && potentialInfinite) { 143 | var unfeasibleWarning = `The group assignment '${set.name}' is not feasible, the function has broken out to prevent an infinite loop.` 144 | warnings.push(unfeasibleWarning) 145 | console.log(unfeasibleWarning) 146 | break; 147 | } 148 | queue = queue.filter((item, idx) => !indicesToErase.includes(idx)) 149 | newlyAssigned.forEach((assn) => { 150 | currentByPerson[assn.person.wcaUserId] = assn.group 151 | currentByGroup[assn.group.wcif.id].push(assn.person) 152 | if (assn.person.wcaUserId in preAssignedByPerson) { 153 | delete preAssignedByPerson[assn.person.wcaUserId] 154 | preAssignedByGroup[assn.group.wcif.id] -= 1 155 | preAssignedTotal -= 1 156 | } 157 | }) 158 | } 159 | for (const personId in currentByPerson) { 160 | assignmentsByPerson[personId] = {group: currentByPerson[personId], set: set.name} 161 | } 162 | for (const groupId in currentByGroup) { 163 | currentByGroup[groupId].forEach((person) => { 164 | if (!assignmentsByGroup[groupId].some((assignment) => assignment.person.wcaUserId == person.wcaUserId)) { 165 | assignmentsByGroup[groupId].push({person: person, set: set.name}) 166 | } 167 | }) 168 | } 169 | }) 170 | 171 | assignStations(stationRules, groups, assignmentsByGroup, assignmentsByPerson) 172 | 173 | for (const groupId in assignmentsByGroup) { 174 | assignmentsByGroup[groupId].sort( 175 | (a1, a2) => lib.personalBest(a1.person, round) < lib.personalBest(a2.person, round) ? -1 : 1) 176 | } 177 | 178 | people.forEach((person) => { 179 | if (person.wcaUserId in assignmentsByPerson) { 180 | var assignment = { 181 | activityId: assignmentsByPerson[person.wcaUserId].group.wcif.id, 182 | assignmentCode: "competitor", 183 | } 184 | if ('stationNumber' in assignmentsByPerson[person.wcaUserId]) { 185 | assignment.stationNumber = assignmentsByPerson[person.wcaUserId].stationNumber 186 | } 187 | person.assignments.push(assignment) 188 | } 189 | }) 190 | return { 191 | round: round, 192 | groups: groups, 193 | assignments: assignmentsByGroup, 194 | warnings: warnings, 195 | } 196 | } 197 | 198 | function constructModel(queue, groupsToUse, scorers, assignmentsByGroup, currentByGroup, preAssignedByPerson, conflictingActivitiesByGroup) { 199 | var model = { 200 | optimize: 'score', 201 | opType: 'max', 202 | constraints: {}, 203 | variables: {}, 204 | ints: {}, 205 | } 206 | queue.slice(0, 100).forEach((queueItem) => { 207 | var personKey = queueItem.person.wcaUserId.toString() 208 | model.constraints[personKey] = {min: 0, max: 1} 209 | var scores = {} 210 | var total = 0 211 | groupsToUse.forEach((group) => { 212 | if (personKey in preAssignedByPerson && preAssignedByPerson[personKey] != group.wcif.id) { 213 | return 214 | } 215 | if (!queueItem.person.assignments.every((assignment) => !conflictingActivitiesByGroup[group.wcif.id].includes(assignment.activityId))) { 216 | return 217 | } 218 | var newScore = 0 219 | scorers.forEach((scorer) => { 220 | newScore += scorer.getScore(queueItem.person, group, assignmentsByGroup[group.wcif.id].map((assignment) => assignment.person).concat(currentByGroup[group.wcif.id])) 221 | }) 222 | total += newScore 223 | scores[group.wcif.id] = newScore 224 | }) 225 | groupsToUse.forEach((group) => { 226 | if (!(group.wcif.id in scores)) { 227 | return 228 | } 229 | // Normalize all of the scores so that the average score is -idx. 230 | var adjustedScore = scores[group.wcif.id] - total / groupsToUse.length - queueItem.idx 231 | var groupKey = 'g' + group.wcif.id 232 | var key = personKey + '-' + groupKey 233 | model.variables[key] = { 234 | score: adjustedScore, 235 | totalAssigned: 1, 236 | } 237 | model.variables[key][personKey] = 1 238 | model.variables[key][groupKey] = 1 239 | model.variables[key][key] = 1 240 | model.constraints[key] = {min: 0, max: 1} 241 | model.ints[key] = 1 242 | }) 243 | }) 244 | groupsToUse.forEach((group) => { 245 | model.constraints['g' + group.wcif.id] = {min: 0, max: 1} 246 | }) 247 | var numToAssign = Math.min(queue.length, groupsToUse.length) 248 | model.constraints.totalAssigned = {equal: numToAssign} 249 | return model 250 | } 251 | 252 | function assignStations(stationRules, groups, assignmentsByGroup, assignmentsByPerson) { 253 | stationRules.forEach((rule) => { 254 | groups.filter((group) => rule.groupFilter({Group: group})).forEach((group) => { 255 | assignmentsByGroup[group.wcif.id].sort((a1, a2) => { 256 | switch (rule.mode) { 257 | case "ascending": 258 | return rule.sortKey({Person: a1.person}) < rule.sortKey({Person: a2.person}) ? -1 : 1 259 | case "descending": 260 | return rule.sortKey({Person: a2.person}) < rule.sortKey({Person: a1.person}) ? 1 : -1 261 | case "arbitrary": 262 | return Math.random() - 0.5 263 | } 264 | }).forEach((assignment, idx) => { 265 | assignmentsByPerson[assignment.person.wcaUserId].stationNumber = idx + 1 266 | assignment.stationNumber = idx + 1 267 | }) 268 | }) 269 | }) 270 | } 271 | 272 | class AssignmentSet { 273 | constructor(name, personFilter, groupFilter, featured) { 274 | this.name = name 275 | this.personFilter = personFilter 276 | this.groupFilter = groupFilter 277 | this.featured = featured 278 | } 279 | } 280 | 281 | class StationAssignmentRule { 282 | constructor(groupFilter, mode, sortKey) { 283 | this.groupFilter = groupFilter 284 | this.mode = mode 285 | this.sortKey = sortKey 286 | } 287 | } 288 | 289 | module.exports = { 290 | Assign: Assign, 291 | AssignmentSet: AssignmentSet, 292 | StationAssignmentRule: StationAssignmentRule, 293 | } 294 | -------------------------------------------------------------------------------- /groups/scorers.js: -------------------------------------------------------------------------------- 1 | const lib = require('./../lib') 2 | 3 | class ByMatchingValue { 4 | constructor(value, score, limit) { 5 | this.value = value 6 | this.score = score 7 | this.limit = limit 8 | this.cachedValues = {} 9 | } 10 | 11 | getValue(person) { 12 | if (!(person.wcaUserId in this.cachedValues)) { 13 | this.cachedValues[person.wcaUserId] = this.value({Person: person}) 14 | } 15 | return this.cachedValues[person.wcaUserId] 16 | } 17 | 18 | getScore(person, group, otherPeople) { 19 | var val = this.getValue(person) 20 | var matching = otherPeople.filter((p) => this.getValue(p) == val).length 21 | if (this.limit !== null && matching > this.limit) { 22 | matching = this.limit 23 | } 24 | return matching * this.score 25 | } 26 | } 27 | 28 | class ByFilters { 29 | constructor(personFilter, groupFilter, score) { 30 | this.personFilter = personFilter 31 | this.groupFilter = groupFilter 32 | this.personCache = {} 33 | this.groupCache = {} 34 | this.score = score 35 | } 36 | 37 | getScore(person, group, otherPeople) { 38 | if (!(person.wcaUserId in this.personCache)) { 39 | this.personCache[person.wcaUserId] = this.personFilter({Person: person}) 40 | } 41 | if (!this.personCache[person.wcaUserId]) { 42 | return 0 43 | } 44 | if (!(group.activityCode in this.groupCache)) { 45 | this.groupCache[group.wcif.id] = this.groupFilter({Group: group}) 46 | } 47 | if (!this.groupCache[group.wcif.id]) { 48 | return 0 49 | } 50 | return this.score 51 | } 52 | } 53 | 54 | class RecentlyCompeted { 55 | constructor(competition, groupFilter, otherGroupFilter, scoreFn) { 56 | this.groupFilter = groupFilter 57 | this.scoreFn = scoreFn 58 | this.otherGroups = Object.fromEntries( 59 | lib.allGroups(competition).filter((group) => otherGroupFilter({Group: group})).map((group) => [group.wcif.id, group])) 60 | this.groupCache = {} 61 | } 62 | 63 | getScore(person, group, otherPeople) { 64 | if (!(group.activityCode in this.groupCache)) { 65 | this.groupCache[group.wcif.id] = this.groupFilter({Group: group}) 66 | } 67 | if (!this.groupCache[group.wcif.id]) { 68 | return 0 69 | } 70 | var groupEndTimes = person.assignments.filter((assignment) => (assignment.activityId in this.otherGroups)) 71 | .filter((assignment) => (assignment.assignmentCode == 'competitor')) 72 | .map((assignment) => this.otherGroups[assignment.activityId].endTime) 73 | .filter((t) => t <= group.startTime) 74 | 75 | if (groupEndTimes.length == 0) { 76 | return 0 77 | } 78 | var mostRecent = groupEndTimes.reduce((a, b) => b > a ? b : a) 79 | var timeSince = group.startTime.diff(mostRecent, 'minutes').minutes 80 | return this.scoreFn({Number: timeSince}) 81 | } 82 | } 83 | 84 | module.exports = { 85 | ByMatchingValue: ByMatchingValue, 86 | ByFilters: ByFilters, 87 | RecentlyCompeted: RecentlyCompeted, 88 | } 89 | -------------------------------------------------------------------------------- /lib.js: -------------------------------------------------------------------------------- 1 | const { DateTime } = require('luxon') 2 | 3 | const attemptResult = require('./attempt_result') 4 | const groupLib = require('./group') 5 | 6 | function getWcifEvent(competition, activity) { 7 | return competition.events.filter((evt) => evt.id == activity.eventId)[0] 8 | } 9 | 10 | function getWcifRound(competition, activity) { 11 | return getWcifEvent(competition, activity).rounds.filter((rd) => rd.id == activity.id())[0] 12 | } 13 | 14 | function personalBest(person, evt, type='default') { 15 | const eventId = evt.eventId 16 | if (type == 'default') { 17 | type = ['333bf', '444bf', '555bf', '333mbf'].includes(eventId) ? 'single' : 'average' 18 | } 19 | 20 | var matching = person.personalBests.filter((best) => best.eventId === eventId && best.type === type) 21 | if (matching.length == 0) { 22 | return null 23 | } else { 24 | return new attemptResult.AttemptResult(matching[0].best, eventId) 25 | } 26 | } 27 | 28 | function miscActivityForId(competition, activityId) { 29 | var matching = competition.schedule.venues.map((venue) => venue.rooms).flat() 30 | .map((room) => room.activities.map((activity) => new groupLib.Group(activity, room, competition))).flat() 31 | .filter((activity) => activity.wcif.id == activityId) 32 | if (matching.length) { 33 | return matching[0] 34 | } 35 | return null 36 | } 37 | 38 | function allActivitiesForRoundId(competition, roundId) { 39 | return competition.schedule.venues.map((venue) => venue.rooms).flat() 40 | .map((room) => room.activities 41 | .map((activity) => activity.childActivities).flat() 42 | .map((activity) => new groupLib.Group(activity, room, competition))).flat() 43 | .filter(activity => activity.activityCode.group(null).id() === roundId) 44 | } 45 | 46 | function allGroups(competition) { 47 | return competition.schedule.venues.map((venue) => venue.rooms).flat() 48 | .map((room) => { 49 | return room.activities 50 | .map((activity) => activity.childActivities).flat() 51 | .map((activity) => new groupLib.Group(activity, room, competition)) 52 | }).flat() 53 | } 54 | 55 | function groupForActivityId(competition, activityId) { 56 | var matching = competition.schedule.venues.map((venue) => venue.rooms).flat() 57 | .map((room) => { 58 | return room.activities 59 | .map((activity) => activity.childActivities).flat() 60 | .filter((activity) => activity.id == activityId) 61 | .map((activity) => [activity, room]) 62 | }).flat() 63 | if (matching.length) { 64 | return new groupLib.Group(matching[0][0], matching[0][1], competition) 65 | } 66 | return null 67 | } 68 | 69 | function groupsForRoundCode(competition, roundCode) { 70 | return allGroups(competition).filter((group) => roundCode.contains(group.activityCode)) 71 | } 72 | 73 | function startTime(group, competition) { 74 | return DateTime.fromISO(group.wcif.startTime).setZone(competition.schedule.venues[0].timezone) 75 | } 76 | 77 | function endTime(group, competition) { 78 | return DateTime.fromISO(group.wcif.endTime).setZone(competition.schedule.venues[0].timezone) 79 | } 80 | 81 | module.exports = { 82 | getWcifEvent: getWcifEvent, 83 | getWcifRound: getWcifRound, 84 | personalBest: personalBest, 85 | allGroups: allGroups, 86 | allActivitiesForRoundId: allActivitiesForRoundId, 87 | groupForActivityId: groupForActivityId, 88 | groupsForRoundCode: groupsForRoundCode, 89 | startTime: startTime, 90 | endTime: endTime, 91 | miscActivityForId: miscActivityForId, 92 | } 93 | -------------------------------------------------------------------------------- /main.js: -------------------------------------------------------------------------------- 1 | const dotenv = require('dotenv') 2 | const env = process.env.ENV || 'DEV' 3 | dotenv.config({ path: '.env.' + env }) 4 | 5 | const auth = require('./auth') 6 | const competition = require('./competition') 7 | 8 | const bodyParser = require('body-parser') 9 | const cookieSession = require('cookie-session') 10 | const express = require('express') 11 | const favicon = require('serve-favicon') 12 | 13 | var app = express() 14 | 15 | app.set('view engine', 'pug') 16 | app.use(cookieSession({ 17 | keys: [process.env.COOKIE_SECRET], 18 | maxAge: 5 * 24 * 60 * 60 * 1000 // 5 days 19 | })) 20 | app.use(bodyParser.urlencoded({ extended: true })) 21 | app.use(bodyParser.json()) 22 | app.use(favicon(__dirname + '/static/favicon.ico')) 23 | app.use(express.static('static')) 24 | app.use(auth.redirectIfNotLoggedIn) 25 | app.use('/auth', auth.router) 26 | app.get('/', async function(req, res) { 27 | var startTime = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString() 28 | var competitions = await auth.getWcaApi('/api/v0/competitions?managed_by_me=true&start=' + startTime, req, res); 29 | res.render('index', {'competitions': competitions}) 30 | }) 31 | app.use(competition.router) 32 | 33 | app.listen(process.env.PORT, function() { 34 | console.log('Server running at http://localhost:%d', process.env.PORT); 35 | console.log('Using WCA server running at %s', process.env.WCA_HOST); 36 | }) 37 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "c-preprocessor": "^0.2.13", 4 | "cookie-session": "^2.1.0", 5 | "dotenv": "^16.4.5", 6 | "express": "^4.21.2", 7 | "fs-extra": "^11.2.0", 8 | "google-auth-library": "^9.10", 9 | "google-spreadsheet": "^4.1.2", 10 | "javascript-lp-solver": "^0.4.24", 11 | "luxon": "^3.4.4", 12 | "openid-client": "^5.6.5", 13 | "peggy": "^4.0.2", 14 | "pug": "^3.0.3", 15 | "serve-favicon": "^2.5.0" 16 | }, 17 | "name": "natshelper", 18 | "description": "Helper app for CubingUSA Nats scheduling.", 19 | "version": "1.0.0", 20 | "main": "main.js", 21 | "scripts": { 22 | "dev-server": "ENV=DEV npx nodemon -i wcif_data .", 23 | "test": "echo \"Error: no test specified\" && exit 1" 24 | }, 25 | "repository": { 26 | "type": "git", 27 | "url": "git+https://github.com/cubingusa/natshelper.git" 28 | }, 29 | "author": "", 30 | "license": "ISC", 31 | "bugs": { 32 | "url": "https://github.com/cubingusa/natshelper/issues" 33 | }, 34 | "homepage": "https://github.com/cubingusa/natshelper#readme" 35 | } 36 | -------------------------------------------------------------------------------- /parser/driver.js: -------------------------------------------------------------------------------- 1 | const { DateTime } = require('luxon') 2 | 3 | const activityCode = require('./../activity_code') 4 | const attemptResult = require('./../attempt_result') 5 | 6 | function parseType(type) { 7 | var parsed = type.match(/([\$a-zA-Z][a-zA-Z0-9<>]*)\((.*)\)/) 8 | return { 9 | type: parsed ? parsed[1] : type, 10 | params: parsed ? parsed[2].split(',').map((x) => { return { type: x.trim() } } ) : [], 11 | } 12 | } 13 | 14 | function parseGenerics(type) { 15 | var parsed = type.match(/([\$a-zA-Z][a-zA-Z0-9]*)<(.*)>/) 16 | return { 17 | type: parsed ? parsed[1] : type, 18 | generics: parsed ? parsed[2].split(',').map((x) => { return { type: x.trim() } } ) : [], 19 | } 20 | } 21 | 22 | function assembleType(type) { 23 | if (type.params.length > 0) { 24 | return type.type + '(' + type.params.join(',') + ')' 25 | } else { 26 | return type.type 27 | } 28 | } 29 | 30 | function typesMatch(typeA, typeB) { 31 | var ttA = parseGenerics(typeA.type) 32 | var ttB = parseGenerics(typeB.type) 33 | if (ttA.type !== ttB.type && ttA.type !== 'Any' && ttB.type !== 'Any') { 34 | return false 35 | } 36 | if (ttA.generics.length !== ttB.generics.length) { 37 | return false 38 | } 39 | for (let i = 0; i < ttA.generics.length; i++) { 40 | if (!typesMatch(ttA.generics[i], ttB.generics[i])) { 41 | return false 42 | } 43 | } 44 | return true 45 | } 46 | 47 | function literalNode(type, value) { 48 | var serialized = (() => { 49 | switch (type) { 50 | case 'AttemptResult': 51 | return value.value.toString() 52 | case 'Number': 53 | case 'String': 54 | case 'Boolean': 55 | return value 56 | default: 57 | if (value == null) { 58 | return null 59 | } 60 | return value.toString() 61 | } 62 | })() 63 | return { 64 | type: parseType(type), 65 | value: (inParams, ctx) => value, 66 | serialize: () => { return { type: type, value: serialized } }, 67 | mutations: [], 68 | } 69 | } 70 | 71 | function dateTimeNode(type, valueStr) { 72 | return { 73 | type: { type, params: [] }, 74 | value: (inParams, ctx) => DateTime.fromISO(valueStr).setZone(ctx.competition.schedule.venues[0].timezone, { keepLocalTime: true }), 75 | serialize: () => { return { type: type, value: valueStr } }, 76 | mutations: [], 77 | } 78 | } 79 | 80 | function recursivelySubstituteUdfArgs(udf, impl, udfArgs) { 81 | if (impl.type === 'UdfArg' || impl.type === 'SavedUdfArg') { 82 | if (udfArgs.length < impl.argNum) { 83 | throw new Error('Not enough arguments provided to ' + udf.name) 84 | } 85 | return udfArgs[impl.argNum - 1] 86 | } 87 | if (impl.args !== undefined) { 88 | impl.args = impl.args.map((arg) => recursivelySubstituteUdfArgs(udf, arg, udfArgs)) 89 | } 90 | return impl 91 | } 92 | 93 | function udfNode(udf, ctx, args, allowParams) { 94 | var impl = JSON.parse(JSON.stringify(udf.impl)) 95 | var out = parseNode(recursivelySubstituteUdfArgs(udf, impl, args), ctx, allowParams) 96 | out.serialize = () => { 97 | return { 98 | type: 'Function', 99 | name: udf.name, 100 | args: args, 101 | } 102 | } 103 | return out 104 | } 105 | 106 | function substituteExisting(type, generics) { 107 | for (const generic of Object.keys(generics)) { 108 | type.type = type.type.replaceAll('$' + generic, generics[generic]) 109 | type.params.forEach((param) => { 110 | param.type = param.type.replaceAll('$' + generic, generics[generic]) 111 | }) 112 | } 113 | } 114 | 115 | function extractSegment(type) { 116 | var idx = 0 117 | var open = 0 118 | while (idx < type.length) { 119 | switch (type.charAt(idx)) { 120 | case '<': 121 | case '(': 122 | open++ 123 | break 124 | case '>': 125 | case ')': 126 | open-- 127 | break 128 | case ',': 129 | case ' ': 130 | if (open == 0) { 131 | return type.substring(0, idx) 132 | } 133 | } 134 | if (open < 0) { 135 | return type.substring(0, idx) 136 | } 137 | idx++ 138 | } 139 | return type 140 | } 141 | 142 | function extractOne(typeString, expectedString, fn, generics, errors) { 143 | var idx = typeString.indexOf('$') 144 | if (expectedString.substring(0, idx) !== typeString.substring(0, idx)) { 145 | errors.push({ errorType: 'ARGUMENT_WRONG_TYPE_1', 146 | expectedType: expectedString, 147 | actualType: typeString, 148 | generics: generics}) 149 | return {} 150 | } 151 | var generic = extractSegment(typeString.substring(idx + 1)) 152 | var genericValue = extractSegment(expectedString.substring(idx)) 153 | if (fn.genericParams && fn.genericParams.includes(generic)) { 154 | typeString = typeString.replaceAll('$' + generic, genericValue) 155 | if (genericValue !== 'Any') { 156 | generics[generic] = genericValue 157 | } 158 | } else { 159 | errors.push({ errorType: 'INVALID_GENERIC', 160 | argumentType: typeString, 161 | invalidGeneric: generic }) 162 | return {} 163 | } 164 | return { generic: generic, value: genericValue } 165 | } 166 | 167 | function substituteGenerics(typeWithGenerics, matchType, fn, generics, errors) { 168 | substituteExisting(typeWithGenerics, generics) 169 | substituteExisting(matchType, generics) 170 | 171 | while (typeWithGenerics.type.indexOf('$') >= 0) { 172 | var newGeneric = extractOne(typeWithGenerics.type, matchType.type, fn, generics, errors) 173 | if (errors.length > 0) { 174 | return 175 | } 176 | substituteExisting(typeWithGenerics, { [newGeneric.generic]: newGeneric.value }) 177 | generics[newGeneric.generic] = newGeneric.value 178 | } 179 | if (!typesMatch(typeWithGenerics, matchType)) { 180 | errors.push({ errorType: 'UNEXPECTED_TYPE', 181 | expectedType: matchType, 182 | gotType: typeWithGenerics }) 183 | return 184 | } 185 | matchType.params.forEach((param) => { 186 | if (typeWithGenerics.params.map((param) => param.type).includes(param.type)) { 187 | return 188 | } 189 | var paramsWithGeneric = typeWithGenerics.params.filter((p) => p.type.indexOf('$') >= 0) 190 | if (paramsWithGeneric.length == 0) { 191 | return 192 | } 193 | var paramWithGeneric = paramsWithGeneric[0] 194 | while (paramWithGeneric.type.indexOf('$') >= 0) { 195 | var newGeneric = extractOne(paramWithGeneric.type, param.type, fn, generics, errors) 196 | if (errors.length > 0) { 197 | return 198 | } 199 | substituteExisting(typeWithGenerics, { [newGeneric.generic]: newGeneric.value }) 200 | generics[newGeneric.generic] = newGeneric.value 201 | } 202 | }) 203 | } 204 | 205 | function functionNode(functionName, ctx, args, genericsIn, allowParams=true) { 206 | var matchingFunctions = ctx.allFunctions.filter((fn) => fn.name == functionName) 207 | var errors = args.filter((arg) => !!arg.errors).map((arg) => arg.errors).flat() 208 | if (!matchingFunctions.length) { 209 | errors.push({ errorType: 'UNKNOWN_FUNCTION', functionName: functionName}) 210 | } 211 | if (errors.length > 0) { 212 | return {errors: errors} 213 | } 214 | var failedMatches = [] 215 | var successfulMatches = [] 216 | matchingFunctions.forEach((fn) => { 217 | var availableArgs = [...args] 218 | var matchedArgs = [] 219 | var errors = [] 220 | var extraParams = [] 221 | var generics = {} 222 | if ((genericsIn || []).length > 0) { 223 | if (genericsIn.length > (fn.genericParams || []).length) { 224 | errors.push({ 225 | errorType: 'TOO_MANY_GENERICS_PROVIDED', 226 | }) 227 | } else { 228 | for (var i = 0; i < genericsIn.length; i++) { 229 | generics[fn.genericParams[i]] = genericsIn[i]; 230 | } 231 | } 232 | } 233 | fn.args.forEach((arg) => { 234 | // Look for named args. 235 | var matchIdxs = [] 236 | for (var argIdx = 0; argIdx < availableArgs.length; argIdx++) { 237 | if (availableArgs[argIdx].argName == arg.name) { 238 | matchIdxs.push(argIdx) 239 | } 240 | } 241 | // Otherwise, pick the first arg, or all remaining unnamed args if it's repeated. 242 | for (var argIdx = 0; argIdx < availableArgs.length; argIdx++) { 243 | if (!availableArgs[argIdx].argName && (arg.repeated || !matchIdxs.length)) { 244 | matchIdxs.push(argIdx) 245 | } 246 | } 247 | var alreadyUsed = 0 248 | var matches = [] 249 | // Check that all args are the right type. 250 | matchIdxs.forEach((matchIdx) => { 251 | var match = availableArgs[matchIdx - alreadyUsed]; 252 | if ('errorType' in match) { 253 | errors.push(match) 254 | return 255 | } 256 | var matchErrors = []; 257 | var argType = parseType(arg.type) 258 | var matchType = match.type 259 | substituteGenerics(argType, match.type, fn, generics, matchErrors) 260 | 261 | if (typesMatch(matchType, argType)) { 262 | matchType.params.forEach((param) => { 263 | if (!argType.params.map(p => p.type).includes(param.type) && 264 | !extraParams.map(p => p.type).includes(param.type)) { 265 | var requestedBy = (param.requestedBy !== undefined) ? [...param.requestedBy, functionName] : [functionName] 266 | extraParams.push({ type: param.type, requestedBy: requestedBy }) 267 | } 268 | }) 269 | if (extraParams.length && !allowParams) { 270 | matchErrors.push({ errorType: 'UNEXPECTED_PARAMS', 271 | argumentName: arg.name, 272 | expectedType: argType, 273 | actualType: match.type, 274 | generics: generics}) 275 | } 276 | } else { 277 | matchErrors.push({ errorType: 'ARGUMENT_WRONG_TYPE_2', 278 | argumentName: arg.name, 279 | expectedType: arg.type, 280 | actualType: match.type, 281 | generics: generics}) 282 | } 283 | if (!matchErrors.length) { 284 | matches.push(match) 285 | availableArgs.splice(matchIdx - alreadyUsed, 1) 286 | alreadyUsed += 1 287 | } else { 288 | if (!arg.canBeExternal) { 289 | errors = errors.concat(matchErrors) 290 | } 291 | } 292 | }) 293 | // Use the default value if there's no value. 294 | if (!arg.repeated && arg.defaultValue !== undefined && !matches.length) { 295 | matches.push(literalNode(arg.type, arg.defaultValue)) 296 | } 297 | var isExternal = false 298 | // Check that we have exactly one if it's not repeated. 299 | if (!arg.repeated && matches.length == 0) { 300 | if (arg.canBeExternal) { 301 | if (!extraParams.map(p => p.type).includes(arg.type)) { 302 | var type = parseType(arg.type) 303 | substituteExisting(type, generics) 304 | extraParams.push({ 305 | type: assembleType(type), 306 | requestedBy: [functionName] 307 | }) 308 | } 309 | isExternal = true 310 | } else { 311 | errors.push({ errorType: 'ARGUMENT_MISSING', 312 | argument: arg }) 313 | } 314 | } else if (!arg.repeated && matches.length > 1) { 315 | errors.push({ errorType: 'ARGUMENT_WRONG_COUNT', 316 | argumentName: arg.name, 317 | count: matches.length }) 318 | } 319 | 320 | matchedArgs.push({ 321 | arg: arg, 322 | matches: matches, 323 | isExternal: isExternal}) 324 | }) 325 | // Check there are no remaining args. 326 | availableArgs.forEach((arg) => { 327 | errors.push({ errorType: 'UNUSED_ARGUMENT', 328 | argumentType: arg.type}) 329 | }) 330 | 331 | if (errors.length > 0) { 332 | failedMatches.push({fn: fn, errors: errors}) 333 | } else { 334 | var outputType = fn.outputType 335 | for (const generic of Object.keys(generics)) { 336 | outputType = outputType.replaceAll('$' + generic, generics[generic]) 337 | } 338 | successfulMatches.push({fn: fn, args: matchedArgs, extraParams: extraParams, outputType: outputType, generics: generics}) 339 | } 340 | }) 341 | if (successfulMatches.length > 1) { 342 | successfulMatches.sort((a, b) => { 343 | if (a.fn.genericParams === undefined) return -1 344 | if (b.fn.genericParams === undefined) return 1 345 | return a.fn.genericParams.length - b.fn.genericParams.length 346 | }) 347 | if ((successfulMatches[0].fn.genericParams || []).length !== 348 | (successfulMatches[1].fn.genericParams || []).length) { 349 | successfulMatches = [successfulMatches[0]]; 350 | } else { 351 | return {errors: [{ errorType: 'AMBIGUOUS_CALL', 352 | functionName: functionName, 353 | successfulMatches: successfulMatches}]} 354 | } 355 | } 356 | if (successfulMatches.length == 0) { 357 | return {errors: [{ errorType: 'NO_MATCHING_FUNCTION', 358 | functionName: functionName, 359 | failedMatches: failedMatches}]} 360 | } 361 | var fn = successfulMatches[0].fn 362 | var args = successfulMatches[0].args 363 | var generics = successfulMatches[0].generics 364 | var outputType = parseType(successfulMatches[0].outputType) 365 | successfulMatches[0].extraParams.forEach((param) => { 366 | if (!outputType.params.includes(param)) { 367 | outputType.params.push(param) 368 | } 369 | }) 370 | 371 | var mutations = [...(new Set(args.map((arg) => arg.matches).flat().map((match) => match.mutations).flat()))] 372 | if (fn.mutations) { 373 | fn.mutations.forEach((mut) => { 374 | if (!mutations.includes(mut)) { 375 | mutations.push(mut) 376 | } 377 | }) 378 | } 379 | 380 | return { 381 | type: outputType, 382 | value: function(inParams, ctx) { 383 | var argsToUse = [] 384 | if (fn.usesContext) { 385 | argsToUse.push(ctx) 386 | } 387 | if (fn.usesGenericTypes) { 388 | argsToUse.push(generics) 389 | } 390 | 391 | var returnNull = false 392 | for (var i = 0; i < fn.args.length; i++) { 393 | var evalFn = (match) => { 394 | if (fn.args[i].serialized) { 395 | return match.serialize() 396 | } 397 | if (fn.args[i].lazy) { 398 | return (newParams) => { 399 | var mergedParams = {} 400 | Object.assign(mergedParams, inParams) 401 | Object.assign(mergedParams, newParams) 402 | return match.value(mergedParams, ctx) 403 | } 404 | } 405 | var value = match.value(inParams, ctx) 406 | if (value === null && !fn.args[i].nullable) { 407 | returnNull = true 408 | } 409 | return value 410 | } 411 | if (fn.args[i].repeated) { 412 | argsToUse.push(args[i].matches.map(evalFn)) 413 | } else if (args[i].isExternal) { 414 | var type = parseType(args[i].arg.type) 415 | substituteExisting(type, generics) 416 | argsToUse.push(inParams[assembleType(type)]) 417 | } else { 418 | argsToUse.push(evalFn(args[i].matches[0])) 419 | } 420 | } 421 | if (returnNull) { 422 | return null 423 | } 424 | var outputType = parseType(fn.outputType) 425 | outputType.params.forEach((param) => { 426 | argsToUse.push(inParams[param.type]) 427 | }) 428 | 429 | ctx.logger.start('FN_' + fn.name) 430 | var out = fn.implementation(...argsToUse) 431 | ctx.logger.stop('FN_' + fn.name) 432 | return out 433 | }, 434 | serialize: function() { 435 | return { 436 | type: 'Function', 437 | name: functionName, 438 | args: args.map((arg) => arg.matches.map((match) => { 439 | var out = match.serialize() 440 | out.argName = arg.name 441 | return out 442 | })).flat(), 443 | generics: genericsIn, 444 | } 445 | }, 446 | mutations: mutations, 447 | } 448 | } 449 | 450 | function activityNode(activityId) { 451 | var code = activityCode.parse(activityId) 452 | if (code) { 453 | var type = code.attemptNumber ? 'Attempt' : (code.roundNumber ? 'Round' : 'Event') 454 | return { 455 | type: { type, params: [] }, 456 | value: (inParams, ctx) => code, 457 | serialize: () => { return { type: 'Activity', activityId: activityId } }, 458 | mutations: [], 459 | } 460 | } else { 461 | return { 462 | errorType: 'INVALID_ACTIVITY_ID', 463 | activityId: activityId, 464 | } 465 | } 466 | } 467 | 468 | function savedUdfArgNode(argNum, argType, ctx) { 469 | throw new Error("Error substituting UDF.") 470 | } 471 | 472 | function udfArgNode(argNum, argType, ctx) { 473 | return { 474 | type: parseType(argType), 475 | value: (inParams, ctx) => { 476 | throw new Error("UDF args should only be used inside Define().") 477 | }, 478 | serialize: () => { return { type: 'SavedUdfArg', argNum: argNum, argType: argType } }, 479 | mutations: [], 480 | } 481 | } 482 | 483 | function personNode(node, ctx) { 484 | return { 485 | type: {type: 'Person', params: [] }, 486 | value: (inParams, ctx) => { 487 | const matchingPeople = ctx.competition.persons.filter((person) => { 488 | if (node.wcaId) { 489 | return person.wcaId === node.wcaId 490 | } else if (node.wcaUserId) { 491 | return person.wcaUserId === +node.wcaUserId 492 | } 493 | }) 494 | if (matchingPeople.length === 0) { 495 | return null 496 | } 497 | return matchingPeople[0] 498 | }, 499 | serialize: () => node, 500 | mutations: [], 501 | } 502 | } 503 | 504 | function parseNode(node, ctx, allowParams) { 505 | var out = (() => { 506 | switch (node.type) { 507 | case 'Function': 508 | if (ctx.udfs[node.name]) { 509 | return udfNode(ctx.udfs[node.name], ctx, node.args, allowParams) 510 | } 511 | return functionNode(node.name, ctx, 512 | node.args.map((arg) => parseNode(arg, ctx, true)), 513 | node.generics, allowParams) 514 | case 'Activity': 515 | return activityNode(node.activityId) 516 | case 'AttemptResult': 517 | return literalNode('AttemptResult', attemptResult.parseString(node.value)) 518 | case 'UdfArg': 519 | return udfArgNode(node.argNum, node.argType, ctx) 520 | case 'SavedUdfArg': 521 | return savedUdfArgNode(node.argNum, node.argType, ctx) 522 | case 'Person': 523 | return personNode(node, ctx) 524 | case 'DateTime': 525 | case 'Date': 526 | return dateTimeNode(node.type, node.value) 527 | default: 528 | return literalNode(node.type, node.value) 529 | } 530 | })() 531 | if (!!out.errors) { 532 | return out 533 | } 534 | if (!!node.argName) { 535 | out.argName = node.argName 536 | } 537 | return out 538 | } 539 | 540 | module.exports = { 541 | parseNode: parseNode, 542 | parseType: parseType, 543 | } 544 | -------------------------------------------------------------------------------- /parser/grammar.pegjs: -------------------------------------------------------------------------------- 1 | Input 2 | = head:Expression _ tail:Input { return [head].concat(tail) } 3 | / "" { return [] } 4 | 5 | Expression 6 | = _ fn:Variable generics:("<" TypeList ">")? "(" _ args:ArgList _ ")" _ { return { type: 'Function', name: fn, args: args, generics: !!generics ? generics[1] : [] } } 7 | / AttemptResultLiteral 8 | / BooleanLiteral 9 | / DateTimeLiteral 10 | / DateLiteral 11 | / PersonLiteral 12 | / NumberLiteral 13 | / ActivityLiteral 14 | / StringLiteral 15 | / BinaryOperation 16 | / UdfArg 17 | / Array 18 | 19 | ArgList 20 | = head:Arg tail:(_ "," _ @Arg)* { return [head, ...tail] } 21 | / "" { return [] } 22 | 23 | Arg 24 | = Expression 25 | / argName:$[a-zA-Z]+ "=" expr:Expression { expr.argName = argName; return expr } 26 | 27 | Whitespace 28 | = [ \t]* 29 | 30 | WhitespaceLine 31 | = Whitespace "#" ([^\n\r]*)? 32 | / Whitespace 33 | 34 | _ 35 | = WhitespaceLine $[\n\r]+ _ 36 | / WhitespaceLine 37 | 38 | Variable 39 | = v:$([a-zA-Z][a-zA-Z0-9]*) { return v } 40 | 41 | NumberLiteral 42 | = rawNumber:$("-"?[0-9\.]+) { return { type: 'Number', value: +rawNumber } } 43 | 44 | BooleanLiteral 45 | = "true" { return { type: 'Boolean', value: true } } 46 | / "false" { return { type: 'Boolean', value: false } } 47 | 48 | ActivityLiteral 49 | = "_" activityId:$[a-zA-Z0-9-]* { return { type: 'Activity', activityId: activityId } } 50 | 51 | StringLiteral 52 | = '"' rawString:$[^"]* '"' { return { type: 'String', value: rawString } } 53 | 54 | AttemptResultLiteral 55 | = value:$([0-9][0-9\.:]*[mps]) { return { type: 'AttemptResult', value: value } } 56 | / "DNF" { return { type: 'AttemptResult', value: 'DNF' } } 57 | / "DNS" { return { type: 'AttemptResult', value: 'DNS' } } 58 | 59 | PersonLiteral 60 | = wcaId:$([0-9][0-9][0-9][0-9][A-Z][A-Z][A-Z][A-Z][0-9][0-9]) { return { type: 'Person', wcaId: wcaId } } 61 | / "p" wcaUserId:$([0-9]+) { return { type: 'Person', wcaUserId: wcaUserId } } 62 | 63 | DateTimeLiteral 64 | = value:$([0-9][0-9][0-9][0-9] "-" [0-9][0-9] "-" [0-9][0-9] "T" [0-9][0-9] ":" [0-9][0-9] ":" [0-9][0-9]) { return { type: 'DateTime', value: value } } 65 | / value:$([0-9][0-9][0-9][0-9] "-" [0-9][0-9] "-" [0-9][0-9] "T" [0-9][0-9] ":" [0-9][0-9]) { return { type: 'DateTime', value: value } } 66 | 67 | DateLiteral 68 | = value:$([0-9][0-9][0-9][0-9] "-" [0-9][0-9] "-" [0-9][0-9]) { return { type: 'Date', value: value } } 69 | 70 | BinaryOperation 71 | = "(" left:Expression _ "||" _ right:Expression ")" { return { type: 'Function', name: 'Or', args: [left, right] } } 72 | / "(" left:Expression _ "&&" _ right:Expression ")" { return { type: 'Function', name: 'And', args: [left, right] } } 73 | / "(" left:Expression _ ">" _ right:Expression ")" { return { type: 'Function', name: 'GreaterThan', args: [left, right] } } 74 | / "(" left:Expression _ "<" _ right:Expression ")" { return { type: 'Function', name: 'GreaterThan', args: [right, left] } } 75 | / "(" left:Expression _ ">=" _ right:Expression ")" { return { type: 'Function', name: 'GreaterThanOrEqualTo', args: [left, right] } } 76 | / "(" left:Expression _ "<=" _ right:Expression ")" { return { type: 'Function', name: 'GreaterThanOrEqualTo', args: [right, left] } } 77 | / "(" left:Expression _ "==" _ right:Expression ")" { return { type: 'Function', name: 'EqualTo', args: [left, right] } } 78 | / "(" left:Expression _ "!=" _ right:Expression ")" { return { type: 'Function', name: 'Not', args: [{ type: 'Function', name: 'EqualTo', args: [left, right] }] }} 79 | / "(" left:Expression _ "+" _ right:Expression ")" { return { type: 'Function', name: 'Add', args: [left, right] } } 80 | / "(" left:Expression _ "-" _ right:Expression ")" { return { type: 'Function', name: 'Subtract', args: [left, right] } } 81 | / "(" left:Expression _ "*" _ right:Expression ")" { return { type: 'Function', name: 'Multiply', args: [left, right] } } 82 | / "(" left:Expression _ "/" _ right:Expression ")" { return { type: 'Function', name: 'Divide', args: [left, right] } } 83 | 84 | Array 85 | = "[" vals:ExpressionList "]" { return { type: 'Function', name: 'MakeArray', args: vals } } 86 | / "[" _ "]" { return { type: 'Function', name: 'MakeEmptyArray', args: [] } } 87 | 88 | ExpressionList 89 | = head:Expression tail:(_ "," _ @Expression)* { return [head, ...tail] } 90 | 91 | UdfArg 92 | = "{" argNum:$[0-9]* "," _ argType:Type "}" { return { type: 'UdfArg', argNum: argNum, argType: argType} } 93 | 94 | Type 95 | = base:$[a-zA-Z]* generics:("<" TypeList ">")? params:("(" TypeList ")")? { 96 | var out = base 97 | if (!!generics) { 98 | out += "<" + generics[1].join(", ") + ">" 99 | } 100 | if (!!params) { 101 | out += "(" + params[1].join(", ") + ")" 102 | } 103 | return out 104 | } 105 | 106 | TypeList 107 | = head:Type tail:(_ "," _ @Type)* { return [head, ...tail] } 108 | / "" { return [] } 109 | -------------------------------------------------------------------------------- /parser/parser.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs/promises') 2 | const util = require('util') 3 | const peggy = require('peggy') 4 | 5 | const driver = require('./driver') 6 | 7 | async function parse(text, req, res, ctx, allowParams) { 8 | var out = { 9 | outputs: [], 10 | mutations: [], 11 | } 12 | var parser = null 13 | const data = await fs.readFile('parser/grammar.pegjs') 14 | try { 15 | parser = peggy.generate(data.toString()) 16 | } catch (err) { 17 | console.log(err) 18 | var line = data.toString().split('\n')[err.location.start.line - 1] 19 | out.outputs.push({ 20 | type: 'Error', 21 | data: { 22 | type: 'GrammarParseError', 23 | data: { 24 | line: line, 25 | location: err.location, 26 | } 27 | } 28 | }) 29 | return out 30 | } 31 | var tree = null 32 | try { 33 | tree = parser.parse(text, {startRule: "Input"}) 34 | } catch (err) { 35 | console.log(err) 36 | console.log(util.inspect(err.expected, {depth: null})) 37 | var line = text.split('\n')[err.location.start.line - 1] 38 | out.outputs.push({ 39 | type: 'Error', 40 | data: { 41 | type: 'InputParseError', 42 | data: { 43 | line: line, 44 | location: err.location, 45 | } 46 | } 47 | }) 48 | return out 49 | } 50 | try { 51 | for (const expr of tree) { 52 | var parsedExpr = driver.parseNode(expr, ctx, allowParams) 53 | if (parsedExpr.errors) { 54 | out.mutations = [] 55 | out.outputs = parsedExpr.errors.map((err) => { return { type: 'Error', data: err } }) 56 | return out 57 | } 58 | var outputType = parsedExpr.type 59 | if (outputType.params.length) { 60 | out.mutations = [] 61 | out.outputs = [{ type: 'Error', data: { errorType: 'WRONG_OUTPUT_TYPE', type: outputType } }] 62 | return out 63 | } 64 | var scriptResult = await parsedExpr.value({}, ctx) 65 | out.outputs.push({ type: outputType.type, data: scriptResult }) 66 | parsedExpr.mutations.forEach((mutation) => { 67 | if (!out.mutations.includes(mutation)) { 68 | out.mutations.push(mutation) 69 | } 70 | }) 71 | } 72 | out.outputs.push({ type: 'CompetitionWCIF', data: ctx.competition }) 73 | } catch (e) { 74 | out.outputs.splice(0, 0, {type: 'Exception', data: e.stack}) 75 | out.mutations = [] 76 | } 77 | return out 78 | } 79 | 80 | module.exports = { 81 | parse: parse, 82 | } 83 | -------------------------------------------------------------------------------- /perf.js: -------------------------------------------------------------------------------- 1 | class PerfLogger { 2 | constructor() { 3 | this.totalTimes = {} 4 | this.calls = {} 5 | this.totalCalls = 0 6 | this.active = {} 7 | this.firstLog = null 8 | this.lastLog = null 9 | this.enabled = false 10 | } 11 | 12 | start(ref) { 13 | this.active[ref] = (new Date()).getTime() 14 | if (this.lastLog === null) { 15 | this.firstLog = this.active[ref] 16 | this.lastLog = this.active[ref] 17 | } 18 | } 19 | 20 | stop(ref) { 21 | var stop = (new Date()).getTime() 22 | var start = this.active[ref] 23 | delete this.active[ref] 24 | var time = (stop - start) 25 | if (this.totalTimes[ref] === undefined) { 26 | this.totalTimes[ref] = 0 27 | this.calls[ref] = 0 28 | } 29 | this.totalTimes[ref] += time 30 | this.calls[ref] += 1 31 | this.totalCalls += 1 32 | 33 | if (stop - this.lastLog > 5000) { 34 | this.log() 35 | } 36 | } 37 | 38 | log() { 39 | this.lastLog = (new Date()).getTime() 40 | if (this.enabled) { 41 | console.log('Total time: ' + (this.lastLog - this.firstLog) / 1000) 42 | console.log('Calls: ') 43 | Object.entries(this.totalTimes).sort((a, b) => b[1] - a[1]).forEach((entry) => { 44 | console.log(entry[0] + ': ' + (entry[1] / 1000) + ' (' + this.calls[entry[0]] + ')') 45 | }) 46 | console.log('') 47 | } 48 | } 49 | } 50 | 51 | module.exports = { PerfLogger } 52 | -------------------------------------------------------------------------------- /pug_functions.js: -------------------------------------------------------------------------------- 1 | const { DateTime } = require('luxon') 2 | const activityCode = require('./activity_code') 3 | const extension = require('./extension') 4 | const lib = require('./lib') 5 | 6 | module.exports = { 7 | DateTime: DateTime, 8 | parseActivityCode: activityCode.parse, 9 | getExtension: extension.getExtension, 10 | personalBest: lib.personalBest, 11 | } 12 | -------------------------------------------------------------------------------- /specification.md: -------------------------------------------------------------------------------- 1 | # NatsHelper WCIF Extensions 2 | 3 | *Version: 1.0* 4 | 5 | NatsHelper defines a number of extensions to [WCIF](https://github.com/thewca/wcif), the WCA's specification of competition data. These extensions are documented here, though they shouldn't be consumed by other applications without discussing with us first. 6 | 7 | ## Objects 8 | 9 | We define extensions to a few of the objects defined in the [WCIF spec](https://github.com/thewca/wcif/blob/master/specification.md). For each WCIF object `Object` we define an extension of type `org.cubingusa.natshelper.v1.Object`. Please refer to the WCIF spec for details about what each object represents. 10 | 11 | - [Activity](#Activity) 12 | - [Competition](#Competition) 13 | - [Person](#Person) 14 | - [Function](#Function) 15 | 16 | ### Activity 17 | 18 | For a top-level `Activity` (i.e. an `Activity` which is not the `childActivity` of another `Activity`), the following data is stored: 19 | 20 | | Attribute | Type | Description | 21 | | --- | --- | --- | 22 | | `adjustment` | `String` | How many groups will be omitted from this `Activity` on this `Room`, relative to other `Room`s. For example, `+2-1` indicates that the first two and last one groups will be skipped. | 23 | 24 | ### Competition 25 | 26 | This is used as global storage for the competition. 27 | 28 | | Attribute | Type | Description | 29 | | --- | --- | --- | 30 | | `udf` | `Function` | User-defined functions for this competition. | 31 | 32 | ### Person 33 | 34 | | Attribute | Type | Description | 35 | | --- | --- | --- | 36 | | `properties` | `Object` | A mapping of user-defined key-value pairs. | 37 | | `staffUnavailable` | `Function` | A function returning true for groups that this person is not able to participate as a staff member. | 38 | 39 | ### Function 40 | 41 | Some properties are stored as functions, which can be executed later. The structure of these properties is as follows: 42 | 43 | | Attribute | Type | Description | 44 | | --- | --- | --- | 45 | | `cmd` | `String` | The NatsScript command used to define this function. | 46 | | `impl` | `Object` | The parsed command tree for this function. | 47 | -------------------------------------------------------------------------------- /staff/assign.js: -------------------------------------------------------------------------------- 1 | const solver = require('javascript-lp-solver') 2 | const activityCode = require('./../activity_code') 3 | const extension = require('./../extension') 4 | const lib = require('./../lib') 5 | const driver = require('./../parser/driver') 6 | 7 | function shuffle(array) { 8 | var cur = array.length; 9 | 10 | while (cur != 0) { 11 | var rnd = Math.floor(Math.random() * cur); 12 | cur--; 13 | 14 | [array[cur], array[rnd]] = [array[rnd], array[cur]]; 15 | } 16 | } 17 | 18 | function AssignImpl(ctx, activities, persons, jobs, scorers, overwrite, name, avoidConflicts, unavailable) { 19 | var competition = ctx.competition 20 | var allGroups = lib.allGroups(competition) 21 | var groupIds = activities.map((group) => group.wcif.id) 22 | 23 | // Check if there's anyone who already has a staff assignment. 24 | var peopleAlreadyAssigned = competition.persons.filter((person) => { 25 | return person.assignments.filter((assignment) => { 26 | return assignment.assignmentCode !== 'competitor' && groupIds.includes(assignment.activityId) 27 | }).length > 0 28 | }) 29 | if (peopleAlreadyAssigned.length > 0) { 30 | if (overwrite) { 31 | peopleAlreadyAssigned.forEach((person) => { 32 | person.assignments = person.assignments.filter((assignment) => { 33 | return assignment.assignmentCode === 'competitor' || !groupIds.includes(assignment.activityId) 34 | }) 35 | }) 36 | } else { 37 | return { 38 | round: name, 39 | warnings: ['Jobs are already saved. Not overwriting unless overwrite=true is added.'], 40 | assignments: { 41 | activities: activities, 42 | jobs: {}, 43 | }, 44 | } 45 | } 46 | } 47 | 48 | var out = { 49 | round: name, 50 | warnings: [], 51 | assignments: { 52 | activities: activities, 53 | jobs: {}, 54 | }, 55 | } 56 | 57 | var jobAssignments = out.assignments.jobs 58 | jobs.forEach((job) => { 59 | if (job.assignStations) { 60 | [...Array(job.count).keys()].forEach((num) => { 61 | jobAssignments[job.name + '-' + (num + 1)] = [] 62 | }) 63 | } else { 64 | jobAssignments[job.name] = [] 65 | } 66 | }) 67 | 68 | var unavailableByPerson = {} 69 | persons.forEach((person) => { 70 | unavailableByPerson[person.wcaUserId] = unavailable({Person: person}) || [] 71 | }) 72 | 73 | activities.forEach((activity, idx) => { 74 | var conflictingGroupIds = allGroups.filter((otherGroup) => { 75 | return activity.startTime < otherGroup.endTime && otherGroup.startTime < activity.endTime 76 | }).map((activity) => activity.wcif.id) 77 | var eligiblePeople = persons.filter((person) => { 78 | if (avoidConflicts && 79 | !person.assignments.every((assignment) => !conflictingGroupIds.includes(assignment.activityId))) { 80 | return false 81 | } 82 | return !unavailableByPerson[person.wcaUserId].some((unavail) => unavail(activity)) 83 | }) 84 | var neededPeople = jobs.map((job) => job.count).reduce((a, v) => a+v) 85 | if (eligiblePeople.length < neededPeople) { 86 | out.warnings.push('Not enough people for activity ' + activity.name() + ' (needed ' + neededPeople + ', got ' + eligiblePeople.length + ')') 87 | return 88 | } 89 | var model = { 90 | optimize: 'score', 91 | opType: 'max', 92 | constraints: {}, 93 | variables: {}, 94 | ints: {}, 95 | } 96 | jobs.forEach((job) => { 97 | if (job.assignStations) { 98 | [...Array(job.count).keys()].forEach((num) => { 99 | model.constraints['job-' + job.name + '-' + (num + 1)] = {equal: 1} 100 | }) 101 | } else { 102 | model.constraints['job-' + job.name] = {equal: job.count} 103 | } 104 | }) 105 | shuffle(eligiblePeople) 106 | eligiblePeople.forEach((person, idx) => { 107 | model.constraints['person-' + idx] = {min: 0, max: 1} 108 | var personScore = 0 109 | scorers.forEach((scorer) => { 110 | if (!scorer.caresAboutJobs) { 111 | var start = Date.now() 112 | var subscore = scorer.Score(competition, person, activity) 113 | var end = Date.now() 114 | personScore += subscore 115 | } 116 | }) 117 | jobs.forEach((job) => { 118 | if (!job.eligibility({Person: person})) { 119 | return 120 | } 121 | var jobScore = personScore 122 | scorers.forEach((scorer) => { 123 | if (scorer.caresAboutJobs && !scorer.caresAboutStations) { 124 | var start = Date.now() 125 | var subscore = scorer.Score(competition, person, activity, job.name) 126 | var end = Date.now() 127 | jobScore += subscore 128 | } 129 | }) 130 | var stations = job.assignStations ? [...Array(job.count).keys()] : [null] 131 | stations.forEach((num) => { 132 | var numStr = (num === null) ? '' : '-' + (num + 1) 133 | var score = jobScore 134 | scorers.forEach((scorer) => { 135 | if (scorer.caresAboutStations) { 136 | var start = Date.now() 137 | var subscore = scorer.Score(competition, person, activity, job.name, num + 1) 138 | var end = Date.now() 139 | score += subscore 140 | } 141 | }) 142 | var key = 'assignment-' + idx + '-' + job.name + numStr 143 | model.variables[key] = {score: score} 144 | model.variables[key]['person-' + idx] = 1 145 | model.variables[key]['job-' + job.name + numStr] = 1 146 | model.variables[key][key] = 1 147 | model.constraints[key] = {min: 0, max: 1} 148 | model.ints[key] = 1 149 | }) 150 | }) 151 | }) 152 | var start = Date.now() 153 | var solution = solver.Solve(model) 154 | var end = Date.now() 155 | if (!solution.feasible) { 156 | out.warnings.push('Failed to find a solution for activity ' + activity.name()) 157 | jobs.forEach((job) => { 158 | var jobEligiblePeople = eligiblePeople.filter((person) => { 159 | return job.eligibility({Person: person}) 160 | }).map((person) => person.name) 161 | out.warnings.push('Eligible people for ' + job.name + ': ' + jobEligiblePeople.toString()) 162 | }) 163 | return 164 | } 165 | Object.keys(solution).forEach((key) => { 166 | if (!key.startsWith('assignment-') || solution[key] !== 1) { 167 | return 168 | } 169 | var spl = key.split('-') 170 | var personIdx = +spl[1] 171 | var jobName = spl[2] 172 | var stationNumber = null 173 | if (spl.length > 3) { 174 | stationNumber = +spl[3] 175 | } 176 | 177 | const person = eligiblePeople[personIdx]; 178 | var totalScore = 0 179 | var breakdown = {} 180 | scorers.forEach((scorer, idx) => { 181 | var subscore = scorer.Score(competition, person, activity, jobName, stationNumber) 182 | totalScore += subscore 183 | breakdown[scorer.constructor.name + idx] = subscore 184 | }) 185 | var jobKey = jobName + (stationNumber ? '-' + stationNumber : '') 186 | var activityKey = activity.wcif.id 187 | if (!(activityKey in jobAssignments[jobKey])) { 188 | jobAssignments[jobKey][activityKey] = [] 189 | } 190 | jobAssignments[jobKey][activityKey].push({ 191 | person: person, 192 | score: { 193 | total: totalScore, 194 | breakdown: breakdown, 195 | } 196 | }) 197 | if (!person.assignments) { 198 | person.assignments = [] 199 | } 200 | person.assignments.push({ 201 | activityId: activity.wcif.id, 202 | assignmentCode: 'staff-' + jobName, 203 | stationNumber: stationNumber 204 | }) 205 | }) 206 | }) 207 | return out 208 | } 209 | 210 | function Assign(ctx, round, groupFilter, persons, jobs, scorers, overwrite, avoidConflicts, unavailable) { 211 | var competition = ctx.competition 212 | var groups = lib.groupsForRoundCode(competition, round).filter((group) => { 213 | return groupFilter({Group: group}) 214 | }) 215 | return AssignImpl(ctx, groups, persons, jobs, scorers, overwrite, round.toString(), avoidConflicts, unavailable) 216 | } 217 | 218 | function AssignMisc(ctx, activityId, persons, jobs, scorers, overwrite, avoidConflicts) { 219 | var activity = lib.miscActivityForId(ctx.competition, activityId) 220 | if (activity === null) { 221 | return { 222 | round: 'unknown', 223 | warnings: ['No activity found.'], 224 | assignments: { 225 | activities: [], 226 | jobs: {}, 227 | }, 228 | } 229 | } 230 | return AssignImpl(ctx, [activity], persons, jobs, scorers, overwrite, activity.name(), avoidConflicts) 231 | } 232 | 233 | function Job(name, count, assignStations, eligibility) { 234 | return { 235 | name: name, 236 | count: count, 237 | assignStations: assignStations, 238 | eligibility: eligibility, 239 | } 240 | } 241 | 242 | module.exports = { 243 | Assign: Assign, 244 | AssignMisc: AssignMisc, 245 | Job: Job, 246 | } 247 | -------------------------------------------------------------------------------- /staff/scorers.js: -------------------------------------------------------------------------------- 1 | const extension = require('./../extension') 2 | const lib = require('./../lib') 3 | 4 | class JobCountScorer { 5 | constructor(weight) { 6 | this.weight = weight 7 | this.caresAboutStations = false 8 | this.caresAboutJobs = false 9 | this.name = 'JobCountScorer' 10 | } 11 | 12 | Score(competition, person, group) { 13 | return this.weight * person.assignments.filter((assignment) => assignment.assignmentCode.startsWith('staff-')).length 14 | } 15 | } 16 | 17 | class PriorAssignmentScorer { 18 | constructor(competition, staffingWeight, competingWeight, startTime) { 19 | this.staffingWeight = staffingWeight 20 | this.competingWeight = competingWeight 21 | this.startTime = startTime 22 | this.name = 'PriorAssignmentScorer' 23 | this.groupsById = Object.fromEntries(lib.allGroups(competition).map((g) => [g.wcif.id, g])) 24 | } 25 | 26 | Score(competition, person, group) { 27 | var staffingHours = 0 28 | var competingHours = 0 29 | var startTime = lib.startTime(group, competition) 30 | for (const assignment of person.assignments) { 31 | var otherGroup = null 32 | if (assignment.activityId in this.groupsById) { 33 | otherGroup = this.groupsById[assignment.activityId] 34 | } else { 35 | otherGroup = lib.miscActivityForId(competition, assignment.activityId) 36 | } 37 | if (otherGroup !== null) { 38 | var otherStart = lib.startTime(otherGroup, competition) 39 | var otherEnd = lib.endTime(otherGroup, competition) 40 | if (otherStart < startTime && (this.startTime === null || otherStart > this.startTime)) { 41 | var hours = otherEnd.diff(otherStart, 'hours').as('hours') 42 | if (assignment.assignmentCode.startsWith('staff-')) { 43 | staffingHours += hours 44 | } else { 45 | competingHours += hours 46 | } 47 | } 48 | } 49 | } 50 | return this.staffingWeight * staffingHours + this.competingWeight * competingHours 51 | } 52 | } 53 | 54 | class PreferenceScorer { 55 | constructor(weight, prefix, prior, allJobs) { 56 | this.weight = weight 57 | this.prefix = prefix 58 | this.prior = prior 59 | this.allJobs = allJobs 60 | this.caresAboutStations = false 61 | this.caresAboutJobs = true 62 | this.name = 'PreferenceScorer' 63 | } 64 | 65 | Score(competition, person, group, job) { 66 | var ext = extension.getExtension(person, 'Person') || {} 67 | var prefs = Object.entries((ext.properties || {})) 68 | .filter((e) => e[0].startsWith(this.prefix)) 69 | .map((e) => [e[0].slice(this.prefix.length), e[1]]) 70 | var totalPrefs = prefs.reduce((s, e) => s + e[1], 0) 71 | if (totalPrefs === 0) { 72 | return 0 73 | } 74 | var ratios = Object.fromEntries(prefs.map((e) => [e[0], e[1] / totalPrefs])) 75 | if (!(job in ratios)) { 76 | return -100000 77 | } 78 | 79 | var allAssignments = person.assignments 80 | .filter((assignment) => assignment.assignmentCode.startsWith('staff-')) 81 | var matchingAssignments = allAssignments.filter((assignment) => assignment.assignmentCode === 'staff-' + job) 82 | if (allAssignments.length === 0) { 83 | return 0 84 | } 85 | var targetRatio = ratios[job] 86 | var actualRatio = matchingAssignments.length / allAssignments.length 87 | var decay = Math.min(allAssignments.length, this.prior) / this.prior 88 | return decay * this.weight * (targetRatio - actualRatio) 89 | } 90 | } 91 | 92 | function PrecedingAssignment(person, group, groupsById) { 93 | var assignmentsFiltered = person.assignments.filter((assignment) => { 94 | return groupsById[assignment.activityId] !== undefined && 95 | +groupsById[assignment.activityId].endTime === +group.startTime 96 | }) 97 | if (assignmentsFiltered.length) { 98 | return assignmentsFiltered[0] 99 | } 100 | return null 101 | } 102 | 103 | class PrecedingAssignmentsScorer { 104 | constructor(competition, center, posWeight, negWeight, assignmentFilter, jobs) { 105 | this.center = center 106 | this.posWeight = posWeight 107 | this.negWeight = negWeight 108 | this.assignmentFilter = assignmentFilter 109 | this.groupsById = Object.fromEntries(lib.allGroups(competition).map((g) => [g.wcif.id, g])) 110 | this.caresAboutStations = false 111 | this.caresAboutJobs = true 112 | this.jobs = jobs 113 | } 114 | 115 | Score(competition, person, group, job, stationNumber) { 116 | if (!(this.jobs || []).includes(job)) { 117 | return 0 118 | } 119 | var assignment = PrecedingAssignment(person, group, this.groupsById) 120 | if (assignment === null || !this.assignmentFilter(assignment, job)) { 121 | return 0 122 | } 123 | var mostRecentGroup = this.groupsById[assignment.activityId] 124 | var endTime = mostRecentGroup.endTime 125 | var startTime = mostRecentGroup.startTime 126 | while (assignment !== null && this.assignmentFilter(assignment, job)) { 127 | var nextGroup = this.groupsById[assignment.activityId] 128 | startTime = nextGroup.startTime 129 | assignment = PrecedingAssignment(person, nextGroup, this.groupsById) 130 | } 131 | var totalTime = endTime.diff(startTime, 'minutes').minutes 132 | if (totalTime > this.center) { 133 | return (totalTime - this.center) / this.center * this.posWeight 134 | } else { 135 | return (this.center - totalTime) / this.center * this.negWeight 136 | } 137 | } 138 | } 139 | 140 | class MismatchedStationScorer { 141 | constructor(competition, weight) { 142 | this.groupsById = Object.fromEntries(lib.allGroups(competition).map((g) => [g.wcif.id, g])) 143 | this.caresAboutStations = true 144 | this.caresAboutJobs = true 145 | this.weight = weight 146 | } 147 | Score(competition, person, group, job, stationNumber) { 148 | var previousAssignment = PrecedingAssignment(person, group, this.groupsById) 149 | if (stationNumber !== null && previousAssignment !== null && 150 | previousAssignment.stationNumber !== null && 151 | 'staff-' + job === previousAssignment.assignmentCode && 152 | previousAssignment.stationNumber !== stationNumber) { 153 | return this.weight 154 | } 155 | return 0 156 | } 157 | } 158 | 159 | class SolvingSpeedScorer { 160 | constructor(event, maxTime, weight, jobs) { 161 | this.event = event 162 | this.maxTime = maxTime 163 | this.weight = weight 164 | this.jobs = jobs 165 | this.caresAboutStations = false 166 | this.caresAboutJobs = true 167 | this.name = 'SolvingSpeedScorer' 168 | } 169 | 170 | Score(competition, person, group, job) { 171 | if (!(this.jobs || []).includes(job)) { 172 | return 0 173 | } 174 | var pr = lib.personalBest(person, this.event) 175 | if (pr > this.maxTime || pr == null) { 176 | return 0 177 | } 178 | return this.weight * (1 - pr.value / this.maxTime) 179 | } 180 | } 181 | 182 | class GroupScorer { 183 | constructor(condition, weight) { 184 | this.condition = condition 185 | this.weight = weight 186 | this.caresAboutStations = false 187 | this.caresAboutJobs = false 188 | this.name = 'GroupScorer' 189 | } 190 | 191 | Score(competition, person, group) { 192 | if (this.condition({Person: person, Group: group})) { 193 | return this.weight 194 | } else { 195 | return 0 196 | } 197 | } 198 | } 199 | 200 | class FollowingGroupScorer { 201 | constructor(competition, weight) { 202 | this.groupToTime = Object.fromEntries( 203 | lib.allGroups(competition).map((group) => [group.wcif.id, group.startTime.toSeconds()])) 204 | this.weight = weight 205 | this.caresAboutStations = false 206 | this.caresAboutJobs = false 207 | } 208 | 209 | Score(competition, person, group) { 210 | if (person.assignments.filter((assignment) => assignment.assignmentCode === 'competitor') 211 | .map((assignment) => this.groupToTime[assignment.activityId]) 212 | .includes(group.endTime.toSeconds())) { 213 | return this.weight 214 | } else { 215 | return 0 216 | } 217 | } 218 | } 219 | 220 | class PersonPropertyScorer { 221 | constructor(filter, weight) { 222 | this.filter = filter 223 | this.weight = weight 224 | } 225 | 226 | Score(competition, person, group) { 227 | if (this.filter({Person: person, Group: group})) { 228 | return this.weight 229 | } else { 230 | return 0 231 | } 232 | } 233 | } 234 | 235 | class ComputedWeightScorer { 236 | constructor(weightFn, jobs) { 237 | this.weightFn = weightFn 238 | this.caresAboutJobs = true 239 | this.jobs = jobs 240 | } 241 | 242 | Score(competition, person, group, job) { 243 | if (!this.jobs.includes(job)) { 244 | return 0 245 | } 246 | return this.weightFn({Person: person}) 247 | } 248 | } 249 | 250 | class ConditionalScorer { 251 | constructor(personCondition, groupCondition, jobCondition, stationCondition, score) { 252 | this.personCondition = personCondition 253 | this.groupCondition = groupCondition 254 | this.jobCondition = jobCondition 255 | this.stationCondition = stationCondition 256 | this.score = score 257 | this.caresAboutJobs = true 258 | this.caresAboutStations = true 259 | } 260 | 261 | Score(competition, person, group, job, stationNumber) { 262 | if (this.personCondition({Person: person}) && 263 | this.groupCondition({Group: group}) && 264 | this.jobCondition({String: job}) && 265 | this.stationCondition({Number: stationNumber})) { 266 | return this.score 267 | } 268 | return 0 269 | } 270 | } 271 | 272 | module.exports = { 273 | JobCountScorer: JobCountScorer, 274 | PriorAssignmentScorer: PriorAssignmentScorer, 275 | PreferenceScorer: PreferenceScorer, 276 | PrecedingAssignmentsScorer: PrecedingAssignmentsScorer, 277 | MismatchedStationScorer: MismatchedStationScorer, 278 | SolvingSpeedScorer: SolvingSpeedScorer, 279 | GroupScorer: GroupScorer, 280 | FollowingGroupScorer: FollowingGroupScorer, 281 | PersonPropertyScorer, 282 | ComputedWeightScorer, 283 | ConditionalScorer, 284 | } 285 | -------------------------------------------------------------------------------- /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cubingusa/compscript/ff19160bace03588a16e132b3e72ffd04ba6b73f/static/favicon.ico -------------------------------------------------------------------------------- /views/cluster.pug: -------------------------------------------------------------------------------- 1 | mixin clusters(data) 2 | h3= data.name + ' clusters' 3 | table(class='cluster') 4 | tr 5 | th Cluster 6 | th People 7 | each constraint in data.constraints 8 | th= constraint 9 | each cluster in data.clusters 10 | tr 11 | td= cluster.id 12 | td 13 | div= cluster.persons.length 14 | a(onclick='toggleCluster(' + cluster.id + ')' id='link-' + cluster.id) [show] 15 | each constraint in data.constraints 16 | td= cluster.constraints[constraint] 17 | each person in cluster.persons 18 | tr(class='person-row cluster-' + cluster.id) 19 | td= person.person.name 20 | td= person.person.wcaId 21 | each constraint in data.constraints 22 | if person.constraints[constraint] === true 23 | td(style='background-color: #00ff00')= person.constraints[constraint] 24 | else 25 | td= person.constraints[constraint] 26 | -------------------------------------------------------------------------------- /views/dispatch.pug: -------------------------------------------------------------------------------- 1 | include cluster.pug 2 | include grammar_error.pug 3 | include groups.pug 4 | include help.pug 5 | include spreadsheet.pug 6 | include staff.pug 7 | include table.pug 8 | include udfs.pug 9 | 10 | mixin dispatch(value) 11 | if value.data === null 12 | i= "null" 13 | else 14 | if value.type == 'Error' 15 | pre= JSON.stringify(value.data, null, 2) 16 | if value.type == 'InputParseError' 17 | +inputParseError(value.data) 18 | if value.type == 'GrammarParseError' 19 | +grammarParseError(value.data) 20 | if value.type == 'Exception' 21 | pre= value.data 22 | if value.type == 'Table' 23 | +table(value.data) 24 | if value.type == 'String' 25 | div= value.data 26 | if value.type == 'Number' 27 | div= value.data 28 | if value.type == 'Boolean' 29 | div= value.data.toString() 30 | if value.type == 'Header' 31 | h3= value.data 32 | if value.type == 'ClusteringResult' 33 | +clusters(value.data) 34 | if value.type == 'GroupAssignmentResult' 35 | +groups(value.data) 36 | if value.type == 'ReadSpreadsheetResult' 37 | +spreadsheet(value.data) 38 | if value.type == 'StaffAssignmentResult' 39 | +staff(value.data) 40 | if value.type == 'AttemptResult' 41 | div= value.data.toString() 42 | if value.type == 'Multi' 43 | each data in value.data 44 | +dispatch(data) 45 | if value.type.startsWith('Array<') 46 | each data in value.data 47 | +dispatch({ type: value.type.substr(6, value.type.length - 7), data: data }) 48 | if value.type.startsWith('Tuple<') 49 | each data, idx in value.data 50 | +dispatch({ type: value.type.substr(6, value.type.length - 7).split(',')[idx].trim(), data: data }) 51 | if value.type == 'Person' 52 | div= value.data.name 53 | if value.type == 'ListFunctionsOutput' 54 | +list_functions(value.data) 55 | if value.type == 'FunctionHelp' 56 | +help(value.data) 57 | if value.type == 'DateTime' 58 | div= value.data.toLocaleString(fn.DateTime.DATETIME_MED) 59 | if value.type == 'Date' 60 | div= value.data.toLocaleString(fn.DateTime.DATE_FULL) 61 | if value.type == 'Event' 62 | div= value.data.eventId 63 | if value.type == 'NoPageBreak' 64 | div(style={'break-inside': 'avoid'}) 65 | each data in value.data 66 | +dispatch(data) 67 | if value.type == 'ListScriptsOutput' 68 | +list_scripts(value.data) 69 | if value.type == 'CompetitionWCIF' 70 | a(href=`data:application/json;charset=utf-8,${encodeURIComponent(JSON.stringify(value.data))}`, target='_blank'). 71 | Open WCIF as JSON in a new tab 72 | -------------------------------------------------------------------------------- /views/error.pug: -------------------------------------------------------------------------------- 1 | if comp.statusMessage 2 | div(style='color: green')= comp.statusMessage 3 | if comp.error 4 | div(style='color: red')= comp.error 5 | -------------------------------------------------------------------------------- /views/grammar_error.pug: -------------------------------------------------------------------------------- 1 | mixin inputParseError(data) 2 | div Failed to parse input 3 | div= "Line " + data.location.start.line 4 | pre= data.line + '\n' + '~'.repeat(data.location.start.column - 1) + '^' 5 | 6 | mixin grammarParseError(data) 7 | div Failed to parse grammar 8 | div= "Line " + data.location.start.line 9 | pre= data.line + '\n' + '~'.repeat(data.location.start.column - 1) + '^' 10 | -------------------------------------------------------------------------------- /views/groups.pug: -------------------------------------------------------------------------------- 1 | mixin groups(data) 2 | h3= data.round.toString() + " Groups" 3 | each warning in data.warnings 4 | pre= JSON.stringify(warning, null, 2) 5 | each group in data.groups 6 | if group.wcif.id in data.assignments 7 | h4= group.name() + " (" + data.assignments[group.wcif.id].length + ")" 8 | table 9 | tr 10 | th Name 11 | th WCA ID 12 | th Country 13 | th Personal Best 14 | th Assignment Set 15 | each assignment in data.assignments[group.wcif.id] 16 | tr 17 | td= assignment.person.name + ('stationNumber' in assignment ? (' (' + assignment.stationNumber + ')') : '') 18 | td 19 | a(href='https://wca.link/' + assignment.person.wcaId)= assignment.person.wcaId 20 | td= assignment.person.countryIso2 21 | - var pb = fn.personalBest(assignment.person, data.round) 22 | if pb 23 | td= pb.toString() 24 | else 25 | td= "" 26 | td= assignment.set 27 | -------------------------------------------------------------------------------- /views/help.pug: -------------------------------------------------------------------------------- 1 | mixin list_functions(data) 2 | h3 Built-in Functions 3 | ul 4 | each fn in data 5 | li 6 | a(href=`?script=Help("${fn}")`)= fn 7 | 8 | mixin help(data) 9 | each fn in data 10 | h3= fn.name 11 | if fn.docs 12 | div= fn.docs 13 | if fn.genericParams !== undefined 14 | h4 Generics 15 | ul 16 | each generic in fn.genericParams 17 | li= generic 18 | if fn.args.length > 0 19 | h4 Args 20 | ul 21 | each arg in fn.args 22 | li= arg.name 23 | = ` (type: ${arg.type})` 24 | if arg.nullable 25 | | (nullable) 26 | if arg.defaultValue !== undefined 27 | = ` (default: ${arg.defaultValue})` 28 | if arg.canBeExternal 29 | | (can be external) 30 | if arg.repeated 31 | | (repeated) 32 | if arg.lazy 33 | | (lazy) 34 | h4 Return Type 35 | ul 36 | li= fn.outputType 37 | if fn.mutations 38 | h4 Mutations 39 | ul 40 | each mutation in fn.mutations 41 | li= mutation 42 | 43 | mixin spreadsheet(data) 44 | if data.warnings.length > 0 45 | p 46 | = data.warnings.length + ' warnings' 47 | ul 48 | each warning in data.warnings 49 | li= warning 50 | = 'Loaded ' + data.loaded + ' people' 51 | -------------------------------------------------------------------------------- /views/index.pug: -------------------------------------------------------------------------------- 1 | html 2 | head 3 | title Compscript 4 | body 5 | h1 Compscript 6 | each competition in competitions 7 | p 8 | a(href='/'+competition.id)= competition.name 9 | -------------------------------------------------------------------------------- /views/script.css: -------------------------------------------------------------------------------- 1 | table,tr,td,th { 2 | border: 1px solid black; 3 | border-collapse: collapse; 4 | padding: 5px; 5 | } 6 | 7 | .cluster .person-row { 8 | display: none; 9 | font-size: 12px; 10 | } 11 | -------------------------------------------------------------------------------- /views/script.js: -------------------------------------------------------------------------------- 1 | function toggleCluster(cluster) { 2 | document.querySelectorAll('.cluster-' + cluster).forEach((elt) => { 3 | if (elt.style.display == 'table-row') { 4 | elt.style.display = 'none' 5 | document.getElementById('link-' + cluster).innerHTML = '[show]' 6 | } else { 7 | elt.style.display = 'table-row' 8 | document.getElementById('link-' + cluster).innerHTML = '[hide]' 9 | } 10 | }) 11 | } 12 | -------------------------------------------------------------------------------- /views/script.pug: -------------------------------------------------------------------------------- 1 | include dispatch.pug 2 | html 3 | head 4 | title Compscript 5 | style 6 | include script.css 7 | link(rel='stylesheet', href='/cubing-icons.css') 8 | script 9 | include script.js 10 | body 11 | h1 12 | a(href='/' + comp.id)= comp.name 13 | = " Compscript" 14 | include error.pug 15 | div(id='competitionId' data-competitionId=comp.id style='display:none') 16 | form(method='POST') 17 | h3= "Execute saved script" 18 | if files.length 19 | select(name='filename') 20 | option() 21 | each file in files 22 | option(value=file selected=(file === selectedFile))= file 23 | br 24 | h3= "Run custom script" 25 | textarea(name='script' cols=100 rows=10)= script 26 | br 27 | label 28 | input(type='checkbox' name='dryrun' checked=dryrun) 29 | = "Dry Run" 30 | label 31 | input(type='checkbox' name='clearCache' checked=clearCache) 32 | = "Clear Cache" 33 | br 34 | input(type='submit') 35 | if dryrunWarning 36 | p 37 | i Note: this was a dry run, so no changes were made. 38 | br 39 | each output in outputs 40 | div 41 | +dispatch(output) 42 | -------------------------------------------------------------------------------- /views/spreadsheet.pug: -------------------------------------------------------------------------------- 1 | mixin spreadsheet(data) 2 | if data.warnings.length > 0 3 | p 4 | = data.warnings.length + ' warnings' 5 | ul 6 | each warning in data.warnings 7 | li= warning 8 | = 'Loaded ' + data.loaded + ' people' 9 | -------------------------------------------------------------------------------- /views/staff.pug: -------------------------------------------------------------------------------- 1 | mixin staff(data) 2 | h3= data.round.toString() + " Staff Assignments" 3 | each warning in data.warnings 4 | pre= JSON.stringify(warning, null, 2) 5 | table 6 | tr 7 | th Job 8 | each activity in data.assignments.activities 9 | th= activity.name() 10 | each jobName in Object.keys(data.assignments.jobs) 11 | tr 12 | td= jobName 13 | each activity in data.assignments.activities 14 | td 15 | if activity.wcif.id in data.assignments.jobs[jobName] 16 | each subAssignment in data.assignments.jobs[jobName][activity.wcif.id] 17 | div= subAssignment.person.name 18 | -------------------------------------------------------------------------------- /views/table.pug: -------------------------------------------------------------------------------- 1 | mixin table(data) 2 | if data.rows.length == 0 3 | i No rows were returned 4 | table 5 | tr 6 | each header in data.headers 7 | th= header 8 | each row in data.rows 9 | tr 10 | each cell in row 11 | td(style=cell.value == null ? 'background-color: #ccc' : '') 12 | if cell.link !== null 13 | a(href=cell.link) 14 | if cell.value !== null 15 | =cell.value.toString() 16 | else 17 | if cell.value !== null 18 | =cell.value.toString() 19 | -------------------------------------------------------------------------------- /views/udfs.pug: -------------------------------------------------------------------------------- 1 | mixin list_scripts(data) 2 | ul 3 | each fn in data 4 | li 5 | a(href=`?script=${fn.name}()&filename=${selectedFile}`)= fn.name 6 | -------------------------------------------------------------------------------- /wcif_data/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cubingusa/compscript/ff19160bace03588a16e132b3e72ffd04ba6b73f/wcif_data/.keep --------------------------------------------------------------------------------