[
  {
    "path": ".circleci/config.yml",
    "content": "version: 2\njobs:\n  build:\n    docker:\n      - image: circleci/node:12.19\n        environment:\n          NODE_ENV: circle_ci\n    steps:\n      - checkout\n      - run: ./bin/install-circleci\n      - run: ./bin/lint\n      - run: ./bin/test\n"
  },
  {
    "path": ".dockerignore",
    "content": ".data\ndist\n**/dist\n**/dist-commonjs\nnode_modules\n**/node_modules\n.vagrant\n.dockerignore\nDockerfile\nnpm-debug.*\n.git\n.hg\n.svn\nconfig/local.json\n"
  },
  {
    "path": ".editorconfig",
    "content": "# editorconfig.org\nroot = true\n\n[*]\nindent_style = space\nindent_size = 2\nend_of_line = lf\ncharset = utf-8\ntrim_trailing_whitespace = true\ninsert_final_newline = true\n\n[*.md]\ntrim_trailing_whitespace = false\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE.md",
    "content": "<!--- Provide a general summary of the issue in the Title above -->\n\n## Context\n<!--- Provide a more detailed introduction to the issue itself, and why you consider it to be a bug -->\n\n## Expected Behavior\n<!--- Tell us what should happen -->\n\n## Actual Behavior\n<!--- Tell us what happens instead -->\n\n## Possible Fix\n<!--- Not obligatory, but suggest a fix or reason for the bug -->\n\n## Steps to Reproduce\n<!--- Provide a link to a live example, or an unambiguous set of steps to -->\n<!--- reproduce this bug include code to reproduce, if relevant -->\n1. \n2. \n3. \n4. \n\n## Context\n<!--- How has this bug affected you? What were you trying to accomplish? -->\n\n## Your Environment\n<!--- Include as many relevant details about the environment you experienced the bug in -->\n* Environment name and version (e.g. Chrome 39, etc):\n* Operating System and version (desktop or mobile):\n"
  },
  {
    "path": ".github/PULL_REQUEST_TEMPLATE.md",
    "content": "<!--- Provide a general summary of your changes in the Title above -->\n\n## Description\n<!--- Describe your changes in detail -->\n\n## Motivation and Context\n<!--- Why is this change required? What problem does it solve? -->\n<!--- If it fixes an open issue, please link to the issue here. -->\n\n## How Has This Been Tested?\n<!--- Please describe in detail how you tested your changes. -->\n<!--- Include details of your testing environment, and the tests you ran to -->\n<!--- see how your change affects other areas of the code, etc. -->\n\n## Screenshots (if appropriate):\n\n## Types of changes\n<!--- What types of changes does your code introduce? Put an `x` in all the boxes that apply: -->\n- [ ] Bug fix (non-breaking change which fixes an issue)\n- [ ] New feature (non-breaking change which adds functionality)\n- [ ] Breaking change (fix or feature that would cause existing functionality to change)\n\n## Checklist:\n<!--- Go over all the following points, and put an `x` in all the boxes that apply. -->\n<!--- If you're unsure about any of these, don't hesitate to ask. We're here to help! -->\n- [ ] I have added tests to cover my changes."
  },
  {
    "path": ".gitignore",
    "content": ".idea/\nnode_modules/\nnpm-debug.*\ntmp/\n.tmp/\ndist/\ndist-commonjs/\n.sw[a-z]\n.DS_Store\nbuild/\n*.js.map\n*.css.map\n.awcache\nansible_playbook.retry\n.data\nlerna-debug.log\nsource-context.json\nsource-contexts.json\n*.rdb\n"
  },
  {
    "path": "CHANGELOG.md",
    "content": "# v1.0.5\n\n* Fix bug where settings page ids are set to integers ([PR](https://github.com/conversationai/conversationai-moderator/pull/7))\n* Update frontend validation for articleId to allow for alphanumeric ids  ([PR](https://github.com/conversationai/conversationai-moderator/pull/6))\n\n# v1.0.4\n\n* Update data validation for publisher commentActions endpoint ([PR](https://github.com/conversationai/conversationai-moderator/pull/1))\n\n# v1.0.0\n\n* Open Source\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# How to contribute\n\nWe'd love to accept your patches and contributions to this project. There are\njust a few small guidelines you need to follow.\n\n## Contributor License Agreement\n\nContributions to this project must be accompanied by a Contributor License\nAgreement. You (or your employer) retain the copyright to your contribution,\nthis simply gives us permission to use and redistribute your contributions as\npart of the project. Head over to <https://cla.developers.google.com/> to see\nyour current agreements on file or to sign a new one.\n\nYou generally only need to submit a CLA once, so if you've already submitted one\n(even if it was for a different project), you probably don't need to do it\nagain.\n\n## Code reviews\n\nAll submissions, including submissions by project members, require review. We\nuse GitHub pull requests for this purpose. Consult [GitHub Help] for more\ninformation on using pull requests.\n\n[GitHub Help]: https://help.github.com/articles/about-pull-requests/\n"
  },
  {
    "path": "LICENSE",
    "content": "                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   Copyright {2016} {Jigsaw}\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License.\n"
  },
  {
    "path": "QUICKSTART.md",
    "content": "These are instructions on how to get a youtube instance of moderator running on a Google Cloud compute VM.\n\nStep 0:\n-------\n\nCreate a Google cloud project and a VM running 'Ubuntu 18.04 LTS Minimal' in the [Google console](https://console.cloud.google.com/compute/instances).\n\nYou'll also need to create a [firewall rule](https://console.cloud.google.com/networking/firewalls/list) to allow\nHTTP traffic, and add that rule to your VM network tags.\n\nYou'll also need to allocate a domain name for your new VM  - unfortunately the Google OAuth servers won't work with IP addresses.\n\nStep 1:\n-------\n\nCreate an OAuth2.0 Client ID  entry in the [Google console](https://console.developers.google.com/apis/credentials).\nAdd the following Authorised redirect URIs:\n\n```\nhttp://<domain name from step 0>/api/auth/callback/google\nhttp://<domain name from step 0>/api/youtube/callback\n```\n\nYou'll be asked to enter this information when you first log into OS Moderator.\n\nStep 2:\n-------\n\nOpen a terminal to the VM and install the following packages.\n  \n```\nsudo apt update\nsudo apt dist-upgrade -y\nsudo apt install -y nodejs npm docker.io git\nsudo usermod -a -G docker `whoami`\nsudo curl -L https://github.com/docker/compose/releases/download/1.21.2/docker-compose-$(uname -s)-$(uname -m) -o /usr/local/bin/docker-compose\nsudo chmod +x /usr/local/bin/docker-compose\nexit\n```\n\nStep 3:\n-------\n\nOpen a *new* terminal and download the code:\n\n```\ngit clone git@github.com:conversationai/conversationai-moderator.git conversationai-moderator\ncd conversationai-moderator\n```\n\nStep 4:\n-------\n\nGet a google cloud API key for the ConversationAI service.  (To be documented...)\n\nStep 5:\n-------\n\nSet the following environment variables\n```\nexport MODERATOR_URL=http://<domain name from step 0>\nexport GOOGLE_CLOUD_API_KEY=<API key from step 4>\nexport DATABASE_PASSWORD=password\n\nStep 6:\n-------\n\nRun the service\n\n```\ndocker-compose -f deployments/local/docker-compose.yml up -d\n```\n\nWhen it is up and running, point your browser in the right direction:\nhttp://<domain name from step 0>/\n"
  },
  {
    "path": "README.md",
    "content": "OSMod - The ConversationAI Moderator App\n========================================\n\nDeploying an OSMod instance\n---------------------------\n\n### Configuration\n\nThe configuration is found in packages/config/index.js.  It is pretty self explanatory.\nAll settings can be overridden via environment variables.\n\nOf particular note, the following have no sensible defaults, and\nmust be set in the environment before anything will work.\n\n* `DATABASE_NAME`: The MySQL database name, e.g., 'os_moderator'.\n* `DATABASE_USER`: The MySQL database user, e.g., 'os_moderator'.\n* `DATABASE_PASSWORD`: The MySQL database password.\n\nIn a production setting, you'll also have to set the following:\n\n* `MODERATOR_URL`: URL (including protocol, host and port) that OSMOD will listen on.\n\n### System setup:\n\nInstall mysql, node (v10 or better), npm (v6 or better) and redis.  Instructions for Ubuntu:\n\n```bash\nsudo apt install mysql-server nodejs npm redis\nsudo apt install build-essential libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev\nsudo npm install -g npm n\nsudo n v10\nhash -r\n```\n\n#### System setup -- Docker\n\nIf you want to run your moderator instances in one of the preconfigured docker containers,\nyou'll need to install docker.  E.g., to install on Ubuntu 18.04 using apt\n\n```bash\nsudo apt install docker.io\n\n# Add docker group to your account so you can talk to the local docker server.\n# You probably need to log out and back in for groups to take effect.\nsudo usermod -a -G docker `whoami`\n\nsudo curl -L https://github.com/docker/compose/releases/download/1.21.2/docker-compose-$(uname -s)-$(uname -m) -o /usr/local/bin/docker-compose\nsudo chmod +x /usr/local/bin/docker-compose\n\n# check things work\ndocker version\ndocker-compose --version.\n```\n\n### Install dependencies and run the server\n\nInstall all node dependencies and run initial typescript compile.\n\n```bash\n./bin/install\n```\n\nSetup local MySQL:\n\n```bash\nsudo mysql << EOF\nCREATE DATABASE $DATABASE_NAME;\nCREATE USER IF NOT EXISTS '$DATABASE_USER' IDENTIFIED BY '$DATABASE_PASSWORD';\nGRANT ALL on $DATABASE_NAME.* to $DATABASE_USER;\nEOF\n\nsudo mysql $DATABASE_NAME < seed/initial-database.sql\ncd packages/backend-api\nnpx sequelize db:migrate\ncd -\n\n# Add a service user that can talk to the Perspective API:\nbin/osmod users:create --group moderator --name \"PerspectiveAPI\" \\\n  --moderator-type \"perspective-api\" --api-key $YOUR_PERSPECTIVE_API_KEY\n\n# Run the server\nbin/watch\n```\n\n#### Alternatively, run in a docker container\n\nTo run the service in a local docker container, run the following commands:\n\n```bash\n# Make sure any local instances of MySQL and Redis are not running\n# E.g., on Ubuntu, stop the services\nsudo systemctl stop mysql.service redis_6379.service redis-server.service\n\n# Create docker images and launch the service\ndocker-compose -f deployments/local/docker-compose.yml up -d\n```\n\nThe docker-compose scripts will initialise the database and create an API service user,\nso you don't need to do those steps manually.\n\nTo shut down the service and delete all your containers:\n\n```bash\ndocker-compose -f deployments/local/docker-compose.yml down\n```\n\nAnd to see what the container is doing:\n\n```bash\ndocker-compose -f deployments/local/docker-compose.yml logs\n```\n\nThe `osmod` CLI\n---------------\n\nYou can manage your OSMod system using the osmod commandline tool:\n\n```bash\n./bin/osmod <command> <options>\n```\n\nwhere `command` is one of\n\n* `users:create`                     Create new OS Moderator users\n* `users:get-token`                  Get a JWT token for a user specified by id or email\n* `comments:rescore`                 Rescore comment.\n* `comments:send-to-scorer`          Send comments to Endpoint of user object to get scored.\n* `comments:calculate-text-size`     Using node-canvas, calculate a single comment height at a given width.\n* `comments:recalculate-text-sizes`  Using node-canvas, recalculate comment heights at a given width.\n* `comments:recalculate-top-scores`  Recalculate comment top scores.\n* `comments:flag`                    Flag comments.\n* `comments:delete`                  Delete all comments from the database.\n* `denormalize`                      Re-run denormalize counts\n\n\n#### Managing Users\n\nIf you are an administrator, you can create other administrators, general moderator users,\nand service users via the settings pages in the OSMod UI.  Also, if there are no admin users,\nthe UI will turn the first user to log in into an admin.  But you can also create users via the commandline.\n\nCreate a human user:\n\n```bash\n./bin/osmod users:create --group general --name \"Name\" --email \"$EMAIL_OF_USER\"\n```\n\nReplace `general` with `admin` if you want to create an administrator.\n\n\nTo create a service user - i.e., one that can connect via the API but not via the UI:\n\n```bash\n./bin/osmod users:create --group service --name \"Robot\"\n```\n\nService users will require a JWT token.  You can get this via the UI, or via running the following command:\n\n```bash\n./bin/osmod users:get-token --id 4\n```\n\n### Management commands\n\nTo run a local server on `:8080` and front-end on `:8000`\n\n```bash\n./bin/watch\n```\n\n### Lint\n\n```bash\n./bin/lint\n```\n\noptionally you can run lint-fix to attempt auto-fixing most lint errors\n\n```bash\n./bin/lint-fix\n```\n\n### Storybook\n\nTo preview individual widgets and components used by the the OSMod UI:\n\n```bash\n./bin/storybook\n```\n\nThe frontend unit tests also use storybook to generate a HTML snapshot of the resulting widgets.  \nIt then compares this snapshot to a stored version, allowing you to review and approve any changes. \n\nTo update stories that need new snapshots, go to `packages/frontend-web` and run\n\n```bash\nnpm run storybook:test -- -u\n```\n\n## Development\n\nThe project uses [lerna](https://www.npmjs.com/package/lerna) to help manage\ndevelopment [the several npm packages](packages/README.md) that are in this\nrepository. Lerna sym-links package dependencies within this repository. Lerna\nis also used to publish updates to all these packages at once.\n\n## Running tests\n\nTo run the tests, you'll need to tweak your enviornment:\n\n```bash\n# Some tests need admin privileges to clean out the database\nexport DATABASE_NAME=os_moderator_test\nexport DATABASE_USER=root\n\n# Run all the tests\nNODE_ENV=test bin/test\n\n# or you can run individual tests:\ncd packages/backend-api\nNODE_ENV=test npm run test\nNODE_ENV=test ../../node_modules/.bin/ts-mocha 'src/test/domain/comments/*.spec.js' --recursive --timeout 10000\n```\n\nThe `bin/test` script uses lerna to first compile all the typescript to javascript,\nthen runs all the tests.\n\nDeleting and recreating the database schema can take a very long time, hence the long timeout above.\nYou may need to increase this even further if your system is particularly slow.\n\nIf you want to run a test in the debugger, add the --inspect-brk flag to the mocha invocation,\nthen connect using the chrome inspector (URL: `chrome://inspect`).\n\n## What a running service looks like\n\nWhile there can be many ways to setup a service, in general a deployment will\ntypically be a single VM instance running these services:\n\nA MySQL database that holds all of the applications state (See\n[the data model doc](docs/modeling.md)).\n\n*  Frontend-Webserver service hosting the static ReactJS site. This sends\n   messages to the Backend API service.\n*  Backend API service responsible for querying the SQL database and sending\n   data to the front-end service. This is also the endpoint that receives\n   requests from the commenting platform it is supporting moderation of; and\n   it sends requests back to the commenting platform with user actions (e.g. to\n   reject or approve comments).\n*  Backend Work Queue service responsible for managing concurrent queue of\n   asynchronous work. TODO(ldixon): add reddis stuff?\n*  Some number of assistant services responsible for automating tasks.\n   Typically this is just calling ML services like\n   [the Perspective API](https://perspectiveapi.com/)\n   \n"
  },
  {
    "path": "bin/build",
    "content": "#!/bin/bash\nnpx lerna run build\n"
  },
  {
    "path": "bin/initdb",
    "content": "#!/bin/bash\n\nbasename=`dirname $0`\nmysqlx=\"mysql -u root -p${DATABASE_PASSWORD}\"\nif [ ! -z \"${DATABASE_HOST}\" ]; then\n  mysqlx=\"$mysqlx -h ${DATABASE_HOST}\"\nfi\n\nuntil $mysqlx -e \"\" ; do\n  echo \"Can't configure the database:-(  waiting...\"\n  sleep 10\ndone\n\nif ! $mysqlx ${DATABASE_NAME} -e \"select count(*) from SequelizeMeta;\"; then\n  echo \"Creating database and API service user.\"\n  echo\n  $mysqlx << EOF\nCREATE DATABASE ${DATABASE_NAME};\nCREATE USER '${DATABASE_USER}' IDENTIFIED BY '${DATABASE_PASSWORD}';\nGRANT ALL on ${DATABASE_NAME}.* to ${DATABASE_USER};\nEOF\n\n  $mysqlx ${DATABASE_NAME} < ${basename}/../seed/initial-database.sql\nfi\n\necho \"Running migrations.\"\ncd ${basename}/../packages/backend-api\nnpx sequelize db:migrate\ncd -\n"
  },
  {
    "path": "bin/install",
    "content": "#!/bin/bash\nset -e\n# Running npm install blats the package-lock.json file, as it doesn't know anything about\n# the sub-packages.\n# So if we are not doing a clean build, we save it off and restore it after installing tools.\n\nbasename=`dirname $0`\ncd \"${basename}/..\"\n\nif [ -f package-lock.json.bak ]; then\n  rm package-lock.json.bak\nfi\n\nif [ \"$1\" == \"clean\" ]; then\n  echo Removing old packages so we fetch everything from scratch\n  for i in . packages/backend-api packages/frontend-web; do\n    rm -Rf \"${i}/node_modules\" \"${i}/package-lock.json\"\n  done\nelse\n  cp package-lock.json package-lock.json.bak\nfi\n\n# Get packages for the root of the system, in particular lerna\nnpm install\n\nif [ -f package-lock.json.bak ]; then\n  mv package-lock.json.bak package-lock.json\nfi\n\n# Use lerna to link together sub-modules, then build\n./bin/link-packages\n./bin/build\n"
  },
  {
    "path": "bin/install-circleci",
    "content": "#!/bin/bash\n\nNODE_ENV=development npm install\n./node_modules/.bin/lerna bootstrap\n./node_modules/.bin/lerna run build\n"
  },
  {
    "path": "bin/link-packages",
    "content": "#!/bin/bash\nnpx lerna bootstrap\n"
  },
  {
    "path": "bin/lint",
    "content": "#!/bin/bash\n\n./node_modules/.bin/lerna run lint\n"
  },
  {
    "path": "bin/lint-fix",
    "content": "#!/bin/bash\n\n./node_modules/.bin/lerna run lint:fix\n"
  },
  {
    "path": "bin/osmod",
    "content": "#!/bin/bash\n\nbasename=`dirname $0`\n# Forward to ${basename}/../packages/backend-api/bin/osmod.js\n\nC=''\n\nfor i in \"$@\"; do\n    case \"$i\" in\n        *\\'*)\n            i=`printf \"%s\" \"$i\" | sed \"s/'/'\\\"'\\\"'/g\"`\n            ;;\n        *) : ;;\n    esac\n    C=\"$C '$i'\"\ndone\n\nbash -c \"${basename}/../packages/backend-api/bin/osmod.js$C\"\n"
  },
  {
    "path": "bin/run",
    "content": "#!/bin/bash\n# Script to run some moderator component inside a docker container\nset -e\n\nbasename=`dirname $0`\nserver=$1\nlogs=$2\n\nif [ -z \"${server}\" ]; then\n  echo You need to specify a server name.\n  exit 1\nfi\n\nif [ -z \"${logs}\" ]; then\n  logs=/tmp/logs\n  echo \"Using default log directory ($logs)\"\nelse\n  echo Logging to $logs\nfi\n\nmkdir -p ${logs}\nlogs=`readlink -f ${logs}`\n\nexport FRONTEND_URL=${server}\nexport API_URL=${server}/api\n\n# TODO fix initdb ${basename}/initdb\ncd ${basename}/../packages/backend-api\n\nnow=`date`\necho Starting: $now  >> ${logs}/server.log\necho Starting: $now  >> ${logs}/processor.log\necho Starting: $now  >> ${logs}/worker.log\n\nnode dist/processor.js 2>&1 | tee -a ${logs}/processor.log &\nnode dist/worker.js 2>&1 | tee -a ${logs}/worker.log &\nnode dist/server.js 2>&1 | tee -a ${logs}/server.log\n"
  },
  {
    "path": "bin/storybook",
    "content": "#!/bin/bash\n\ncd packages/frontend-web\nnpm run storybook &\ncd -\n\nwait\n"
  },
  {
    "path": "bin/sync-db",
    "content": "#!/bin/bash\n\n# keyword arguments\n# 1st argument is .yaml file to pull env_variables from\n\n# This script clears the terminal, attempts to pull db dump from dev,\n# and apply it to your local db, overwriting it.\n\nparse_yaml() {\n   local prefix=$2\n   local s='[[:space:]]*' w='[a-zA-Z0-9_]*' fs=$(echo @|tr @ '\\034')\n   sed -ne \"s|^\\($s\\)\\($w\\)$s:$s\\\"\\(.*\\)\\\"$s\\$|\\1$fs\\2$fs\\3|p\" \\\n        -e \"s|^\\($s\\)\\($w\\)$s:$s\\(.*\\)$s\\$|\\1$fs\\2$fs\\3|p\"  $1 |\n   awk -F$fs '{\n      indent = length($1)/2;\n      vname[indent] = $2;\n      for (i in vname) {if (i > indent) {delete vname[i]}}\n      if (length($3) > 0) {\n         vn=\"\"; for (i=0; i<indent; i++) {vn=(vn)(vname[i])(\"_\")}\n         printf(\"%s%s%s=\\\"%s\\\"\\n\", \"'$prefix'\",vn, $2, $3);\n      }\n   }'\n}\n\neval $(parse_yaml $1 \"config_\")\n\necho \"Dropping your local db instance\"\necho\necho \"Creating new os_moderator database on local\"\n\necho \"This password is for your local db root user\"\nmysql -uroot -e \"DROP DATABASE IF EXISTS os_moderator; CREATE DATABASE os_moderator;\"\n\necho \"Btw: This download will take a minute...\"\nmysqldump -h $config_env_variables_DATABASE_HOST --user=$config_env_variables_DATABASE_USER --password=$config_env_variables_DATABASE_PASSWORD --set-gtid-purged=OFF os_moderator > os_moderator.sql\necho \"Finished downloading\"\necho\necho \"restoring db from .sql backup\"\necho\nmysql -uroot os_moderator -e \"SOURCE os_moderator.sql\"\nrm os_moderator.sql\necho \"mysqldump is complete\"\n"
  },
  {
    "path": "bin/test",
    "content": "#!/bin/bash\n\nnpx lerna run compile\nnpx lerna run test\n\n"
  },
  {
    "path": "bin/watch",
    "content": "#!/bin/bash\nset -e\n\nexport FRONTEND_URL=http://localhost:8000\nexport API_URL=http://localhost:8080\n\nif [ -z \"$1\" ]; then\n  FRONTEND=1\n  BACKEND=1\n  PROCESSING=1\nelse\n  while [ -n \"$1\" ]; do\n    if [ \"$1\" == frontend ]; then\n      FRONTEND=1\n    elif [ \"$1\" == backend ]; then\n      BACKEND=1\n    elif [ \"$1\" == processing ]; then\n      BACKEND=1\n      PROCESSING=1\n    fi\n    shift\n  done\nfi\n\nif [ -n \"$FRONTEND\" ]; then\n  cd packages/frontend-web\n  npm run watch &\n  cd -\nfi\n\nif [ -n \"$BACKEND\" ]; then\n  cd packages/frontend-web\n  npm run compile:lib\n  cd -\n  cd packages/backend-api\n  npx ts-node-dev --inspect=5858 src/server.ts &\n  if [ -n \"$PROCESSING\" ]; then\n    npx ts-node-dev --inspect=5857 src/processor.ts &\n    npx ts-node-dev --inspect=5856 src/worker.ts &\n  fi\n  cd -\nfi\n\nwait\n"
  },
  {
    "path": "deployments/gcloud/Dockerfile",
    "content": "FROM gcr.io/google_appengine/nodejs\n\nRUN install_node v8.11.1\n\nWORKDIR /app/\nCOPY . /app/\n\nRUN npm cache verify\n\nRUN bin/install\n\nEXPOSE 8000 8080\n\nCMD bin/run\n"
  },
  {
    "path": "deployments/gcloud/README.md",
    "content": "# Deploying ConversationAI Moderator to Google Cloud\n\n## Preparing for the deployment\n\n### Install `gcloud`, `docker`, `kubectl` etc.\n\nInstructions for installing gcloud can be found [here](https://cloud.google.com/sdk/docs/quickstart-linux).\n\nYou'll find instructions for installing docker in the [root README](../../README.md)\n\nOnce you've installed gcloud and docker, run the following commands to prepare\nthe system for installing moderator:\n\n```bash\ngcloud components install kubectl alpha\ngcloud auth configure-docker\n```\n\n### Create a GCloud project\n\nBefore you can do anything else, you need to create a Google Cloud project,\nand assign a billing account\n\nThere are many instructions on how to do this via the console.  If you want\nto do it via the commandline, run the following commands:\n\n```bash\n# You can see a list of your billing IDs by running\ngcloud alpha billing accounts list\n\n# Set up the project details\nPROJECT=<your project ID, e.g., conversationai-moderator-your-name>\nREGION=<A region close to home, e.g., europe-west2>\nBILLING=<your billing ID>\n\ngcloud projects create $PROJECT --name=\"Conversation AI Modereator\"\ngcloud alpha billing projects link $PROJECT --billing-account=$BILLING\n\ngcloud config set project $PROJECT\ngcloud config set compute/zone $REGION\n\n# Probably not an exaustive list.  Update if you discover any that are\n# missing\ngcloud services enable sql-component.googleapis.com sqladmin.googleapis.com\n```\n\n### Allocate a domain name and IP address\n\nYou will probably want to allocate a domain name and static IP address\nfor your moderator instance, especially for the production case.  You can\nfind instructions on how to do this [here](https://cloud.google.com/kubernetes-engine/docs/tutorials/configuring-domain-name-static-ip).\n\n(TODO: not yet integrated with the scripts.  Need to add static IP address as an\nenvironment item, and use it to set up Google OAuth.)\n\nOnce you've allocated a hostname and IP address, you'll have enough information\nto set the API_URL and FRONTEND_URL environment variables, and to configure\nthe\n\n### Set up the Google Cloud SQL proxy\n\nDuring the deployment process, we need to connect to the Google Cloud MySQL\ninstance to initialise and populate the database.  Also, we'll need access\nto provision to proopulate users via the CLI.\n\nTo do this, we'll need to create a connection using the cloud SQL proxy.\n\nTo create the /cloudsql directory and fetch the cloud_sql_proxy script, run\nthe following:\n\n```bash\nsudo mkdir -p /cloudsql\nsudo chmod 777 /cloudsql\nwget https://dl.google.com/cloudsql/cloud_sql_proxy.linux.amd64 -O /cloudsql/cloud_sql_proxy\nchmod +x /cloudsql/cloud_sql_proxy\n```\n\nYou'll also need to create a service user with the necessary permissions,\nthen create a key file with that user's private key:\n\n```bash\nSQL_MANAGER=sql-manager\n\ngcloud iam service-accounts create $SQL_MANAGER --display-name \"SQL Manager\"\ngcloud projects add-iam-policy-binding $PROJECT \\\n   --member serviceAccount:$SQL_MANAGER@$PROJECT.iam.gserviceaccount.com \\\n   --role roles/cloudsql.client\ngcloud iam service-accounts keys create /cloudsql/key.json \\\n   --iam-account $SQL_MANAGER@$PROJECT.iam.gserviceaccount.com\n```\n\nIf you want to connect from a different machine, skip this second step.  Instead, once\nyou have set up the `/cloudsql` directory as described above, just copy the\n`/cloudsql/key.json` file into place and you are good to go.\n\nTo start the SQL proxy and connect to the database identified by `$SQL_INSTANCE_NAME`,\nrun the following commands:\n\n```bash\nSQL_CONNECTION=`gcloud sql instances describe $SQL_INSTANCE_NAME --format \"value(connectionName)\"`\n/cloudsql/cloud_sql_proxy -dir=/cloudsql -instances=$SQL_CONNECTION -credential_file=/cloudsql/key.json &\n```\n\nYou'll then be able to access the database via the appropriate socket file in `/cloudsql`, e.g., :\n\n```bash\nDATABASE_SOCKET=/cloudsql/$SQL_CONNECTION bin/osmod users:create --group general --name \"Name\" --email \"email@example.com\"\nmysql --socket=/cloudsql/$SQL_CONNECTION --user=root --password=$DATABASE_PASSWORD\n```\n\n## Do the deployment\n\n### Generate a docker file and upload it to a registry\n\nYou can skip this step if you've got a prerolled docker image for the moderator.\n(You'll need to do this step if you are rolling out a new version.\n\n```bash\nMODERATOR_IMAGE_ID=eu.gcr.io/$PROJECT/conversationai-moderator:<version>\n\ncd <root of moderator source tree>\ndocker build -f deployments/gcloud/Dockerfile -t $MODERATOR_IMAGE_ID .\ndocker push $MODERATOR_IMAGE_ID\n```\n\nYou can test out your docker image by running:\n\n```bash\ndocker run --publish 8080:8080 --publish 8000:8000 \\\n   --env DATABASE_SOCKET=/cloudsql/$SQL_CONNECTION \\\n   --env DATABASE_NAME=$DATABASE_NAME \\\n   --env DATABASE_USER=$DATABASE_USER \\\n   --env DATABASE_PASSWORD=$DATABASE_PASSWORD \\\n   --env GOOGLE_SCORE_AUTH=$GOOGLE_SCORE_AUTH \\\n   --mount type=bind,source=/cloudsql,destination=/cloudsql/\n   $MODERATOR_IMAGE_ID\n```\n\nYou can adjust the above environment settings to connect to the database instance\nyou require.  The above settings assume you are connecting to the\n\n### Set up an `ENVIRONMENT` file and run the deploy script\n\nSubsequent steps need you to set a large number of parameters.  The easiest way\nto do this is to create an environment file.  E.g.,\n\n```\ncat > ENVIRONMENT << EOF\nexport PROJECT=<as set above>\nexport REGION=<as set above>\nexport BILLING=<as set above>\n\nexport DATABASE_NAME=os_moderator\nexport DATABASE_USER=os_moderator\nexport DATABASE_PASSWORD=password\nexport GOOGLE_SCORE_AUTH=<get this from Jigsaw/Perspective team>\nexport FRONTEND_URL=http://<hostname or IP address>/\nexport API_URL=http://<hostname or IP address>:8080/\n\nexport MODERATOR_IMAGE_ID=eu.gcr.io/$PROJECT/conversationai-moderator:<version as above>\nexport SQL_INSTANCE_NAME=conversationai-moderator-db\nexport SQL_MANAGER=sql-manager\n```\n\n### Deploy the MySQL database\nInstall and configure the MySQL database.\n\n```bash\n. ENVIRONMENT\n./deploy-sql.sh\n```\n\nYou'll only need to do this once.\n\n### Deploy the app using Kubernetes\n\nFirst of all, create your kubernetes cluster.  For normal usage, you only need to\ncreate a cluster with one node.  We assume the cluster is called\n`conversationai-moderator`.\n\n```bash\n. ENVIRONMENT\ngcloud container clusters create conversationai-moderator --num-nodes=1 --region=$REGION\n```\n\nNext, deploy the moderator app.  You'll need to rerun this step every time\nyou want to upgrade the moderator.\n\n```bash\n. ENVIRONMENT\n./deploy.sh\n```\n\nYou can see the state of the app in the Kubernetes console, or by running\n\n```bash\nkubectl describe deployments conversationai-moderator\n```\n\n\n## TODO:\n - Integrate statically allocated IP address\n   e.g., https://cloud.google.com/kubernetes-engine/docs/tutorials/configuring-domain-name-static-ip\n - Separate frontend and api into separate containers?\n - Enable SSH in the load balancer\n\n\n"
  },
  {
    "path": "deployments/gcloud/deploy-sql.sh",
    "content": "#!/bin/bash\n# Create a managed SQL instance and populate it with initial data\n# This script assumes the following environment variables have been set\n\n# SQL_INSTANCE_NAME - Label to use for the ConversationAI MySQL instance\n# DATABASE_NAME - Name of the database\n# DATABASE_USER - Database user\n# DATABASE_PASSWORD - Database password\n\n# It also assumes that gcloud is configured to manage the correct Google\n# Cloud project and compute region, and that an appropriate service account\n# has been created.  See the README for details on how to set these things\n# up\n\nset -e\nset -u\n\nif [ -z \"$SQL_INSTANCE_NAME\" ]; then\n  echo \"SQL_INSTANCE_NAME is not defined\"\n  exit;\nfi\n\nif [ -z \"$DATABASE_NAME\" ]; then\n  echo \"DATABASE_NAME is not defined\"\n  exit;\nfi\n\nif [ -z \"$DATABASE_USER\" ]; then\n  echo \"DATABASE_USER is not defined\"\n  exit;\nfi\n\nif [ -z \"$DATABASE_PASSWORD\" ]; then\n  echo \"DATABASE_PASSWORD is not defined\"\n  exit;\nfi\n\ngcloud sql instances create $SQL_INSTANCE_NAME --tier=db-g1-small --database-version=MYSQL_5_7\ngcloud sql users set-password root % --instance $SQL_INSTANCE_NAME --password $DATABASE_PASSWORD\ngcloud sql users create $DATABASE_USER % --instance=$SQL_INSTANCE_NAME --password=$DATABASE_PASSWORD\ngcloud sql databases create $DATABASE_NAME --instance=$SQL_INSTANCE_NAME\n\nexport SQL_CONNECTION=`gcloud sql instances describe $SQL_INSTANCE_NAME --format \"value(connectionName)\"`\n\n# Set up SQL proxy on local machine so we can tunnel through the firewall and access the database\n/cloudsql/cloud_sql_proxy -dir=/cloudsql -instances=$SQL_CONNECTION -credential_file=/cloudsql/key.json &\nmysql --socket=/cloudsql/$SQL_CONNECTION --user=root --password=$DATABASE_PASSWORD << EOF\nGRANT ALL on $DATABASE_NAME.* to $DATABASE_USER\nEOF\nmysql --socket=/cloudsql/$SQL_CONNECTION --user=$DATABASE_USER --password=$DATABASE_PASSWORD $DATABASE_NAME < seed/initial-database.sql\n\n"
  },
  {
    "path": "deployments/gcloud/deploy.sh",
    "content": "#!/bin/bash\n# Use kubectl to deploy the app to the\nset -e\nset -u\n\nexport SQL_CONNECTION=`gcloud sql instances describe $SQL_INSTANCE_NAME --format \"value(connectionName)\"`\n\n# TODO: May need to destroy secrets first.\nkubectl create secret generic cloudsql-instance-credentials --from-file=credentials.json=/cloudsql/key.json\nkubectl create secret generic moderator-configuration \\\n  --from-literal=DATABASE_NAME=$DATABASE_NAME \\\n  --from-literal=DATABASE_USER=$DATABASE_USER \\\n  --from-literal=DATABASE_PASSWORD=$DATABASE_PASSWORD \\\n  --from-literal=GOOGLE_SCORE_AUTH=$GOOGLE_SCORE_AUTH \\\n  --from-literal=SQL_CONNECTION=$SQL_CONNECTION\n\nenvsubst < kubernetes-deployment.yaml | kubectl apply -f -\n# TODO: Use a static IP address if one is allocated.\nkubectl apply -f kubernetes-networking.yaml\n\n\n\n\n\n"
  },
  {
    "path": "deployments/gcloud/kubernetes-deployment.yaml",
    "content": "apiVersion: extensions/v1beta1\nkind: Deployment\nmetadata:\n  name: conversationai-moderator\n  labels:\n    app: conversationai-moderator\nspec:\n  template:\n    metadata:\n      labels:\n        app: conversationai-moderator\n    spec:\n      containers:\n        - name: moderator\n          # TODO Need to replace this with image created by deployment script\n          image: ${MODERATOR_IMAGE_ID}:latest\n          ports:\n            - containerPort: 8000\n              hostPort: 80\n            - containerPort: 8080\n              hostPort: 8080\n          env:\n            - name: DATABASE_NAME\n              valueFrom:\n                secretKeyRef:\n                  name: moderator-configuration\n                  key: DATABASE_NAME\n            - name: DATABASE_USER\n              valueFrom:\n                secretKeyRef:\n                  name: moderator-configuration\n                  key: DATABASE_USER\n            - name: DATABASE_PASSWORD\n              valueFrom:\n                secretKeyRef:\n                  name: moderator-configuration\n                  key: DATABASE_PASSWORD\n            - name: GOOGLE_SCORE_AUTH\n              valueFrom:\n                secretKeyRef:\n                  name: moderator-configuration\n                  key: GOOGLE_SCORE_AUTH\n        - name: cloudsql-proxy\n          image: gcr.io/cloudsql-docker/gce-proxy:1.11\n          command: [\"/cloud_sql_proxy\"]\n          args: [\"-instances=$(SQL_CONNECTION)=tcp:3306\",\n                 \"-credential_file=/secrets/cloudsql/credentials.json\"]\n          env:\n            - name: SQL_CONNECTION\n              valueFrom:\n                secretKeyRef:\n                  name: moderator-configuration\n                  key: SQL_CONNECTION\n          volumeMounts:\n            - name: cloudsql-instance-credentials\n              mountPath: /secrets/cloudsql\n              readOnly: true\n        - name: redis-server\n          image: launcher.gcr.io/google/redis3\n      volumes:\n        - name: cloudsql-instance-credentials\n          secret:\n            secretName: cloudsql-instance-credentials\n"
  },
  {
    "path": "deployments/gcloud/kubernetes-networking.yaml",
    "content": "apiVersion: v1\nkind: Service\nmetadata:\n  name: moderator-networking\nspec:\n  type: LoadBalancer\n  ports:\n    - name: frontend\n      port: 80\n      targetPort: 8000\n      protocol: TCP\n    - name: api\n      port: 8080\n      targetPort: 8080\n      protocol: TCP\n  selector:\n    app: conversationai-moderator"
  },
  {
    "path": "deployments/local/Dockerfile",
    "content": "FROM gcr.io/google_appengine/nodejs\n\nRUN install_node v8.11.1 && apt update && apt dist-upgrade -y && apt install -y mysql-client\n\nWORKDIR /app/\nCOPY . /app/\n\nRUN npm cache verify && bin/install\n\nEXPOSE 80 80\n\nCMD bin/run\n"
  },
  {
    "path": "deployments/local/README.md",
    "content": "# Run application locally in a docker collection\n\nThe scripts and configuration files contained in this directory create 3 containers:\n\n- A MySQL database container\n- A Redis datastore container\n- A container for everything else\n\nThe contents of the latter container are taken from the local filesystem.\n\nYou'll find details on how to create and use these containers in the\nroot [README.md](../../README.md)\n\n\n"
  },
  {
    "path": "deployments/local/docker-compose.yml",
    "content": "version: '3'\nservices:\n  database:\n    container_name: database\n    image: 'mysql:5.7.16'\n    volumes:\n      - './.data/db:/var/lib/mysql'\n    restart: always\n    environment:\n      MYSQL_ROOT_PASSWORD: \"${DATABASE_PASSWORD}\"\n      MYSQL_DATABASE: 'os_moderator'\n      MYSQL_USER: 'os_moderator'\n      MYSQL_PASSWORD: \"${DATABASE_PASSWORD}\"\n    ports:\n      - '3306:3306'\n  redis:\n    container_name: redis\n    image: 'redis:3.2.1'\n    ports:\n      - '6379:6379'\n  server:\n    build:\n      context: ../..\n      dockerfile: \"deployments/local/Dockerfile\"\n    environment:\n      DATABASE_HOST: database\n      DATABASE_NAME: 'os_moderator'\n      DATABASE_USER: 'os_moderator'\n      DATABASE_PASSWORD: \"${DATABASE_PASSWORD}\"\n      REDIS_URL: 'redis://redis:6379'\n      HTTPS_LINKS_ONLY: 'false'\n      APP_NAME: 'Moderator'\n      GOOGLE_CLOUD_API_KEY: \"${GOOGLE_CLOUD_API_KEY}\"\n      PORT: 80\n    ports:\n      - \"80:80\"\n    links:\n        - database\n        - redis\n"
  },
  {
    "path": "deployments/standalone/.dockerignore",
    "content": ".data\ndist\ndist-commonjs\nnode_modules\n.vagrant\n.dockerignore\nDockerfile\nnpm-debug.*\n.git\n.hg\n.svn\nconfig/local.json\n"
  },
  {
    "path": "deployments/standalone/Dockerfile",
    "content": "FROM ubuntu:bionic\n\nRUN apt update && apt --assume-yes dist-upgrade && apt --assume-yes install mysql-server nodejs npm redis supervisor && npm install -g npm\n\nENV DATABASE_PASSWORD=$DATABASE_PASSWORD\n\nWORKDIR /app\n\nCOPY . /app\n\nRUN bin/install\n\nRUN deployments/standalone/initialise_db.sh\n"
  },
  {
    "path": "deployments/standalone/initialise_db.sh",
    "content": "#!/bin/bash\n\nmkdir -p /var/run/mysqld\nchown mysql:mysql /var/run/mysqld/\n\n/usr/bin/mysqld_safe --skip-grant-tables --pid-file=/run/mysqld/mysqld.pid &\n\nsleep 5\n\nmysql -u root << EOF\nDELETE FROM mysql.user WHERE User='';\nDELETE FROM mysql.user WHERE User='root' AND Host NOT IN ('localhost', '127.0.0.1', '::1');\nDROP DATABASE IF EXISTS test;\nDELETE FROM mysql.db WHERE Db='test' OR Db='test\\\\_%';\nFLUSH PRIVILEGES;\nCREATE DATABASE os_moderator;\nCREATE USER 'os_moderator' IDENTIFIED BY '$DATABASE_PASSWORD';\nGRANT ALL on os_moderator.* to os_moderator;\nEOF\n\nmysql os_moderator < seed/initial-database.sql\n\nmysql -u root << EOF\nUPDATE mysql.user SET Password=PASSWORD('$DATABASE_PASSWORD') WHERE User='root';\nEOF\n\ncd ${basename}/../packages/backend-api\nnpx sequelize db:migrate\ncd -\n"
  },
  {
    "path": "design-files/Moderator-StickerSheet-20161117-DAS/document.json",
    "content": "{\"_class\":\"document\",\"do_objectID\":\"FDD7F69B-2AF0-4FAE-9B51-45D7040E8FCB\",\"assets\":{\"_class\":\"assetCollection\",\"colors\":[],\"gradients\":[],\"imageCollection\":{\"_class\":\"imageCollection\",\"images\":{}},\"images\":[]},\"currentPageIndex\":0,\"enableLayerInteraction\":true,\"enableSliceInteraction\":true,\"foreignSymbols\":[],\"layerStyles\":{\"_class\":\"sharedStyleContainer\",\"objects\":[{\"_class\":\"sharedStyle\",\"do_objectID\":\"DC56376C-8162-4E24-BB3C-91C5B43AD324\",\"name\":\"Material\\/Icon dark\",\"value\":{\"_class\":\"style\",\"contextSettings\":{\"_class\":\"graphicsContextSettings\",\"blendMode\":0,\"opacity\":0.5399999618530273},\"endDecorationType\":0,\"fills\":[{\"_class\":\"fill\",\"isEnabled\":true,\"color\":{\"_class\":\"color\",\"alpha\":1,\"blue\":0,\"green\":0,\"red\":0},\"fillType\":0,\"noiseIndex\":0,\"noiseIntensity\":0,\"patternFillType\":0,\"patternTileScale\":1}],\"miterLimit\":10,\"sharedObjectID\":\"DC56376C-8162-4E24-BB3C-91C5B43AD324\",\"startDecorationType\":0}},{\"_class\":\"sharedStyle\",\"do_objectID\":\"E9914C62-FB70-43EA-B840-F1BE2DEEA401\",\"name\":\"Material\\/Light\\/Menu\",\"value\":{\"_class\":\"style\",\"endDecorationType\":0,\"fills\":[{\"_class\":\"fill\",\"isEnabled\":true,\"color\":{\"_class\":\"color\",\"alpha\":1,\"blue\":0.9803921568627451,\"green\":0.9803921568627451,\"red\":0.9803921568627451},\"fillType\":0,\"noiseIndex\":0,\"noiseIntensity\":0,\"patternFillType\":0,\"patternTileScale\":1}],\"miterLimit\":10,\"shadows\":[{\"_class\":\"shadow\",\"isEnabled\":true,\"blurRadius\":8,\"color\":{\"_class\":\"color\",\"alpha\":0.24,\"blue\":0,\"green\":0,\"red\":0},\"contextSettings\":{\"_class\":\"graphicsContextSettings\",\"blendMode\":0,\"opacity\":1},\"offsetX\":0,\"offsetY\":8,\"spread\":0},{\"_class\":\"shadow\",\"isEnabled\":true,\"blurRadius\":8,\"color\":{\"_class\":\"color\",\"alpha\":0.12,\"blue\":0,\"green\":0,\"red\":0},\"contextSettings\":{\"_class\":\"graphicsContextSettings\",\"blendMode\":0,\"opacity\":1},\"offsetX\":0,\"offsetY\":0,\"spread\":0}],\"sharedObjectID\":\"E9914C62-FB70-43EA-B840-F1BE2DEEA401\",\"startDecorationType\":0}},{\"_class\":\"sharedStyle\",\"do_objectID\":\"ACF14791-EE9C-41D8-9F0E-C0BD9795FFE9\",\"name\":\"Material\\/Light\\/Dialog\",\"value\":{\"_class\":\"style\",\"borders\":[{\"_class\":\"border\",\"isEnabled\":true,\"color\":{\"_class\":\"color\",\"alpha\":1,\"blue\":0.592,\"green\":0.592,\"red\":0.592},\"fillType\":1,\"gradient\":{\"_class\":\"gradient\",\"elipseLength\":1,\"from\":\"{0.5, 0}\",\"gradientType\":0,\"shouldSmoothenOpacity\":false,\"stops\":[{\"_class\":\"gradientStop\",\"color\":{\"_class\":\"color\",\"alpha\":0,\"blue\":0,\"green\":0,\"red\":0},\"position\":0},{\"_class\":\"gradientStop\",\"color\":{\"_class\":\"color\",\"alpha\":0,\"blue\":0,\"green\":0,\"red\":0},\"position\":0.95},{\"_class\":\"gradientStop\",\"color\":{\"_class\":\"color\",\"alpha\":0.04,\"blue\":0,\"green\":0,\"red\":0},\"position\":1}],\"to\":\"{0.5, 1}\"},\"position\":1,\"thickness\":0.5},{\"_class\":\"border\",\"isEnabled\":true,\"color\":{\"_class\":\"color\",\"alpha\":1,\"blue\":0.592,\"green\":0.592,\"red\":0.592},\"contextSettings\":{\"_class\":\"graphicsContextSettings\",\"blendMode\":8,\"opacity\":1},\"fillType\":1,\"gradient\":{\"_class\":\"gradient\",\"elipseLength\":1,\"from\":\"{0.5, 0}\",\"gradientType\":0,\"shouldSmoothenOpacity\":false,\"stops\":[{\"_class\":\"gradientStop\",\"color\":{\"_class\":\"color\",\"alpha\":0.8,\"blue\":1,\"green\":1,\"red\":1},\"position\":0},{\"_class\":\"gradientStop\",\"color\":{\"_class\":\"color\",\"alpha\":0.4,\"blue\":1,\"green\":1,\"red\":1},\"position\":0.04936005799731481},{\"_class\":\"gradientStop\",\"color\":{\"_class\":\"color\",\"alpha\":0,\"blue\":1,\"green\":1,\"red\":1},\"position\":0.2},{\"_class\":\"gradientStop\",\"color\":{\"_class\":\"color\",\"alpha\":0,\"blue\":1,\"green\":1,\"red\":1},\"position\":1}],\"to\":\"{0.5, 1}\"},\"position\":1,\"thickness\":0.5}],\"endDecorationType\":0,\"fills\":[{\"_class\":\"fill\",\"isEnabled\":true,\"color\":{\"_class\":\"color\",\"alpha\":1,\"blue\":1,\"green\":1,\"red\":1},\"fillType\":0,\"noiseIndex\":0,\"noiseIntensity\":0,\"patternFillType\":0,\"patternTileScale\":1}],\"miterLimit\":10,\"shadows\":[{\"_class\":\"shadow\",\"isEnabled\":true,\"blurRadius\":24,\"color\":{\"_class\":\"color\",\"alpha\":0.3,\"blue\":0,\"green\":0,\"red\":0},\"contextSettings\":{\"_class\":\"graphicsContextSettings\",\"blendMode\":0,\"opacity\":1},\"offsetX\":0,\"offsetY\":24,\"spread\":0},{\"_class\":\"shadow\",\"isEnabled\":true,\"blurRadius\":24,\"color\":{\"_class\":\"color\",\"alpha\":0.22,\"blue\":0,\"green\":0,\"red\":0},\"contextSettings\":{\"_class\":\"graphicsContextSettings\",\"blendMode\":0,\"opacity\":1},\"offsetX\":0,\"offsetY\":0,\"spread\":0}],\"sharedObjectID\":\"ACF14791-EE9C-41D8-9F0E-C0BD9795FFE9\",\"startDecorationType\":0}}]},\"layerSymbols\":{\"_class\":\"symbolContainer\",\"objects\":[]},\"layerTextStyles\":{\"_class\":\"sharedTextStyleContainer\",\"objects\":[{\"_class\":\"sharedStyle\",\"do_objectID\":\"CB2CC42C-F552-418E-A76F-6D0EDA1AD3BF\",\"name\":\"Section\",\"value\":{\"_class\":\"style\",\"endDecorationType\":0,\"miterLimit\":10,\"sharedObjectID\":\"CB2CC42C-F552-418E-A76F-6D0EDA1AD3BF\",\"startDecorationType\":0,\"textStyle\":{\"_class\":\"textStyle\",\"encodedAttributes\":{\"MSAttributedStringFontAttribute\":{\"_archive\":\"YnBsaXN0MDDUAQIDBAUGJidYJHZlcnNpb25YJG9iamVjdHNZJGFyY2hpdmVyVCR0b3ASAAGGoKkHCA0XGBkaGyJVJG51bGzSCQoLDFYkY2xhc3NfEBpOU0ZvbnREZXNjcmlwdG9yQXR0cmlidXRlc4AIgALTDg8JEBMWV05TLmtleXNaTlMub2JqZWN0c6IREoADgASiFBWABYAGgAdfEBNOU0ZvbnRTaXplQXR0cmlidXRlXxATTlNGb250TmFtZUF0dHJpYnV0ZSNALAAAAAAAAF8QGUlUQ0ZyYW5rbGluR290aGljU3RkLURlbWnSHB0eH1okY2xhc3NuYW1lWCRjbGFzc2VzXxATTlNNdXRhYmxlRGljdGlvbmFyeaMeICFcTlNEaWN0aW9uYXJ5WE5TT2JqZWN00hwdIyRfEBBOU0ZvbnREZXNjcmlwdG9yoiUhXxAQTlNGb250RGVzY3JpcHRvcl8QD05TS2V5ZWRBcmNoaXZlctEoKVRyb290gAEACAARABoAIwAtADIANwBBAEcATABTAHAAcgB0AHsAgwCOAJEAkwCVAJgAmgCcAJ4AtADKANMA7wD0AP8BCAEeASIBLwE4AT0BUAFTAWYBeAF7AYAAAAAAAAACAQAAAAAAAAAqAAAAAAAAAAAAAAAAAAABgg==\"},\"NSColor\":{\"_archive\":\"YnBsaXN0MDDUAQIDBAUGFRZYJHZlcnNpb25YJG9iamVjdHNZJGFyY2hpdmVyVCR0b3ASAAGGoKMHCA9VJG51bGzTCQoLDA0OVU5TUkdCXE5TQ29sb3JTcGFjZVYkY2xhc3NPECcwLjE0OTAxOTYwNzggMC4xNDkwMTk2MDc4IDAuMTQ5MDE5NjA3OAAQAYAC0hAREhNaJGNsYXNzbmFtZVgkY2xhc3Nlc1dOU0NvbG9yohIUWE5TT2JqZWN0XxAPTlNLZXllZEFyY2hpdmVy0RcYVHJvb3SAAQgRGiMtMjc7QUhOW2KMjpCVoKmxtL3P0tcAAAAAAAABAQAAAAAAAAAZAAAAAAAAAAAAAAAAAAAA2Q==\"},\"NSKern\":0,\"NSParagraphStyle\":{\"_archive\":\"YnBsaXN0MDDUAQIDBAUGIyRYJHZlcnNpb25YJG9iamVjdHNZJGFyY2hpdmVyVCR0b3ASAAGGoKUHCBUZH1UkbnVsbNYJCgsMDQ4PEBEQExBfEBJOU1BhcmFncmFwaFNwYWNpbmdaTlNUYWJTdG9wc18QEk5TV3JpdGluZ0RpcmVjdGlvblxOU1RleHRCbG9ja3NWJGNsYXNzW05TVGV4dExpc3RzI0AkAAAAAAAAgAIQAYACgASAAtIWDRcYWk5TLm9iamVjdHOggAPSGhscHVokY2xhc3NuYW1lWCRjbGFzc2VzV05TQXJyYXmiHB5YTlNPYmplY3TSGhsgIV8QF05TTXV0YWJsZVBhcmFncmFwaFN0eWxloyAiHl8QEE5TUGFyYWdyYXBoU3R5bGVfEA9OU0tleWVkQXJjaGl2ZXLRJSZUcm9vdIABAAgAEQAaACMALQAyADcAPQBDAFAAZQBwAIUAkgCZAKUArgCwALIAtAC2ALgAvQDIAMkAywDQANsA5ADsAO8A+AD9ARcBGwEuAUABQwFIAAAAAAAAAgEAAAAAAAAAJwAAAAAAAAAAAAAAAAAAAUo=\"}}}}},{\"_class\":\"sharedStyle\",\"do_objectID\":\"D531A342-3DE7-4169-AF05-BE4F54B37453\",\"name\":\"Article title\",\"value\":{\"_class\":\"style\",\"contextSettings\":{\"_class\":\"graphicsContextSettings\",\"blendMode\":0,\"opacity\":0.86},\"endDecorationType\":0,\"miterLimit\":10,\"sharedObjectID\":\"D531A342-3DE7-4169-AF05-BE4F54B37453\",\"startDecorationType\":0,\"textStyle\":{\"_class\":\"textStyle\",\"encodedAttributes\":{\"MSAttributedStringFontAttribute\":{\"_archive\":\"YnBsaXN0MDDUAQIDBAUGJidYJHZlcnNpb25YJG9iamVjdHNZJGFyY2hpdmVyVCR0b3ASAAGGoKkHCA0XGBkaGyJVJG51bGzSCQoLDFYkY2xhc3NfEBpOU0ZvbnREZXNjcmlwdG9yQXR0cmlidXRlc4AIgALTDg8JEBMWV05TLmtleXNaTlMub2JqZWN0c6IREoADgASiFBWABYAGgAdfEBNOU0ZvbnRTaXplQXR0cmlidXRlXxATTlNGb250TmFtZUF0dHJpYnV0ZSNAMAAAAAAAAF8QD0NoZWx0ZW5oYW0tQm9va9IcHR4fWiRjbGFzc25hbWVYJGNsYXNzZXNfEBNOU011dGFibGVEaWN0aW9uYXJ5ox4gIVxOU0RpY3Rpb25hcnlYTlNPYmplY3TSHB0jJF8QEE5TRm9udERlc2NyaXB0b3KiJSFfEBBOU0ZvbnREZXNjcmlwdG9yXxAPTlNLZXllZEFyY2hpdmVy0SgpVHJvb3SAAQAIABEAGgAjAC0AMgA3AEEARwBMAFMAcAByAHQAewCDAI4AkQCTAJUAmACaAJwAngC0AMoA0wDlAOoA9QD+ARQBGAElAS4BMwFGAUkBXAFuAXEBdgAAAAAAAAIBAAAAAAAAACoAAAAAAAAAAAAAAAAAAAF4\"},\"NSStrokeWidth\":0,\"NSKern\":0,\"NSColor\":{\"_archive\":\"YnBsaXN0MDDUAQIDBAUGHyBYJHZlcnNpb25YJG9iamVjdHNZJGFyY2hpdmVyVCR0b3ASAAGGoKUHCBEVHFUkbnVsbNQJCgsMDQ4PEFVOU1JHQlxOU0NvbG9yU3BhY2VfEBJOU0N1c3RvbUNvbG9yU3BhY2VWJGNsYXNzRjAgMCAwABABgAKABNISDBMUVE5TSUQQAYAD0hYXGBlaJGNsYXNzbmFtZVgkY2xhc3Nlc1xOU0NvbG9yU3BhY2WiGhtcTlNDb2xvclNwYWNlWE5TT2JqZWN00hYXHR5XTlNDb2xvcqIdG18QD05TS2V5ZWRBcmNoaXZlctEhIlRyb290gAEACAARABoAIwAtADIANwA9AEMATABSAF8AdAB7AIIAhACGAIgAjQCSAJQAlgCbAKYArwC8AL8AzADVANoA4gDlAPcA+gD\\/AAAAAAAAAgEAAAAAAAAAIwAAAAAAAAAAAAAAAAAAAQE=\"},\"NSStrokeColor\":{\"_archive\":\"YnBsaXN0MDDUAQIDBAUGFRZYJHZlcnNpb25YJG9iamVjdHNZJGFyY2hpdmVyVCR0b3ASAAGGoKMHCA9VJG51bGzTCQoLDA0OVU5TUkdCXE5TQ29sb3JTcGFjZVYkY2xhc3NMMC42IDAuNiAwLjYAEAKAAtIQERITWiRjbGFzc25hbWVYJGNsYXNzZXNXTlNDb2xvcqISFFhOU09iamVjdF8QD05TS2V5ZWRBcmNoaXZlctEXGFRyb290gAEIERojLTI3O0FITltib3FzeIOMlJegsrW6AAAAAAAAAQEAAAAAAAAAGQAAAAAAAAAAAAAAAAAAALw=\"},\"NSParagraphStyle\":{\"_archive\":\"YnBsaXN0MDDUAQIDBAUGJSZYJHZlcnNpb25YJG9iamVjdHNZJGFyY2hpdmVyVCR0b3ASAAGGoKUHCBcbIVUkbnVsbNgJCgsMDQ4PEBESExQVFhYVViRjbGFzc1pOU1RhYlN0b3BzXU5TSGVhZGVyTGV2ZWxbTlNUZXh0TGlzdHNfEB9OU0FsbG93c1RpZ2h0ZW5pbmdGb3JUcnVuY2F0aW9uXxAPTlNNYXhMaW5lSGVpZ2h0XxAPTlNNaW5MaW5lSGVpZ2h0XxASTlNXcml0aW5nRGlyZWN0aW9ugASAACNACAAAAAAAAIACEAEjQDgAAAAAAADSGAkZGlpOUy5vYmplY3RzoIAD0hwdHh9aJGNsYXNzbmFtZVgkY2xhc3Nlc1dOU0FycmF5oh4gWE5TT2JqZWN00hwdIiNfEBdOU011dGFibGVQYXJhZ3JhcGhTdHlsZaMiJCBfEBBOU1BhcmFncmFwaFN0eWxlXxAPTlNLZXllZEFyY2hpdmVy0ScoVHJvb3SAAQAIABEAGgAjAC0AMgA3AD0AQwBUAFsAZgB0AIAAogC0AMYA2wDdAN8A6ADqAOwA9QD6AQUBBgEIAQ0BGAEhASkBLAE1AToBVAFYAWsBfQGAAYUAAAAAAAACAQAAAAAAAAApAAAAAAAAAAAAAAAAAAABhw==\"}}}}},{\"_class\":\"sharedStyle\",\"do_objectID\":\"C25F20A1-60B3-40B1-BCDE-FC0E9111F18D\",\"name\":\"Hello Lucas! Style\",\"value\":{\"_class\":\"style\",\"endDecorationType\":0,\"miterLimit\":10,\"sharedObjectID\":\"C25F20A1-60B3-40B1-BCDE-FC0E9111F18D\",\"startDecorationType\":0,\"textStyle\":{\"_class\":\"textStyle\",\"encodedAttributes\":{\"MSAttributedStringFontAttribute\":{\"_archive\":\"YnBsaXN0MDDUAQIDBAUGJidYJHZlcnNpb25YJG9iamVjdHNZJGFyY2hpdmVyVCR0b3ASAAGGoKkHCA0XGBkaGyJVJG51bGzSCQoLDFYkY2xhc3NfEBpOU0ZvbnREZXNjcmlwdG9yQXR0cmlidXRlc4AIgALTDg8JEBMWV05TLmtleXNaTlMub2JqZWN0c6IREoADgASiFBWABYAGgAdfEBNOU0ZvbnRTaXplQXR0cmlidXRlXxATTlNGb250TmFtZUF0dHJpYnV0ZSNALAAAAAAAAF8QGUlUQ0ZyYW5rbGluR290aGljU3RkLURlbWnSHB0eH1okY2xhc3NuYW1lWCRjbGFzc2VzXxATTlNNdXRhYmxlRGljdGlvbmFyeaMeICFcTlNEaWN0aW9uYXJ5WE5TT2JqZWN00hwdIyRfEBBOU0ZvbnREZXNjcmlwdG9yoiUhXxAQTlNGb250RGVzY3JpcHRvcl8QD05TS2V5ZWRBcmNoaXZlctEoKVRyb290gAEACAARABoAIwAtADIANwBBAEcATABTAHAAcgB0AHsAgwCOAJEAkwCVAJgAmgCcAJ4AtADKANMA7wD0AP8BCAEeASIBLwE4AT0BUAFTAWYBeAF7AYAAAAAAAAACAQAAAAAAAAAqAAAAAAAAAAAAAAAAAAABgg==\"},\"NSKern\":0,\"NSColor\":{\"_archive\":\"YnBsaXN0MDDUAQIDBAUGHyBYJHZlcnNpb25YJG9iamVjdHNZJGFyY2hpdmVyVCR0b3ASAAGGoKUHCBEVHFUkbnVsbNQJCgsMDQ4PEFVOU1JHQlxOU0NvbG9yU3BhY2VfEBJOU0N1c3RvbUNvbG9yU3BhY2VWJGNsYXNzRjEgMSAxABABgAKABNISDBMUVE5TSUQQAYAD0hYXGBlaJGNsYXNzbmFtZVgkY2xhc3Nlc1xOU0NvbG9yU3BhY2WiGhtcTlNDb2xvclNwYWNlWE5TT2JqZWN00hYXHR5XTlNDb2xvcqIdG18QD05TS2V5ZWRBcmNoaXZlctEhIlRyb290gAEACAARABoAIwAtADIANwA9AEMATABSAF8AdAB7AIIAhACGAIgAjQCSAJQAlgCbAKYArwC8AL8AzADVANoA4gDlAPcA+gD\\/AAAAAAAAAgEAAAAAAAAAIwAAAAAAAAAAAAAAAAAAAQE=\"},\"NSParagraphStyle\":{\"_archive\":\"YnBsaXN0MDDUAQIDBAUGJSZYJHZlcnNpb25YJG9iamVjdHNZJGFyY2hpdmVyVCR0b3ASAAGGoKUHCBcbIVUkbnVsbNcJCgsMDQ4PEBESExQVFlYkY2xhc3NaTlNUYWJTdG9wc11OU0hlYWRlckxldmVsXxASTlNQYXJhZ3JhcGhTcGFjaW5nXxAPTlNNaW5MaW5lSGVpZ2h0XxASTlNXcml0aW5nRGlyZWN0aW9uW05TQWxpZ25tZW50gASAAiM\\/8AAAAAAAACNAJAAAAAAAACNAPIAAAAAAABABEATSGAkZGlpOUy5vYmplY3RzoIAD0hwdHh9aJGNsYXNzbmFtZVgkY2xhc3Nlc1dOU0FycmF5oh4gWE5TT2JqZWN00hwdIiNfEBdOU011dGFibGVQYXJhZ3JhcGhTdHlsZaMiJCBfEBBOU1BhcmFncmFwaFN0eWxlXxAPTlNLZXllZEFyY2hpdmVy0ScoVHJvb3SAAQAIABEAGgAjAC0AMgA3AD0AQwBSAFkAZAByAIcAmQCuALoAvAC+AMcA0ADZANsA3QDiAO0A7gDwAPUBAAEJAREBFAEdASIBPAFAAVMBZQFoAW0AAAAAAAACAQAAAAAAAAApAAAAAAAAAAAAAAAAAAABbw==\"}}}}},{\"_class\":\"sharedStyle\",\"do_objectID\":\"63446B2F-1571-456D-89FD-2A26EA213F12\",\"name\":\"Material\\/Dark\\/Title\",\"value\":{\"_class\":\"style\",\"endDecorationType\":0,\"miterLimit\":10,\"sharedObjectID\":\"63446B2F-1571-456D-89FD-2A26EA213F12\",\"startDecorationType\":0,\"textStyle\":{\"_class\":\"textStyle\",\"encodedAttributes\":{\"MSAttributedStringFontAttribute\":{\"_archive\":\"YnBsaXN0MDDUAQIDBAUGJidYJHZlcnNpb25YJG9iamVjdHNZJGFyY2hpdmVyVCR0b3ASAAGGoKkHCA0XGBkaGyJVJG51bGzSCQoLDFYkY2xhc3NfEBpOU0ZvbnREZXNjcmlwdG9yQXR0cmlidXRlc4AIgALTDg8JEBMWV05TLmtleXNaTlMub2JqZWN0c6IREoADgASiFBWABYAGgAdfEBNOU0ZvbnRTaXplQXR0cmlidXRlXxATTlNGb250TmFtZUF0dHJpYnV0ZSNANAAAAAAAAF1Sb2JvdG8tTWVkaXVt0hwdHh9aJGNsYXNzbmFtZVgkY2xhc3Nlc18QE05TTXV0YWJsZURpY3Rpb25hcnmjHiAhXE5TRGljdGlvbmFyeVhOU09iamVjdNIcHSMkXxAQTlNGb250RGVzY3JpcHRvcqIlIV8QEE5TRm9udERlc2NyaXB0b3JfEA9OU0tleWVkQXJjaGl2ZXLRKClUcm9vdIABAAgAEQAaACMALQAyADcAQQBHAEwAUwBwAHIAdAB7AIMAjgCRAJMAlQCYAJoAnACeALQAygDTAOEA5gDxAPoBEAEUASEBKgEvAUIBRQFYAWoBbQFyAAAAAAAAAgEAAAAAAAAAKgAAAAAAAAAAAAAAAAAAAXQ=\"},\"NSColor\":{\"_archive\":\"YnBsaXN0MDDUAQIDBAUGFRZYJHZlcnNpb25YJG9iamVjdHNZJGFyY2hpdmVyVCR0b3ASAAGGoKMHCA9VJG51bGzTCQoLDA0OVU5TUkdCXE5TQ29sb3JTcGFjZVYkY2xhc3NGMSAxIDEAEAGAAtIQERITWiRjbGFzc25hbWVYJGNsYXNzZXNXTlNDb2xvcqISFFhOU09iamVjdF8QD05TS2V5ZWRBcmNoaXZlctEXGFRyb290gAEIERojLTI3O0FITltiaWttcn2GjpGarK+0AAAAAAAAAQEAAAAAAAAAGQAAAAAAAAAAAAAAAAAAALY=\"},\"NSLigature\":0,\"NSParagraphStyle\":{\"_archive\":\"YnBsaXN0MDDUAQIDBAUGIiNYJHZlcnNpb25YJG9iamVjdHNZJGFyY2hpdmVyVCR0b3ASAAGGoKUHCBQYHlUkbnVsbNYJCgsMDQ4PEBESExFbTlNBbGlnbm1lbnRaTlNUYWJTdG9wc18QD05TTWluTGluZUhlaWdodFtOU1RleHRMaXN0c1YkY2xhc3NfEA9OU01heExpbmVIZWlnaHQQBIAAI0A8AAAAAAAAgAKABNIVDRYXWk5TLm9iamVjdHOggAPSGRobHFokY2xhc3NuYW1lWCRjbGFzc2VzV05TQXJyYXmiGx1YTlNPYmplY3TSGRofIF8QF05TTXV0YWJsZVBhcmFncmFwaFN0eWxlox8hHV8QEE5TUGFyYWdyYXBoU3R5bGVfEA9OU0tleWVkQXJjaGl2ZXLRJCVUcm9vdIABAAgAEQAaACMALQAyADcAPQBDAFAAXABnAHkAhQCMAJ4AoACiAKsArQCvALQAvwDAAMIAxwDSANsA4wDmAO8A9AEOARIBJQE3AToBPwAAAAAAAAIBAAAAAAAAACYAAAAAAAAAAAAAAAAAAAFB\"}}}}},{\"_class\":\"sharedStyle\",\"do_objectID\":\"69ED5B4A-408D-4DCD-A1CB-392D2A7E41BB\",\"name\":\"Material\\/Light\\/Body 1 secondary\",\"value\":{\"_class\":\"style\",\"endDecorationType\":0,\"miterLimit\":10,\"sharedObjectID\":\"69ED5B4A-408D-4DCD-A1CB-392D2A7E41BB\",\"startDecorationType\":0,\"textStyle\":{\"_class\":\"textStyle\",\"encodedAttributes\":{\"MSAttributedStringFontAttribute\":{\"_archive\":\"YnBsaXN0MDDUAQIDBAUGJidYJHZlcnNpb25YJG9iamVjdHNZJGFyY2hpdmVyVCR0b3ASAAGGoKkHCA0XGBkaGyJVJG51bGzSCQoLDFYkY2xhc3NfEBpOU0ZvbnREZXNjcmlwdG9yQXR0cmlidXRlc4AIgALTDg8JEBMWV05TLmtleXNaTlMub2JqZWN0c6IREoADgASiFBWABYAGgAdfEBNOU0ZvbnRTaXplQXR0cmlidXRlXxATTlNGb250TmFtZUF0dHJpYnV0ZSNALAAAAAAAAF5Sb2JvdG8tUmVndWxhctIcHR4fWiRjbGFzc25hbWVYJGNsYXNzZXNfEBNOU011dGFibGVEaWN0aW9uYXJ5ox4gIVxOU0RpY3Rpb25hcnlYTlNPYmplY3TSHB0jJF8QEE5TRm9udERlc2NyaXB0b3KiJSFfEBBOU0ZvbnREZXNjcmlwdG9yXxAPTlNLZXllZEFyY2hpdmVy0SgpVHJvb3SAAQAIABEAGgAjAC0AMgA3AEEARwBMAFMAcAByAHQAewCDAI4AkQCTAJUAmACaAJwAngC0AMoA0wDiAOcA8gD7AREBFQEiASsBMAFDAUYBWQFrAW4BcwAAAAAAAAIBAAAAAAAAACoAAAAAAAAAAAAAAAAAAAF1\"},\"NSColor\":{\"_archive\":\"YnBsaXN0MDDUAQIDBAUGFRZYJHZlcnNpb25YJG9iamVjdHNZJGFyY2hpdmVyVCR0b3ASAAGGoKMHCA9VJG51bGzTCQoLDA0OVU5TUkdCXE5TQ29sb3JTcGFjZVYkY2xhc3NPEBMwIDAgMCAwLjU0Mzg0NjI0MDkAEAGAAtIQERITWiRjbGFzc25hbWVYJGNsYXNzZXNXTlNDb2xvcqISFFhOU09iamVjdF8QD05TS2V5ZWRBcmNoaXZlctEXGFRyb290gAEIERojLTI3O0FITltieHp8gYyVnaCpu77DAAAAAAAAAQEAAAAAAAAAGQAAAAAAAAAAAAAAAAAAAMU=\"},\"NSLigature\":0,\"NSParagraphStyle\":{\"_archive\":\"YnBsaXN0MDDUAQIDBAUGIiNYJHZlcnNpb25YJG9iamVjdHNZJGFyY2hpdmVyVCR0b3ASAAGGoKUHCBQYHlUkbnVsbNYJCgsMDQ4PEBESExFbTlNBbGlnbm1lbnRaTlNUYWJTdG9wc18QD05TTWluTGluZUhlaWdodFtOU1RleHRMaXN0c1YkY2xhc3NfEA9OU01heExpbmVIZWlnaHQQBIAAI0A0AAAAAAAAgAKABNIVDRYXWk5TLm9iamVjdHOggAPSGRobHFokY2xhc3NuYW1lWCRjbGFzc2VzV05TQXJyYXmiGx1YTlNPYmplY3TSGRofIF8QF05TTXV0YWJsZVBhcmFncmFwaFN0eWxlox8hHV8QEE5TUGFyYWdyYXBoU3R5bGVfEA9OU0tleWVkQXJjaGl2ZXLRJCVUcm9vdIABAAgAEQAaACMALQAyADcAPQBDAFAAXABnAHkAhQCMAJ4AoACiAKsArQCvALQAvwDAAMIAxwDSANsA4wDmAO8A9AEOARIBJQE3AToBPwAAAAAAAAIBAAAAAAAAACYAAAAAAAAAAAAAAAAAAAFB\"}}}}},{\"_class\":\"sharedStyle\",\"do_objectID\":\"AB229DCC-1C49-4B7D-8079-F9BA9EBB787A\",\"name\":\"Material\\/Desktop\\/Light\\/Subhead\",\"value\":{\"_class\":\"style\",\"endDecorationType\":0,\"miterLimit\":10,\"sharedObjectID\":\"AB229DCC-1C49-4B7D-8079-F9BA9EBB787A\",\"startDecorationType\":0,\"textStyle\":{\"_class\":\"textStyle\",\"encodedAttributes\":{\"MSAttributedStringFontAttribute\":{\"_archive\":\"YnBsaXN0MDDUAQIDBAUGJidYJHZlcnNpb25YJG9iamVjdHNZJGFyY2hpdmVyVCR0b3ASAAGGoKkHCA0XGBkaGyJVJG51bGzSCQoLDFYkY2xhc3NfEBpOU0ZvbnREZXNjcmlwdG9yQXR0cmlidXRlc4AIgALTDg8JEBMWV05TLmtleXNaTlMub2JqZWN0c6IREoADgASiFBWABYAGgAdfEBNOU0ZvbnRTaXplQXR0cmlidXRlXxATTlNGb250TmFtZUF0dHJpYnV0ZSNALgAAAAAAAF5Sb2JvdG8tUmVndWxhctIcHR4fWiRjbGFzc25hbWVYJGNsYXNzZXNfEBNOU011dGFibGVEaWN0aW9uYXJ5ox4gIVxOU0RpY3Rpb25hcnlYTlNPYmplY3TSHB0jJF8QEE5TRm9udERlc2NyaXB0b3KiJSFfEBBOU0ZvbnREZXNjcmlwdG9yXxAPTlNLZXllZEFyY2hpdmVy0SgpVHJvb3SAAQAIABEAGgAjAC0AMgA3AEEARwBMAFMAcAByAHQAewCDAI4AkQCTAJUAmACaAJwAngC0AMoA0wDiAOcA8gD7AREBFQEiASsBMAFDAUYBWQFrAW4BcwAAAAAAAAIBAAAAAAAAACoAAAAAAAAAAAAAAAAAAAF1\"},\"NSColor\":{\"_archive\":\"YnBsaXN0MDDUAQIDBAUGFRZYJHZlcnNpb25YJG9iamVjdHNZJGFyY2hpdmVyVCR0b3ASAAGGoKMHCA9VJG51bGzTCQoLDA0OVU5TUkdCXE5TQ29sb3JTcGFjZVYkY2xhc3NLMCAwIDAgMC44NwAQAYAC0hAREhNaJGNsYXNzbmFtZVgkY2xhc3Nlc1dOU0NvbG9yohIUWE5TT2JqZWN0XxAPTlNLZXllZEFyY2hpdmVy0RcYVHJvb3SAAQgRGiMtMjc7QUhOW2JucHJ3gouTlp+xtLkAAAAAAAABAQAAAAAAAAAZAAAAAAAAAAAAAAAAAAAAuw==\"},\"NSLigature\":0,\"NSParagraphStyle\":{\"_archive\":\"YnBsaXN0MDDUAQIDBAUGIiNYJHZlcnNpb25YJG9iamVjdHNZJGFyY2hpdmVyVCR0b3ASAAGGoKUHCBQYHlUkbnVsbNYJCgsMDQ4PEBESExFbTlNBbGlnbm1lbnRaTlNUYWJTdG9wc18QD05TTWluTGluZUhlaWdodFtOU1RleHRMaXN0c1YkY2xhc3NfEA9OU01heExpbmVIZWlnaHQQBIAAI0A0AAAAAAAAgAKABNIVDRYXWk5TLm9iamVjdHOggAPSGRobHFokY2xhc3NuYW1lWCRjbGFzc2VzV05TQXJyYXmiGx1YTlNPYmplY3TSGRofIF8QF05TTXV0YWJsZVBhcmFncmFwaFN0eWxlox8hHV8QEE5TUGFyYWdyYXBoU3R5bGVfEA9OU0tleWVkQXJjaGl2ZXLRJCVUcm9vdIABAAgAEQAaACMALQAyADcAPQBDAFAAXABnAHkAhQCMAJ4AoACiAKsArQCvALQAvwDAAMIAxwDSANsA4wDmAO8A9AEOARIBJQE3AToBPwAAAAAAAAIBAAAAAAAAACYAAAAAAAAAAAAAAAAAAAFB\"}}}}},{\"_class\":\"sharedStyle\",\"do_objectID\":\"E565A9CE-412B-4E07-B11D-9E9DB95D0595\",\"name\":\"Material\\/Light\\/Title\",\"value\":{\"_class\":\"style\",\"endDecorationType\":0,\"miterLimit\":10,\"sharedObjectID\":\"E565A9CE-412B-4E07-B11D-9E9DB95D0595\",\"startDecorationType\":0,\"textStyle\":{\"_class\":\"textStyle\",\"encodedAttributes\":{\"MSAttributedStringFontAttribute\":{\"_archive\":\"YnBsaXN0MDDUAQIDBAUGJidYJHZlcnNpb25YJG9iamVjdHNZJGFyY2hpdmVyVCR0b3ASAAGGoKkHCA0XGBkaGyJVJG51bGzSCQoLDFYkY2xhc3NfEBpOU0ZvbnREZXNjcmlwdG9yQXR0cmlidXRlc4AIgALTDg8JEBMWV05TLmtleXNaTlMub2JqZWN0c6IREoADgASiFBWABYAGgAdfEBNOU0ZvbnRTaXplQXR0cmlidXRlXxATTlNGb250TmFtZUF0dHJpYnV0ZSNANAAAAAAAAF1Sb2JvdG8tTWVkaXVt0hwdHh9aJGNsYXNzbmFtZVgkY2xhc3Nlc18QE05TTXV0YWJsZURpY3Rpb25hcnmjHiAhXE5TRGljdGlvbmFyeVhOU09iamVjdNIcHSMkXxAQTlNGb250RGVzY3JpcHRvcqIlIV8QEE5TRm9udERlc2NyaXB0b3JfEA9OU0tleWVkQXJjaGl2ZXLRKClUcm9vdIABAAgAEQAaACMALQAyADcAQQBHAEwAUwBwAHIAdAB7AIMAjgCRAJMAlQCYAJoAnACeALQAygDTAOEA5gDxAPoBEAEUASEBKgEvAUIBRQFYAWoBbQFyAAAAAAAAAgEAAAAAAAAAKgAAAAAAAAAAAAAAAAAAAXQ=\"},\"NSKern\":0,\"NSColor\":{\"_archive\":\"YnBsaXN0MDDUAQIDBAUGFRZYJHZlcnNpb25YJG9iamVjdHNZJGFyY2hpdmVyVCR0b3ASAAGGoKMHCA9VJG51bGzTCQoLDA0OVU5TUkdCXE5TQ29sb3JTcGFjZVYkY2xhc3NLMCAwIDAgMC44NwAQAYAC0hAREhNaJGNsYXNzbmFtZVgkY2xhc3Nlc1dOU0NvbG9yohIUWE5TT2JqZWN0XxAPTlNLZXllZEFyY2hpdmVy0RcYVHJvb3SAAQgRGiMtMjc7QUhOW2JucHJ3gouTlp+xtLkAAAAAAAABAQAAAAAAAAAZAAAAAAAAAAAAAAAAAAAAuw==\"},\"NSLigature\":0,\"NSParagraphStyle\":{\"_archive\":\"YnBsaXN0MDDUAQIDBAUGIiNYJHZlcnNpb25YJG9iamVjdHNZJGFyY2hpdmVyVCR0b3ASAAGGoKUHCBQYHlUkbnVsbNYJCgsMDQ4PEBESExFbTlNBbGlnbm1lbnRaTlNUYWJTdG9wc18QD05TTWluTGluZUhlaWdodFtOU1RleHRMaXN0c1YkY2xhc3NfEA9OU01heExpbmVIZWlnaHQQBIAAI0A8AAAAAAAAgAKABNIVDRYXWk5TLm9iamVjdHOggAPSGRobHFokY2xhc3NuYW1lWCRjbGFzc2VzV05TQXJyYXmiGx1YTlNPYmplY3TSGRofIF8QF05TTXV0YWJsZVBhcmFncmFwaFN0eWxlox8hHV8QEE5TUGFyYWdyYXBoU3R5bGVfEA9OU0tleWVkQXJjaGl2ZXLRJCVUcm9vdIABAAgAEQAaACMALQAyADcAPQBDAFAAXABnAHkAhQCMAJ4AoACiAKsArQCvALQAvwDAAMIAxwDSANsA4wDmAO8A9AEOARIBJQE3AToBPwAAAAAAAAIBAAAAAAAAACYAAAAAAAAAAAAAAAAAAAFB\"}}}}},{\"_class\":\"sharedStyle\",\"do_objectID\":\"12DAF224-DCC7-475F-B21D-E6E75E563E9A\",\"name\":\"Material\\/Light\\/Subhead secondary\",\"value\":{\"_class\":\"style\",\"endDecorationType\":0,\"miterLimit\":10,\"sharedObjectID\":\"12DAF224-DCC7-475F-B21D-E6E75E563E9A\",\"startDecorationType\":0,\"textStyle\":{\"_class\":\"textStyle\",\"encodedAttributes\":{\"MSAttributedStringFontAttribute\":{\"_archive\":\"YnBsaXN0MDDUAQIDBAUGJidYJHZlcnNpb25YJG9iamVjdHNZJGFyY2hpdmVyVCR0b3ASAAGGoKkHCA0XGBkaGyJVJG51bGzSCQoLDFYkY2xhc3NfEBpOU0ZvbnREZXNjcmlwdG9yQXR0cmlidXRlc4AIgALTDg8JEBMWV05TLmtleXNaTlMub2JqZWN0c6IREoADgASiFBWABYAGgAdfEBNOU0ZvbnRTaXplQXR0cmlidXRlXxATTlNGb250TmFtZUF0dHJpYnV0ZSNAMAAAAAAAAF5Sb2JvdG8tUmVndWxhctIcHR4fWiRjbGFzc25hbWVYJGNsYXNzZXNfEBNOU011dGFibGVEaWN0aW9uYXJ5ox4gIVxOU0RpY3Rpb25hcnlYTlNPYmplY3TSHB0jJF8QEE5TRm9udERlc2NyaXB0b3KiJSFfEBBOU0ZvbnREZXNjcmlwdG9yXxAPTlNLZXllZEFyY2hpdmVy0SgpVHJvb3SAAQAIABEAGgAjAC0AMgA3AEEARwBMAFMAcAByAHQAewCDAI4AkQCTAJUAmACaAJwAngC0AMoA0wDiAOcA8gD7AREBFQEiASsBMAFDAUYBWQFrAW4BcwAAAAAAAAIBAAAAAAAAACoAAAAAAAAAAAAAAAAAAAF1\"},\"NSParagraphStyle\":{\"_archive\":\"YnBsaXN0MDDUAQIDBAUGICFYJHZlcnNpb25YJG9iamVjdHNZJGFyY2hpdmVyVCR0b3ASAAGGoKUHCBIWHFUkbnVsbNUJCgsMDQ4PEBEQWk5TVGFiU3RvcHNbTlNUZXh0TGlzdHNfEA9OU01pbkxpbmVIZWlnaHRWJGNsYXNzXxAPTlNNYXhMaW5lSGVpZ2h0gACAAiNAOAAAAAAAAIAE0hMMFBVaTlMub2JqZWN0c6CAA9IXGBkaWiRjbGFzc25hbWVYJGNsYXNzZXNXTlNBcnJheaIZG1hOU09iamVjdNIXGB0eXxAXTlNNdXRhYmxlUGFyYWdyYXBoU3R5bGWjHR8bXxAQTlNQYXJhZ3JhcGhTdHlsZV8QD05TS2V5ZWRBcmNoaXZlctEiI1Ryb290gAEACAARABoAIwAtADIANwA9AEMATgBZAGUAdwB+AJAAkgCUAJ0AnwCkAK8AsACyALcAwgDLANMA1gDfAOQA\\/gECARUBJwEqAS8AAAAAAAACAQAAAAAAAAAkAAAAAAAAAAAAAAAAAAABMQ==\"},\"NSLigature\":0,\"NSColor\":{\"_archive\":\"YnBsaXN0MDDUAQIDBAUGFRZYJHZlcnNpb25YJG9iamVjdHNZJGFyY2hpdmVyVCR0b3ASAAGGoKMHCA9VJG51bGzTCQoLDA0OVU5TUkdCXE5TQ29sb3JTcGFjZVYkY2xhc3NPEBMwIDAgMCAwLjU0MTMyNjk5MjgAEAGAAtIQERITWiRjbGFzc25hbWVYJGNsYXNzZXNXTlNDb2xvcqISFFhOU09iamVjdF8QD05TS2V5ZWRBcmNoaXZlctEXGFRyb290gAEIERojLTI3O0FITltieHp8gYyVnaCpu77DAAAAAAAAAQEAAAAAAAAAGQAAAAAAAAAAAAAAAAAAAMU=\"}}}}},{\"_class\":\"sharedStyle\",\"do_objectID\":\"9779A83F-D1E9-4FFE-800D-8152982B444C\",\"name\":\"Material\\/Light\\/Subhead\",\"value\":{\"_class\":\"style\",\"endDecorationType\":0,\"miterLimit\":10,\"sharedObjectID\":\"9779A83F-D1E9-4FFE-800D-8152982B444C\",\"startDecorationType\":0,\"textStyle\":{\"_class\":\"textStyle\",\"encodedAttributes\":{\"MSAttributedStringFontAttribute\":{\"_archive\":\"YnBsaXN0MDDUAQIDBAUGJidYJHZlcnNpb25YJG9iamVjdHNZJGFyY2hpdmVyVCR0b3ASAAGGoKkHCA0XGBkaGyJVJG51bGzSCQoLDFYkY2xhc3NfEBpOU0ZvbnREZXNjcmlwdG9yQXR0cmlidXRlc4AIgALTDg8JEBMWV05TLmtleXNaTlMub2JqZWN0c6IREoADgASiFBWABYAGgAdfEBNOU0ZvbnRTaXplQXR0cmlidXRlXxATTlNGb250TmFtZUF0dHJpYnV0ZSNAMAAAAAAAAF5Sb2JvdG8tUmVndWxhctIcHR4fWiRjbGFzc25hbWVYJGNsYXNzZXNfEBNOU011dGFibGVEaWN0aW9uYXJ5ox4gIVxOU0RpY3Rpb25hcnlYTlNPYmplY3TSHB0jJF8QEE5TRm9udERlc2NyaXB0b3KiJSFfEBBOU0ZvbnREZXNjcmlwdG9yXxAPTlNLZXllZEFyY2hpdmVy0SgpVHJvb3SAAQAIABEAGgAjAC0AMgA3AEEARwBMAFMAcAByAHQAewCDAI4AkQCTAJUAmACaAJwAngC0AMoA0wDiAOcA8gD7AREBFQEiASsBMAFDAUYBWQFrAW4BcwAAAAAAAAIBAAAAAAAAACoAAAAAAAAAAAAAAAAAAAF1\"},\"NSColor\":{\"_archive\":\"YnBsaXN0MDDUAQIDBAUGFRZYJHZlcnNpb25YJG9iamVjdHNZJGFyY2hpdmVyVCR0b3ASAAGGoKMHCA9VJG51bGzTCQoLDA0OVU5TUkdCXE5TQ29sb3JTcGFjZVYkY2xhc3NLMCAwIDAgMC44NwAQAYAC0hAREhNaJGNsYXNzbmFtZVgkY2xhc3Nlc1dOU0NvbG9yohIUWE5TT2JqZWN0XxAPTlNLZXllZEFyY2hpdmVy0RcYVHJvb3SAAQgRGiMtMjc7QUhOW2JucHJ3gouTlp+xtLkAAAAAAAABAQAAAAAAAAAZAAAAAAAAAAAAAAAAAAAAuw==\"},\"NSLigature\":0,\"NSParagraphStyle\":{\"_archive\":\"YnBsaXN0MDDUAQIDBAUGIiNYJHZlcnNpb25YJG9iamVjdHNZJGFyY2hpdmVyVCR0b3ASAAGGoKUHCBQYHlUkbnVsbNYJCgsMDQ4PEBESExFbTlNBbGlnbm1lbnRaTlNUYWJTdG9wc18QD05TTWluTGluZUhlaWdodFtOU1RleHRMaXN0c1YkY2xhc3NfEA9OU01heExpbmVIZWlnaHQQBIAAI0A4AAAAAAAAgAKABNIVDRYXWk5TLm9iamVjdHOggAPSGRobHFokY2xhc3NuYW1lWCRjbGFzc2VzV05TQXJyYXmiGx1YTlNPYmplY3TSGRofIF8QF05TTXV0YWJsZVBhcmFncmFwaFN0eWxlox8hHV8QEE5TUGFyYWdyYXBoU3R5bGVfEA9OU0tleWVkQXJjaGl2ZXLRJCVUcm9vdIABAAgAEQAaACMALQAyADcAPQBDAFAAXABnAHkAhQCMAJ4AoACiAKsArQCvALQAvwDAAMIAxwDSANsA4wDmAO8A9AEOARIBJQE3AToBPwAAAAAAAAIBAAAAAAAAACYAAAAAAAAAAAAAAAAAAAFB\"}}}}}]},\"pages\":[{\"_class\":\"MSJSONFileReference\",\"_ref_class\":\"MSImmutablePage\",\"_ref\":\"pages\\/226D6615-C16F-4B84-92E0-291B2F1B15C4\"},{\"_class\":\"MSJSONFileReference\",\"_ref_class\":\"MSImmutablePage\",\"_ref\":\"pages\\/1B67083D-3430-4865-A36A-6C687A1EEB45\"}]}"
  },
  {
    "path": "design-files/Moderator-StickerSheet-20161117-DAS/meta.json",
    "content": "{\"commit\":\"335a30073fcb2dc64a0abd6148ae147d694c887d\",\"appVersion\":\"43.1\",\"build\":39012,\"app\":\"com.bohemiancoding.sketch3\",\"pagesAndArtboards\":{\"226D6615-C16F-4B84-92E0-291B2F1B15C4\":{\"name\":\"Page 1\",\"artboards\":{\"13915B01-7A46-4D0E-BFEA-D8170EF6C531\":{\"name\":\"Tablet 9inch\"}}},\"1B67083D-3430-4865-A36A-6C687A1EEB45\":{\"name\":\"Symbols\",\"artboards\":{\"DDB0E68B-BEEE-4E3A-9844-0443F2E09696\":{\"name\":\"Material\\/Icons black\\/refresh\"},\"34CA2EB5-EBA4-4D7A-9C5B-1EC39EA97E4F\":{\"name\":\"Material\\/Icons black\\/close\"},\"57C2B1F6-1718-4BD9-85D3-39BEFA28613B\":{\"name\":\"Material\\/Icons white\\/arrow back\"},\"7C5A0EC0-D963-4207-80CB-49ED115BF9B5\":{\"name\":\"Material\\/Android\\/Navbar 1024dp black\"},\"A0EAD81F-5486-461D-846C-2016D95CE579\":{\"name\":\"Material\\/Icons white\\/close\"},\"76C8A3F1-FA9D-4553-9035-374EEF18CBF2\":{\"name\":\"Material\\/Icons black\\/arrow back\"},\"E33E2440-9E0D-4801-A0C4-5F0D7AA40DE6\":{\"name\":\"Material\\/Icons white\\/more vert\"},\"10E7654C-E936-403F-9CEF-AA512F39B60F\":{\"name\":\"Material\\/Icons white\\/arrow drop down\"},\"8E30A0EB-7E44-41FB-B562-98898C86C5CF\":{\"name\":\"Material\\/Icons black\\/arrow drop up\"},\"D284E6EB-A900-4B52-B3A1-0461AFBE8EFD\":{\"name\":\"Material\\/Android\\/Status bar 1024dp black\"},\"D11A2A32-7CF0-4C46-B91D-9FE1B6C7CBB3\":{\"name\":\"Material\\/Icons white\\/search\"},\"21B63B46-4C5E-4D74-9E80-2B8CCF870C32\":{\"name\":\"Material\\/Icons black\\/check\"},\"CA799155-0C43-4E3B-8162-8521128A451A\":{\"name\":\"Material\\/Icons black\\/more vert\"},\"3D9F5A8F-3429-4C85-881D-96C7B13601E9\":{\"name\":\"Material\\/Android\\/Status bar content light\"}}}},\"fonts\":[\"ITCFranklinGothicStd-Med\",\"ITCFranklinGothicStd-Book\",\"ITCFranklinGothicStd-Hvy\",\".SFNSText\",\"CheltenhamStd-Book\",\"ITCFranklinGothicStd-Demi\",\"Roboto-Medium\",\"Cheltenham-Book\"],\"created\":{\"app\":\"com.bohemiancoding.sketch3\",\"commit\":\"566dab740f05650c87722a04bbcb154884097f92\",\"version\":87,\"appVersion\":\"42\",\"variant\":\"NONAPPSTORE\",\"build\":36781},\"version\":88,\"saveHistory\":[\"NONAPPSTORE.36781\",\"NONAPPSTORE.39012\"],\"autosaved\":0,\"variant\":\"NONAPPSTORE\"}"
  },
  {
    "path": "design-files/Moderator-StickerSheet-20161117-DAS/pages/1B67083D-3430-4865-A36A-6C687A1EEB45.json",
    "content": "{\"_class\":\"page\",\"do_objectID\":\"1B67083D-3430-4865-A36A-6C687A1EEB45\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"constrainProportions\":false,\"height\":300,\"width\":300,\"x\":0,\"y\":0},\"isFlippedHorizontal\":false,\"isFlippedVertical\":false,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":0,\"name\":\"Symbols\",\"nameIsFixed\":false,\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"style\":{\"_class\":\"style\",\"endDecorationType\":0,\"miterLimit\":10,\"startDecorationType\":0},\"hasClickThrough\":true,\"layers\":[{\"_class\":\"symbolMaster\",\"do_objectID\":\"3D9F5A8F-3429-4C85-881D-96C7B13601E9\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"constrainProportions\":false,\"height\":24,\"width\":118,\"x\":100,\"y\":0},\"isFlippedHorizontal\":false,\"isFlippedVertical\":false,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":1,\"name\":\"Material\\/Android\\/Status bar content light\",\"nameIsFixed\":true,\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"style\":{\"_class\":\"style\",\"endDecorationType\":0,\"fills\":[{\"_class\":\"fill\",\"do_objectID\":\"B99E6927-021E-45DC-B685-DA1D14859377\",\"isEnabled\":true,\"color\":{\"_class\":\"color\",\"alpha\":1,\"blue\":1,\"green\":1,\"red\":1},\"fillType\":0,\"noiseIndex\":0,\"noiseIntensity\":0,\"patternFillType\":1,\"patternTileScale\":1}],\"miterLimit\":10,\"startDecorationType\":0},\"hasClickThrough\":true,\"layers\":[{\"_class\":\"group\",\"do_objectID\":\"07EA6ABD-426B-469E-85BA-0D2542F1EB5B\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"constrainProportions\":false,\"height\":16,\"width\":36,\"x\":74,\"y\":4},\"isFlippedHorizontal\":false,\"isFlippedVertical\":false,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":0,\"name\":\"time\",\"nameIsFixed\":true,\"originalObjectID\":\"07EA6ABD-426B-469E-85BA-0D2542F1EB5B\",\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"style\":{\"_class\":\"style\",\"contextSettings\":{\"_class\":\"graphicsContextSettings\",\"blendMode\":0,\"opacity\":0.9},\"endDecorationType\":0,\"fills\":[{\"_class\":\"fill\",\"isEnabled\":true,\"color\":{\"_class\":\"color\",\"alpha\":1,\"blue\":0,\"green\":0,\"red\":0},\"fillType\":0,\"noiseIndex\":0,\"noiseIntensity\":0,\"patternFillType\":0,\"patternTileScale\":1}],\"miterLimit\":10,\"startDecorationType\":0},\"hasClickThrough\":false,\"layers\":[{\"_class\":\"text\",\"do_objectID\":\"2851D932-9391-44B6-BA6A-55668EC918E6\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"constrainProportions\":false,\"height\":16,\"width\":36,\"x\":0,\"y\":0},\"isFlippedHorizontal\":false,\"isFlippedVertical\":false,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":0,\"name\":\"12:30\",\"nameIsFixed\":false,\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"style\":{\"_class\":\"style\",\"endDecorationType\":0,\"fills\":[{\"_class\":\"fill\",\"isEnabled\":true,\"color\":{\"_class\":\"color\",\"alpha\":1,\"blue\":1,\"green\":1,\"red\":1},\"fillType\":0,\"noiseIndex\":0,\"noiseIntensity\":0,\"patternFillType\":0,\"patternTileScale\":1}],\"miterLimit\":10,\"startDecorationType\":0,\"textStyle\":{\"_class\":\"textStyle\",\"encodedAttributes\":{\"NSLigature\":0,\"MSAttributedStringFontAttribute\":{\"_archive\":\"YnBsaXN0MDDUAQIDBAUGJidYJHZlcnNpb25YJG9iamVjdHNZJGFyY2hpdmVyVCR0b3ASAAGGoKkHCA0XGBkaGyJVJG51bGzSCQoLDFYkY2xhc3NfEBpOU0ZvbnREZXNjcmlwdG9yQXR0cmlidXRlc4AIgALTDg8JEBMWV05TLmtleXNaTlMub2JqZWN0c6IREoADgASiFBWABYAGgAdfEBNOU0ZvbnRTaXplQXR0cmlidXRlXxATTlNGb250TmFtZUF0dHJpYnV0ZSNALAAAAAAAAF1Sb2JvdG8tTWVkaXVt0hwdHh9aJGNsYXNzbmFtZVgkY2xhc3Nlc18QE05TTXV0YWJsZURpY3Rpb25hcnmjHiAhXE5TRGljdGlvbmFyeVhOU09iamVjdNIcHSMkXxAQTlNGb250RGVzY3JpcHRvcqIlIV8QEE5TRm9udERlc2NyaXB0b3JfEA9OU0tleWVkQXJjaGl2ZXLRKClUcm9vdIABAAgAEQAaACMALQAyADcAQQBHAEwAUwBwAHIAdAB7AIMAjgCRAJMAlQCYAJoAnACeALQAygDTAOEA5gDxAPoBEAEUASEBKgEvAUIBRQFYAWoBbQFyAAAAAAAAAgEAAAAAAAAAKgAAAAAAAAAAAAAAAAAAAXQ=\"},\"NSParagraphStyle\":{\"_archive\":\"YnBsaXN0MDDUAQIDBAUGFhdYJHZlcnNpb25YJG9iamVjdHNZJGFyY2hpdmVyVCR0b3ASAAGGoKMHCA9VJG51bGzTCQoLDA0OWk5TVGFiU3RvcHNWJGNsYXNzW05TQWxpZ25tZW50gACAAhAB0hAREhNaJGNsYXNzbmFtZVgkY2xhc3Nlc18QF05TTXV0YWJsZVBhcmFncmFwaFN0eWxloxIUFV8QEE5TUGFyYWdyYXBoU3R5bGVYTlNPYmplY3RfEA9OU0tleWVkQXJjaGl2ZXLRGBlUcm9vdIABCBEaIy0yNztBSFNaZmhqbHF8hZ+jtr\\/R1NkAAAAAAAABAQAAAAAAAAAaAAAAAAAAAAAAAAAAAAAA2w==\"}}}},\"attributedString\":{\"_class\":\"MSAttributedString\",\"archivedAttributedString\":{\"_archive\":\"YnBsaXN0MDDUAQIDBAUGS0xYJHZlcnNpb25YJG9iamVjdHNZJGFyY2hpdmVyVCR0b3ASAAGGoK8QFAcIDxAcHR4fICQsLS4vMDc7QUVHVSRudWxs0wkKCwwNDlhOU1N0cmluZ1YkY2xhc3NcTlNBdHRyaWJ1dGVzgAKAE4ADVTEyOjMw0xESChMXG1dOUy5rZXlzWk5TLm9iamVjdHOjFBUWgASABYAGoxgZGoAHgAiAEIASWk5TTGlnYXR1cmVfEB9NU0F0dHJpYnV0ZWRTdHJpbmdGb250QXR0cmlidXRlXxAQTlNQYXJhZ3JhcGhTdHlsZRAA0gohIiNfEBpOU0ZvbnREZXNjcmlwdG9yQXR0cmlidXRlc4APgAnTERIKJSgroiYngAqAC6IpKoAMgA2ADl8QE05TRm9udFNpemVBdHRyaWJ1dGVfEBNOU0ZvbnROYW1lQXR0cmlidXRlI0AsAAAAAAAAXVJvYm90by1NZWRpdW3SMTIzNFokY2xhc3NuYW1lWCRjbGFzc2VzXxATTlNNdXRhYmxlRGljdGlvbmFyeaMzNTZcTlNEaWN0aW9uYXJ5WE5TT2JqZWN00jEyODlfEBBOU0ZvbnREZXNjcmlwdG9yojo2XxAQTlNGb250RGVzY3JpcHRvctM8Cj0+P0BaTlNUYWJTdG9wc1tOU0FsaWdubWVudIAAgBEQAdIxMkJDXxAXTlNNdXRhYmxlUGFyYWdyYXBoU3R5bGWjQkQ2XxAQTlNQYXJhZ3JhcGhTdHlsZdIxMjVGojU20jEySElfEBJOU0F0dHJpYnV0ZWRTdHJpbmeiSjZfEBJOU0F0dHJpYnV0ZWRTdHJpbmdfEA9OU0tleWVkQXJjaGl2ZXLRTU5Ucm9vdIABAAgAEQAaACMALQAyADcATgBUAFsAZABrAHgAegB8AH4AhACLAJMAngCiAKQApgCoAKwArgCwALIAtAC\\/AOEA9AD2APsBGAEaARwBIwEmASgBKgEtAS8BMQEzAUkBXwFoAXYBewGGAY8BpQGpAbYBvwHEAdcB2gHtAfQB\\/wILAg0CDwIRAhYCMAI0AkcCTAJPAlQCaQJsAoECkwKWApsAAAAAAAACAQAAAAAAAABPAAAAAAAAAAAAAAAAAAACnQ==\"}},\"automaticallyDrawOnUnderlyingPath\":false,\"dontSynchroniseWithSymbol\":false,\"glyphBounds\":\"{{0, 0}, {36, 16.40625}}\",\"heightIsClipped\":false,\"lineSpacingBehaviour\":0,\"textBehaviour\":0}]},{\"_class\":\"group\",\"do_objectID\":\"42D99C57-EF76-4D8A-928A-44B249108E0B\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"constrainProportions\":true,\"height\":16,\"width\":16,\"x\":55,\"y\":4},\"isFlippedHorizontal\":false,\"isFlippedVertical\":false,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":0,\"name\":\"battery\",\"nameIsFixed\":true,\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"style\":{\"_class\":\"style\",\"endDecorationType\":0,\"fills\":[{\"_class\":\"fill\",\"isEnabled\":true,\"color\":{\"_class\":\"color\",\"alpha\":1,\"blue\":0,\"green\":0,\"red\":0},\"fillType\":0,\"noiseIndex\":0,\"noiseIntensity\":0,\"patternFillType\":0,\"patternTileScale\":1}],\"miterLimit\":10,\"startDecorationType\":0},\"hasClickThrough\":false,\"layers\":[{\"_class\":\"shapeGroup\",\"do_objectID\":\"948320AB-B4A4-4749-8445-86978E2DC560\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"constrainProportions\":false,\"height\":16,\"width\":16,\"x\":0,\"y\":0},\"isFlippedHorizontal\":false,\"isFlippedVertical\":false,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":0,\"name\":\"bounds\",\"nameIsFixed\":true,\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"style\":{\"_class\":\"style\",\"endDecorationType\":0,\"miterLimit\":10,\"startDecorationType\":0},\"hasClickThrough\":false,\"layers\":[{\"_class\":\"shapePath\",\"do_objectID\":\"79A9EE07-A92C-4BE1-BE3A-2762F4D51368\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"constrainProportions\":false,\"height\":16,\"width\":16,\"x\":0,\"y\":0},\"isFlippedHorizontal\":false,\"isFlippedVertical\":false,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":0,\"name\":\"Path\",\"nameIsFixed\":false,\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"booleanOperation\":-1,\"edited\":true,\"path\":{\"_class\":\"path\",\"isClosed\":true,\"points\":[{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0, 0}\",\"curveMode\":1,\"curveTo\":\"{0, 0}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0, 0}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{1, 0}\",\"curveMode\":1,\"curveTo\":\"{1, 0}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{1, 0}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{1, 1}\",\"curveMode\":1,\"curveTo\":\"{1, 1}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{1, 1}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0, 1}\",\"curveMode\":1,\"curveTo\":\"{0, 1}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0, 1}\"}]}}],\"clippingMaskMode\":0,\"hasClippingMask\":false,\"windingRule\":1},{\"_class\":\"shapeGroup\",\"do_objectID\":\"FE57A042-EDF4-4C50-82B0-380EF7B28AC3\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"constrainProportions\":false,\"height\":14,\"width\":9,\"x\":3,\"y\":1},\"isFlippedHorizontal\":false,\"isFlippedVertical\":false,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":0,\"name\":\"Shape\",\"nameIsFixed\":false,\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"style\":{\"_class\":\"style\",\"endDecorationType\":0,\"fills\":[{\"_class\":\"fill\",\"isEnabled\":true,\"color\":{\"_class\":\"color\",\"alpha\":1,\"blue\":1,\"green\":1,\"red\":1},\"fillType\":0,\"noiseIndex\":0,\"noiseIntensity\":0,\"patternFillType\":0,\"patternTileScale\":1}],\"miterLimit\":10,\"startDecorationType\":0},\"hasClickThrough\":false,\"layers\":[{\"_class\":\"shapePath\",\"do_objectID\":\"9DEEA577-F6E6-47F4-8439-C2A4A0045478\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"constrainProportions\":false,\"height\":14,\"width\":9,\"x\":0,\"y\":0},\"isFlippedHorizontal\":false,\"isFlippedVertical\":false,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":0,\"name\":\"Path\",\"nameIsFixed\":false,\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"booleanOperation\":-1,\"edited\":true,\"path\":{\"_class\":\"path\",\"isClosed\":true,\"points\":[{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.72222222222222221, 0.12337662337662333}\",\"curveMode\":1,\"curveTo\":\"{0.72222222222222221, 0.12337662337662333}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0.66666666666666663, 0.062500000000000014}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.72222222222222221, 2.6404969480761258e-16}\",\"curveMode\":1,\"curveTo\":\"{0.72222222222222221, 2.6404969480761258e-16}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0.66666666666666663, 2.6962559169468085e-16}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.33333333333333331, 7.9301644616082606e-18}\",\"curveMode\":1,\"curveTo\":\"{0.33333333333333331, 7.9301644616082606e-18}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0.33333333333333331, 7.9301644616082606e-18}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.33333333333333331, 0.12337662337662332}\",\"curveMode\":1,\"curveTo\":\"{0.33333333333333331, 0.12337662337662332}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0.33333333333333331, 0.0625}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0, 0.1194805194805194}\",\"curveMode\":1,\"curveTo\":\"{0, 0.047402597402597425}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0, 0.0625}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0, 1}\",\"curveMode\":1,\"curveTo\":\"{0, 1}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0, 1}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{1, 1}\",\"curveMode\":1,\"curveTo\":\"{1, 1}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{1, 1}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{1, 0.047402597402597377}\",\"curveMode\":1,\"curveTo\":\"{1, 0.11948051948051941}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{1, 0.0625}\"}]}}],\"clippingMaskMode\":0,\"hasClippingMask\":false,\"windingRule\":1}]},{\"_class\":\"group\",\"do_objectID\":\"3DA9B3DF-5C04-4A39-9C53-9969BD080CF8\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"constrainProportions\":true,\"height\":16,\"width\":16,\"x\":35,\"y\":4},\"isFlippedHorizontal\":false,\"isFlippedVertical\":false,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":0,\"name\":\"cellular\",\"nameIsFixed\":true,\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"style\":{\"_class\":\"style\",\"endDecorationType\":0,\"fills\":[{\"_class\":\"fill\",\"isEnabled\":true,\"color\":{\"_class\":\"color\",\"alpha\":1,\"blue\":0,\"green\":0,\"red\":0},\"fillType\":0,\"noiseIndex\":0,\"noiseIntensity\":0,\"patternFillType\":0,\"patternTileScale\":1}],\"miterLimit\":10,\"startDecorationType\":0},\"hasClickThrough\":false,\"layers\":[{\"_class\":\"shapeGroup\",\"do_objectID\":\"9CE34C1D-0979-49AC-9C7A-5CC1C8AB236B\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"constrainProportions\":false,\"height\":16,\"width\":16,\"x\":0,\"y\":0},\"isFlippedHorizontal\":false,\"isFlippedVertical\":false,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":0,\"name\":\"bounds\",\"nameIsFixed\":true,\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"style\":{\"_class\":\"style\",\"endDecorationType\":0,\"miterLimit\":10,\"startDecorationType\":0},\"hasClickThrough\":false,\"layers\":[{\"_class\":\"shapePath\",\"do_objectID\":\"167CE758-4A23-4C76-9056-9B8685747098\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"constrainProportions\":false,\"height\":16,\"width\":16,\"x\":0,\"y\":0},\"isFlippedHorizontal\":false,\"isFlippedVertical\":false,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":0,\"name\":\"Path\",\"nameIsFixed\":false,\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"booleanOperation\":-1,\"edited\":true,\"path\":{\"_class\":\"path\",\"isClosed\":true,\"points\":[{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0, 0}\",\"curveMode\":1,\"curveTo\":\"{0, 0}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0, 0}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{1, 0}\",\"curveMode\":1,\"curveTo\":\"{1, 0}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{1, 0}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{1, 1}\",\"curveMode\":1,\"curveTo\":\"{1, 1}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{1, 1}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0, 1}\",\"curveMode\":1,\"curveTo\":\"{0, 1}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0, 1}\"}]}}],\"clippingMaskMode\":0,\"hasClippingMask\":false,\"windingRule\":1},{\"_class\":\"shapeGroup\",\"do_objectID\":\"772A4B91-3052-43B7-9601-9EEC2B2FEAE6\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"constrainProportions\":true,\"height\":14,\"width\":14,\"x\":0,\"y\":1},\"isFlippedHorizontal\":false,\"isFlippedVertical\":false,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":0,\"name\":\"Shape\",\"nameIsFixed\":false,\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"style\":{\"_class\":\"style\",\"endDecorationType\":0,\"fills\":[{\"_class\":\"fill\",\"isEnabled\":true,\"color\":{\"_class\":\"color\",\"alpha\":1,\"blue\":1,\"green\":1,\"red\":1},\"fillType\":0,\"noiseIndex\":0,\"noiseIntensity\":0,\"patternFillType\":0,\"patternTileScale\":1}],\"miterLimit\":10,\"startDecorationType\":0},\"hasClickThrough\":false,\"layers\":[{\"_class\":\"shapePath\",\"do_objectID\":\"224429C0-2948-4BCB-8BEF-1562B4041907\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"constrainProportions\":false,\"height\":14,\"width\":14,\"x\":0,\"y\":0},\"isFlippedHorizontal\":false,\"isFlippedVertical\":false,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":0,\"name\":\"Path\",\"nameIsFixed\":false,\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"booleanOperation\":-1,\"edited\":true,\"path\":{\"_class\":\"path\",\"isClosed\":true,\"points\":[{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0, 1}\",\"curveMode\":1,\"curveTo\":\"{0, 1}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0, 1}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{1, 1}\",\"curveMode\":1,\"curveTo\":\"{1, 1}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{1, 1}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{1, 0}\",\"curveMode\":1,\"curveTo\":\"{1, 0}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{1, 0}\"}]}}],\"clippingMaskMode\":0,\"hasClippingMask\":false,\"windingRule\":1}]},{\"_class\":\"group\",\"do_objectID\":\"A33967C6-E840-423D-ABC0-352996D1DAEC\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"constrainProportions\":true,\"height\":16,\"width\":20,\"x\":14,\"y\":4},\"isFlippedHorizontal\":false,\"isFlippedVertical\":false,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":0,\"name\":\"wifi\",\"nameIsFixed\":true,\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"style\":{\"_class\":\"style\",\"endDecorationType\":0,\"fills\":[{\"_class\":\"fill\",\"isEnabled\":true,\"color\":{\"_class\":\"color\",\"alpha\":1,\"blue\":0,\"green\":0,\"red\":0},\"fillType\":0,\"noiseIndex\":0,\"noiseIntensity\":0,\"patternFillType\":0,\"patternTileScale\":1}],\"miterLimit\":10,\"startDecorationType\":0},\"hasClickThrough\":false,\"layers\":[{\"_class\":\"shapeGroup\",\"do_objectID\":\"DA3E3AFB-0BCC-4441-8062-925273087AF6\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"constrainProportions\":false,\"height\":16,\"width\":16,\"x\":2,\"y\":0},\"isFlippedHorizontal\":false,\"isFlippedVertical\":false,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":0,\"name\":\"bounds\",\"nameIsFixed\":true,\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"style\":{\"_class\":\"style\",\"endDecorationType\":0,\"miterLimit\":10,\"startDecorationType\":0},\"hasClickThrough\":false,\"layers\":[{\"_class\":\"shapePath\",\"do_objectID\":\"D15D00AC-B194-4DEB-807C-B29A7A0C30D1\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"constrainProportions\":false,\"height\":16,\"width\":16,\"x\":0,\"y\":0},\"isFlippedHorizontal\":false,\"isFlippedVertical\":false,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":0,\"name\":\"Path\",\"nameIsFixed\":false,\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"booleanOperation\":-1,\"edited\":true,\"path\":{\"_class\":\"path\",\"isClosed\":true,\"points\":[{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0, 0}\",\"curveMode\":1,\"curveTo\":\"{0, 0}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0, 0}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{1, 0}\",\"curveMode\":1,\"curveTo\":\"{1, 0}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{1, 0}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{1, 1}\",\"curveMode\":1,\"curveTo\":\"{1, 1}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{1, 1}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0, 1}\",\"curveMode\":1,\"curveTo\":\"{0, 1}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0, 1}\"}]}}],\"clippingMaskMode\":0,\"hasClippingMask\":false,\"windingRule\":1},{\"_class\":\"shapeGroup\",\"do_objectID\":\"9DCE5055-9A08-4B23-B1D5-943FB838D12C\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"constrainProportions\":true,\"height\":14,\"width\":18.0452558296814,\"x\":0.9773720851803773,\"y\":1},\"isFlippedHorizontal\":false,\"isFlippedVertical\":false,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":0,\"name\":\"Shape\",\"nameIsFixed\":true,\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"style\":{\"_class\":\"style\",\"endDecorationType\":0,\"fills\":[{\"_class\":\"fill\",\"isEnabled\":true,\"color\":{\"_class\":\"color\",\"alpha\":1,\"blue\":1,\"green\":1,\"red\":1},\"fillType\":0,\"noiseIndex\":0,\"noiseIntensity\":0,\"patternFillType\":0,\"patternTileScale\":1}],\"miterLimit\":10,\"startDecorationType\":0},\"hasClickThrough\":false,\"layers\":[{\"_class\":\"oval\",\"do_objectID\":\"17C69D78-7C2A-46AF-B6C5-2106D448D51A\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"constrainProportions\":true,\"height\":30,\"width\":30,\"x\":-5.9773720851804,\"y\":0},\"isFlippedHorizontal\":false,\"isFlippedVertical\":false,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":0,\"name\":\"Path\",\"nameIsFixed\":false,\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"booleanOperation\":-1,\"edited\":false,\"path\":{\"_class\":\"path\",\"isClosed\":true,\"points\":[{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.77614237490000004, 1}\",\"curveMode\":2,\"curveTo\":\"{0.22385762510000001, 1}\",\"hasCurveFrom\":true,\"hasCurveTo\":true,\"point\":\"{0.5, 1}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{1, 0.22385762510000001}\",\"curveMode\":2,\"curveTo\":\"{1, 0.77614237490000004}\",\"hasCurveFrom\":true,\"hasCurveTo\":true,\"point\":\"{1, 0.5}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.22385762510000001, 0}\",\"curveMode\":2,\"curveTo\":\"{0.77614237490000004, 0}\",\"hasCurveFrom\":true,\"hasCurveTo\":true,\"point\":\"{0.5, 0}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0, 0.77614237490000004}\",\"curveMode\":2,\"curveTo\":\"{0, 0.22385762510000001}\",\"hasCurveFrom\":true,\"hasCurveTo\":true,\"point\":\"{0, 0.5}\"}]}},{\"_class\":\"shapePath\",\"do_objectID\":\"AC4876A8-B88A-47AD-A48E-3023127CD30D\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"constrainProportions\":false,\"height\":14,\"width\":23,\"x\":-2.4773720851804,\"y\":0},\"isFlippedHorizontal\":false,\"isFlippedVertical\":false,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":0,\"name\":\"Path 13\",\"nameIsFixed\":false,\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"booleanOperation\":2,\"edited\":true,\"path\":{\"_class\":\"path\",\"isClosed\":true,\"points\":[{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.13043478260869393, 1.0000000000000004}\",\"curveMode\":1,\"curveTo\":\"{0.13043478260869393, 1.0000000000000004}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0.50000000000000067, 1}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{1, 0}\",\"curveMode\":1,\"curveTo\":\"{1, 0}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{1, 0}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{3.8616453030440226e-17, 0}\",\"curveMode\":1,\"curveTo\":\"{3.8616453030440226e-17, 0}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{3.8616453030440226e-17, 0}\"}]}}],\"clippingMaskMode\":0,\"hasClippingMask\":false,\"windingRule\":1}]}],\"backgroundColor\":{\"_class\":\"color\",\"alpha\":1,\"blue\":0.2588235294117647,\"green\":0.2588235294117647,\"red\":0.2588235294117647},\"hasBackgroundColor\":true,\"horizontalRulerData\":{\"_class\":\"rulerData\",\"base\":0,\"guides\":[]},\"includeBackgroundColorInExport\":true,\"includeInCloudUpload\":true,\"verticalRulerData\":{\"_class\":\"rulerData\",\"base\":0,\"guides\":[]},\"includeBackgroundColorInInstance\":false,\"symbolID\":\"080C0EF9-47BD-4900-BB91-42C80173D424\"},{\"_class\":\"symbolMaster\",\"do_objectID\":\"7C5A0EC0-D963-4207-80CB-49ED115BF9B5\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"constrainProportions\":false,\"height\":48,\"width\":1024,\"x\":318,\"y\":0},\"isFlippedHorizontal\":false,\"isFlippedVertical\":false,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":1,\"name\":\"Material\\/Android\\/Navbar 1024dp black\",\"nameIsFixed\":false,\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"style\":{\"_class\":\"style\",\"endDecorationType\":0,\"miterLimit\":10,\"startDecorationType\":0},\"hasClickThrough\":true,\"layers\":[{\"_class\":\"shapeGroup\",\"do_objectID\":\"FD276149-9FFE-47EE-AFEF-6E94CAAB15AB\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"constrainProportions\":false,\"height\":48,\"width\":1024,\"x\":0,\"y\":0},\"isFlippedHorizontal\":false,\"isFlippedVertical\":false,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":0,\"name\":\"navbar bg\",\"nameIsFixed\":true,\"originalObjectID\":\"FD276149-9FFE-47EE-AFEF-6E94CAAB15AB\",\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"style\":{\"_class\":\"style\",\"endDecorationType\":0,\"fills\":[{\"_class\":\"fill\",\"isEnabled\":true,\"color\":{\"_class\":\"color\",\"alpha\":1,\"blue\":0,\"green\":0,\"red\":0},\"fillType\":0,\"noiseIndex\":0,\"noiseIntensity\":0,\"patternFillType\":0,\"patternTileScale\":1}],\"miterLimit\":10,\"startDecorationType\":0},\"hasClickThrough\":false,\"layers\":[{\"_class\":\"rectangle\",\"do_objectID\":\"93E17AB9-FD66-4372-A444-CA8F05F091D4\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"constrainProportions\":false,\"height\":48,\"width\":1024,\"x\":0,\"y\":0},\"isFlippedHorizontal\":false,\"isFlippedVertical\":false,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":0,\"name\":\"Rectangle-path\",\"nameIsFixed\":false,\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"booleanOperation\":-1,\"edited\":false,\"path\":{\"_class\":\"path\",\"isClosed\":true,\"points\":[{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0, 0}\",\"curveMode\":1,\"curveTo\":\"{0, 0}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0, 0}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0, 1}\",\"curveMode\":1,\"curveTo\":\"{0, 1}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0, 1}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{1, 1}\",\"curveMode\":1,\"curveTo\":\"{1, 1}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{1, 1}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{1, 0}\",\"curveMode\":1,\"curveTo\":\"{1, 0}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{1, 0}\"}]},\"fixedRadius\":0,\"hasConvertedToNewRoundCorners\":true}],\"clippingMaskMode\":0,\"hasClippingMask\":false,\"windingRule\":1},{\"_class\":\"shapeGroup\",\"do_objectID\":\"C32AB187-22D2-4FD4-A756-372306E00456\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"constrainProportions\":true,\"height\":16,\"width\":16,\"x\":709,\"y\":16},\"isFlippedHorizontal\":false,\"isFlippedVertical\":false,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":0,\"name\":\"recent\",\"nameIsFixed\":true,\"originalObjectID\":\"C32AB187-22D2-4FD4-A756-372306E00456\",\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"style\":{\"_class\":\"style\",\"borders\":[{\"_class\":\"border\",\"isEnabled\":true,\"color\":{\"_class\":\"color\",\"alpha\":1,\"blue\":1,\"green\":1,\"red\":1},\"fillType\":0,\"position\":1,\"thickness\":2}],\"endDecorationType\":0,\"miterLimit\":10,\"startDecorationType\":0},\"hasClickThrough\":false,\"layers\":[{\"_class\":\"rectangle\",\"do_objectID\":\"C05F4D69-B880-485E-9DEA-0445FDB5CEBE\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"constrainProportions\":false,\"height\":16,\"width\":16,\"x\":0,\"y\":0},\"isFlippedHorizontal\":false,\"isFlippedVertical\":false,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":0,\"name\":\"Path\",\"nameIsFixed\":false,\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"booleanOperation\":-1,\"edited\":false,\"path\":{\"_class\":\"path\",\"isClosed\":true,\"points\":[{\"_class\":\"curvePoint\",\"cornerRadius\":2,\"curveFrom\":\"{0, 0}\",\"curveMode\":1,\"curveTo\":\"{0, 0}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0, 0}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":2,\"curveFrom\":\"{0, 1}\",\"curveMode\":1,\"curveTo\":\"{0, 1}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0, 1}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":2,\"curveFrom\":\"{1, 1}\",\"curveMode\":1,\"curveTo\":\"{1, 1}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{1, 1}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":2,\"curveFrom\":\"{1, 0}\",\"curveMode\":1,\"curveTo\":\"{1, 0}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{1, 0}\"}]},\"fixedRadius\":2,\"hasConvertedToNewRoundCorners\":true}],\"clippingMaskMode\":0,\"hasClippingMask\":false,\"windingRule\":1},{\"_class\":\"shapeGroup\",\"do_objectID\":\"CA1CFE60-94CC-4DFE-ADB7-228099132327\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"constrainProportions\":true,\"height\":16,\"width\":16,\"x\":504.9975292549875,\"y\":16.00097492402438},\"isFlippedHorizontal\":false,\"isFlippedVertical\":false,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":0,\"name\":\"home\",\"nameIsFixed\":true,\"originalObjectID\":\"CA1CFE60-94CC-4DFE-ADB7-228099132327\",\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"style\":{\"_class\":\"style\",\"borders\":[{\"_class\":\"border\",\"isEnabled\":true,\"color\":{\"_class\":\"color\",\"alpha\":1,\"blue\":1,\"green\":1,\"red\":1},\"fillType\":0,\"position\":1,\"thickness\":2}],\"endDecorationType\":0,\"miterLimit\":10,\"startDecorationType\":0},\"hasClickThrough\":false,\"layers\":[{\"_class\":\"oval\",\"do_objectID\":\"8873C58B-0DDD-45A4-8A28-EC4E572620DF\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"constrainProportions\":false,\"height\":16,\"width\":16,\"x\":0,\"y\":0},\"isFlippedHorizontal\":false,\"isFlippedVertical\":false,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":0,\"name\":\"Path\",\"nameIsFixed\":false,\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"booleanOperation\":-1,\"edited\":false,\"path\":{\"_class\":\"path\",\"isClosed\":true,\"points\":[{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.77614237490000004, 1}\",\"curveMode\":2,\"curveTo\":\"{0.22385762510000001, 1}\",\"hasCurveFrom\":true,\"hasCurveTo\":true,\"point\":\"{0.5, 1}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{1, 0.22385762510000001}\",\"curveMode\":2,\"curveTo\":\"{1, 0.77614237490000004}\",\"hasCurveFrom\":true,\"hasCurveTo\":true,\"point\":\"{1, 0.5}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.22385762510000001, 0}\",\"curveMode\":2,\"curveTo\":\"{0.77614237490000004, 0}\",\"hasCurveFrom\":true,\"hasCurveTo\":true,\"point\":\"{0.5, 0}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0, 0.77614237490000004}\",\"curveMode\":2,\"curveTo\":\"{0, 0.22385762510000001}\",\"hasCurveFrom\":true,\"hasCurveTo\":true,\"point\":\"{0, 0.5}\"}]}}],\"clippingMaskMode\":0,\"hasClippingMask\":false,\"windingRule\":1},{\"_class\":\"shapeGroup\",\"do_objectID\":\"B139CB06-4803-4C60-9112-3DEE3F51D109\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"constrainProportions\":false,\"height\":17.45461322817407,\"width\":14.99505859673756,\"x\":301.0024707450125,\"y\":15.27269338591304},\"isFlippedHorizontal\":false,\"isFlippedVertical\":false,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":0,\"name\":\"back\",\"nameIsFixed\":true,\"originalObjectID\":\"B139CB06-4803-4C60-9112-3DEE3F51D109\",\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"style\":{\"_class\":\"style\",\"borders\":[{\"_class\":\"border\",\"isEnabled\":true,\"color\":{\"_class\":\"color\",\"alpha\":1,\"blue\":1,\"green\":1,\"red\":1},\"fillType\":0,\"position\":1,\"thickness\":2}],\"endDecorationType\":0,\"miterLimit\":10,\"startDecorationType\":0},\"hasClickThrough\":true,\"layers\":[{\"_class\":\"shapePath\",\"do_objectID\":\"466F6847-C95A-4EAD-A447-C48DCC7870DC\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"constrainProportions\":false,\"height\":17.45461322817407,\"width\":14.99505859673756,\"x\":0,\"y\":1.77635683940025e-15},\"isFlippedHorizontal\":false,\"isFlippedVertical\":false,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":0,\"name\":\"Path\",\"nameIsFixed\":false,\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"booleanOperation\":-1,\"edited\":true,\"path\":{\"_class\":\"path\",\"isClosed\":true,\"points\":[{\"_class\":\"curvePoint\",\"cornerRadius\":2,\"curveFrom\":\"{0.99999997746672664, -0.044212864809782863}\",\"curveMode\":1,\"curveTo\":\"{0.99999997746672664, -0.044212864809782863}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0.99999997746672664, -0.044212864809782863}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":2,\"curveFrom\":\"{0.72049598702496187, 1.1868950936476208}\",\"curveMode\":1,\"curveTo\":\"{1.2795040182693471, 0.90175412939323807}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{1.0000000026471545, 1.0443246115204294}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":2,\"curveFrom\":\"{-0.067018176924187017, 0.5000558548053492}\",\"curveMode\":1,\"curveTo\":\"{-0.067018176924187017, 0.5000558548053492}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{-0.067018176924187017, 0.5000558548053492}\"}]}}],\"clippingMaskMode\":0,\"hasClippingMask\":false,\"windingRule\":1}],\"backgroundColor\":{\"_class\":\"color\",\"alpha\":1,\"blue\":1,\"green\":1,\"red\":1},\"hasBackgroundColor\":false,\"horizontalRulerData\":{\"_class\":\"rulerData\",\"base\":0,\"guides\":[]},\"includeBackgroundColorInExport\":true,\"includeInCloudUpload\":true,\"verticalRulerData\":{\"_class\":\"rulerData\",\"base\":0,\"guides\":[]},\"includeBackgroundColorInInstance\":false,\"symbolID\":\"ED9B7E91-942C-4237-9FBB-46015072DC87\"},{\"_class\":\"symbolMaster\",\"do_objectID\":\"E33E2440-9E0D-4801-A0C4-5F0D7AA40DE6\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"constrainProportions\":false,\"height\":24,\"width\":12,\"x\":1442,\"y\":0},\"isFlippedHorizontal\":false,\"isFlippedVertical\":false,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":1,\"name\":\"Material\\/Icons white\\/more vert\",\"nameIsFixed\":true,\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"style\":{\"_class\":\"style\",\"endDecorationType\":0,\"fills\":[{\"_class\":\"fill\",\"do_objectID\":\"80066115-E9C9-49E6-A544-44ED048B1155\",\"isEnabled\":true,\"color\":{\"_class\":\"color\",\"alpha\":1,\"blue\":1,\"green\":1,\"red\":1},\"fillType\":0,\"noiseIndex\":0,\"noiseIntensity\":0,\"patternFillType\":1,\"patternTileScale\":1}],\"miterLimit\":10,\"startDecorationType\":0},\"hasClickThrough\":true,\"layers\":[{\"_class\":\"shapeGroup\",\"do_objectID\":\"81A456AC-794A-4C12-82B7-424EEBB853DA\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"do_objectID\":\"4E763800-4F35-4D88-BD54-27F2228FFD7B\",\"constrainProportions\":false,\"height\":16,\"width\":4,\"x\":4,\"y\":4},\"isFlippedHorizontal\":false,\"isFlippedVertical\":true,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":0,\"name\":\"Shape\",\"nameIsFixed\":false,\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"style\":{\"_class\":\"style\",\"do_objectID\":\"78B24937-2B1B-4AC0-8304-D12FF139276F\",\"endDecorationType\":0,\"fills\":[{\"_class\":\"fill\",\"isEnabled\":true,\"color\":{\"_class\":\"color\",\"alpha\":1,\"blue\":1,\"green\":1,\"red\":1},\"fillType\":0,\"noiseIndex\":0,\"noiseIntensity\":0,\"patternFillType\":0,\"patternTileScale\":1}],\"miterLimit\":10,\"sharedObjectID\":\"A93742BB-00F9-4F61-A4D3-18497765722F\",\"startDecorationType\":0},\"hasClickThrough\":false,\"layers\":[{\"_class\":\"shapePath\",\"do_objectID\":\"4CC0D4E9-894A-4BCE-9A28-86E02BC355DD\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"constrainProportions\":false,\"height\":4,\"width\":4,\"x\":0,\"y\":12},\"isFlippedHorizontal\":false,\"isFlippedVertical\":false,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":0,\"name\":\"Path\",\"nameIsFixed\":false,\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"booleanOperation\":-1,\"edited\":true,\"path\":{\"_class\":\"path\",\"isClosed\":true,\"points\":[{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.77499999999999991, 0}\",\"curveMode\":4,\"curveTo\":\"{0.5, 0}\",\"hasCurveFrom\":true,\"hasCurveTo\":false,\"point\":\"{0.5, 0}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{1, 0.77499999999999991}\",\"curveMode\":2,\"curveTo\":\"{1, 0.22500000000000009}\",\"hasCurveFrom\":true,\"hasCurveTo\":true,\"point\":\"{1, 0.5}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.22500000000000009, 1}\",\"curveMode\":2,\"curveTo\":\"{0.77499999999999991, 1}\",\"hasCurveFrom\":true,\"hasCurveTo\":true,\"point\":\"{0.5, 1}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0, 0.22500000000000009}\",\"curveMode\":2,\"curveTo\":\"{0, 0.77499999999999991}\",\"hasCurveFrom\":true,\"hasCurveTo\":true,\"point\":\"{0, 0.5}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.5, 0}\",\"curveMode\":4,\"curveTo\":\"{0.22500000000000009, 0}\",\"hasCurveFrom\":false,\"hasCurveTo\":true,\"point\":\"{0.5, 0}\"}]}},{\"_class\":\"shapePath\",\"do_objectID\":\"19FCD26B-6C74-46C6-95AF-4FBD48018DFA\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"constrainProportions\":false,\"height\":4,\"width\":4,\"x\":0,\"y\":6},\"isFlippedHorizontal\":false,\"isFlippedVertical\":false,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":0,\"name\":\"Path\",\"nameIsFixed\":false,\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"booleanOperation\":-1,\"edited\":true,\"path\":{\"_class\":\"path\",\"isClosed\":true,\"points\":[{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.22500000000000009, 1}\",\"curveMode\":4,\"curveTo\":\"{0.5, 1}\",\"hasCurveFrom\":true,\"hasCurveTo\":false,\"point\":\"{0.5, 1}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0, 0.22500000000000009}\",\"curveMode\":2,\"curveTo\":\"{0, 0.77499999999999991}\",\"hasCurveFrom\":true,\"hasCurveTo\":true,\"point\":\"{0, 0.5}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.77499999999999991, 0}\",\"curveMode\":2,\"curveTo\":\"{0.22500000000000009, 0}\",\"hasCurveFrom\":true,\"hasCurveTo\":true,\"point\":\"{0.5, 0}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{1, 0.77499999999999991}\",\"curveMode\":2,\"curveTo\":\"{1, 0.22500000000000009}\",\"hasCurveFrom\":true,\"hasCurveTo\":true,\"point\":\"{1, 0.5}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.5, 1}\",\"curveMode\":4,\"curveTo\":\"{0.77499999999999991, 1}\",\"hasCurveFrom\":false,\"hasCurveTo\":true,\"point\":\"{0.5, 1}\"}]}},{\"_class\":\"shapePath\",\"do_objectID\":\"14AD854B-BD0A-4BA8-B581-7ECE76C1CA08\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"constrainProportions\":false,\"height\":4,\"width\":4,\"x\":0,\"y\":0},\"isFlippedHorizontal\":false,\"isFlippedVertical\":false,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":0,\"name\":\"Path\",\"nameIsFixed\":false,\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"booleanOperation\":-1,\"edited\":true,\"path\":{\"_class\":\"path\",\"isClosed\":true,\"points\":[{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.22500000000000009, 1}\",\"curveMode\":4,\"curveTo\":\"{0.5, 1}\",\"hasCurveFrom\":true,\"hasCurveTo\":false,\"point\":\"{0.5, 1}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0, 0.22499999999999964}\",\"curveMode\":2,\"curveTo\":\"{0, 0.77500000000000036}\",\"hasCurveFrom\":true,\"hasCurveTo\":true,\"point\":\"{0, 0.5}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.77499999999999991, 0}\",\"curveMode\":2,\"curveTo\":\"{0.22500000000000009, 0}\",\"hasCurveFrom\":true,\"hasCurveTo\":true,\"point\":\"{0.5, 0}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{1, 0.77500000000000036}\",\"curveMode\":2,\"curveTo\":\"{1, 0.22499999999999964}\",\"hasCurveFrom\":true,\"hasCurveTo\":true,\"point\":\"{1, 0.5}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.5, 1}\",\"curveMode\":4,\"curveTo\":\"{0.77499999999999991, 1}\",\"hasCurveFrom\":false,\"hasCurveTo\":true,\"point\":\"{0.5, 1}\"}]}}],\"clippingMaskMode\":0,\"hasClippingMask\":false,\"windingRule\":1}],\"backgroundColor\":{\"_class\":\"color\",\"alpha\":1,\"blue\":0.2588235294117647,\"green\":0.2588235294117647,\"red\":0.2588235294117647},\"hasBackgroundColor\":true,\"horizontalRulerData\":{\"_class\":\"rulerData\",\"base\":0,\"guides\":[]},\"includeBackgroundColorInExport\":true,\"includeInCloudUpload\":true,\"verticalRulerData\":{\"_class\":\"rulerData\",\"base\":0,\"guides\":[]},\"includeBackgroundColorInInstance\":false,\"symbolID\":\"0E94AECA-E862-48AE-9B52-257AAE09FC3A\"},{\"_class\":\"symbolMaster\",\"do_objectID\":\"57C2B1F6-1718-4BD9-85D3-39BEFA28613B\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"constrainProportions\":false,\"height\":24,\"width\":24,\"x\":1554,\"y\":0},\"isFlippedHorizontal\":false,\"isFlippedVertical\":false,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":1,\"name\":\"Material\\/Icons white\\/arrow back\",\"nameIsFixed\":true,\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"style\":{\"_class\":\"style\",\"endDecorationType\":0,\"fills\":[{\"_class\":\"fill\",\"do_objectID\":\"24C96E56-1A8A-4A56-B78D-E65C53111097\",\"isEnabled\":true,\"color\":{\"_class\":\"color\",\"alpha\":1,\"blue\":1,\"green\":1,\"red\":1},\"fillType\":0,\"noiseIndex\":0,\"noiseIntensity\":0,\"patternFillType\":1,\"patternTileScale\":1}],\"miterLimit\":10,\"startDecorationType\":0},\"hasClickThrough\":true,\"layers\":[{\"_class\":\"shapeGroup\",\"do_objectID\":\"E5F819E4-BE46-40EB-B37C-15B389D007FE\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"constrainProportions\":false,\"height\":16,\"width\":15.99999999999994,\"x\":3.700000000000045,\"y\":4},\"isFlippedHorizontal\":false,\"isFlippedVertical\":false,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":0,\"name\":\"Shape\",\"nameIsFixed\":false,\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"style\":{\"_class\":\"style\",\"do_objectID\":\"5A6AFA73-58B5-4893-9E5C-F87AD791FC88\",\"endDecorationType\":0,\"fills\":[{\"_class\":\"fill\",\"isEnabled\":true,\"color\":{\"_class\":\"color\",\"alpha\":1,\"blue\":1,\"green\":1,\"red\":1},\"fillType\":0,\"noiseIndex\":0,\"noiseIntensity\":0,\"patternFillType\":0,\"patternTileScale\":1}],\"miterLimit\":10,\"sharedObjectID\":\"A93742BB-00F9-4F61-A4D3-18497765722F\",\"startDecorationType\":0},\"hasClickThrough\":false,\"layers\":[{\"_class\":\"shapePath\",\"do_objectID\":\"7364F9DB-12C9-4030-BA13-488063868527\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"constrainProportions\":false,\"height\":16,\"width\":15.99999999999994,\"x\":0,\"y\":0},\"isFlippedHorizontal\":false,\"isFlippedVertical\":false,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":0,\"name\":\"Path\",\"nameIsFixed\":false,\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"booleanOperation\":-1,\"edited\":true,\"path\":{\"_class\":\"path\",\"isClosed\":true,\"points\":[{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{1, 0.4375}\",\"curveMode\":1,\"curveTo\":\"{1, 0.4375}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{1, 0.4375}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.23749999999999799, 0.4375}\",\"curveMode\":1,\"curveTo\":\"{0.23749999999999799, 0.4375}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0.23749999999999799, 0.4375}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.58750000000000069, 0.087499999999998579}\",\"curveMode\":1,\"curveTo\":\"{0.58750000000000069, 0.087499999999998579}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0.58750000000000069, 0.087499999999998579}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.50000000000000178, 0}\",\"curveMode\":1,\"curveTo\":\"{0.50000000000000178, 0}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0.50000000000000178, 0}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0, 0.5}\",\"curveMode\":1,\"curveTo\":\"{0, 0.5}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0, 0.5}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.50000000000000178, 1}\",\"curveMode\":1,\"curveTo\":\"{0.50000000000000178, 1}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0.50000000000000178, 1}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.58750000000000069, 0.91250000000000142}\",\"curveMode\":1,\"curveTo\":\"{0.58750000000000069, 0.91250000000000142}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0.58750000000000069, 0.91250000000000142}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.23749999999999799, 0.5625}\",\"curveMode\":1,\"curveTo\":\"{0.23749999999999799, 0.5625}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0.23749999999999799, 0.5625}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{1, 0.5625}\",\"curveMode\":1,\"curveTo\":\"{1, 0.5625}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{1, 0.5625}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{1, 0.4375}\",\"curveMode\":1,\"curveTo\":\"{1, 0.4375}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{1, 0.4375}\"}]}}],\"clippingMaskMode\":0,\"hasClippingMask\":false,\"windingRule\":1}],\"backgroundColor\":{\"_class\":\"color\",\"alpha\":1,\"blue\":0.2588235294117647,\"green\":0.2588235294117647,\"red\":0.2588235294117647},\"hasBackgroundColor\":true,\"horizontalRulerData\":{\"_class\":\"rulerData\",\"base\":0,\"guides\":[]},\"includeBackgroundColorInExport\":true,\"includeInCloudUpload\":true,\"verticalRulerData\":{\"_class\":\"rulerData\",\"base\":0,\"guides\":[]},\"includeBackgroundColorInInstance\":false,\"symbolID\":\"399C1B51-205C-4A6A-B53D-5715C2003838\"},{\"_class\":\"symbolMaster\",\"do_objectID\":\"D284E6EB-A900-4B52-B3A1-0461AFBE8EFD\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"constrainProportions\":false,\"height\":24,\"width\":1024,\"x\":1678,\"y\":0},\"isFlippedHorizontal\":false,\"isFlippedVertical\":false,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":0,\"name\":\"Material\\/Android\\/Status bar 1024dp black\",\"nameIsFixed\":false,\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"style\":{\"_class\":\"style\",\"endDecorationType\":0,\"miterLimit\":10,\"startDecorationType\":0},\"hasClickThrough\":true,\"layers\":[{\"_class\":\"shapeGroup\",\"do_objectID\":\"8C2C177A-6BDB-4A27-AED7-BFD6E8C28CFC\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"constrainProportions\":false,\"height\":24,\"width\":1024,\"x\":0,\"y\":0},\"isFlippedHorizontal\":false,\"isFlippedVertical\":false,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":0,\"name\":\"status bar bg\",\"nameIsFixed\":true,\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"style\":{\"_class\":\"style\",\"endDecorationType\":0,\"fills\":[{\"_class\":\"fill\",\"isEnabled\":true,\"color\":{\"_class\":\"color\",\"alpha\":1,\"blue\":0,\"green\":0,\"red\":0},\"fillType\":0,\"noiseIndex\":0,\"noiseIntensity\":0,\"patternFillType\":0,\"patternTileScale\":1}],\"miterLimit\":10,\"startDecorationType\":0},\"hasClickThrough\":false,\"layers\":[{\"_class\":\"rectangle\",\"do_objectID\":\"1A534E53-529F-43A2-9E31-1B2F30F8B3F9\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"constrainProportions\":false,\"height\":24,\"width\":1024,\"x\":0,\"y\":0},\"isFlippedHorizontal\":false,\"isFlippedVertical\":false,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":0,\"name\":\"Rectangle-path\",\"nameIsFixed\":false,\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"booleanOperation\":-1,\"edited\":false,\"path\":{\"_class\":\"path\",\"isClosed\":true,\"points\":[{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0, 0}\",\"curveMode\":1,\"curveTo\":\"{0, 0}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0, 0}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0, 1}\",\"curveMode\":1,\"curveTo\":\"{0, 1}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0, 1}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{1, 1}\",\"curveMode\":1,\"curveTo\":\"{1, 1}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{1, 1}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{1, 0}\",\"curveMode\":1,\"curveTo\":\"{1, 0}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{1, 0}\"}]},\"fixedRadius\":0,\"hasConvertedToNewRoundCorners\":true}],\"clippingMaskMode\":0,\"hasClippingMask\":false,\"windingRule\":1},{\"_class\":\"symbolInstance\",\"do_objectID\":\"47930CB2-9DE7-4E64-B046-ED7FCD4A8102\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"constrainProportions\":false,\"height\":24,\"width\":118,\"x\":906,\"y\":0},\"isFlippedHorizontal\":false,\"isFlippedVertical\":false,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":0,\"name\":\"status bar content\",\"nameIsFixed\":false,\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"style\":{\"_class\":\"style\",\"endDecorationType\":0,\"miterLimit\":10,\"startDecorationType\":0},\"horizontalSpacing\":0,\"masterInfluenceEdgeMaxXPadding\":0,\"masterInfluenceEdgeMaxYPadding\":11,\"masterInfluenceEdgeMinXPadding\":0,\"masterInfluenceEdgeMinYPadding\":0,\"symbolID\":\"080C0EF9-47BD-4900-BB91-42C80173D424\",\"verticalSpacing\":0,\"overrides\":{\"0\":{}}}],\"backgroundColor\":{\"_class\":\"color\",\"alpha\":1,\"blue\":1,\"green\":1,\"red\":1},\"hasBackgroundColor\":false,\"horizontalRulerData\":{\"_class\":\"rulerData\",\"base\":0,\"guides\":[]},\"includeBackgroundColorInExport\":true,\"includeInCloudUpload\":true,\"verticalRulerData\":{\"_class\":\"rulerData\",\"base\":0,\"guides\":[]},\"includeBackgroundColorInInstance\":false,\"symbolID\":\"88160964-34E8-4518-8C33-9A5CE739F4A7\"},{\"_class\":\"symbolMaster\",\"do_objectID\":\"D11A2A32-7CF0-4C46-B91D-9FE1B6C7CBB3\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"do_objectID\":\"620DB548-1D66-401B-B650-ADCC059489A4\",\"constrainProportions\":false,\"height\":24,\"width\":24,\"x\":2802,\"y\":0},\"isFlippedHorizontal\":false,\"isFlippedVertical\":false,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":0,\"name\":\"Material\\/Icons white\\/search\",\"nameIsFixed\":true,\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"style\":{\"_class\":\"style\",\"endDecorationType\":0,\"fills\":[{\"_class\":\"fill\",\"do_objectID\":\"1EC8BD03-31CD-4BC9-B0E7-DE236CF32471\",\"isEnabled\":true,\"color\":{\"_class\":\"color\",\"alpha\":1,\"blue\":1,\"green\":1,\"red\":1},\"fillType\":0,\"noiseIndex\":0,\"noiseIntensity\":0,\"patternFillType\":1,\"patternTileScale\":1}],\"miterLimit\":10,\"startDecorationType\":0},\"hasClickThrough\":true,\"layers\":[{\"_class\":\"shapeGroup\",\"do_objectID\":\"B11F8503-58EE-4781-AD1E-23038435CBB8\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"do_objectID\":\"4D3B3EC6-B673-4CA9-B6CD-EDD0BDA8E718\",\"constrainProportions\":false,\"height\":17.491,\"width\":17.49,\"x\":3,\"y\":3},\"isFlippedHorizontal\":false,\"isFlippedVertical\":true,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":0,\"name\":\"Shape\",\"nameIsFixed\":true,\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"style\":{\"_class\":\"style\",\"do_objectID\":\"F1F9F873-3F66-43CC-BF1C-30FC1724183E\",\"endDecorationType\":0,\"fills\":[{\"_class\":\"fill\",\"isEnabled\":true,\"color\":{\"_class\":\"color\",\"alpha\":1,\"blue\":1,\"green\":1,\"red\":1},\"fillType\":0,\"noiseIndex\":0,\"noiseIntensity\":0,\"patternFillType\":0,\"patternTileScale\":1}],\"miterLimit\":10,\"sharedObjectID\":\"A93742BB-00F9-4F61-A4D3-18497765722F\",\"startDecorationType\":0},\"hasClickThrough\":false,\"layers\":[{\"_class\":\"shapePath\",\"do_objectID\":\"D72E3724-172E-4777-AEC1-7E2FFBAF73CC\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"do_objectID\":\"E9107034-A95A-4613-B57D-EC674C4C4836\",\"constrainProportions\":false,\"height\":17.491,\"width\":17.49,\"x\":0,\"y\":0},\"isFlippedHorizontal\":false,\"isFlippedVertical\":true,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":0,\"name\":\"Path\",\"nameIsFixed\":false,\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"booleanOperation\":-1,\"edited\":true,\"path\":{\"_class\":\"path\",\"isClosed\":true,\"points\":[{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.7148084619782733, 0.62889486021382424}\",\"curveMode\":1,\"curveTo\":\"{0.7148084619782733, 0.62889486021382424}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0.7148084619782733, 0.62889486021382424}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.66941109205260141, 0.62889486021382424}\",\"curveMode\":1,\"curveTo\":\"{0.66941109205260141, 0.62889486021382424}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0.66941109205260141, 0.62889486021382424}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.70937678673527726, 0.5482248013263965}\",\"curveMode\":4,\"curveTo\":\"{0.65363064608347621, 0.61322966096849807}\",\"hasCurveFrom\":true,\"hasCurveTo\":false,\"point\":\"{0.65363064608347621, 0.61322966096849807}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.74328187535734702, 0.16637127665656626}\",\"curveMode\":3,\"curveTo\":\"{0.74328187535734702, 0.46395289005774398}\",\"hasCurveFrom\":true,\"hasCurveTo\":true,\"point\":\"{0.74328187535734702, 0.37161969012635071}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.16638078902229844, 0}\",\"curveMode\":2,\"curveTo\":\"{0.57690108633504855, 0}\",\"hasCurveFrom\":true,\"hasCurveTo\":true,\"point\":\"{0.37164093767867351, 0}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0, 0.57686810359613516}\",\"curveMode\":2,\"curveTo\":\"{0, 0.16637127665656626}\",\"hasCurveFrom\":true,\"hasCurveTo\":true,\"point\":\"{0, 0.37161969012635071}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.46397941680960547, 0.74323938025270142}\",\"curveMode\":3,\"curveTo\":\"{0.16638078902229844, 0.74323938025270142}\",\"hasCurveFrom\":true,\"hasCurveTo\":true,\"point\":\"{0.37164093767867351, 0.74323938025270142}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.61320754716981118, 0.65370762106226066}\",\"curveMode\":4,\"curveTo\":\"{0.5481989708404803, 0.70939340232119374}\",\"hasCurveFrom\":false,\"hasCurveTo\":true,\"point\":\"{0.61320754716981118, 0.65370762106226066}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.62898799313893639, 0.66937282030758682}\",\"curveMode\":1,\"curveTo\":\"{0.62898799313893639, 0.66937282030758682}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0.62898799313893639, 0.66937282030758682}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.62898799313893639, 0.7146532502429821}\",\"curveMode\":1,\"curveTo\":\"{0.62898799313893639, 0.7146532502429821}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0.62898799313893639, 0.7146532502429821}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.91475128644939963, 1}\",\"curveMode\":1,\"curveTo\":\"{0.91475128644939963, 1}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0.91475128644939963, 1}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{1, 0.91475616031101714}\",\"curveMode\":1,\"curveTo\":\"{1, 0.91475616031101714}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{1, 0.91475616031101714}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.7148084619782733, 0.62889486021382424}\",\"curveMode\":1,\"curveTo\":\"{0.7148084619782733, 0.62889486021382424}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0.7148084619782733, 0.62889486021382424}\"}]}},{\"_class\":\"shapePath\",\"do_objectID\":\"FA3610BD-4C4F-47C4-B7B8-440CE1981E3A\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"do_objectID\":\"C2BF318E-DE70-4EA3-8ABC-D8A0303D827E\",\"constrainProportions\":false,\"height\":9,\"width\":9,\"x\":2,\"y\":6.490999999999985},\"isFlippedHorizontal\":false,\"isFlippedVertical\":true,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":0,\"name\":\"Path\",\"nameIsFixed\":false,\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"booleanOperation\":-1,\"edited\":true,\"path\":{\"_class\":\"path\",\"isClosed\":true,\"points\":[{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.2237777777777778, 1}\",\"curveMode\":4,\"curveTo\":\"{0.5, 1}\",\"hasCurveFrom\":true,\"hasCurveTo\":false,\"point\":\"{0.5, 1}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0, 0.22388888888888894}\",\"curveMode\":2,\"curveTo\":\"{0, 0.77622222222222226}\",\"hasCurveFrom\":true,\"hasCurveTo\":true,\"point\":\"{0, 0.5}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.77611111111111108, 0}\",\"curveMode\":2,\"curveTo\":\"{0.2237777777777778, 0}\",\"hasCurveFrom\":true,\"hasCurveTo\":true,\"point\":\"{0.5, 0}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{1, 0.77622222222222226}\",\"curveMode\":2,\"curveTo\":\"{1, 0.22388888888888894}\",\"hasCurveFrom\":true,\"hasCurveTo\":true,\"point\":\"{1, 0.5}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.5, 1}\",\"curveMode\":4,\"curveTo\":\"{0.77611111111111108, 1}\",\"hasCurveFrom\":false,\"hasCurveTo\":true,\"point\":\"{0.5, 1}\"}]}}],\"clippingMaskMode\":0,\"hasClippingMask\":false,\"windingRule\":1}],\"backgroundColor\":{\"_class\":\"color\",\"alpha\":1,\"blue\":0.2588235294117647,\"green\":0.2588235294117647,\"red\":0.2588235294117647},\"hasBackgroundColor\":true,\"horizontalRulerData\":{\"_class\":\"rulerData\",\"base\":0,\"guides\":[]},\"includeBackgroundColorInExport\":true,\"includeInCloudUpload\":true,\"verticalRulerData\":{\"_class\":\"rulerData\",\"base\":0,\"guides\":[]},\"includeBackgroundColorInInstance\":false,\"symbolID\":\"0D519726-DC4C-4BF3-8DA2-C387E9DF606E\"},{\"_class\":\"symbolMaster\",\"do_objectID\":\"10E7654C-E936-403F-9CEF-AA512F39B60F\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"constrainProportions\":false,\"height\":24,\"width\":24,\"x\":2926,\"y\":0},\"isFlippedHorizontal\":false,\"isFlippedVertical\":false,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":1,\"name\":\"Material\\/Icons white\\/arrow drop down\",\"nameIsFixed\":true,\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"style\":{\"_class\":\"style\",\"endDecorationType\":0,\"fills\":[{\"_class\":\"fill\",\"isEnabled\":true,\"color\":{\"_class\":\"color\",\"alpha\":1,\"blue\":1,\"green\":1,\"red\":1},\"fillType\":0,\"noiseIndex\":0,\"noiseIntensity\":0,\"patternFillType\":1,\"patternTileScale\":1}],\"miterLimit\":10,\"startDecorationType\":0},\"hasClickThrough\":true,\"layers\":[{\"_class\":\"shapeGroup\",\"do_objectID\":\"57F766A9-1E61-4A19-9FC8-B5C38370035E\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"constrainProportions\":false,\"height\":5,\"width\":10,\"x\":7,\"y\":10},\"isFlippedHorizontal\":false,\"isFlippedVertical\":false,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":0,\"name\":\"Shape\",\"nameIsFixed\":false,\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"style\":{\"_class\":\"style\",\"endDecorationType\":0,\"fills\":[{\"_class\":\"fill\",\"isEnabled\":true,\"color\":{\"_class\":\"color\",\"alpha\":1,\"blue\":1,\"green\":1,\"red\":1},\"fillType\":0,\"noiseIndex\":0,\"noiseIntensity\":0,\"patternFillType\":0,\"patternTileScale\":1}],\"miterLimit\":10,\"sharedObjectID\":\"A93742BB-00F9-4F61-A4D3-18497765722F\",\"startDecorationType\":0},\"hasClickThrough\":false,\"layers\":[{\"_class\":\"shapePath\",\"do_objectID\":\"A65159E5-B368-48D8-AAD4-6143D1C687F7\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"constrainProportions\":false,\"height\":5,\"width\":10,\"x\":0,\"y\":0},\"isFlippedHorizontal\":false,\"isFlippedVertical\":false,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":0,\"name\":\"Path\",\"nameIsFixed\":false,\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"booleanOperation\":-1,\"edited\":true,\"path\":{\"_class\":\"path\",\"isClosed\":true,\"points\":[{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0, 0}\",\"curveMode\":1,\"curveTo\":\"{0, 0}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0, 0}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.5, 1}\",\"curveMode\":1,\"curveTo\":\"{0.5, 1}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0.5, 1}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{1, 0}\",\"curveMode\":1,\"curveTo\":\"{1, 0}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{1, 0}\"}]}}],\"clippingMaskMode\":0,\"hasClippingMask\":false,\"windingRule\":1}],\"backgroundColor\":{\"_class\":\"color\",\"alpha\":1,\"blue\":0.2588235294117647,\"green\":0.2588235294117647,\"red\":0.2588235294117647},\"hasBackgroundColor\":true,\"horizontalRulerData\":{\"_class\":\"rulerData\",\"base\":0,\"guides\":[]},\"includeBackgroundColorInExport\":true,\"includeInCloudUpload\":true,\"verticalRulerData\":{\"_class\":\"rulerData\",\"base\":0,\"guides\":[]},\"includeBackgroundColorInInstance\":false,\"symbolID\":\"13DF2748-134F-4932-AFF1-A04B20BCEB5D\"},{\"_class\":\"symbolMaster\",\"do_objectID\":\"8E30A0EB-7E44-41FB-B562-98898C86C5CF\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"do_objectID\":\"E92EB70E-0A8F-47D2-A9FC-AE07CFC84D88\",\"constrainProportions\":false,\"height\":24,\"width\":24,\"x\":3050,\"y\":0},\"isFlippedHorizontal\":false,\"isFlippedVertical\":false,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":1,\"name\":\"Material\\/Icons black\\/arrow drop up\",\"nameIsFixed\":true,\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"style\":{\"_class\":\"style\",\"endDecorationType\":0,\"miterLimit\":10,\"startDecorationType\":0},\"hasClickThrough\":true,\"layers\":[{\"_class\":\"shapeGroup\",\"do_objectID\":\"C9FDF8B1-CADE-47D0-BA77-F1699B31B101\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"do_objectID\":\"7AFE403B-7BFC-4AC0-95E6-8CB29E41EE9E\",\"constrainProportions\":false,\"height\":5,\"width\":10,\"x\":7,\"y\":9},\"isFlippedHorizontal\":false,\"isFlippedVertical\":false,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":0,\"name\":\"Shape\",\"nameIsFixed\":false,\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"style\":{\"_class\":\"style\",\"do_objectID\":\"66D2FB6D-BAF8-4D74-A80B-82B160E929FF\",\"endDecorationType\":0,\"fills\":[{\"_class\":\"fill\",\"isEnabled\":true,\"color\":{\"_class\":\"color\",\"alpha\":1,\"blue\":0,\"green\":0,\"red\":0},\"fillType\":0,\"noiseIndex\":0,\"noiseIntensity\":0,\"patternFillType\":0,\"patternTileScale\":1}],\"miterLimit\":10,\"sharedObjectID\":\"DC56376C-8162-4E24-BB3C-91C5B43AD324\",\"startDecorationType\":0},\"hasClickThrough\":false,\"layers\":[{\"_class\":\"shapePath\",\"do_objectID\":\"C941E34F-358F-403D-8D24-FC0377A5CF66\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"constrainProportions\":false,\"height\":5,\"width\":10,\"x\":0,\"y\":0},\"isFlippedHorizontal\":false,\"isFlippedVertical\":false,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":0,\"name\":\"Path\",\"nameIsFixed\":false,\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"booleanOperation\":-1,\"edited\":true,\"path\":{\"_class\":\"path\",\"isClosed\":true,\"points\":[{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0, 1}\",\"curveMode\":1,\"curveTo\":\"{0, 1}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0, 1}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.5, 0}\",\"curveMode\":1,\"curveTo\":\"{0.5, 0}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0.5, 0}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{1, 1}\",\"curveMode\":1,\"curveTo\":\"{1, 1}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{1, 1}\"}]}}],\"clippingMaskMode\":0,\"hasClippingMask\":false,\"windingRule\":1}],\"backgroundColor\":{\"_class\":\"color\",\"alpha\":1,\"blue\":1,\"green\":1,\"red\":1},\"hasBackgroundColor\":false,\"horizontalRulerData\":{\"_class\":\"rulerData\",\"base\":0,\"guides\":[]},\"includeBackgroundColorInExport\":true,\"includeInCloudUpload\":true,\"verticalRulerData\":{\"_class\":\"rulerData\",\"base\":0,\"guides\":[]},\"includeBackgroundColorInInstance\":false,\"symbolID\":\"FB5EAE15-93F8-464A-90D4-ED01D9F6980B\"},{\"_class\":\"symbolMaster\",\"do_objectID\":\"A0EAD81F-5486-461D-846C-2016D95CE579\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"constrainProportions\":false,\"height\":24,\"width\":24,\"x\":3174,\"y\":0},\"isFlippedHorizontal\":false,\"isFlippedVertical\":false,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":1,\"name\":\"Material\\/Icons white\\/close\",\"nameIsFixed\":true,\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"style\":{\"_class\":\"style\",\"endDecorationType\":0,\"fills\":[{\"_class\":\"fill\",\"do_objectID\":\"F365C82D-DE5F-4804-A574-685B2F36B01D\",\"isEnabled\":true,\"color\":{\"_class\":\"color\",\"alpha\":1,\"blue\":1,\"green\":1,\"red\":1},\"fillType\":0,\"noiseIndex\":0,\"noiseIntensity\":0,\"patternFillType\":1,\"patternTileScale\":1}],\"miterLimit\":10,\"startDecorationType\":0},\"hasClickThrough\":true,\"layers\":[{\"_class\":\"shapeGroup\",\"do_objectID\":\"1761D2BD-4632-4D21-92C3-0FC1207AAFCF\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"constrainProportions\":false,\"height\":14,\"width\":14,\"x\":5,\"y\":5},\"isFlippedHorizontal\":false,\"isFlippedVertical\":false,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":0,\"name\":\"Shape\",\"nameIsFixed\":false,\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"style\":{\"_class\":\"style\",\"endDecorationType\":0,\"fills\":[{\"_class\":\"fill\",\"isEnabled\":true,\"color\":{\"_class\":\"color\",\"alpha\":1,\"blue\":1,\"green\":1,\"red\":1},\"fillType\":0,\"noiseIndex\":0,\"noiseIntensity\":0,\"patternFillType\":0,\"patternTileScale\":1}],\"miterLimit\":10,\"sharedObjectID\":\"A93742BB-00F9-4F61-A4D3-18497765722F\",\"startDecorationType\":0},\"hasClickThrough\":false,\"layers\":[{\"_class\":\"shapePath\",\"do_objectID\":\"D966142A-CD98-46C9-BDAD-9D321B106E74\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"constrainProportions\":false,\"height\":14,\"width\":14,\"x\":0,\"y\":0},\"isFlippedHorizontal\":false,\"isFlippedVertical\":false,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":0,\"name\":\"Path\",\"nameIsFixed\":false,\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"booleanOperation\":-1,\"edited\":true,\"path\":{\"_class\":\"path\",\"isClosed\":true,\"points\":[{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{1, 0.099999999999998382}\",\"curveMode\":1,\"curveTo\":\"{1, 0.099999999999998382}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{1, 0.099999999999998382}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.90000000000000158, 0}\",\"curveMode\":1,\"curveTo\":\"{0.90000000000000158, 0}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0.90000000000000158, 0}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.5, 0.40000000000000163}\",\"curveMode\":1,\"curveTo\":\"{0.5, 0.40000000000000163}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0.5, 0.40000000000000163}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.099999999999998382, 0}\",\"curveMode\":1,\"curveTo\":\"{0.099999999999998382, 0}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0.099999999999998382, 0}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0, 0.099999999999998382}\",\"curveMode\":1,\"curveTo\":\"{0, 0.099999999999998382}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0, 0.099999999999998382}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.40000000000000163, 0.5}\",\"curveMode\":1,\"curveTo\":\"{0.40000000000000163, 0.5}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0.40000000000000163, 0.5}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0, 0.90000000000000158}\",\"curveMode\":1,\"curveTo\":\"{0, 0.90000000000000158}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0, 0.90000000000000158}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.099999999999998382, 1}\",\"curveMode\":1,\"curveTo\":\"{0.099999999999998382, 1}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0.099999999999998382, 1}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.5, 0.59999999999999842}\",\"curveMode\":1,\"curveTo\":\"{0.5, 0.59999999999999842}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0.5, 0.59999999999999842}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.90000000000000158, 1}\",\"curveMode\":1,\"curveTo\":\"{0.90000000000000158, 1}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0.90000000000000158, 1}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{1, 0.90000000000000158}\",\"curveMode\":1,\"curveTo\":\"{1, 0.90000000000000158}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{1, 0.90000000000000158}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.59999999999999842, 0.5}\",\"curveMode\":1,\"curveTo\":\"{0.59999999999999842, 0.5}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0.59999999999999842, 0.5}\"}]}}],\"clippingMaskMode\":0,\"hasClippingMask\":false,\"windingRule\":1}],\"backgroundColor\":{\"_class\":\"color\",\"alpha\":1,\"blue\":0.2588235294117647,\"green\":0.2588235294117647,\"red\":0.2588235294117647},\"hasBackgroundColor\":true,\"horizontalRulerData\":{\"_class\":\"rulerData\",\"base\":0,\"guides\":[]},\"includeBackgroundColorInExport\":true,\"includeInCloudUpload\":true,\"verticalRulerData\":{\"_class\":\"rulerData\",\"base\":0,\"guides\":[]},\"includeBackgroundColorInInstance\":false,\"symbolID\":\"7FD66874-2EE6-4590-BBF7-EE5C8FCEA405\"},{\"_class\":\"symbolMaster\",\"do_objectID\":\"21B63B46-4C5E-4D74-9E80-2B8CCF870C32\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"do_objectID\":\"0896C0E2-B56B-42C7-BF44-C662D526E64D\",\"constrainProportions\":false,\"height\":24,\"width\":24,\"x\":3298,\"y\":0},\"isFlippedHorizontal\":false,\"isFlippedVertical\":false,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":1,\"name\":\"Material\\/Icons black\\/check\",\"nameIsFixed\":true,\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"style\":{\"_class\":\"style\",\"endDecorationType\":0,\"miterLimit\":10,\"startDecorationType\":0},\"hasClickThrough\":true,\"layers\":[{\"_class\":\"shapeGroup\",\"do_objectID\":\"80BA1CB4-1207-4D70-A6A4-B01D84837060\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"do_objectID\":\"FA46C3A8-545B-4470-A9E9-59CE80683639\",\"constrainProportions\":false,\"height\":13.39999999999998,\"width\":17.60000000000002,\"x\":3.399993896484375,\"y\":5.599999999999909},\"isFlippedHorizontal\":false,\"isFlippedVertical\":false,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":0,\"name\":\"Shape\",\"nameIsFixed\":false,\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"style\":{\"_class\":\"style\",\"do_objectID\":\"C501C759-A703-4F90-A2D6-4AB5F3E4AE55\",\"endDecorationType\":0,\"fills\":[{\"_class\":\"fill\",\"isEnabled\":true,\"color\":{\"_class\":\"color\",\"alpha\":1,\"blue\":0,\"green\":0,\"red\":0},\"fillType\":0,\"noiseIndex\":0,\"noiseIntensity\":0,\"patternFillType\":0,\"patternTileScale\":1}],\"miterLimit\":10,\"sharedObjectID\":\"DC56376C-8162-4E24-BB3C-91C5B43AD324\",\"startDecorationType\":0},\"hasClickThrough\":false,\"layers\":[{\"_class\":\"shapePath\",\"do_objectID\":\"2A9AB63B-64CF-4B9E-BE9B-7C9B8EA62134\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"do_objectID\":\"27C107F2-9A34-4531-B77F-B9EFFF0BEEC1\",\"constrainProportions\":false,\"height\":13.39999999999998,\"width\":17.60000000000002,\"x\":0,\"y\":0},\"isFlippedHorizontal\":false,\"isFlippedVertical\":false,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":0,\"name\":\"Path\",\"nameIsFixed\":false,\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"booleanOperation\":-1,\"edited\":true,\"path\":{\"_class\":\"path\",\"isClosed\":true,\"points\":[{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.31818181818181906, 0.79104477611940605}\",\"curveMode\":1,\"curveTo\":\"{0.31818181818181906, 0.79104477611940605}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0.31818181818181906, 0.79104477611940605}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.07954545454545961, 0.47761194029850657}\",\"curveMode\":1,\"curveTo\":\"{0.07954545454545961, 0.47761194029850657}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0.07954545454545961, 0.47761194029850657}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0, 0.58208955223880354}\",\"curveMode\":1,\"curveTo\":\"{0, 0.58208955223880354}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0, 0.58208955223880354}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.31818181818181906, 1}\",\"curveMode\":1,\"curveTo\":\"{0.31818181818181906, 1}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0.31818181818181906, 1}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{1, 0.10447761194029699}\",\"curveMode\":1,\"curveTo\":\"{1, 0.10447761194029699}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{1, 0.10447761194029699}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.92045454545454686, 0}\",\"curveMode\":1,\"curveTo\":\"{0.92045454545454686, 0}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0.92045454545454686, 0}\"}]}}],\"clippingMaskMode\":0,\"hasClippingMask\":false,\"windingRule\":1}],\"backgroundColor\":{\"_class\":\"color\",\"alpha\":1,\"blue\":1,\"green\":1,\"red\":1},\"hasBackgroundColor\":false,\"horizontalRulerData\":{\"_class\":\"rulerData\",\"base\":0,\"guides\":[]},\"includeBackgroundColorInExport\":true,\"includeInCloudUpload\":true,\"verticalRulerData\":{\"_class\":\"rulerData\",\"base\":0,\"guides\":[]},\"includeBackgroundColorInInstance\":false,\"symbolID\":\"173D909A-EFDA-4D89-BD96-A54732F86FBC\"},{\"_class\":\"symbolMaster\",\"do_objectID\":\"34CA2EB5-EBA4-4D7A-9C5B-1EC39EA97E4F\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"constrainProportions\":false,\"height\":24,\"width\":24,\"x\":3422,\"y\":0},\"isFlippedHorizontal\":false,\"isFlippedVertical\":false,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":0,\"name\":\"Material\\/Icons black\\/close\",\"nameIsFixed\":true,\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"style\":{\"_class\":\"style\",\"endDecorationType\":0,\"miterLimit\":10,\"startDecorationType\":0},\"hasClickThrough\":true,\"layers\":[{\"_class\":\"shapeGroup\",\"do_objectID\":\"0F1B587E-CE5B-4C68-AE12-9259ACBAD47F\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"constrainProportions\":false,\"height\":14,\"width\":14,\"x\":5,\"y\":5},\"isFlippedHorizontal\":false,\"isFlippedVertical\":false,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":0,\"name\":\"Shape\",\"nameIsFixed\":false,\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"style\":{\"_class\":\"style\",\"do_objectID\":\"5E67E0D0-732F-4BED-BA12-13E7712F916D\",\"endDecorationType\":0,\"fills\":[{\"_class\":\"fill\",\"isEnabled\":true,\"color\":{\"_class\":\"color\",\"alpha\":1,\"blue\":0,\"green\":0,\"red\":0},\"fillType\":0,\"noiseIndex\":0,\"noiseIntensity\":0,\"patternFillType\":0,\"patternTileScale\":1}],\"miterLimit\":10,\"sharedObjectID\":\"DC56376C-8162-4E24-BB3C-91C5B43AD324\",\"startDecorationType\":0},\"hasClickThrough\":false,\"layers\":[{\"_class\":\"shapePath\",\"do_objectID\":\"792E7CCE-2239-4F83-8C42-B81247D8DB36\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"constrainProportions\":false,\"height\":14,\"width\":14,\"x\":0,\"y\":0},\"isFlippedHorizontal\":false,\"isFlippedVertical\":false,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":0,\"name\":\"Path\",\"nameIsFixed\":false,\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"booleanOperation\":-1,\"edited\":true,\"path\":{\"_class\":\"path\",\"isClosed\":true,\"points\":[{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{1, 0.099999999999998382}\",\"curveMode\":1,\"curveTo\":\"{1, 0.099999999999998382}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{1, 0.099999999999998382}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.90000000000000158, 0}\",\"curveMode\":1,\"curveTo\":\"{0.90000000000000158, 0}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0.90000000000000158, 0}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.5, 0.40000000000000163}\",\"curveMode\":1,\"curveTo\":\"{0.5, 0.40000000000000163}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0.5, 0.40000000000000163}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.099999999999998382, 0}\",\"curveMode\":1,\"curveTo\":\"{0.099999999999998382, 0}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0.099999999999998382, 0}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0, 0.099999999999998382}\",\"curveMode\":1,\"curveTo\":\"{0, 0.099999999999998382}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0, 0.099999999999998382}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.40000000000000163, 0.5}\",\"curveMode\":1,\"curveTo\":\"{0.40000000000000163, 0.5}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0.40000000000000163, 0.5}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0, 0.90000000000000158}\",\"curveMode\":1,\"curveTo\":\"{0, 0.90000000000000158}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0, 0.90000000000000158}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.099999999999998382, 1}\",\"curveMode\":1,\"curveTo\":\"{0.099999999999998382, 1}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0.099999999999998382, 1}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.5, 0.59999999999999842}\",\"curveMode\":1,\"curveTo\":\"{0.5, 0.59999999999999842}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0.5, 0.59999999999999842}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.90000000000000158, 1}\",\"curveMode\":1,\"curveTo\":\"{0.90000000000000158, 1}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0.90000000000000158, 1}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{1, 0.90000000000000158}\",\"curveMode\":1,\"curveTo\":\"{1, 0.90000000000000158}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{1, 0.90000000000000158}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.59999999999999842, 0.5}\",\"curveMode\":1,\"curveTo\":\"{0.59999999999999842, 0.5}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0.59999999999999842, 0.5}\"}]}}],\"clippingMaskMode\":0,\"hasClippingMask\":false,\"windingRule\":1}],\"backgroundColor\":{\"_class\":\"color\",\"alpha\":1,\"blue\":1,\"green\":1,\"red\":1},\"hasBackgroundColor\":false,\"horizontalRulerData\":{\"_class\":\"rulerData\",\"base\":0,\"guides\":[]},\"includeBackgroundColorInExport\":true,\"includeInCloudUpload\":true,\"verticalRulerData\":{\"_class\":\"rulerData\",\"base\":0,\"guides\":[]},\"includeBackgroundColorInInstance\":false,\"symbolID\":\"A2EB1EF8-52D0-4370-A4C9-6E97D54C71F5\"},{\"_class\":\"symbolMaster\",\"do_objectID\":\"CA799155-0C43-4E3B-8162-8521128A451A\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"constrainProportions\":false,\"height\":24,\"width\":12,\"x\":3546,\"y\":0},\"isFlippedHorizontal\":false,\"isFlippedVertical\":false,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":1,\"name\":\"Material\\/Icons black\\/more vert\",\"nameIsFixed\":true,\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"style\":{\"_class\":\"style\",\"endDecorationType\":0,\"miterLimit\":10,\"startDecorationType\":0},\"hasClickThrough\":true,\"layers\":[{\"_class\":\"shapeGroup\",\"do_objectID\":\"428FB459-330F-4D52-AE8E-8992B42D0835\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"do_objectID\":\"90C406E0-0192-4A84-9910-606A3A1C3D98\",\"constrainProportions\":false,\"height\":16,\"width\":4,\"x\":4,\"y\":4},\"isFlippedHorizontal\":false,\"isFlippedVertical\":false,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":0,\"name\":\"Shape\",\"nameIsFixed\":false,\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"style\":{\"_class\":\"style\",\"do_objectID\":\"768DEAB1-5B2D-4B48-92DD-7E7E8A2175C1\",\"endDecorationType\":0,\"fills\":[{\"_class\":\"fill\",\"isEnabled\":true,\"color\":{\"_class\":\"color\",\"alpha\":1,\"blue\":0,\"green\":0,\"red\":0},\"fillType\":0,\"noiseIndex\":0,\"noiseIntensity\":0,\"patternFillType\":0,\"patternTileScale\":1}],\"miterLimit\":10,\"sharedObjectID\":\"DC56376C-8162-4E24-BB3C-91C5B43AD324\",\"startDecorationType\":0},\"hasClickThrough\":false,\"layers\":[{\"_class\":\"shapePath\",\"do_objectID\":\"3EBF9BF2-E838-4353-B39A-C5A0B2267E83\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"constrainProportions\":false,\"height\":4,\"width\":4,\"x\":0,\"y\":0},\"isFlippedHorizontal\":false,\"isFlippedVertical\":false,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":0,\"name\":\"Path\",\"nameIsFixed\":false,\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"booleanOperation\":-1,\"edited\":true,\"path\":{\"_class\":\"path\",\"isClosed\":true,\"points\":[{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.77500000000000568, 1}\",\"curveMode\":4,\"curveTo\":\"{0.5, 1}\",\"hasCurveFrom\":true,\"hasCurveTo\":false,\"point\":\"{0.5, 1}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{1, 0.22499999999999432}\",\"curveMode\":2,\"curveTo\":\"{1, 0.77500000000000568}\",\"hasCurveFrom\":true,\"hasCurveTo\":true,\"point\":\"{1, 0.5}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.22499999999999432, 0}\",\"curveMode\":2,\"curveTo\":\"{0.77500000000000568, 0}\",\"hasCurveFrom\":true,\"hasCurveTo\":true,\"point\":\"{0.5, 0}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0, 0.77500000000000568}\",\"curveMode\":2,\"curveTo\":\"{0, 0.22499999999999432}\",\"hasCurveFrom\":true,\"hasCurveTo\":true,\"point\":\"{0, 0.5}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.5, 1}\",\"curveMode\":4,\"curveTo\":\"{0.22499999999999432, 1}\",\"hasCurveFrom\":false,\"hasCurveTo\":true,\"point\":\"{0.5, 1}\"}]}},{\"_class\":\"shapePath\",\"do_objectID\":\"E47C78F6-6493-40B8-94F4-05DCB1FED9D2\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"constrainProportions\":false,\"height\":4,\"width\":4,\"x\":0,\"y\":6},\"isFlippedHorizontal\":false,\"isFlippedVertical\":false,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":0,\"name\":\"Path\",\"nameIsFixed\":false,\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"booleanOperation\":-1,\"edited\":true,\"path\":{\"_class\":\"path\",\"isClosed\":true,\"points\":[{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.22499999999999432, 0}\",\"curveMode\":4,\"curveTo\":\"{0.5, 0}\",\"hasCurveFrom\":true,\"hasCurveTo\":false,\"point\":\"{0.5, 0}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0, 0.77500000000000568}\",\"curveMode\":2,\"curveTo\":\"{0, 0.22499999999999432}\",\"hasCurveFrom\":true,\"hasCurveTo\":true,\"point\":\"{0, 0.5}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.77500000000000568, 1}\",\"curveMode\":2,\"curveTo\":\"{0.22499999999999432, 1}\",\"hasCurveFrom\":true,\"hasCurveTo\":true,\"point\":\"{0.5, 1}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{1, 0.22499999999999432}\",\"curveMode\":2,\"curveTo\":\"{1, 0.77500000000000568}\",\"hasCurveFrom\":true,\"hasCurveTo\":true,\"point\":\"{1, 0.5}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.5, 0}\",\"curveMode\":4,\"curveTo\":\"{0.77500000000000568, 0}\",\"hasCurveFrom\":false,\"hasCurveTo\":true,\"point\":\"{0.5, 0}\"}]}},{\"_class\":\"shapePath\",\"do_objectID\":\"5D799E6A-A1E8-440D-8ECE-3353F766E0E3\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"constrainProportions\":false,\"height\":4,\"width\":4,\"x\":0,\"y\":12},\"isFlippedHorizontal\":false,\"isFlippedVertical\":false,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":0,\"name\":\"Path\",\"nameIsFixed\":false,\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"booleanOperation\":-1,\"edited\":true,\"path\":{\"_class\":\"path\",\"isClosed\":true,\"points\":[{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.22499999999999432, 0}\",\"curveMode\":4,\"curveTo\":\"{0.5, 0}\",\"hasCurveFrom\":true,\"hasCurveTo\":false,\"point\":\"{0.5, 0}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0, 0.77500000000000568}\",\"curveMode\":2,\"curveTo\":\"{0, 0.22499999999999432}\",\"hasCurveFrom\":true,\"hasCurveTo\":true,\"point\":\"{0, 0.5}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.77500000000000568, 1}\",\"curveMode\":2,\"curveTo\":\"{0.22499999999999432, 1}\",\"hasCurveFrom\":true,\"hasCurveTo\":true,\"point\":\"{0.5, 1}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{1, 0.22499999999999432}\",\"curveMode\":2,\"curveTo\":\"{1, 0.77500000000000568}\",\"hasCurveFrom\":true,\"hasCurveTo\":true,\"point\":\"{1, 0.5}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.5, 0}\",\"curveMode\":4,\"curveTo\":\"{0.77500000000000568, 0}\",\"hasCurveFrom\":false,\"hasCurveTo\":true,\"point\":\"{0.5, 0}\"}]}}],\"clippingMaskMode\":0,\"hasClippingMask\":false,\"windingRule\":1}],\"backgroundColor\":{\"_class\":\"color\",\"alpha\":1,\"blue\":1,\"green\":1,\"red\":1},\"hasBackgroundColor\":false,\"horizontalRulerData\":{\"_class\":\"rulerData\",\"base\":0,\"guides\":[]},\"includeBackgroundColorInExport\":true,\"includeInCloudUpload\":true,\"verticalRulerData\":{\"_class\":\"rulerData\",\"base\":0,\"guides\":[]},\"includeBackgroundColorInInstance\":false,\"symbolID\":\"CBDCF326-5C6B-4C9C-BAA5-D47108544398\"},{\"_class\":\"symbolMaster\",\"do_objectID\":\"DDB0E68B-BEEE-4E3A-9844-0443F2E09696\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"constrainProportions\":false,\"height\":24,\"width\":24,\"x\":3658,\"y\":0},\"isFlippedHorizontal\":false,\"isFlippedVertical\":false,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":1,\"name\":\"Material\\/Icons black\\/refresh\",\"nameIsFixed\":true,\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"style\":{\"_class\":\"style\",\"endDecorationType\":0,\"miterLimit\":10,\"startDecorationType\":0},\"hasClickThrough\":true,\"layers\":[{\"_class\":\"shapeGroup\",\"do_objectID\":\"71365292-C3BA-4844-8627-D09CBFF6B16C\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"do_objectID\":\"31B62D58-947D-4E7E-99F0-DD70B73B987B\",\"constrainProportions\":false,\"height\":16,\"width\":16,\"x\":4,\"y\":4},\"isFlippedHorizontal\":false,\"isFlippedVertical\":false,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":0,\"name\":\"Shape\",\"nameIsFixed\":false,\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"style\":{\"_class\":\"style\",\"do_objectID\":\"15A1DE9C-27EF-4D0A-81DF-E0417196D753\",\"endDecorationType\":0,\"fills\":[{\"_class\":\"fill\",\"isEnabled\":true,\"color\":{\"_class\":\"color\",\"alpha\":1,\"blue\":0,\"green\":0,\"red\":0},\"fillType\":0,\"noiseIndex\":0,\"noiseIntensity\":0,\"patternFillType\":0,\"patternTileScale\":1}],\"miterLimit\":10,\"sharedObjectID\":\"DC56376C-8162-4E24-BB3C-91C5B43AD324\",\"startDecorationType\":0},\"hasClickThrough\":false,\"layers\":[{\"_class\":\"shapePath\",\"do_objectID\":\"7A117936-C128-47F8-84D2-1A3C5DBA4C50\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"constrainProportions\":false,\"height\":16,\"width\":16.00000000000023,\"x\":0,\"y\":0},\"isFlippedHorizontal\":false,\"isFlippedVertical\":false,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":0,\"name\":\"Path\",\"nameIsFixed\":false,\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"booleanOperation\":-1,\"edited\":true,\"path\":{\"_class\":\"path\",\"isClosed\":true,\"points\":[{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.22499999999999112, 0}\",\"curveMode\":3,\"curveTo\":\"{0.63749999999997953, 0}\",\"hasCurveFrom\":true,\"hasCurveTo\":true,\"point\":\"{0.49999999999999289, 0}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0, 0.77499999999999858}\",\"curveMode\":2,\"curveTo\":\"{0, 0.22500000000000142}\",\"hasCurveFrom\":true,\"hasCurveTo\":true,\"point\":\"{0, 0.5}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.73124999999999241, 1}\",\"curveMode\":3,\"curveTo\":\"{0.22499999999999112, 1}\",\"hasCurveFrom\":true,\"hasCurveTo\":true,\"point\":\"{0.49999999999999289, 1}\"},{\"_class\":\"curvePoint\",\"do_objectID\":\"261A7BB7-7918-43C1-8D27-FC39440F388B\",\"cornerRadius\":0,\"curveFrom\":\"{0.98124999999998885, 0.625}\",\"curveMode\":4,\"curveTo\":\"{0.92499999999998406, 0.83749999999999858}\",\"hasCurveFrom\":false,\"hasCurveTo\":true,\"point\":\"{0.99999999999998579, 0.625}\"},{\"_class\":\"curvePoint\",\"do_objectID\":\"27222968-0B55-472C-AE54-1407E1911126\",\"cornerRadius\":0,\"curveFrom\":\"{0.80000000000000004, 0.76874999999999716}\",\"curveMode\":4,\"curveTo\":\"{0.84999999999999643, 0.625}\",\"hasCurveFrom\":true,\"hasCurveTo\":false,\"point\":\"{0.87499999999998757, 0.625}\"},{\"_class\":\"curvePoint\",\"do_objectID\":\"DF22ECE7-5C80-41A9-A9E9-9B1D5C1C21CE\",\"cornerRadius\":0,\"curveFrom\":\"{0.29375000000001289, 0.875}\",\"curveMode\":3,\"curveTo\":\"{0.66249999999999909, 0.875}\",\"hasCurveFrom\":true,\"hasCurveTo\":true,\"point\":\"{0.49999999999999289, 0.875}\"},{\"_class\":\"curvePoint\",\"do_objectID\":\"53943DFA-AA6A-4950-9E9C-8633972AC481\",\"cornerRadius\":0,\"curveFrom\":\"{0.12500000000001243, 0.29375000000000284}\",\"curveMode\":2,\"curveTo\":\"{0.12500000000001243, 0.70624999999999716}\",\"hasCurveFrom\":true,\"hasCurveTo\":true,\"point\":\"{0.12499999999999822, 0.5}\"},{\"_class\":\"curvePoint\",\"do_objectID\":\"8B7B0347-6EC3-471A-8AB5-CD06B2BB0EFE\",\"cornerRadius\":0,\"curveFrom\":\"{0.60625000000000839, 0.125}\",\"curveMode\":3,\"curveTo\":\"{0.29375000000001289, 0.125}\",\"hasCurveFrom\":true,\"hasCurveTo\":true,\"point\":\"{0.49999999999999289, 0.125}\"},{\"_class\":\"curvePoint\",\"do_objectID\":\"1D91563B-F479-4C72-BAB5-46F19D15D658\",\"cornerRadius\":0,\"curveFrom\":\"{0.76250000000000617, 0.23749999999999716}\",\"curveMode\":4,\"curveTo\":\"{0.69374999999999865, 0.16875000000000284}\",\"hasCurveFrom\":false,\"hasCurveTo\":true,\"point\":\"{0.74999999999998934, 0.25}\"},{\"_class\":\"curvePoint\",\"do_objectID\":\"54ED3EEF-9F06-41E9-AF50-47EC812276C7\",\"cornerRadius\":0,\"curveFrom\":\"{0.56250000000000622, 0.4375}\",\"curveMode\":1,\"curveTo\":\"{0.56250000000000622, 0.4375}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0.56249999999999201, 0.4375}\"},{\"_class\":\"curvePoint\",\"do_objectID\":\"8438DED0-BA59-4644-A2E3-FA34C7C5033A\",\"cornerRadius\":0,\"curveFrom\":\"{1, 0.4375}\",\"curveMode\":1,\"curveTo\":\"{1, 0.4375}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0.99999999999998579, 0.4375}\"},{\"_class\":\"curvePoint\",\"do_objectID\":\"FB4E35A0-C58C-465B-BBB4-FF18EC71CC06\",\"cornerRadius\":0,\"curveFrom\":\"{1, 0}\",\"curveMode\":1,\"curveTo\":\"{1, 0}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0.99999999999998579, 0}\"},{\"_class\":\"curvePoint\",\"do_objectID\":\"EFBA6B62-8E3C-4B84-B90E-BCFE1E58D208\",\"cornerRadius\":0,\"curveFrom\":\"{0.76249999999997775, 0.0625}\",\"curveMode\":1,\"curveTo\":\"{0.84999999999998221, 0.14999999999999858}\",\"hasCurveFrom\":true,\"hasCurveTo\":false,\"point\":\"{0.87499999999998757, 0.125}\"}]}}],\"clippingMaskMode\":0,\"hasClippingMask\":false,\"windingRule\":1}],\"backgroundColor\":{\"_class\":\"color\",\"alpha\":1,\"blue\":1,\"green\":1,\"red\":1},\"hasBackgroundColor\":false,\"horizontalRulerData\":{\"_class\":\"rulerData\",\"base\":0,\"guides\":[]},\"includeBackgroundColorInExport\":true,\"includeInCloudUpload\":true,\"verticalRulerData\":{\"_class\":\"rulerData\",\"base\":0,\"guides\":[]},\"includeBackgroundColorInInstance\":false,\"symbolID\":\"C6E3F5B3-C968-48FF-9085-D3562D0EAA93\"},{\"_class\":\"symbolMaster\",\"do_objectID\":\"76C8A3F1-FA9D-4553-9035-374EEF18CBF2\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"do_objectID\":\"3BBD04CE-B0B0-48A6-BCC3-E8C8934CB89B\",\"constrainProportions\":false,\"height\":24,\"width\":24,\"x\":3782,\"y\":0},\"isFlippedHorizontal\":false,\"isFlippedVertical\":false,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":1,\"name\":\"Material\\/Icons black\\/arrow back\",\"nameIsFixed\":true,\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"style\":{\"_class\":\"style\",\"endDecorationType\":0,\"miterLimit\":10,\"startDecorationType\":0},\"hasClickThrough\":true,\"layers\":[{\"_class\":\"shapeGroup\",\"do_objectID\":\"469B41DE-EE2A-427D-BEA8-B1DA6D113C8D\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"do_objectID\":\"DFD20384-5EF2-48B5-BEB4-74796F3663B7\",\"constrainProportions\":false,\"height\":16,\"width\":15.99999999999994,\"x\":4,\"y\":4},\"isFlippedHorizontal\":false,\"isFlippedVertical\":false,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":0,\"name\":\"Shape\",\"nameIsFixed\":false,\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"style\":{\"_class\":\"style\",\"do_objectID\":\"B29063E0-AB00-400D-A11C-8E5670377D84\",\"endDecorationType\":0,\"fills\":[{\"_class\":\"fill\",\"isEnabled\":true,\"color\":{\"_class\":\"color\",\"alpha\":1,\"blue\":0,\"green\":0,\"red\":0},\"fillType\":0,\"noiseIndex\":0,\"noiseIntensity\":0,\"patternFillType\":0,\"patternTileScale\":1}],\"miterLimit\":10,\"sharedObjectID\":\"DC56376C-8162-4E24-BB3C-91C5B43AD324\",\"startDecorationType\":0},\"hasClickThrough\":false,\"layers\":[{\"_class\":\"shapePath\",\"do_objectID\":\"0EEF4BF2-0C9B-44A6-AB3B-C0421BA31C82\",\"exportOptions\":{\"_class\":\"exportOptions\",\"exportFormats\":[],\"includedLayerIds\":[],\"layerOptions\":0,\"shouldTrim\":false},\"frame\":{\"_class\":\"rect\",\"constrainProportions\":false,\"height\":16,\"width\":15.99999999999994,\"x\":0,\"y\":0},\"isFlippedHorizontal\":false,\"isFlippedVertical\":false,\"isLocked\":false,\"isVisible\":true,\"layerListExpandedType\":0,\"name\":\"Path\",\"nameIsFixed\":false,\"resizingType\":0,\"rotation\":0,\"shouldBreakMaskChain\":false,\"booleanOperation\":-1,\"edited\":true,\"path\":{\"_class\":\"path\",\"isClosed\":true,\"points\":[{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{1, 0.4375}\",\"curveMode\":1,\"curveTo\":\"{1, 0.4375}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{1, 0.4375}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.23749999999999799, 0.4375}\",\"curveMode\":1,\"curveTo\":\"{0.23749999999999799, 0.4375}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0.23749999999999799, 0.4375}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.58750000000000069, 0.087499999999998579}\",\"curveMode\":1,\"curveTo\":\"{0.58750000000000069, 0.087499999999998579}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0.58750000000000069, 0.087499999999998579}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.50000000000000178, 0}\",\"curveMode\":1,\"curveTo\":\"{0.50000000000000178, 0}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0.50000000000000178, 0}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0, 0.5}\",\"curveMode\":1,\"curveTo\":\"{0, 0.5}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0, 0.5}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.50000000000000178, 1}\",\"curveMode\":1,\"curveTo\":\"{0.50000000000000178, 1}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0.50000000000000178, 1}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.58750000000000069, 0.91250000000000142}\",\"curveMode\":1,\"curveTo\":\"{0.58750000000000069, 0.91250000000000142}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0.58750000000000069, 0.91250000000000142}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{0.23749999999999799, 0.5625}\",\"curveMode\":1,\"curveTo\":\"{0.23749999999999799, 0.5625}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{0.23749999999999799, 0.5625}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{1, 0.5625}\",\"curveMode\":1,\"curveTo\":\"{1, 0.5625}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{1, 0.5625}\"},{\"_class\":\"curvePoint\",\"cornerRadius\":0,\"curveFrom\":\"{1, 0.4375}\",\"curveMode\":1,\"curveTo\":\"{1, 0.4375}\",\"hasCurveFrom\":false,\"hasCurveTo\":false,\"point\":\"{1, 0.4375}\"}]}}],\"clippingMaskMode\":0,\"hasClippingMask\":false,\"windingRule\":1}],\"backgroundColor\":{\"_class\":\"color\",\"alpha\":1,\"blue\":1,\"green\":1,\"red\":1},\"hasBackgroundColor\":false,\"horizontalRulerData\":{\"_class\":\"rulerData\",\"base\":0,\"guides\":[]},\"includeBackgroundColorInExport\":true,\"includeInCloudUpload\":true,\"verticalRulerData\":{\"_class\":\"rulerData\",\"base\":0,\"guides\":[]},\"includeBackgroundColorInInstance\":false,\"symbolID\":\"B18F04FF-1BB0-41C0-939B-7EB372AF033F\"}],\"horizontalRulerData\":{\"_class\":\"rulerData\",\"base\":0,\"guides\":[]},\"includeInCloudUpload\":true,\"verticalRulerData\":{\"_class\":\"rulerData\",\"base\":0,\"guides\":[]}}"
  },
  {
    "path": "design-files/Moderator-StickerSheet-20161117-DAS/user.json",
    "content": "{\"FDD7F69B-2AF0-4FAE-9B51-45D7040E8FCB\":{\"pageListHeight\":1016,\"exportableLayerSelection\":[\"FAC6D00E-86E5-45BC-B761-CA9C39DD5233\",\"DC571BE3-F1C5-4526-BA2A-9E4AB741EFF0\",\"5798BAD5-8ED5-409A-87F4-567E0A63E777\",\"66A8E8B0-AB0E-4B94-8E31-2C0F0E16F20A\",\"0E479248-891F-42FD-8327-648B15C87E04\",\"57C3F8E0-276C-4ACB-BDB3-8193E9EA7742\",\"5B4982B7-8726-418E-9D14-2AEDA65B8DFF\",\"6A87051D-B660-41EC-9BDB-488D63F0C56D\",\"0ED4F5B1-85B9-4C2C-A3C9-B98E2D4980DA\",\"83FDAE46-C90F-4972-A27A-53E25B1D2C95\",\"DE991B07-B57F-4889-9D6E-D26F914CE247\",\"759BEA3D-2EC8-4B10-BEA4-C945D63E9908\",\"76B56D15-9C7B-41DD-B915-969F64DF243A\",\"A4960E90-D518-4C70-ABD3-CCE07FB682AC\",\"CD81B3A5-D489-484E-AC6B-000D7F3788D6\",\"1A57493A-78A1-47A5-9CD8-E94EC457B557\",\"6E06B227-EDA2-4053-89B7-92CD8D6D1A35\",\"E2CB16E3-20B5-4210-A90E-E115BC41B9E9\",\"AB3EA644-5829-4734-BAA8-A65E5A65C9AC\",\"B36E8C3C-2368-48AF-9F48-BF0C929339B7\",\"9C115BA1-01FD-4EB5-A0ED-3F302E44863B\",\"933CC81D-2C9B-452A-AC5B-6405D4C607C4\",\"B26015EB-FB4C-4310-A22D-DF70FE99CB1D\",\"E18798F8-EB42-472D-8D37-4061B912D75F\",\"971BFAFC-6721-4AD8-848B-439035F684E9\",\"E580DDC7-059E-4509-A8C2-AA5DD98B9EFF\",\"410F9419-53B5-49B2-8F33-917D0C005E78\",\"D6681D07-6D98-45CC-BABB-0E966D101F3F\",\"1A9E010D-ED70-4FEE-A2B7-AE50B4B6495D\",\"C52C86AD-DFBD-47DB-918E-70E1E839263F\",\"38A0707D-E950-4E99-902E-02B985A31968\",\"131D9DA7-5413-48C7-8852-D77E2C92E339\",\"08674E09-346B-4798-A00F-82D253CBA7A6\",\"CF85FC69-3CEE-4AEA-8F1D-0A4D0F2447B7\",\"CED58EDB-021E-42C9-A52E-A296F29B0E9B\",\"C8F2A3CE-3FFE-4FCD-B841-BEFA971219A3\",\"50080C0C-942A-4186-B601-FD70D4EB756D\",\"9A65DFE6-0C2D-4B5E-BC48-4B30B0C39EBF\",\"AF964B9C-3384-4A3E-B398-CDB2E3659409\",\"4758FACA-A200-4913-8793-0D803A62EA75\",\"711392F8-7726-4EF4-85C9-8E07F436E413\",\"7E5FCE1E-3DC5-4048-9283-B61D8BE599B7\",\"19986F6A-1B19-403F-B544-AF7D046D1200\",\"6ED93965-5641-4CFF-BBCB-08B4FCAB98AC\",\"A39A8B9A-83A0-4AF5-830A-902AEB658BE3\",\"533F043C-236F-45E2-B277-6B683B7AB111\",\"66256BA5-F050-482B-BFE3-0BF96B9B25E1\",\"550873D6-3F8B-4EF2-9E4C-8C98B3405FB7\",\"941D41CD-30D7-431B-B632-A9DD3FB56BFB\",\"A3DC2D33-661C-4F1C-8E76-299C4C6683E0\",\"DB4F3B63-3DDC-4A63-8B60-D4D5A00859BF\"],\"cloudShare\":null},\"1B67083D-3430-4865-A36A-6C687A1EEB45\":{\"scrollOrigin\":\"{-12647, 613}\",\"zoomValue\":4},\"226D6615-C16F-4B84-92E0-291B2F1B15C4\":{\"scrollOrigin\":\"{9851, 2885}\",\"zoomValue\":1}}"
  },
  {
    "path": "docs/auth.md",
    "content": "# OS Moderator Authentication\n\nOS Moderator leverages a Google OAuth 2 authentication flow to trade on our server for JWT tokens.  The following outlines how it all works.\n\n## Google OAuth Flow\n\nGoogle OAuth (version 2) is used to actually authenticate users. [Passport](https://github.com/jaredhanson/passport) and [passport-google-oauth2](https://github.com/jaredhanson/passport-google-oauth2) are used on the backend and, upon receiving and verifying the access token:\n\n1. A unique `User` model instance based on the email address, is created or retrieved\n2. A unique `UserSocialAuth` model instance for the user / provider / provider id is created or retrieved\n3. A JWT token is created and the user is redirected to the homepage with it in the query string, like: `/?token=(token string)`\n\n## Protecting Resources\n\nTo make an endpoint require a valid JWT token, pass in Passport authentication middleware like so:\n\n```js\nrouter.get(\n    '/some/protected/path',\n    passport.authenticate('jwt', {session: false}),\n    (request, response) => {\n        response.send('Some private stuff!');\n    });\n```\n\nThe JWT provider fetches and verifies the user encoded in the token with every request (see the `verify` function in `server/domain/auth/providers/jwt.ts`) and Passport makes a `user` object (a `User` Sequelize model instance) on the `request` object.\n\n## Logging In\n\nTo log in, navigate to `/auth/login/google` and it will kick off the OAuth process with Google. You can optionally pass a `redirect` query string parameter of an encoded URL (e.g `encodeURIComponent('http://radsite.com')`) and it will be redirected to on successful login with a `token` query string parameter tacked on to it.\n\n## Accessing Protected Resources\n\nTo use your JWT token to access protected resources, you must pass it into the `Authorization` HTTP header like so: `Authorization: JWT (token string)`.\n\n## Token Expiration\n\nTokens expire for human users (as opposed to users whose `group` is set to `service`, which do not expire) after the number of minutes set in the configuration value `token_expiration_minutes` (`server/config/index.js`). The expiration is calculated server-side based on the `iat` (issued at timestamp) embedded in the token.\n\n## Removing/Deactivating users\n\nTo remove/deactivate users, there is a simple `isActive` boolean field, which you can set to `false`/`0`. As long as the user record remains in the `users` table, the user should not be able to reauthenticate.\n"
  },
  {
    "path": "docs/comment_flow.md",
    "content": "# OSMOD Comment State Flow\n\nThis document describes the path of a comment through the OSMOD system.\n\n1. The integration module (e.g., the YouTube module) pulls categories, articles and comments from the target system.\n\n2. For each comment, we create a task to send the comment out to assistants for processing.\n\n3. The task queue creates a `CommentScoreRequest` and submits [the request](osmod_assistant_protocol.md) to all users in the `service` group that have a configured `endpoint` path.\n\n4. Each assistant `POST`s back to the `callback` URL they were given, which contains the specific `CommentScoreRequest` id from the original request.\n\n5. Once all assistants have either called back, or timed out, a task is created to run automated `ModerationRule`s.\n\n6. The rule task is handled by running all `ModerationRule`s against a single comment. If the rule results in a resolution (approve or reject), the comment is considered resolved.\n\n7. If a rule did not resolve a comment, it is made available to the OSMOD frontend.\n\n8. The OSMOD frontend can approve or reject comments either singularly or in bulk.  The OSMOD frontend can also update the disposition of previously resolved comments.\n\n9. For each resolved comment, the integration module updates the target system to implement the comment's final disposition.\n\n10. Party 🎉🎉🎉\n"
  },
  {
    "path": "docs/modeling.md",
    "content": "# The Data Model for Osmod\n\n## Connecting to remote SQL Google Cloud database\n\nFrom [a cloud shell in your project](https://cloud.google.com/shell/docs/), you can run:\n\n`gcloud beta sql connect <instance-name> --user=<username>`\n\nTo connect to your SQL instance. You'll need to create the username from the\n[google cloud SQL config interface](https://pantheon.corp.google.com/sql/instances/osmod-development-branch/users).\n\n## The SQL Data Model\n\n[The SQL table construction file](https://github.com/conversationai/conversationai-moderator/blob/master/seed/initial-database.sql)\n\nDefault database name: `os_moderator`\n\nThe tables:\n\n  Object               | Tables_in_os_moderator    | Description\n-----------------------|---------------------------|-------------\n-                      | SequelizeMeta             | List of migrations that have been applied\nUser                   | users                     | OSMod Administrators, moderators, and other user-like entities\nCategory               | categories                | Categories from the moderated system (e.g., corresponds to channels for Youtube)\nArticle                | articles                  | Articles from the moderated system (e.g., videos for YouTube)\nComment                | comments                  | Comments from the moderated system\nCommentFlag            | comment_flag              |\nCommentScoreRequest    | comment_score_requests    |\nCommentScore           | comment_scores            |\nCommentSize            | comment_sizes             |\nCommentSummaryScore    | comment_summary_scores    |\nCommentTopScore        | comment_top_scores        |\nDecision               | decisions                 |\nModerationRule         | moderation_rules          |\nPreselect              | preselects                |\nTaggingSensitivity     | tagging_sensitivities     |\nTag                    | tags                      |\nModeratorAssignment    | moderator_assignments     | (Join table) Moderators assigned to moderate comments for the given article\nUserCategoryAssignment | user_category_assignments | (Join table) Moderators assigned to moderate comments for the given category\nCSRF                   | csrfs                     |\nUserSocialAuth         | user_social_auths         |\n\n### User\n\nUsers are users of Osmod. This is the moderation team, and people who admin the\nOsmod system using the UI.\n\n- id (int) (required)\n- group (enum: general, admin, service, youtube) (required)\n- email (string) (required for all but users in the \"service\" group)\n- name (string) (required)\n- isActive (tinyint) (required)\n- avatarURL (string)\n- extra (json)\n\n*Indexes*:\n- group\n- isActive\n- email (unique)\n\nService users serve 2 purposes:\n- They are used to authenticate non-humans that want to access to the system.  Use the [get-token](../README.md#the-osmod-cli) CLI command to create a suitable JWT token for this purpose.\n- They are used to store the configuration for a moderator endpoint.\n\nFor the latter, the required configuration is stored in the extra field in a JSON blob that looks like\n\n```json\n{\n  serviceType: 'moderator',\n  endpointType: 'perspective-api',\n  endpoint: <URL of endpoint>,\n  extra: <Any extra configuration required by this endpoint.>\n}\n```\n\n\n### UserSocialAuth\n\nThis table holds information about the passport authentication configuration for\nusers in the `Users` table. It is used to allow users to login with social\nnetworks, e.g. using their google account.\n\n- id (int) (required)\n- userId (foreign key: User) (required)\n- socialId (string) (required) (provider user id)\n- provider (string) (required) (name of auth provider, e.g. \"google\")\n- extra (json)\n\n*Indexes*:\n- userId + provider (unique) (don't let the same user use the same provider multiple times)\n- socialId + provider (unique) (don't allow the same provider user to authenticate on multiple accounts)\n\n### Category\n\nRepresents a higher level collection of articles.  Categories correspond to e.g., site sections, YouTube channels, or Reddit subreddits.\n\nModeration Rules are configured at the category level and apply to all articles in the category. Moderators can be assigned at this level.\n\n- id (number) (required)\n- sourceId (string) (optional) Original id from target system\n- ownerId (foreign key: User) (optional) Service user that created this article.\n- label (string) (required)\n- isActive (boolean) (required, default true) Whether this category is being actively managed.\n- count (int) Number of comments in this category\n- unprocessedCount (int) Denormalize SUM of articles' unprocessedCount\n- unmoderatedCount (int) Denormalize SUM of articles' unmoderatedCount\n- moderatedCount (int) Denormalize SUM of articles' moderatedCount\n- highlightedCount (int) Denormalize SUM of articles' highlightedCount\n- approvedCount (int) Denormalize SUM of articles' approvedCount\n- rejectedCount (int) Denormalize SUM of articles' rejectedCount\n- deferredCount (int) Denormalize SUM of articles' deferredCount\n- flaggedCount (int) Denormalize SUM of articles' flaggedCount\n- batchedCount (int) Denormalize SUM of articles' batchedCount\n- extra (json)\n\n### Article\n\nThis table holds the articles that can be commented on.\n\n- id (bigint) (required)\n- sourceId (string) (required)\n- ownerId (foreign key: User) (optional) Service user that created this article.\n- sourceCreatedAt (Created ISO 8601 timestamp from target system)\n- categoryId (foreign key: Category) (optional)\n- title (string) (required)\n- text (string) (required)\n- url (string) (required)\n- isAutoModerated (tinyint) (required) (Indicates whether the article is subject to automated moderation rules)\n- isCommentingEnabled (boolean) (Indicates whether commenting is enabled.  This field should be automatically synchronised with the host platform to enable/disable commenting.\n- count (int) Number of comments in this article\n- unprocessedCount (int) (Denormalized count of unprocessed comments (isScored = false))\n- unmoderatedCount (int) (Denormalized count of unmoderated comments (isScored = true AND isModerated = false))\n- moderatedCount (int) (Denormalized count of moderated comments (isScored = true AND isModerated = true))\n- highlightedCount (int) (Denormalize COUNT of comments with highlightedCount > 0)\n- approvedCount (int) (Denormalize COUNT of comments with approvedCount > 0)\n- rejectedCount (int) (Denormalize COUNT of comments with rejectedCount > 0)\n- deferredCount (int) (Denormalize COUNT of comments with deferredCount > 0)\n- flaggedCount (int) (Denormalize COUNT of comments with flaggedCount > 0)\n- batchedCount (int) (Denormalize COUNT of comments with batchedCount > 0)\n- createdAt (datetime)\n- modifiedAt (datetime)\n- lastModeratedAt (datetime) Time when a moderation action was last done.\n- extra (json)\n\n*Indexes*:\n- sourceId (unique)\n- categoryId\n\n### ModeratorAssignment\n\nThis tables holds which users are assigned to which articles.\n\n- id (int) (required)\n- user (foreign key: User) (required)\n- article (foreign key: Article) (required)\n\n*Indexes*:\n- user + article (unique)\n- user\n\n### Comment\n\nThis table holds the comments, and the state of the comments.\n\n- id (bigint) (required)\n- sourceId (string) (required) (Original id from target system)\n- ownerId (foreign key: User) (optional) Service user that uploaded this comment.\n- replyToSourceId (string) (optional foreign key: self.sourceId)\n- replyId (foreign key: Comment) (id of comment this is a reply to)\n- authorSourceId (string) (required) (id of author on the target system)\n- article (foreign key: Article) (required)\n- author (json) (required)\n- text (long text) (required)\n- isScored (tinyint) (required)\n- isModerated (tinyint)\n- isAccepted (tinyint)\n- isDeferred (tinyint)\n- isHighlighted (tinyint)\n- isBatchResolved (tinyint)\n- isAutoResolved (tinyint) (Indicates if the comment was auto-accepted/rejected based on a rule(s))\n- sourceCreatedAt (datetime) (required) (time comment created on target system)\n- sentForScoring (datetime)\n- sentBackToPublisher (datetime)\n- extra (json)\n\n*Indexes*\n- sourceId (unique)\n- isAccepted\n- isDeferred\n- isHighlighted\n- isBatchResolved\n- isAutoResolved\n- sentForScoring\n\n### CommentSize\n- commentId (int) (required)\n- width (int) (required)\n- height (int) (required)\n\n*Indexes*:\n- commentId, width\n\n### UserCategoryAssignment\n- userId (int) (required)\n- categoryId (int) (required)\n\n*Indexes*:\n- userId\n- categoryId\n\n#### Notes\n- Used for hasAndBelongsToMany for category assignments on users.\n\n#### States\n- unscored: sentForScoring == null\n- scored: sentForScoring != null && isScored == 1 (set as such when all related `CommentScoreRequest`s `doneAt` fields are set)\n- accepted: isAccepted == 1\n- rejected: isAccepted == 0\n- deferred: isAccepted == null && isDeferred == 1\n- highlighted: isAccepted == 1 && isHighlighted == 1\n\n### CommentScoreRequest\n- id (bigint)\n- comment (foreign key: Comment) (required)\n- userId (foreign key: User) (required)\n- sentAt (datetime) (required)\n- doneAt (datetime)\n\n### CommentScore\n- id (bigint)\n- commentId (foreign key: Comment)\n- sourceType (enum: User, Moderator, Machine) (required)\n- sourceId (string) (optional identifier so that scores can be retracted, like for publisher recommendations)\n- commentScoreRequestId (foreign key: CommentScoreRequest) (set for \"machine\" sources)\n- score (float) (required) (0 - 1) (these get set to 1 for non-machine sources)\n- annotationStart (int)\n- annotationEnd (int)\n- confirmedUserId (int)\n- isConfirmed (tinyint)\n- extra (json)\n- createdAt (datetime) (required)\n- updatedAt (datetime)\n- TagId (int) (foreign key: Tags)\n\n*Indexes*:\n- comment\n\n### CommentTopScore\n- commentId (foreign key: Comment)\n- tagId (foreign key: Tag)\n- commentScoreId (foreign key: CommentScore)\n\n*Indexes*:\n- commentId/tagId\n\n### CommentSummaryScore\n- commentId (foreign key: Comment)\n- tagId (foreign key: Tag)\n- score (float) (required)\n- confirmedUserId (int)\n- isConfirmed (bool)\n\n*Indexes*:\n- commentId/tagId\n\n### CommentFlag\n\nAn attribute of the comment indicating the comment has been flagged for some reason on the target platform.\nCurrently, there is no means of setting this flag in OSMod, though we display counts of flagged comments.\nTODO: Document how a flagged comment appears in the UI.\n\n- id (bigint)\n- commentId (foreign key: Comment)\n- sourceId (string) (optional identifier so that scores can be retracted, like for publisher recommendations)\n- extra (json)\n- createdAt (datetime) (required)\n- updatedAt (datetime)\n\n*Indexes*:\n- commentId\n\n### Decision\n- id(int) (required)\n- commentId (foreign key: Comment) (require)\n- userId (foreign key: User) (optional, if source === User)\n- moderationRuleId (foreign key: ModerationRule) (optional, if source === Rule)\n- status (enum: Accept, Reject, Defer) (required)\n- source (enum: User, Rule) (required)\n- sentBackToPublisher (datetime)\n\n#### Notes\n\nRepresents a log of decisions made by OSMod.\n\n### ModerationRule\n- id (int) (required)\n- tagId (foreign key: Tag) (required)\n- categoryId (foreign key: Category)\n- lowerThreshold (smallint) (required)\n- upperThreshold (smallint) (required)\n- action (enum: Approve, Reject, Defer, Highlight) (required)\n- createdBy (foreign key: User)\n\n*Indexes*:\n- categoryId\n\n### CommentReply\n- commentId (int) (required)\n- replyId (int) (required)\n\n*Indexes*:\n- commentId\n- replyId\n\n#### Notes\n- Used for hasAndBelongsToMany for replies on a comment.\n\n### Tag\n- id (int) (required)\n- key (string) (required) (raw key for tag, e.g. `ATTACK_ON_COMMENTER`)\n- label (string) (required) (display name, e.g. `Attack of Commenter`)\n- color (string) (required) (hex color, e.g. `#c0ff33`)\n- description (string) (optional) (short description, e.g. `A verbale attack directed towards author`)\n- isInBatchView (bool) (is the tag to be shown on the front-end)\n- isTaggable (bool) (tags that would show up in reason to reject or moderateor selected tags, but not the tag selector for batch view)\n\n*Indexes*:\n- key (unique)\n\n### Preselect\n- id (int) (required)\n- tagId (foreign key: Tag)\n- categoryId (foreign key: Category)\n- lowerThreshold (smallint) (required)\n- upperThreshold (smallint) (required)\n- createdBy (foreign key: User)\n\n### TaggingSensitivity\n- id (int) (required)\n- tagId (foreign key: Tag)\n- categoryId (foreign key: Category)\n- lowerThreshold (smallint) (required)\n- upperThreshold (smallint) (required)\n- createdBy (foreign key: User)\n\n\n## Mappings\n\n### YouTube\n\nFor YouTube we use the following map:\n\n* YouTube Channel -> OSMod Category\n* YouTube Video -> OSMod Article\n* YouTube Comment ->  OSMod Comment\n\nYouTube allows comments on the channel as well as the Video.  To accommodate this, there is a special article that corresponds to the channel itself, labelled \"Channel comments.\"\n\nYou can connect multiple YouTube accounts to a single OSMod instance.  Each connection has an associated user of type \"youtube\" that stores the necessary authentication credentials.\nWe set the ownerID field of each category/article/comment fetched from YouTube to point to the YouTube user that is responsible for this entity.\n"
  },
  {
    "path": "docs/osmod_assistant_protocol.md",
    "content": "# OSMOD Assistant Protocol\n\nThis document describes the protocol for interacting with the \"assistant\", the\ncomponent that bridges between the OSMOD backend and the machine learning\nmodels.\n\nThe OSMOD backend provides comments and articles to the assistant, which in\nturn gives scores on each comment.\n\n## Send a Comment for Scoring\n\nTo get scores for a comment, the OSMOD backend sends a `POST` to the assistant\nendpoint `/api/score-comment` in the following format:\n\n```javascript\n{\n  \"comment\": {\n    \"commentId\": \"123\",\n\n    // UTF-8 text of the comment.\n    \"plainText\": \"We are condemned to act out this sad, once unimaginable farce. Sad!\",\n\n    // Optional HTML content.\n    \"htmlText\": \"We are <b>condemned</b> to act out this sad, once unimaginable farce. Sad!\",\n\n    \"links\": {\n      // OSMOD API endpoint for retrieving more data about the comment.\n      \"self\": \"https://osmod-backend/api/rest/comments/123\",\n    },\n  },\n\n  \"article\": {\n    \"articleId\": \"456\",\n\n    // UTF-8 text of the article.\n    \"plainText\": \"The beauty of me is that I'm very rich.\",\n\n    // Optional HTML content.\n    \"htmlText\": \"The <i>beauty</i> of me is that I'm very rich.\",\n\n    \"links\": {\n      // OSMOD API endpoint for retrieving more data about the comment.\n      \"self\": \"https://osmod-backend/api/rest/articles/456\",\n    },\n  },\n\n  // Single comment that this comment is responding to. Often null.\n  \"inReplyToComment\": {\n    \"commentId\": \"789\",\n\n    // UTF-8 text of the comment.\n    \"plainText\": \"We are condemned to act out this sad, once unimaginable farce. Sad!\",\n\n    // Optional HTML content.\n    \"htmlText\": \"We are <b>condemned</b> to act out this sad, once unimaginable farce. Sad!\",\n\n    \"links\": {\n      // OSMOD API endpoint for retrieving more data about the comment.\n      \"self\": \"https://osmod-backend/api/rest/comments/789\"\n    },\n  },\n\n  // Whether to include summary scores for the comment. Optional: by default,\n  // summary scores aren't included. See documentation of response object below.\n  \"includeSummaryScores\": true,\n\n  \"links\": {\n    // Full URL of backend endpoint that the assistant should post scores to.\n    // See next section.\n    \"callback\": \"https://osmod-backend/api/assistant/comment-scores/123\"\n  }\n}\n```\n\nOnce the assistant has scores for the comment, it will `POST` them to the\nendpoint specified in `links.callback`. That endpoint is described next.\n\n## Receive Comment Scores\n\nThe OSMOD backend will provide the endpoint\n`/api/assistant/comment-scores/:id`.  The comment ID is embedded as the last\nparameter in the URL, which is constructed and sent as the `links.callback`\nfield in the scoring request described above.\n\nThe POST data from the assistant is in the following format:\n\n```javascript\n{\n  // A map from \"attribute\" to list of score objects. Each score object contains\n  // a score value for a span of the original comment text. There may be\n  // multiple score objects for each attribute that describe different text\n  // spans.\n  //\n  // The possible attribute string values are:\n  // ATTACK_ON_AUTHOR\n  // ATTACK_ON_COMMENTER\n  // ATTACK_ON_PUBLISHER\n  // INCOHERENT\n  // INFLAMMATORY\n  // LIKELY_TO_REJECT\n  // OBSCENE\n  // OFF_TOPIC\n  // SPAM\n  // UNSUBSTANTIAL\n  \"scores\": {\n    \"ATTACK_ON_COMMENTER\": [\n      {\n        // Number between 0 and 1, inclusive. Greater values mean higher\n        // confidence that the attribute applies to this span of text.\n        \"score\": 0.2,\n        // Integer describing the span of the original comment text that\n        // the score applies to. The values are in UTF-16 codepoints. \"end\" is\n        // exclusive.\n        // Example: for the text \"Hi - I have the best words!\", the begin/end\n        // pair of (0,2) describes the string \"Hi\", and the pair (5,26)\n        // describes the string \"I have the best words\".\n        \"begin\": 0,\n        \"end\": 62,\n      },\n    ],\n    \"INFLAMMATORY\": [\n      {\n        \"score\": 0.4,\n        \"begin\": 0,\n        \"end\": 62,\n      },\n      {\n        \"score\": 0.7,\n        \"begin\": 63,\n        \"end\": 66,\n      },\n    ],\n  },\n\n  // A map from \"attribute\" to a single overall score for the entire comment.\n  // The set of keys between `summaryScores` and `scores` should be the same\n  // (that is, an attribute with per-span scores should also have a summary\n  // score, and vice versa).\n  //\n  // `summaryScores` is returned if `includeSummaryScores` was true in the\n  // request.\n  \"summaryScores\": {\n    \"ATTACK_ON_COMMENTER\": 0.2,\n    \"INFLAMMATORY\": 0.45\n  },\n\n  // String describing problems encountered during scoring. The `scores` and\n  // `error` fields should be mutually exclusive.\n  \"error\": \"Problem scoring text: connection to ML backend timed out.\",\n}\n```\n\n## Assistant connection details\n\nThe assistant can be reached at https://osmod-assistant.appspot.com/.\n\nFor testing/debugging, one can specify the `sync` field in the scoring request,\nand the assistant will respond with the score result, as opposed to posting the\nresult to the `links.callback` endpoint.\n\nExample:\n```\n$ curl -H 'Content-Type: application/json' --data '{\"sync\": true, \"comment\": {\"plainText\": \"you big darn dummy!\"} }' https://osmod-assistant.appspot.com/api/score-comment\n{\"scores\":{\"ATTACK_ON_COMMENTER\":[{\"score\":0.66,\"begin\":0,\"end\":18}],\"INFLAMMATORY\":[{\"score\":0.792,\"begin\":0,\"end\":18}],\"OBSCENITY\":[{\"score\":0.2,\"begin\":8,\"end\":11}]}}\n```\n"
  },
  {
    "path": "docs/osmod_services_api.md",
    "content": "# OSMOD Services API\n\nThe OSMOD Services API allows publishing and tagging operations to comments.\n\nThis documentation covers the services:\n\nComment Actions\n\n> * /api/services/commentActions/approve\n> * /api/services/commentActions/reject\n> * /api/services/commentActions/defer\n> * /api/services/commentActions/highlight\n> * /api/services/commentActions/tag/:tagid\n> * /api/services/commentActions/tagCommentSummaryScores/:tagid\n\nComment Score Actions\n\n> * /api/services/commentActions/:commentid/scores\n> * /api/services/commentActions/:commentid/scores/:commentscoreid/reset\n> * /api/services/commentActions/:commentid/scores/:commentscoreid/confirm\n> * /api/services/commentActions/:commentid/scores/:commentscoreid/reject\n> * /api/services/commentActions/:commentid/scores/:commentscoreid\n\n## Comment Actions\nComment Actions allow control over a comment to approve, reject, highlight, defer, and tag.\n\n### approve\nApprove all comment id(s)\n\nA `POST` to `/api/services/commentActions/approve` with a body containing the ID(s) of the comment(s) to trigger the action upon.\n\nBody Example using single commentId:\n\n```javascript\n{\n  \"data\" : [\n    { commentId: '12', userId: '1' }\n  ]\n}\n```\n\nOr, for a bulk command using multiple commentId\n\n```javascript\n{\n  \"data\" : [\n    { commentId: '12', userId: '1' },\n    { commentId: '13', userId: '1' },\n    { commentId: '17', userId: '1' }\n  ]\n}\n```\n\n### reject\nReject all comment id(s)\n\nA `POST` to `/api/services/commentActions/reject` with a body containing the ID(s) of the comment(s) to trigger the action upon.\n\nBody Example using single commentId:\n\n```javascript\n{\n  \"data\" : [\n    { commentId: '12', userId: '1' }\n  ]\n}\n```\n\nOr, for a bulk command using multiple commentId\n\n```javascript\n{\n  \"data\" : [\n    { commentId: '12', userId: '1' },\n    { commentId: '13', userId: '1' },\n    { commentId: '17', userId: '1' }\n  ]\n}\n```\n\n\n### defer\nDefer all comment id(s)\n\nA `POST` to `/api/services/commentActions/defer` with a body containing the ID(s) of the comment(s) to trigger the action upon.\n\nBody Example using single commentId:\n\n```javascript\n{\n  \"data\" : [\n    { commentId: '12', userId: '1' }\n  ]\n}\n```\n\nOr, for a bulk command using multiple commentId\n\n```javascript\n{\n  \"data\" : [\n    { commentId: '12', userId: '1' },\n    { commentId: '13', userId: '1' },\n    { commentId: '17', userId: '1' }\n  ]\n}\n```\n\n\n### highlight\nHighlight all comment id(s)\n\nA `POST` to `/api/services/commentActions/highlight` with a body containing the ID(s) of the comment(s) to trigger the action upon.\n\nBody Example using single commentId:\n\n```javascript\n{\n  \"data\" : [\n    { commentId: '12', userId: '1' }\n  ]\n}\n```\n\nOr, for a bulk command using multiple commentId\n\n```javascript\n{\n  \"data\" : [\n    { commentId: '12', userId: '1' },\n    { commentId: '13', userId: '1' },\n    { commentId: '17', userId: '1' }\n  ]\n}\n```\n\n### tag\nTag all comment id(s) with the tag ID provided.\n\nA `POST` to `/api/services/commentActions/tag/:tagid` with a body containing the ID(s) of the comment(s) to trigger the action upon.\n\nBody Example using single commentId:\n\n```javascript\n{\n  \"data\" : [\n    12\n  ]\n}\n```\n\nOr, for a bulk command using multiple commentId\n\n```javascript\n{\n  \"data\" : [\n    12,13,17\n  ]\n}\n```\n\n### tagCommentSummaryScores\nTag all comment id(s) comment summary score with the tag ID provided.\n\nA `POST` to `/api/services/commentActions/tagCommentSummaryScores/:tagid` with a body containing the ID(s) of the comment(s) to trigger the action upon.\n\nBody Example using single commentId:\n\n```javascript\n{\n  \"data\" : [\n    12\n  ]\n}\n```\n\nOr, for a bulk command using multiple commentId\n\n```javascript\n{\n  \"data\" : [\n    12,13,17\n  ]\n}\n```\n\n## Comment Score Actions\nComment Score Actions allow control over the internal content of a comment. This allows specific words or phrases to be tagged and controlled. The comment detail actions allow control over tag adding, removal, rejecting and confirming.\n\n### add\nAdd a tag to a set of content within a single comment.\n\nA `POST` to `/api/services/commentActions/:commentid/scores` with a body containing the object:\n\n```javascript\n{\n  \"data\" : {\n  \t // The tag id of the tag to be added to this selection\n    \"tagId\": \"1\",\n    // The start position of the character in the string of the comment text.\n\t  \"annotationStart\": 130,\n\t // The end position of the character in the string of the comment text.\n\t  \"annotationEnd\": 145\n  }\n}\n```\n\n### remove\nRemove a tag from the comment.\n\nA `DELETE` to `/api/services/commentActions/:commentid/scores/:commentscoreid`\n\n### confirm\nConfirming a tag that a previous user (or machine) has added to a particular set of content within the comment.\n\nA `POST` to `/api/services/commentActions/:commentid/scores/:commentscoreid/confirm` with no body\n\n### reject\nRejecting a tag that a previous user (or machine) has added to a particular set of content within the comment.\n\nA `POST` to `/api/services/commentActions/:commentid/scores/:commentscoreid/reject` with no body\n"
  },
  {
    "path": "docs/osmod_task_api.md",
    "content": "#OSMODTaskAPI\n\nThe OSMOD Task API exposes the worker tasks on HTTP endpoints to support\n`push`-style message consumption.\n\nThis documentation covers the following tasks:\n\n```text\nprocessMachineScore\nheartbeat\nprocessTagAddition\nprocessTagRevocation\nsendCommentForScoring\ndeferComments\nhighlightComments\ntagComments\ntagCommentSummaryScores\nacceptComments\nrejectComments\nresetTag\nresetComments\nconfirmTag\nconfirmCommentSummaryScore\nrejectCommentSummaryScore\nrejectTag\naddTag\nremoveTag\n```\n\n\n## Payload Formats\n\nAll payloads should be wrapped in a `data` object of the form...\n```json\n{\n    \"data\": {...}\n}\n```\n\n```json\n{\n    \"data\": [...]\n}\n```\n\nRefer to [backend-queue/tasks](https://github.com/Jigsaw-Code/moderator/tree/dev/packages/backend-queue/src/tasks) for the task schemas.\n\nField names correspond to key-value in the JSON objects and the types are the expected payload type formats.\n\nFor example, take a `processMachineScore` task with the following data interface.\n```typescript\nexport interface IProcessMachineScoreData {\n  commentId: number;\n  userId: number;\n  scoreData: IScoreData;\n  runImmediately?: boolean;\n}\n```\n\nIts payload would look like the following JSON body.\n```json\n{\n    \"data\": {\n        \"commentId\": \"1\",\n        \"userId\": \"1\",\n        \"scoreData\": {\n            \"scores\": [{\"score\": 0.8, \"begin\": 1, \"end\": 53}],\n            \"summaryScores\": {\n                \"OBSCENE\": 0.9,\n                \"INFLAMMATORY\": 0.7\n            }\n        },\n        \"runImmediately\": true\n    }\n}\n```\n"
  },
  {
    "path": "docs/sql_queries.sql",
    "content": "\n -- articles with counts of real comments, and the table's counts cache for\n -- and unmoderated and moderated.\nCREATE VIEW articles_with_counts AS\n  SELECT a.id, a.categoryId, a.unmoderatedCount, a.moderatedCount, m.commentCount, a.title\n   FROM\n    ((SELECT articleId, COUNT(*) as commentCount\n      FROM comments GROUP BY articleId) AS m\n    INNER JOIN articles AS a\n    ON a.id = m.articleId);\n\n-- Categories with the counts\nCREATE VIEW category_ids_with_counts AS\n   (SELECT categoryId, COUNT(*) as articleCount,\n       SUM(commentCount) as commentCount,\n       SUM(unmoderatedCount) as unmoderatedCount,\n       SUM(moderatedCount) as moderatedCount\n    FROM articles_with_counts\n    GROUP BY categoryId);\n\n-- Named categories with counts\nCREATE VIEW categories_with_counts AS\n  (SELECT g.id, g.label, g.unmoderatedCount,\n          g.moderatedCount, c.articleCount, c.commentCount\n    FROM (category_ids_with_counts AS c\n    INNER JOIN categories AS g\n    ON c.categoryId = g.id));\n\n-- See counter for some articles.\nSELECT * FROM articles_with_counts LIMIT 10;\n\n-- See the counts for categories\nSELECT * FROM categories_with_counts LIMIT 10;\n\n-- Reset Article Counts\nUPDATE articles AS a\nINNER JOIN articles_with_counts AS c\nON a.id = c.id\nSET\n  a.moderatedCount = 0,\n  a.unmoderatedCount = c.commentCount;\n\n-- Update Category Counts\nUPDATE categories AS g\nINNER JOIN categories_with_counts AS c\nON g.id = c.id\nSET\n  g.moderatedCount = 0,\n  g.unmoderatedCount = c.commentCount;\n\n-- Reset comment's moderation state\nUPDATE comments SET\n isModerated = false,\n sentBackToPublisher = NULL,\n isAccepted = false,\n isDeferred = false,\n isHighlighted = false,\n isBatchResolved = false,\n isAutoResolved = false;\n\n-- Remove the decisions made\nDELETE FROM decisions;\n\n-- Reset article counts\nUPDATE articles SET\nmoderatedCount = 0,\nhighlightedCount = 0,\napprovedCount = 0,\nrejectedCount = 0,\ndeferedCount = 0,\nbatchedCount = 0;\n\n-- Reset category counts\nUPDATE categories SET\nmoderatedCount = 0,\nhighlightedCount = 0,\napprovedCount = 0,\nrejectedCount = 0,\ndeferedCount = 0,\nbatchedCount = 0;\n"
  },
  {
    "path": "docs/worker.md",
    "content": "# OS Moderator Worker/Task Queue\n\nWe have a worker/task queue using [Kue](https://github.com/Automattic/kue) and [Kue Scheduler](https://github.com/lykmapipo/kue-scheduler) for repeating tasks, which uses [Redis](http://redis.io/) as its backend.\n\n## Running the worker\n\n### In Development\n\n#### With Docker\n\nYou can run all services from the root of the project like so:\n\n```bash\ndocker-compose up\n```\n\nThis will run the client, server, MySQL, Redis, and the worker.\n\n#### Running Ad-Hoc\n\nYou'll need to make [Redis](http://redis.io/) is installed, which you can do with [Homebrew](http://brew.sh/):\n\n```bash\nbrew install redis\n```\n\nAnd you can run it like so:\n\n```bash\nredis-server /usr/local/etc/redis.conf\n```\n\nThere's a watch command to compile Typescript and run the worker:\n\n```bash\ncd server\nnpm run watch:worker\n```\n\n### In Real Life\n\nFor production the worker should be run like so:\n\n```bash\ncd server\nnpm run compile\nnode dist/worker/index.js\n```\n\n## Tasks\n\n### Adding New Tasks\n\nAll tasks are conventionally organized into their own files under `server/worker/tasks/` and loaded passively by `server/worker/tasks/index.ts`, which is loaded by the worker entrypoint file at `server/worker/index.ts`.\n\n### Queuing Tasks\n\nWhen queuing a task, all that's happening is that meta information around the task is being logged to Redis, to be picked up by a running worker. This means that you can queue tasks as long as Redis is running, but they will not actually be executed unless a worker is also running to pick them up. Keep this in mind if you're queuing up lots of tasks without a worker running, as when you spin it up it will start processing them immediately.\n\nTo avoid this, particularly for local development, sometimes it's best to flush Redis (WARNING: This removes _everything_ from Redis):\n\n```bash\n# Spin up a Redis REPL and enter the 'FLUSHALL' command to delete everything\nredis-cli\n127.0.0.1:6379> FLUSHALL\n```\n\nTo programmatically queue up a task, do the following:\n\n```js\nimport { queue } from './worker/queue';\n\nqueue\n    .create('nameOfTask', {someArgument1: 5, someArgument: 'Hello'})\n    .save();\n```\n\n### Repeating/Scheduled Tasks\n\n[kue-scheduler](https://github.com/lykmapipo/kue-scheduler) is in place to support repeating/scheduled tasks. To create a repeating task, you must define one, then in the main worker entry point, `worker/index.ts`, inside of the conditional checking whether to run scheduled tasks or not (`if (config.get('worker.run_scheduled_tasks')) { ... }`) you'll add your schedule and it should look something like this:\n\n```js\nconst repeatingJob = queue\n    // This is standard a Kue function to create a run of a task\n\n    .create('nameOfYourTask')\n\n    // This makes your repeating task unique, so that the runs of\n    // it won't overlap\n    // Uses: https://github.com/lykmapipo/kue-unique\n\n    .unique(true)\n\n    // This makes sure your job is removed on completion and that it\n    // will repeat. Without this, the job run information will stay in\n    // Redis and the `.unique` call will make it so it doesn't run again\n\n    .removeOnComplete(true)\n\n    // Time to live in milliseconds. This is a standard Kue function and\n    // is a good idea so your job runs don't hang if there are issues with it\n\n    .ttl(1000 * 60);\n\n// This schedules your task, the time syntax accepts a cron-ish syntax:\n// https://github.com/ncb000gt/node-cron\n// ... as well as human readable intervals:\n// https://github.com/rschmukler/human-interval\n\nqueue.every('6 hours', repeatingJob);\n```\n\n#### Issues with changing intervals\n\nKue scheduer is pretty finnicky and if you change the time interval, it seems to have issues picking it up, as it seems to store bits of data about the repeating data in various spots in the Redis DB that it doesn't resolve intuitively. You can fix this by running a FLUSHALL on Redis in your local environment if you don't care about deleting everything, but this should probably not happen in production. You can rename the task (maybe even the unique key...) and that would solve it.\n\n"
  },
  {
    "path": "docs/youtube_integration.md",
    "content": "# OS Moderator YouTube integration\n\n## Synchronisation of Channels and Videos\n\nIn the OSMod UI, Channels are mapped to categories/sections, Videos are mapped to articles.\n  \nWhen syncing with YouTube, we fetch all channels that the YouTube account can access, but we only \nsynchronise against videos that we already know about.  In particular, if a video has no comments, \nthen it will not appear.   \n\nChannel/Video synchronisation is kicked periodically (currently once a day).  Though the UI provides a mechanism\nfor doing an immediate sync.  Synchronisation occurs only while OSMod is active, i.e., when there is at least \none moderator actively using the tool. \n\nChannel and Video data is fetched using the following APIS:\n\n### Channel data: \n\nAPI: google.youtube('v3').channels.list\n    \nFor each channel we access the snippet brandingSettings: \nwe are only interested in the id, snippet.titlename, and brandingSettings.channel.moderateComments fields.\n\nIf moderateComments is false, we assume that this channel is not being managed by OSModerator, \nand take no further action.  \nWe eventually plan on providing a mechanism for enabling moderateComments via the OSMOD UI.\n\n### Video data\n\nAPI: google.youtube('v3').videos.list\n\nWe fetch videos that we know about and that are being actively managed.  A video is actively managed if its\nchannel is active and we've seen some comments for that video.\n\nFor each video, we store the id, title, description, channel ID and URL. \n\n## Synchronisation of Comments\n\nAPI: google.youtube('v3').commentThreads.list\n\nFor each active channel, we periodically poll the API for new comments to process.  \nWe do this every few minutes.  We narrow the search by only requesting \nthose comments that haven't yet been moderated (heldForReview).\n\nFor each comment and reply, we are interested in the following:\n - id\n - snippet.textDisplay\n - snippet.publishedAt\n - snippet.videoId \n - snippet.authorDisplayName\n - snippet.authorProfileImageUrl\n - snippet.authorChannelId.value\n\n### Backsync of comments\n\nAPI: google.youtube('v3').comments.setModerationStatus\n\nWe request the snippet and replies.\n\nOnce we have decided what to do with a comment, we set its moderation status via the above API:\n"
  },
  {
    "path": "lerna.json",
    "content": "{\n  \"version\": \"1.1.0\"\n}\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"osmod\",\n  \"license\": \"Apache-2.0\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"https://github.com/conversationai/conversationai-moderator\"\n  },\n  \"engines\": {\n    \"node\": \">=12.13.1\",\n    \"npm\": \">=6.12.1\"\n  },\n  \"dependencies\": {\n    \"lerna\": \"7.1.1\",\n    \"lerna-audit\": \"1.3.3\",\n    \"tslib\": \"2.2.0\",\n    \"tslint\": \"6.1.3\",\n    \"tslint-react\": \"5.0.0\",\n    \"typescript\": \"4.2.4\"\n  }\n}\n"
  },
  {
    "path": "packages/README.md",
    "content": "# The Osmod Packages\n\nThere are several parts to Osmod:\n\n* `backend-api`: the codebase of the API server for Osmod.\n  * Uses: `config`, `frontend-web`\n* `config`: common project configure files.\n* `frontend-web`: the web frontend for Osmod.\n"
  },
  {
    "path": "packages/backend-api/.sequelizerc",
    "content": "const path = require('path');\n\nmodule.exports = {\n  'config': path.resolve('./dist/sequelize-config.js'),\n  'migrations-path': path.resolve('./src/migrations'),\n  'models-path': path.resolve('./dist/models')\n};\n"
  },
  {
    "path": "packages/backend-api/LICENSE",
    "content": "                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   Copyright {2016} {Jigsaw}\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License.\n"
  },
  {
    "path": "packages/backend-api/README.md",
    "content": "# Moderator Backend\nThe Moderator Backend is a Node.js/Express-backed service that provides several APIs for both the front end as well as interactions with external services.\n\n## Table of Contents\n* [Installation](#installation)\n* [Scripts](#scripts)\n* [Worker/Task Queue](docs/worker.md)\n* [Modeling](docs/modeling.md)\n* [Auth](docs/auth.md)\n* [OS Mod](docs/osmod_rest_api.md)\n* [Troubleshooting](troubleshooting)\n\n# Installation\nAdding docs on auth, adding table of contents to README\nThis section will get the project running with all of its setup and dependencies.\n\n## Requirements\n\n* OS X\n* [Docker Engine 1.12+](https://docs.docker.com/engine/installation/)\n* [Docker Compose 1.8+](https://docs.docker.com/compose/install/) (this gets installed with \"Docker for Mac\", but you can also install it piece-meal)\n\n## Deployment\n\nSee [docs/deployment.md](docs/deployment.md).\n\n## Testing\n\nWe use Mocha.js and Chai for testing. Tests will be run automatically on the continuous integration server automatically before deployment, so make sure you run tests locally before pushing anything.\n\nTo run the tests locally, simple run the following from the `server` directory on your VM:\n\n```\nnpm test\n```\n\n## Linting\n\nThe easiest way to lint your work is to run the linting script! From `server` directory on your VM:\n\n```\nnpm run lint\n```\n\nThis will fire off all the linters and fail if any code doesn't pass muster. Note that we run the linter script during the build, so if you're code doesn't pass linting the build *will* fail. Loudly.\n\n\n### TSLint\n\nWe use [TSLint](https://palantir.github.io/tslint/) for linting backend Typescript.\n\n## Running the server in HTTPS mode\n\nCreate a test certificate via the following command\n\n```bash\nopenssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes\n```\n\ncopy the resulting files to the directory `packages/backend-api/sslcert`.\n"
  },
  {
    "path": "packages/backend-api/bin/check_migrations.sh",
    "content": "#!/bin/bash\n\n# Do a sequelize sync and dump the resulting schema:\n\nexport DATABASE_NAME=os_moderator_schema_test_sync\nsudo mysql << EOF\nDROP DATABASE IF EXISTS $DATABASE_NAME;\nCREATE DATABASE $DATABASE_NAME;\nGRANT ALL on $DATABASE_NAME.* to $DATABASE_USER;\nEOF\n\nbin/run_sequelize_sync.js\n\n# Now do the same thing, but this time loading base database and running through\n# the migrations\nexport DATABASE_NAME=os_moderator_schema_test_migrations\nsudo mysql << EOF\nDROP DATABASE IF EXISTS $DATABASE_NAME;\nCREATE DATABASE $DATABASE_NAME;\nGRANT ALL on $DATABASE_NAME.* to $DATABASE_USER;\nEOF\nsudo mysql $DATABASE_NAME < ../../seed/initial-database.sql\nnpx sequelize db:migrate\n\nsudo mysql-schema-diff os_moderator_schema_test_migrations os_moderator_schema_test_sync\n"
  },
  {
    "path": "packages/backend-api/bin/make_migration.sh",
    "content": "#!/bin/bash\nnpx sequelize migration:create --name $1\n"
  },
  {
    "path": "packages/backend-api/bin/osmod-test.js",
    "content": "#!/usr/bin/env node\n\n/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\n'use strict';\n\nconst path = require('path');\nconst yargs = require('yargs');\n\nconst youtube = require(path.join(__dirname, '..', 'dist', 'commands', 'tests', 'youtube'));\nconst generate = require(path.join(__dirname, '..', 'dist', 'commands', 'comments', 'generate'));\nconst imprt = require(path.join(__dirname, '..', 'dist', 'commands', 'comments', 'import'));\n\nyargs\n  .command(youtube)\n  .command(generate)\n  .command(imprt)\n  .demand(1)\n  .demandCommand(1, 'no command specified')\n  .usage('Usage: $0 <command> [options]')\n  .help()\n  .onFinishCommand(() => {process.exit()})\n  .argv;\n"
  },
  {
    "path": "packages/backend-api/bin/osmod.js",
    "content": "#!/usr/bin/env node\n\n/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n/**\n * Entrypoint for the osmod commandline tool.\n *\n * Provides commandline access to various features, including\n *  - managing users\n *  - managing comments\n *\n *  For a full list of available commands, run `osmod.js --help`\n */\n\n'use strict';\n\nconst path = require('path');\nconst yargs = require('yargs');\n\nyargs\n  .command(require(path.join(__dirname, '..', 'dist', 'commands', 'users', 'create')))\n  .command(require(path.join(__dirname, '..', 'dist', 'commands', 'users', 'get_token')))\n  .command(require(path.join(__dirname, '..', 'dist', 'commands', 'comments', 'rescore')))\n  .command(require(path.join(__dirname, '..', 'dist', 'commands', 'comments', 'send_to_scorer')))\n  .command(require(path.join(__dirname, '..', 'dist', 'commands', 'comments', 'calculate_text_size')))\n  .command(require(path.join(__dirname, '..', 'dist', 'commands', 'comments', 'recalculate_text_sizes')))\n  .command(require(path.join(__dirname, '..', 'dist', 'commands', 'comments', 'recalculate_top_scores')))\n  .command(require(path.join(__dirname, '..', 'dist', 'commands', 'comments', 'flag')))\n  .command(require(path.join(__dirname, '..', 'dist', 'commands', 'comments', 'delete')))\n  .command(require(path.join(__dirname, '..', 'dist', 'commands', 'denormalize')))\n  .demand(1)\n  .usage('Usage: $0')\n  .help()\n  .argv;\n"
  },
  {
    "path": "packages/backend-api/bin/run_sequelize_sync.js",
    "content": "#!/usr/bin/env node\n\n/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\n'use strict';\nconst { sequelize } = require('../dist/sequelize-sync');\n(async () => {\n  await sequelize.sync({ force: true });\n  await sequelize.close();\n  process.exit();\n})();\n"
  },
  {
    "path": "packages/backend-api/bin/run_task",
    "content": "#!/usr/bin/env node\n\n/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\n'use strict';\nconst { runTask } = require('../dist');\nrunTask(process.argv[2] || '');\n"
  },
  {
    "path": "packages/backend-api/data/alice.txt",
    "content": "Taken from project Gutenberg\nhttp://www.gutenberg.org/ebooks/11\n\n                       _THE \"STORYLAND\" SERIES_\n\n\n\n                   ALICE'S ADVENTURES IN WONDERLAND\n\n\n\n\n\n\n\n                     SAM'L GABRIEL SONS & COMPANY\n\n                               NEW YORK\n\n\n\n                           Copyright, 1916,\n\n                   by SAM'L GABRIEL SONS & COMPANY\n\n                               NEW YORK\n\n\n\n\nALICE'S ADVENTURES IN WONDERLAND\n\n[Illustration]\n\n\n\n\nI--DOWN THE RABBIT-HOLE\n\n\nAlice was beginning to get very tired of sitting by her sister on the\nbank, and of having nothing to do. Once or twice she had peeped into the\nbook her sister was reading, but it had no pictures or conversations in\nit, \"and what is the use of a book,\" thought Alice, \"without pictures or\nconversations?\"\n\nSo she was considering in her own mind (as well as she could, for the\nday made her feel very sleepy and stupid), whether the pleasure of\nmaking a daisy-chain would be worth the trouble of getting up and\npicking the daisies, when suddenly a White Rabbit with pink eyes ran\nclose by her.\n\nThere was nothing so very remarkable in that, nor did Alice think it so\nvery much out of the way to hear the Rabbit say to itself, \"Oh dear! Oh\ndear! I shall be too late!\" But when the Rabbit actually took a watch\nout of its waistcoat-pocket and looked at it and then hurried on, Alice\nstarted to her feet, for it flashed across her mind that she had never\nbefore seen a rabbit with either a waistcoat-pocket, or a watch to take\nout of it, and, burning with curiosity, she ran across the field after\nit and was just in time to see it pop down a large rabbit-hole, under\nthe hedge. In another moment, down went Alice after it!\n\n[Illustration]\n\nThe rabbit-hole went straight on like a tunnel for some way and then\ndipped suddenly down, so suddenly that Alice had not a moment to think\nabout stopping herself before she found herself falling down what seemed\nto be a very deep well.\n\nEither the well was very deep, or she fell very slowly, for she had\nplenty of time, as she went down, to look about her. First, she tried to\nmake out what she was coming to, but it was too dark to see anything;\nthen she looked at the sides of the well and noticed that they were\nfilled with cupboards and book-shelves; here and there she saw maps and\npictures hung upon pegs. She took down a jar from one of the shelves as\nshe passed. It was labeled \"ORANGE MARMALADE,\" but, to her great\ndisappointment, it was empty; she did not like to drop the jar, so\nmanaged to put it into one of the cupboards as she fell past it.\n\nDown, down, down! Would the fall never come to an end? There was nothing\nelse to do, so Alice soon began talking to herself. \"Dinah'll miss me\nvery much to-night, I should think!\" (Dinah was the cat.) \"I hope\nthey'll remember her saucer of milk at tea-time. Dinah, my dear, I wish\nyou were down here with me!\" Alice felt that she was dozing off, when\nsuddenly, thump! thump! down she came upon a heap of sticks and dry\nleaves, and the fall was over.\n\nAlice was not a bit hurt, and she jumped up in a moment. She looked up,\nbut it was all dark overhead; before her was another long passage and\nthe White Rabbit was still in sight, hurrying down it. There was not a\nmoment to be lost. Away went Alice like the wind and was just in time to\nhear it say, as it turned a corner, \"Oh, my ears and whiskers, how late\nit's getting!\" She was close behind it when she turned the corner, but\nthe Rabbit was no longer to be seen.\n\nShe found herself in a long, low hall, which was lit up by a row of\nlamps hanging from the roof. There were doors all 'round the hall, but\nthey were all locked; and when Alice had been all the way down one side\nand up the other, trying every door, she walked sadly down the middle,\nwondering how she was ever to get out again.\n\nSuddenly she came upon a little table, all made of solid glass. There\nwas nothing on it but a tiny golden key, and Alice's first idea was that\nthis might belong to one of the doors of the hall; but, alas! either the\nlocks were too large, or the key was too small, but, at any rate, it\nwould not open any of them. However, on the second time 'round, she came\nupon a low curtain she had not noticed before, and behind it was a\nlittle door about fifteen inches high. She tried the little golden key\nin the lock, and to her great delight, it fitted!\n\n[Illustration]\n\nAlice opened the door and found that it led into a small passage, not\nmuch larger than a rat-hole; she knelt down and looked along the passage\ninto the loveliest garden you ever saw. How she longed to get out of\nthat dark hall and wander about among those beds of bright flowers and\nthose cool fountains, but she could not even get her head through the\ndoorway. \"Oh,\" said Alice, \"how I wish I could shut up like a telescope!\nI think I could, if I only knew how to begin.\"\n\nAlice went back to the table, half hoping she might find another key on\nit, or at any rate, a book of rules for shutting people up like\ntelescopes. This time she found a little bottle on it (\"which certainly\nwas not here before,\" said Alice), and tied 'round the neck of the\nbottle was a paper label, with the words \"DRINK ME\" beautifully printed\non it in large letters.\n\n\"No, I'll look first,\" she said, \"and see whether it's marked '_poison_'\nor not,\" for she had never forgotten that, if you drink from a bottle\nmarked \"poison,\" it is almost certain to disagree with you, sooner or\nlater. However, this bottle was _not_ marked \"poison,\" so Alice ventured\nto taste it, and, finding it very nice (it had a sort of mixed flavor of\ncherry-tart, custard, pineapple, roast turkey, toffy and hot buttered\ntoast), she very soon finished it off.\n\n       *       *       *       *       *\n\n\"What a curious feeling!\" said Alice. \"I must be shutting up like a\ntelescope!\"\n\nAnd so it was indeed! She was now only ten inches high, and her face\nbrightened up at the thought that she was now the right size for going\nthrough the little door into that lovely garden.\n\nAfter awhile, finding that nothing more happened, she decided on going\ninto the garden at once; but, alas for poor Alice! When she got to the\ndoor, she found she had forgotten the little golden key, and when she\nwent back to the table for it, she found she could not possibly reach\nit: she could see it quite plainly through the glass and she tried her\nbest to climb up one of the legs of the table, but it was too slippery,\nand when she had tired herself out with trying, the poor little thing\nsat down and cried.\n\n\"Come, there's no use in crying like that!\" said Alice to herself rather\nsharply. \"I advise you to leave off this minute!\" She generally gave\nherself very good advice (though she very seldom followed it), and\nsometimes she scolded herself so severely as to bring tears into her\neyes.\n\nSoon her eye fell on a little glass box that was lying under the table:\nshe opened it and found in it a very small cake, on which the words \"EAT\nME\" were beautifully marked in currants. \"Well, I'll eat it,\" said\nAlice, \"and if it makes me grow larger, I can reach the key; and if it\nmakes me grow smaller, I can creep under the door: so either way I'll\nget into the garden, and I don't care which happens!\"\n\nShe ate a little bit and said anxiously to herself, \"Which way? Which\nway?\" holding her hand on the top of her head to feel which way she was\ngrowing; and she was quite surprised to find that she remained the same\nsize. So she set to work and very soon finished off the cake.\n\n[Illustration]\n\n\n\n\nII--THE POOL OF TEARS\n\n\n\"Curiouser and curiouser!\" cried Alice (she was so much surprised that\nfor the moment she quite forgot how to speak good English). \"Now I'm\nopening out like the largest telescope that ever was! Good-by, feet! Oh,\nmy poor little feet, I wonder who will put on your shoes and stockings\nfor you now, dears? I shall be a great deal too far off to trouble\nmyself about you.\"\n\nJust at this moment her head struck against the roof of the hall; in\nfact, she was now rather more than nine feet high, and she at once took\nup the little golden key and hurried off to the garden door.\n\nPoor Alice! It was as much as she could do, lying down on one side, to\nlook through into the garden with one eye; but to get through was more\nhopeless than ever. She sat down and began to cry again.\n\nShe went on shedding gallons of tears, until there was a large pool all\n'round her and reaching half down the hall.\n\nAfter a time, she heard a little pattering of feet in the distance and\nshe hastily dried her eyes to see what was coming. It was the White\nRabbit returning, splendidly dressed, with a pair of white kid-gloves in\none hand and a large fan in the other. He came trotting along in a\ngreat hurry, muttering to himself, \"Oh! the Duchess, the Duchess! Oh!\n_won't_ she be savage if I've kept her waiting!\"\n\nWhen the Rabbit came near her, Alice began, in a low, timid voice, \"If\nyou please, sir--\" The Rabbit started violently, dropped the white\nkid-gloves and the fan and skurried away into the darkness as hard as he\ncould go.\n\n[Illustration]\n\nAlice took up the fan and gloves and she kept fanning herself all the\ntime she went on talking. \"Dear, dear! How queer everything is to-day!\nAnd yesterday things went on just as usual. _Was_ I the same when I got\nup this morning? But if I'm not the same, the next question is, 'Who in\nthe world am I?' Ah, _that's_ the great puzzle!\"\n\nAs she said this, she looked down at her hands and was surprised to see\nthat she had put on one of the Rabbit's little white kid-gloves while\nshe was talking. \"How _can_ I have done that?\" she thought. \"I must be\ngrowing small again.\" She got up and went to the table to measure\nherself by it and found that she was now about two feet high and was\ngoing on shrinking rapidly. She soon found out that the cause of this\nwas the fan she was holding and she dropped it hastily, just in time to\nsave herself from shrinking away altogether.\n\n\"That _was_ a narrow escape!\" said Alice, a good deal frightened at the\nsudden change, but very glad to find herself still in existence. \"And\nnow for the garden!\" And she ran with all speed back to the little door;\nbut, alas! the little door was shut again and the little golden key was\nlying on the glass table as before. \"Things are worse than ever,\"\nthought the poor child, \"for I never was so small as this before,\nnever!\"\n\nAs she said these words, her foot slipped, and in another moment,\nsplash! she was up to her chin in salt-water. Her first idea was that\nshe had somehow fallen into the sea. However, she soon made out that she\nwas in the pool of tears which she had wept when she was nine feet high.\n\n[Illustration]\n\nJust then she heard something splashing about in the pool a little way\noff, and she swam nearer to see what it was: she soon made out that it\nwas only a mouse that had slipped in like herself.\n\n\"Would it be of any use, now,\" thought Alice, \"to speak to this mouse?\nEverything is so out-of-the-way down here that I should think very\nlikely it can talk; at any rate, there's no harm in trying.\" So she\nbegan, \"O Mouse, do you know the way out of this pool? I am very tired\nof swimming about here, O Mouse!\" The Mouse looked at her rather\ninquisitively and seemed to her to wink with one of its little eyes, but\nit said nothing.\n\n\"Perhaps it doesn't understand English,\" thought Alice. \"I dare say it's\na French mouse, come over with William the Conqueror.\" So she began\nagain: \"Ou est ma chatte?\" which was the first sentence in her French\nlesson-book. The Mouse gave a sudden leap out of the water and seemed to\nquiver all over with fright. \"Oh, I beg your pardon!\" cried Alice\nhastily, afraid that she had hurt the poor animal's feelings. \"I quite\nforgot you didn't like cats.\"\n\n\"Not like cats!\" cried the Mouse in a shrill, passionate voice. \"Would\n_you_ like cats, if you were me?\"\n\n\"Well, perhaps not,\" said Alice in a soothing tone; \"don't be angry\nabout it. And yet I wish I could show you our cat Dinah. I think you'd\ntake a fancy to cats, if you could only see her. She is such a dear,\nquiet thing.\" The Mouse was bristling all over and she felt certain it\nmust be really offended. \"We won't talk about her any more, if you'd\nrather not.\"\n\n\"We, indeed!\" cried the Mouse, who was trembling down to the end of its\ntail. \"As if _I_ would talk on such a subject! Our family always _hated_\ncats--nasty, low, vulgar things! Don't let me hear the name again!\"\n\n[Illustration: Alice at the Mad Tea Party.]\n\n\"I won't indeed!\" said Alice, in a great hurry to change the subject of\nconversation. \"Are you--are you fond--of--of dogs? There is such a nice\nlittle dog near our house, I should like to show you! It kills all the\nrats and--oh, dear!\" cried Alice in a sorrowful tone. \"I'm afraid I've\noffended it again!\" For the Mouse was swimming away from her as hard as\nit could go, and making quite a commotion in the pool as it went.\n\nSo she called softly after it, \"Mouse dear! Do come back again, and we\nwon't talk about cats, or dogs either, if you don't like them!\" When the\nMouse heard this, it turned 'round and swam slowly back to her; its face\nwas quite pale, and it said, in a low, trembling voice, \"Let us get to\nthe shore and then I'll tell you my history and you'll understand why it\nis I hate cats and dogs.\"\n\nIt was high time to go, for the pool was getting quite crowded with the\nbirds and animals that had fallen into it; there were a Duck and a Dodo,\na Lory and an Eaglet, and several other curious creatures. Alice led the\nway and the whole party swam to the shore.\n\n[Illustration]\n\n\n\n\nIII--A CAUCUS-RACE AND A LONG TALE\n\n\nThey were indeed a queer-looking party that assembled on the bank--the\nbirds with draggled feathers, the animals with their fur clinging close\nto them, and all dripping wet, cross and uncomfortable.\n\n[Illustration]\n\nThe first question, of course, was how to get dry again. They had a\nconsultation about this and after a few minutes, it seemed quite natural\nto Alice to find herself talking familiarly with them, as if she had\nknown them all her life.\n\nAt last the Mouse, who seemed to be a person of some authority among\nthem, called out, \"Sit down, all of you, and listen to me! _I'll_ soon\nmake you dry enough!\" They all sat down at once, in a large ring, with\nthe Mouse in the middle.\n\n\"Ahem!\" said the Mouse with an important air. \"Are you all ready? This\nis the driest thing I know. Silence all 'round, if you please! 'William\nthe Conqueror, whose cause was favored by the pope, was soon submitted\nto by the English, who wanted leaders, and had been of late much\naccustomed to usurpation and conquest. Edwin and Morcar, the Earls of\nMercia and Northumbria'--\"\n\n\"Ugh!\" said the Lory, with a shiver.\n\n\"--'And even Stigand, the patriotic archbishop of Canterbury, found it\nadvisable'--\"\n\n\"Found _what_?\" said the Duck.\n\n\"Found _it_,\" the Mouse replied rather crossly; \"of course, you know\nwhat 'it' means.\"\n\n\"I know what 'it' means well enough, when _I_ find a thing,\" said the\nDuck; \"it's generally a frog or a worm. The question is, what did the\narchbishop find?\"\n\nThe Mouse did not notice this question, but hurriedly went on, \"'--found\nit advisable to go with Edgar Atheling to meet William and offer him the\ncrown.'--How are you getting on now, my dear?\" it continued, turning to\nAlice as it spoke.\n\n\"As wet as ever,\" said Alice in a melancholy tone; \"it doesn't seem to\ndry me at all.\"\n\n\"In that case,\" said the Dodo solemnly, rising to its feet, \"I move that\nthe meeting adjourn, for the immediate adoption of more energetic\nremedies--\"\n\n\"Speak English!\" said the Eaglet. \"I don't know the meaning of half\nthose long words, and, what's more, I don't believe you do either!\"\n\n\"What I was going to say,\" said the Dodo in an offended tone, \"is that\nthe best thing to get us dry would be a Caucus-race.\"\n\n\"What _is_ a Caucus-race?\" said Alice.\n\n[Illustration]\n\n\"Why,\" said the Dodo, \"the best way to explain it is to do it.\" First it\nmarked out a race-course, in a sort of circle, and then all the party\nwere placed along the course, here and there. There was no \"One, two,\nthree and away!\" but they began running when they liked and left off\nwhen they liked, so that it was not easy to know when the race was over.\nHowever, when they had been running half an hour or so and were quite\ndry again, the Dodo suddenly called out, \"The race is over!\" and they\nall crowded 'round it, panting and asking, \"But who has won?\"\n\nThis question the Dodo could not answer without a great deal of thought.\nAt last it said, \"_Everybody_ has won, and _all_ must have prizes.\"\n\n\"But who is to give the prizes?\" quite a chorus of voices asked.\n\n\"Why, _she_, of course,\" said the Dodo, pointing to Alice with one\nfinger; and the whole party at once crowded 'round her, calling out, in\na confused way, \"Prizes! Prizes!\"\n\nAlice had no idea what to do, and in despair she put her hand into her\npocket and pulled out a box of comfits (luckily the salt-water had not\ngot into it) and handed them 'round as prizes. There was exactly one\na-piece, all 'round.\n\nThe next thing was to eat the comfits; this caused some noise and\nconfusion, as the large birds complained that they could not taste\ntheirs, and the small ones choked and had to be patted on the back.\nHowever, it was over at last and they sat down again in a ring and\nbegged the Mouse to tell them something more.\n\n\"You promised to tell me your history, you know,\" said Alice, \"and why\nit is you hate--C and D,\" she added in a whisper, half afraid that it\nwould be offended again.\n\n\"Mine is a long and a sad tale!\" said the Mouse, turning to Alice and\nsighing.\n\n\"It _is_ a long tail, certainly,\" said Alice, looking down with wonder\nat the Mouse's tail, \"but why do you call it sad?\" And she kept on\npuzzling about it while the Mouse was speaking, so that her idea of the\ntale was something like this:--\n\n    \"Fury said to\n      a mouse, That\n         he met in the\n            house, 'Let\n              us both go\n                to law: _I_\n                 will prosecute\n               _you_.--\n                 Come, I'll\n              take no denial:\n             We\n           must have\n        the trial;\n     For really\n    this morning\n    I've\n    nothing\n    to do.'\n    Said the\n     mouse to\n      the cur,\n       'Such a\n        trial, dear\n           sir, With\n               no jury\n                or judge,\n                   would\n                  be wasting\n                our\n             breath.'\n         'I'll be\n        judge,\n       I'll be\n      jury,'\n     said\n    cunning\n    old\n     Fury;\n      'I'll\n       try\n        the\n         whole\n          cause,\n          and\n         condemn\n       you to\n    death.'\"\n\n\"You are not attending!\" said the Mouse to Alice, severely. \"What are\nyou thinking of?\"\n\n\"I beg your pardon,\" said Alice very humbly, \"you had got to the fifth\nbend, I think?\"\n\n\"You insult me by talking such nonsense!\" said the Mouse, getting up and\nwalking away.\n\n\"Please come back and finish your story!\" Alice called after it. And the\nothers all joined in chorus, \"Yes, please do!\" But the Mouse only shook\nits head impatiently and walked a little quicker.\n\n\"I wish I had Dinah, our cat, here!\" said Alice. This caused a\nremarkable sensation among the party. Some of the birds hurried off at\nonce, and a Canary called out in a trembling voice, to its children,\n\"Come away, my dears! It's high time you were all in bed!\" On various\npretexts they all moved off and Alice was soon left alone.\n\n\"I wish I hadn't mentioned Dinah! Nobody seems to like her down here and\nI'm sure she's the best cat in the world!\" Poor Alice began to cry\nagain, for she felt very lonely and low-spirited. In a little while,\nhowever, she again heard a little pattering of footsteps in the distance\nand she looked up eagerly.\n\n[Illustration]\n\n[Illustration]\n\n\n\n\nIV--THE RABBIT SENDS IN A LITTLE BILL\n\n\nIt was the White Rabbit, trotting slowly back again and looking\nanxiously about as it went, as if it had lost something; Alice heard it\nmuttering to itself, \"The Duchess! The Duchess! Oh, my dear paws! Oh, my\nfur and whiskers! She'll get me executed, as sure as ferrets are\nferrets! Where _can_ I have dropped them, I wonder?\" Alice guessed in a\nmoment that it was looking for the fan and the pair of white kid-gloves\nand she very good-naturedly began hunting about for them, but they were\nnowhere to be seen--everything seemed to have changed since her swim in\nthe pool, and the great hall, with the glass table and the little door,\nhad vanished completely.\n\nVery soon the Rabbit noticed Alice, and called to her, in an angry tone,\n\"Why, Mary Ann, what _are_ you doing out here? Run home this moment and\nfetch me a pair of gloves and a fan! Quick, now!\"\n\n\"He took me for his housemaid!\" said Alice, as she ran off. \"How\nsurprised he'll be when he finds out who I am!\" As she said this, she\ncame upon a neat little house, on the door of which was a bright brass\nplate with the name \"W. RABBIT\" engraved upon it. She went in without\nknocking and hurried upstairs, in great fear lest she should meet the\nreal Mary Ann and be turned out of the house before she had found the\nfan and gloves.\n\nBy this time, Alice had found her way into a tidy little room with a\ntable in the window, and on it a fan and two or three pairs of tiny\nwhite kid-gloves; she took up the fan and a pair of the gloves and was\njust going to leave the room, when her eyes fell upon a little bottle\nthat stood near the looking-glass. She uncorked it and put it to her\nlips, saying to herself, \"I do hope it'll make me grow large again, for,\nreally, I'm quite tired of being such a tiny little thing!\"\n\nBefore she had drunk half the bottle, she found her head pressing\nagainst the ceiling, and had to stoop to save her neck from being\nbroken. She hastily put down the bottle, remarking, \"That's quite\nenough--I hope I sha'n't grow any more.\"\n\nAlas! It was too late to wish that! She went on growing and growing and\nvery soon she had to kneel down on the floor. Still she went on growing,\nand, as a last resource, she put one arm out of the window and one foot\nup the chimney, and said to herself, \"Now I can do no more, whatever\nhappens. What _will_ become of me?\"\n\n[Illustration]\n\nLuckily for Alice, the little magic bottle had now had its full effect\nand she grew no larger. After a few minutes she heard a voice outside\nand stopped to listen.\n\n\"Mary Ann! Mary Ann!\" said the voice. \"Fetch me my gloves this moment!\"\nThen came a little pattering of feet on the stairs. Alice knew it was\nthe Rabbit coming to look for her and she trembled till she shook the\nhouse, quite forgetting that she was now about a thousand times as large\nas the Rabbit and had no reason to be afraid of it.\n\nPresently the Rabbit came up to the door and tried to open it; but as\nthe door opened inwards and Alice's elbow was pressed hard against it,\nthat attempt proved a failure. Alice heard it say to itself, \"Then I'll\ngo 'round and get in at the window.\"\n\n\"_That_ you won't!\" thought Alice; and after waiting till she fancied\nshe heard the Rabbit just under the window, she suddenly spread out her\nhand and made a snatch in the air. She did not get hold of anything,\nbut she heard a little shriek and a fall and a crash of broken glass,\nfrom which she concluded that it was just possible it had fallen into a\ncucumber-frame or something of that sort.\n\nNext came an angry voice--the Rabbit's--\"Pat! Pat! Where are you?\" And\nthen a voice she had never heard before, \"Sure then, I'm here! Digging\nfor apples, yer honor!\"\n\n\"Here! Come and help me out of this! Now tell me, Pat, what's that in\nthe window?\"\n\n\"Sure, it's an arm, yer honor!\"\n\n\"Well, it's got no business there, at any rate; go and take it away!\"\n\nThere was a long silence after this and Alice could only hear whispers\nnow and then, and at last she spread out her hand again and made another\nsnatch in the air. This time there were _two_ little shrieks and more\nsounds of broken glass. \"I wonder what they'll do next!\" thought Alice.\n\"As for pulling me out of the window, I only wish they _could_!\"\n\nShe waited for some time without hearing anything more. At last came a\nrumbling of little cart-wheels and the sound of a good many voices all\ntalking together. She made out the words: \"Where's the other ladder?\nBill's got the other--Bill! Here, Bill! Will the roof bear?--Who's to go\ndown the chimney?--Nay, _I_ sha'n't! _You_ do it! Here, Bill! The master\nsays you've got to go down the chimney!\"\n\nAlice drew her foot as far down the chimney as she could and waited till\nshe heard a little animal scratching and scrambling about in the chimney\nclose above her; then she gave one sharp kick and waited to see what\nwould happen next.\n\nThe first thing she heard was a general chorus of \"There goes Bill!\"\nthen the Rabbit's voice alone--\"Catch him, you by the hedge!\" Then\nsilence and then another confusion of voices--\"Hold up his head--Brandy\nnow--Don't choke him--What happened to you?\"\n\nLast came a little feeble, squeaking voice, \"Well, I hardly know--No\nmore, thank ye. I'm better now--all I know is, something comes at me\nlike a Jack-in-the-box and up I goes like a sky-rocket!\"\n\nAfter a minute or two of silence, they began moving about again, and\nAlice heard the Rabbit say, \"A barrowful will do, to begin with.\"\n\n\"A barrowful of _what_?\" thought Alice. But she had not long to doubt,\nfor the next moment a shower of little pebbles came rattling in at the\nwindow and some of them hit her in the face. Alice noticed, with some\nsurprise, that the pebbles were all turning into little cakes as they\nlay on the floor and a bright idea came into her head. \"If I eat one of\nthese cakes,\" she thought, \"it's sure to make _some_ change in my size.\"\n\nSo she swallowed one of the cakes and was delighted to find that she\nbegan shrinking directly. As soon as she was small enough to get through\nthe door, she ran out of the house and found quite a crowd of little\nanimals and birds waiting outside. They all made a rush at Alice the\nmoment she appeared, but she ran off as hard as she could and soon found\nherself safe in a thick wood.\n\n[Illustration: \"The Duchess tucked her arm affectionately into\nAlice's.\"]\n\n\"The first thing I've got to do,\" said Alice to herself, as she\nwandered about in the wood, \"is to grow to my right size again; and the\nsecond thing is to find my way into that lovely garden. I suppose I\nought to eat or drink something or other, but the great question is\n'What?'\"\n\nAlice looked all around her at the flowers and the blades of grass, but\nshe could not see anything that looked like the right thing to eat or\ndrink under the circumstances. There was a large mushroom growing near\nher, about the same height as herself. She stretched herself up on\ntiptoe and peeped over the edge and her eyes immediately met those of a\nlarge blue caterpillar, that was sitting on the top, with its arms\nfolded, quietly smoking a long hookah and taking not the smallest notice\nof her or of anything else.\n\n[Illustration]\n\n\n\n\nV--ADVICE FROM A CATERPILLAR\n\n\nAt last the Caterpillar took the hookah out of its mouth and addressed\nAlice in a languid, sleepy voice.\n\n\"Who are _you_?\" said the Caterpillar.\n\n[Illustration]\n\nAlice replied, rather shyly, \"I--I hardly know, sir, just at present--at\nleast I know who I _was_ when I got up this morning, but I think I must\nhave changed several times since then.\"\n\n\"What do you mean by that?\" said the Caterpillar, sternly. \"Explain\nyourself!\"\n\n\"I can't explain _myself_, I'm afraid, sir,\" said Alice, \"because I'm\nnot myself, you see--being so many different sizes in a day is very\nconfusing.\" She drew herself up and said very gravely, \"I think you\nought to tell me who _you_ are, first.\"\n\n\"Why?\" said the Caterpillar.\n\nAs Alice could not think of any good reason and the Caterpillar seemed\nto be in a _very_ unpleasant state of mind, she turned away.\n\n\"Come back!\" the Caterpillar called after her. \"I've something important\nto say!\" Alice turned and came back again.\n\n\"Keep your temper,\" said the Caterpillar.\n\n\"Is that all?\" said Alice, swallowing down her anger as well as she\ncould.\n\n\"No,\" said the Caterpillar.\n\nIt unfolded its arms, took the hookah out of its mouth again, and said,\n\"So you think you're changed, do you?\"\n\n\"I'm afraid, I am, sir,\" said Alice. \"I can't remember things as I\nused--and I don't keep the same size for ten minutes together!\"\n\n\"What size do you want to be?\" asked the Caterpillar.\n\n\"Oh, I'm not particular as to size,\" Alice hastily replied, \"only one\ndoesn't like changing so often, you know. I should like to be a _little_\nlarger, sir, if you wouldn't mind,\" said Alice. \"Three inches is such a\nwretched height to be.\"\n\n\"It is a very good height indeed!\" said the Caterpillar angrily, rearing\nitself upright as it spoke (it was exactly three inches high).\n\nIn a minute or two, the Caterpillar got down off the mushroom and\ncrawled away into the grass, merely remarking, as it went, \"One side\nwill make you grow taller, and the other side will make you grow\nshorter.\"\n\n\"One side of _what_? The other side of _what_?\" thought Alice to\nherself.\n\n\"Of the mushroom,\" said the Caterpillar, just as if she had asked it\naloud; and in another moment, it was out of sight.\n\nAlice remained looking thoughtfully at the mushroom for a minute, trying\nto make out which were the two sides of it. At last she stretched her\narms 'round it as far as they would go, and broke off a bit of the edge\nwith each hand.\n\n\"And now which is which?\" she said to herself, and nibbled a little of\nthe right-hand bit to try the effect. The next moment she felt a violent\nblow underneath her chin--it had struck her foot!\n\nShe was a good deal frightened by this very sudden change, as she was\nshrinking rapidly; so she set to work at once to eat some of the other\nbit. Her chin was pressed so closely against her foot that there was\nhardly room to open her mouth; but she did it at last and managed to\nswallow a morsel of the left-hand bit....\n\n\"Come, my head's free at last!\" said Alice; but all she could see, when\nshe looked down, was an immense length of neck, which seemed to rise\nlike a stalk out of a sea of green leaves that lay far below her.\n\n\"Where _have_ my shoulders got to? And oh, my poor hands, how is it I\ncan't see you?\" She was delighted to find that her neck would bend\nabout easily in any direction, like a serpent. She had just succeeded in\ncurving it down into a graceful zigzag and was going to dive in among\nthe leaves, when a sharp hiss made her draw back in a hurry--a large\npigeon had flown into her face and was beating her violently with its\nwings.\n\n[Illustration]\n\n\"Serpent!\" cried the Pigeon.\n\n\"I'm _not_ a serpent!\" said Alice indignantly. \"Let me alone!\"\n\n\"I've tried the roots of trees, and I've tried banks, and I've tried\nhedges,\" the Pigeon went on, \"but those serpents! There's no pleasing\nthem!\"\n\nAlice was more and more puzzled.\n\n\"As if it wasn't trouble enough hatching the eggs,\" said the Pigeon,\n\"but I must be on the look-out for serpents, night and day! And just as\nI'd taken the highest tree in the wood,\" continued the Pigeon, raising\nits voice to a shriek, \"and just as I was thinking I should be free of\nthem at last, they must needs come wriggling down from the sky! Ugh,\nSerpent!\"\n\n\"But I'm _not_ a serpent, I tell you!\" said Alice. \"I'm a--I'm a--I'm a\nlittle girl,\" she added rather doubtfully, as she remembered the number\nof changes she had gone through that day.\n\n\"You're looking for eggs, I know _that_ well enough,\" said the Pigeon;\n\"and what does it matter to me whether you're a little girl or a\nserpent?\"\n\n\"It matters a good deal to _me_,\" said Alice hastily; \"but I'm not\nlooking for eggs, as it happens, and if I was, I shouldn't want\n_yours_--I don't like them raw.\"\n\n\"Well, be off, then!\" said the Pigeon in a sulky tone, as it settled\ndown again into its nest. Alice crouched down among the trees as well as\nshe could, for her neck kept getting entangled among the branches, and\nevery now and then she had to stop and untwist it. After awhile she\nremembered that she still held the pieces of mushroom in her hands, and\nshe set to work very carefully, nibbling first at one and then at the\nother, and growing sometimes taller and sometimes shorter, until she had\nsucceeded in bringing herself down to her usual height.\n\nIt was so long since she had been anything near the right size that it\nfelt quite strange at first. \"The next thing is to get into that\nbeautiful garden--how _is_ that to be done, I wonder?\" As she said this,\nshe came suddenly upon an open place, with a little house in it about\nfour feet high. \"Whoever lives there,\" thought Alice, \"it'll never do to\ncome upon them _this_ size; why, I should frighten them out of their\nwits!\" She did not venture to go near the house till she had brought\nherself down to nine inches high.\n\n\n\n\nVI--PIG AND PEPPER\n\n\nFor a minute or two she stood looking at the house, when suddenly a\nfootman in livery came running out of the wood (judging by his face\nonly, she would have called him a fish)--and rapped loudly at the door\nwith his knuckles. It was opened by another footman in livery, with a\nround face and large eyes like a frog.\n\n[Illustration]\n\nThe Fish-Footman began by producing from under his arm a great letter,\nand this he handed over to the other, saying, in a solemn tone, \"For the\nDuchess. An invitation from the Queen to play croquet.\" The\nFrog-Footman repeated, in the same solemn tone, \"From the Queen. An\ninvitation for the Duchess to play croquet.\" Then they both bowed low\nand their curls got entangled together.\n\nWhen Alice next peeped out, the Fish-Footman was gone, and the other was\nsitting on the ground near the door, staring stupidly up into the sky.\nAlice went timidly up to the door and knocked.\n\n\"There's no sort of use in knocking,\" said the Footman, \"and that for\ntwo reasons. First, because I'm on the same side of the door as you are;\nsecondly, because they're making such a noise inside, no one could\npossibly hear you.\" And certainly there _was_ a most extraordinary noise\ngoing on within--a constant howling and sneezing, and every now and then\na great crash, as if a dish or kettle had been broken to pieces.\n\n\"How am I to get in?\" asked Alice.\n\n\"_Are_ you to get in at all?\" said the Footman. \"That's the first\nquestion, you know.\"\n\nAlice opened the door and went in. The door led right into a large\nkitchen, which was full of smoke from one end to the other; the Duchess\nwas sitting on a three-legged stool in the middle, nursing a baby; the\ncook was leaning over the fire, stirring a large caldron which seemed to\nbe full of soup.\n\n\"There's certainly too much pepper in that soup!\" Alice said to herself,\nas well as she could for sneezing. Even the Duchess sneezed\noccasionally; and as for the baby, it was sneezing and howling\nalternately without a moment's pause. The only two creatures in the\nkitchen that did _not_ sneeze were the cook and a large cat, which was\ngrinning from ear to ear.\n\n\"Please would you tell me,\" said Alice, a little timidly, \"why your cat\ngrins like that?\"\n\n\"It's a Cheshire-Cat,\" said the Duchess, \"and that's why.\"\n\n\"I didn't know that Cheshire-Cats always grinned; in fact, I didn't know\nthat cats _could_ grin,\" said Alice.\n\n\"You don't know much,\" said the Duchess, \"and that's a fact.\"\n\nJust then the cook took the caldron of soup off the fire, and at once\nset to work throwing everything within her reach at the Duchess and the\nbaby--the fire-irons came first; then followed a shower of saucepans,\nplates and dishes. The Duchess took no notice of them, even when they\nhit her, and the baby was howling so much already that it was quite\nimpossible to say whether the blows hurt it or not.\n\n\"Oh, _please_ mind what you're doing!\" cried Alice, jumping up and down\nin an agony of terror.\n\n\"Here! You may nurse it a bit, if you like!\" the Duchess said to Alice,\nflinging the baby at her as she spoke. \"I must go and get ready to play\ncroquet with the Queen,\" and she hurried out of the room.\n\nAlice caught the baby with some difficulty, as it was a queer-shaped\nlittle creature and held out its arms and legs in all directions. \"If I\ndon't take this child away with me,\" thought Alice, \"they're sure to\nkill it in a day or two. Wouldn't it be murder to leave it behind?\" She\nsaid the last words out loud and the little thing grunted in reply.\n\n\"If you're going to turn into a pig, my dear,\" said Alice, \"I'll have\nnothing more to do with you. Mind now!\"\n\nAlice was just beginning to think to herself, \"Now, what am I to do with\nthis creature, when I get it home?\" when it grunted again so violently\nthat Alice looked down into its face in some alarm. This time there\ncould be _no_ mistake about it--it was neither more nor less than a pig;\nso she set the little creature down and felt quite relieved to see it\ntrot away quietly into the wood.\n\nAlice was a little startled by seeing the Cheshire-Cat sitting on a\nbough of a tree a few yards off. The Cat only grinned when it saw her.\n\"Cheshire-Puss,\" began Alice, rather timidly, \"would you please tell me\nwhich way I ought to go from here?\"\n\n\"In _that_ direction,\" the Cat said, waving the right paw 'round, \"lives\na Hatter; and in _that_ direction,\" waving the other paw, \"lives a March\nHare. Visit either you like; they're both mad.\"\n\n\"But I don't want to go among mad people,\" Alice remarked.\n\n\"Oh, you can't help that,\" said the Cat; \"we're all mad here. Do you\nplay croquet with the Queen to-day?\"\n\n\"I should like it very much,\" said Alice, \"but I haven't been invited\nyet.\"\n\n\"You'll see me there,\" said the Cat, and vanished.\n\nAlice had not gone much farther before she came in sight of the house of\nthe March Hare; it was so large a house that she did not like to go near\ntill she had nibbled some more of the left-hand bit of mushroom.\n\n\n\n\nVII--A MAD TEA-PARTY\n\n\nThere was a table set out under a tree in front of the house, and the\nMarch Hare and the Hatter were having tea at it; a Dormouse was sitting\nbetween them, fast asleep.\n\nThe table was a large one, but the three were all crowded together at\none corner of it. \"No room! No room!\" they cried out when they saw Alice\ncoming. \"There's _plenty_ of room!\" said Alice indignantly, and she sat\ndown in a large arm-chair at one end of the table.\n\nThe Hatter opened his eyes very wide on hearing this, but all he said\nwas \"Why is a raven like a writing-desk?\"\n\n\"I'm glad they've begun asking riddles--I believe I can guess that,\" she\nadded aloud.\n\n\"Do you mean that you think you can find out the answer to it?\" said the\nMarch Hare.\n\n\"Exactly so,\" said Alice.\n\n\"Then you should say what you mean,\" the March Hare went on.\n\n\"I do,\" Alice hastily replied; \"at least--at least I mean what I\nsay--that's the same thing, you know.\"\n\n\"You might just as well say,\" added the Dormouse, which seemed to be\ntalking in its sleep, \"that 'I breathe when I sleep' is the same thing\nas 'I sleep when I breathe!'\"\n\n\"It _is_ the same thing with you,\" said the Hatter, and he poured a\nlittle hot tea upon its nose. The Dormouse shook its head impatiently\nand said, without opening its eyes, \"Of course, of course; just what I\nwas going to remark myself.\"\n\n[Illustration]\n\n\"Have you guessed the riddle yet?\" the Hatter said, turning to Alice\nagain.\n\n\"No, I give it up,\" Alice replied. \"What's the answer?\"\n\n\"I haven't the slightest idea,\" said the Hatter.\n\n\"Nor I,\" said the March Hare.\n\nAlice gave a weary sigh. \"I think you might do something better with the\ntime,\" she said, \"than wasting it in asking riddles that have no\nanswers.\"\n\n\"Take some more tea,\" the March Hare said to Alice, very earnestly.\n\n\"I've had nothing yet,\" Alice replied in an offended tone, \"so I can't\ntake more.\"\n\n\"You mean you can't take _less_,\" said the Hatter; \"it's very easy to\ntake _more_ than nothing.\"\n\nAt this, Alice got up and walked off. The Dormouse fell asleep instantly\nand neither of the others took the least notice of her going, though she\nlooked back once or twice; the last time she saw them, they were\ntrying to put the Dormouse into the tea-pot.\n\n[Illustration: The Trial of the Knave of Hearts.]\n\n\"At any rate, I'll never go _there_ again!\" said Alice, as she picked\nher way through the wood. \"It's the stupidest tea-party I ever was at in\nall my life!\" Just as she said this, she noticed that one of the trees\nhad a door leading right into it. \"That's very curious!\" she thought. \"I\nthink I may as well go in at once.\" And in she went.\n\nOnce more she found herself in the long hall and close to the little\nglass table. Taking the little golden key, she unlocked the door that\nled into the garden. Then she set to work nibbling at the mushroom (she\nhad kept a piece of it in her pocket) till she was about a foot high;\nthen she walked down the little passage; and _then_--she found herself\nat last in the beautiful garden, among the bright flower-beds and the\ncool fountains.\n\n\n\n\nVIII--THE QUEEN'S CROQUET GROUND\n\n\nA large rose-tree stood near the entrance of the garden; the roses\ngrowing on it were white, but there were three gardeners at it, busily\npainting them red. Suddenly their eyes chanced to fall upon Alice, as\nshe stood watching them. \"Would you tell me, please,\" said Alice, a\nlittle timidly, \"why you are painting those roses?\"\n\nFive and Seven said nothing, but looked at Two. Two began, in a low\nvoice, \"Why, the fact is, you see, Miss, this here ought to have been a\n_red_ rose-tree, and we put a white one in by mistake; and, if the Queen\nwas to find it out, we should all have our heads cut off, you know. So\nyou see, Miss, we're doing our best, afore she comes, to--\" At this\nmoment, Five, who had been anxiously looking across the garden, called\nout, \"The Queen! The Queen!\" and the three gardeners instantly threw\nthemselves flat upon their faces. There was a sound of many footsteps\nand Alice looked 'round, eager to see the Queen.\n\nFirst came ten soldiers carrying clubs, with their hands and feet at the\ncorners: next the ten courtiers; these were ornamented all over with\ndiamonds. After these came the royal children; there were ten of them,\nall ornamented with hearts. Next came the guests, mostly Kings and\nQueens, and among them Alice recognized the White Rabbit. Then followed\nthe Knave of Hearts, carrying the King's crown on a crimson velvet\ncushion; and last of all this grand procession came THE KING AND THE\nQUEEN OF HEARTS.\n\nWhen the procession came opposite to Alice, they all stopped and looked\nat her, and the Queen said severely, \"Who is this?\" She said it to the\nKnave of Hearts, who only bowed and smiled in reply.\n\n\"My name is Alice, so please Your Majesty,\" said Alice very politely;\nbut she added to herself, \"Why, they're only a pack of cards, after\nall!\"\n\n\"Can you play croquet?\" shouted the Queen. The question was evidently\nmeant for Alice.\n\n\"Yes!\" said Alice loudly.\n\n\"Come on, then!\" roared the Queen.\n\n\"It's--it's a very fine day!\" said a timid voice to Alice. She was\nwalking by the White Rabbit, who was peeping anxiously into her face.\n\n\"Very,\" said Alice. \"Where's the Duchess?\"\n\n\"Hush! Hush!\" said the Rabbit. \"She's under sentence of execution.\"\n\n\"What for?\" said Alice.\n\n\"She boxed the Queen's ears--\" the Rabbit began.\n\n\"Get to your places!\" shouted the Queen in a voice of thunder, and\npeople began running about in all directions, tumbling up against each\nother. However, they got settled down in a minute or two, and the game\nbegan.\n\nAlice thought she had never seen such a curious croquet-ground in her\nlife; it was all ridges and furrows. The croquet balls were live\nhedgehogs, and the mallets live flamingos and the soldiers had to double\nthemselves up and stand on their hands and feet, to make the arches.\n\nThe players all played at once, without waiting for turns, quarrelling\nall the while and fighting for the hedgehogs; and in a very short time,\nthe Queen was in a furious passion and went stamping about and shouting,\n\"Off with his head!\" or \"Off with her head!\" about once in a minute.\n\n\"They're dreadfully fond of beheading people here,\" thought Alice; \"the\ngreat wonder is that there's anyone left alive!\"\n\nShe was looking about for some way of escape, when she noticed a curious\nappearance in the air. \"It's the Cheshire-Cat,\" she said to herself;\n\"now I shall have somebody to talk to.\"\n\n\"How are you getting on?\" said the Cat.\n\n\"I don't think they play at all fairly,\" Alice said, in a rather\ncomplaining tone; \"and they all quarrel so dreadfully one can't hear\noneself speak--and they don't seem to have any rules in particular.\"\n\n\"How do you like the Queen?\" said the Cat in a low voice.\n\n\"Not at all,\" said Alice.\n\n[Illustration]\n\nAlice thought she might as well go back and see how the game was going\non. So she went off in search of her hedgehog. The hedgehog was engaged\nin a fight with another hedgehog, which seemed to Alice an excellent\nopportunity for croqueting one of them with the other; the only\ndifficulty was that her flamingo was gone across to the other side of\nthe garden, where Alice could see it trying, in a helpless sort of way,\nto fly up into a tree. She caught the flamingo and tucked it away under\nher arm, that it might not escape again.\n\nJust then Alice ran across the Duchess (who was now out of prison). She\ntucked her arm affectionately into Alice's and they walked off together.\nAlice was very glad to find her in such a pleasant temper. She was a\nlittle startled, however, when she heard the voice of the Duchess close\nto her ear. \"You're thinking about something, my dear, and that makes\nyou forget to talk.\"\n\n\"The game's going on rather better now,\" Alice said, by way of keeping\nup the conversation a little.\n\n\"'Tis so,\" said the Duchess; \"and the moral of that is--'Oh, 'tis love,\n'tis love that makes the world go 'round!'\"\n\n\"Somebody said,\" Alice whispered, \"that it's done by everybody minding\nhis own business!\"\n\n\"Ah, well! It means much the same thing,\" said the Duchess, digging her\nsharp little chin into Alice's shoulder, as she added \"and the moral of\n_that_ is--'Take care of the sense and the sounds will take care of\nthemselves.'\"\n\nTo Alice's great surprise, the Duchess's arm that was linked into hers\nbegan to tremble. Alice looked up and there stood the Queen in front of\nthem, with her arms folded, frowning like a thunderstorm!\n\n\"Now, I give you fair warning,\" shouted the Queen, stamping on the\nground as she spoke, \"either you or your head must be off, and that in\nabout half no time. Take your choice!\" The Duchess took her choice, and\nwas gone in a moment.\n\n\"Let's go on with the game,\" the Queen said to Alice; and Alice was too\nmuch frightened to say a word, but slowly followed her back to the\ncroquet-ground.\n\nAll the time they were playing, the Queen never left off quarreling with\nthe other players and shouting, \"Off with his head!\" or \"Off with her\nhead!\" By the end of half an hour or so, all the players, except the\nKing, the Queen and Alice, were in custody of the soldiers and under\nsentence of execution.\n\nThen the Queen left off, quite out of breath, and walked away with\nAlice.\n\nAlice heard the King say in a low voice to the company generally, \"You\nare all pardoned.\"\n\nSuddenly the cry \"The Trial's beginning!\" was heard in the distance, and\nAlice ran along with the others.\n\n\n\n\nIX--WHO STOLE THE TARTS?\n\n\nThe King and Queen of Hearts were seated on their throne when they\narrived, with a great crowd assembled about them--all sorts of little\nbirds and beasts, as well as the whole pack of cards: the Knave was\nstanding before them, in chains, with a soldier on each side to guard\nhim; and near the King was the White Rabbit, with a trumpet in one hand\nand a scroll of parchment in the other. In the very middle of the court\nwas a table, with a large dish of tarts upon it. \"I wish they'd get the\ntrial done,\" Alice thought, \"and hand 'round the refreshments!\"\n\nThe judge, by the way, was the King and he wore his crown over his great\nwig. \"That's the jury-box,\" thought Alice; \"and those twelve creatures\n(some were animals and some were birds) I suppose they are the jurors.\"\n\nJust then the White Rabbit cried out \"Silence in the court!\"\n\n\"Herald, read the accusation!\" said the King.\n\n[Illustration]\n\nOn this, the White Rabbit blew three blasts on the trumpet, then\nunrolled the parchment-scroll and read as follows:\n\n    \"The Queen of Hearts, she made some tarts,\n      All on a summer day;\n    The Knave of Hearts, he stole those tarts\n      And took them quite away!\"\n\n\"Call the first witness,\" said the King; and the White Rabbit blew three\nblasts on the trumpet and called out, \"First witness!\"\n\nThe first witness was the Hatter. He came in with a teacup in one hand\nand a piece of bread and butter in the other.\n\n\"You ought to have finished,\" said the King. \"When did you begin?\"\n\nThe Hatter looked at the March Hare, who had followed him into the\ncourt, arm in arm with the Dormouse. \"Fourteenth of March, I _think_ it\nwas,\" he said.\n\n\"Give your evidence,\" said the King, \"and don't be nervous, or I'll have\nyou executed on the spot.\"\n\nThis did not seem to encourage the witness at all; he kept shifting from\none foot to the other, looking uneasily at the Queen, and, in his\nconfusion, he bit a large piece out of his teacup instead of the bread\nand butter.\n\nJust at this moment Alice felt a very curious sensation--she was\nbeginning to grow larger again.\n\nThe miserable Hatter dropped his teacup and bread and butter and went\ndown on one knee. \"I'm a poor man, Your Majesty,\" he began.\n\n\"You're a _very_ poor _speaker_,\" said the King.\n\n\"You may go,\" said the King, and the Hatter hurriedly left the court.\n\n\"Call the next witness!\" said the King.\n\nThe next witness was the Duchess's cook. She carried the pepper-box in\nher hand and the people near the door began sneezing all at once.\n\n\"Give your evidence,\" said the King.\n\n\"Sha'n't,\" said the cook.\n\nThe King looked anxiously at the White Rabbit, who said, in a low voice,\n\"Your Majesty must cross-examine _this_ witness.\"\n\n\"Well, if I must, I must,\" the King said. \"What are tarts made of?\"\n\n\"Pepper, mostly,\" said the cook.\n\nFor some minutes the whole court was in confusion and by the time they\nhad settled down again, the cook had disappeared.\n\n\"Never mind!\" said the King, \"call the next witness.\"\n\nAlice watched the White Rabbit as he fumbled over the list. Imagine her\nsurprise when he read out, at the top of his shrill little voice, the\nname \"Alice!\"\n\n\n\n\nX--ALICE'S EVIDENCE\n\n\n\"Here!\" cried Alice. She jumped up in such a hurry that she tipped over\nthe jury-box, upsetting all the jurymen on to the heads of the crowd\nbelow.\n\n\"Oh, I _beg_ your pardon!\" she exclaimed in a tone of great dismay.\n\n\"The trial cannot proceed,\" said the King, \"until all the jurymen are\nback in their proper places--_all_,\" he repeated with great emphasis,\nlooking hard at Alice.\n\n\"What do you know about this business?\" the King said to Alice.\n\n\"Nothing whatever,\" said Alice.\n\nThe King then read from his book: \"Rule forty-two. _All persons more\nthan a mile high to leave the court_.\"\n\n\"_I'm_ not a mile high,\" said Alice.\n\n\"Nearly two miles high,\" said the Queen.\n\n[Illustration]\n\n\"Well, I sha'n't go, at any rate,\" said Alice.\n\nThe King turned pale and shut his note-book hastily. \"Consider your\nverdict,\" he said to the jury, in a low, trembling voice.\n\n\"There's more evidence to come yet, please Your Majesty,\" said the White\nRabbit, jumping up in a great hurry. \"This paper has just been picked\nup. It seems to be a letter written by the prisoner to--to somebody.\" He\nunfolded the paper as he spoke and added, \"It isn't a letter, after all;\nit's a set of verses.\"\n\n\"Please, Your Majesty,\" said the Knave, \"I didn't write it and they\ncan't prove that I did; there's no name signed at the end.\"\n\n\"You _must_ have meant some mischief, or else you'd have signed your\nname like an honest man,\" said the King. There was a general clapping of\nhands at this.\n\n\"Read them,\" he added, turning to the White Rabbit.\n\nThere was dead silence in the court whilst the White Rabbit read out the\nverses.\n\n\"That's the most important piece of evidence we've heard yet,\" said the\nKing.\n\n\"_I_ don't believe there's an atom of meaning in it,\" ventured Alice.\n\n\"If there's no meaning in it,\" said the King, \"that saves a world of\ntrouble, you know, as we needn't try to find any. Let the jury consider\ntheir verdict.\"\n\n\"No, no!\" said the Queen. \"Sentence first--verdict afterwards.\"\n\n\"Stuff and nonsense!\" said Alice loudly. \"The idea of having the\nsentence first!\"\n\n\"Hold your tongue!\" said the Queen, turning purple.\n\n\"I won't!\" said Alice.\n\n\"Off with her head!\" the Queen shouted at the top of her voice. Nobody\nmoved.\n\n\"Who cares for _you_?\" said Alice (she had grown to her full size by\nthis time). \"You're nothing but a pack of cards!\"\n\n[Illustration]\n\nAt this, the whole pack rose up in the air and came flying down upon\nher; she gave a little scream, half of fright and half of anger, and\ntried to beat them off, and found herself lying on the bank, with her\nhead in the lap of her sister, who was gently brushing away some dead\nleaves that had fluttered down from the trees upon her face.\n\n\"Wake up, Alice dear!\" said her sister. \"Why, what a long sleep you've\nhad!\"\n\n\"Oh, I've had such a curious dream!\" said Alice. And she told her\nsister, as well as she could remember them, all these strange adventures\nof hers that you have just been reading about. Alice got up and ran off,\nthinking while she ran, as well she might, what a wonderful dream it had\nbeen.\n\n[Illustration]\n"
  },
  {
    "path": "packages/backend-api/data/brexit.csv",
    "content": "Brexit,score\r\nAnti democratic assholes,0.95\r\nIgnorant and stupid,0.93\r\nThey are stupid and ignorant with no class,0.91\r\nIt's stupid and wrong,0.89\r\nMorons,0.86\r\nidiots. backward thinking people. nationalists. not accepting facts. susceptible to lies.,0.80\r\nIt's rubbish,0.69\r\nThey are ignorant,0.63\r\nFools,0.62\r\nDaft buggers,0.50\r\nHate it. Didn't vote for it.,0.49\r\nI think they are wrong to think that Britain cant survive without the EU. I think they are cowards.,0.48\r\nDreadful: I'm a Remainer,0.41\r\n\"Disagreed, especially because of the amount of xenophobia and racism\",0.38\r\nleft wing wimps,0.38\r\nIt is a horrible decision that will affect generations negatively across Europe.,0.28\r\n\"I'm broadly in agreement with them, I think they're naive about power sharing in Europe. I resent the idea that pro- Brexiters are racist or anti-immigration\",0.24\r\nThis country is a mess we need to do something to change it. They can think what they like but we voted to leave so man up and deal with it.,0.23\r\n\"They are entitled to their opinions by and large, but I am uneasy and disagree with xenophobic, racist or uninformed views.\",0.21\r\nThey look at life through rose coloured glasses. They lack a sense of reality and grounded attitude. Lack vision and understanding the big picture.,0.21\r\nAmbivalent. On one hand huge europhile. On other hand I think EU has lost its way,0.20\r\n\"People can believe what they want to believe, however I do not appreciate being called a racist for voting leave. There are other reasons people voted leave.\",0.19\r\nI voted Brexit as a protest vote. I didn't think 'we' would win because the Remainers were too confident/bone idle. The resulting displays of xenophobia and race hate appal me as I am the daughter of an economic migrant myself.,0.16\r\nI feel sad that people are so disenfranchised that they feel this decision was their only option,0.13\r\nI am against Brexit and think it is divisive. I was shocked and upset at the result,0.13\r\n\"I have changed my own personal opinion, I voted remain and was initially angry about the decision and thought it was made of ignorance , the behaviour of other remain voters since has changed my mind\",0.13\r\nI am devastated and feel like we are in the brink of disaster,0.12\r\n\"Democracy, people have spoken. People want jobs and we're fed up of money being sent to EU that could fund NHS and education etc\",0.11\r\nWill be bad for the country. The economy will suffer. The leave campaign made clearly misleading claims,0.11\r\nThe people have spoken and the government have a mandate to trigger article 50. We are strong enough to make this work in the countries and British people's I retest.,0.09\r\nI voted for it. Hope we get it. I like a change and a challenge and think something needed to be changed. Break eggs to make an omelette and all that!,0.09\r\n\"I welcome other people's viewpoints. I don't think anyone really knows if Brexit will be a good of bad thing, all we have are opinions, so no point judging others for theirs! I don't believe Brexit is a moral issue so no point in judging!\",0.08\r\nSadly a result of abandonment by politicians of the people they are meant to serveThe majority decided and that should be honoured.,0.06\r\n\"It's going to be interesting but probably not all bad. People have been so polarized that we miss discussing some key points that are obviously a concern for many people i.e. immigration = racism, but that means there is no debate about what people could be worried about, like finite resource issues such as schools or the NHS. That's not helpful in being able to understand the opposing point of view.\",0.06\r\n\"Ambivalent at the moment. Its a hot topic which , naturally, generates a lot of unsubstantiated \"\"hysteria\"\" in my book. However, I'm taking it all in and will possibly have an opinion when the seas have calmed somewhat.\",0.06\r\n\"It is a pity that the country is divided. There are good and bad aspects of the EU. Overall, the EC, EEC before it morphed into the EU was ok. It was reasonable. The EU is currently not reasonable in its outlook and decision making. The accountability of the EU is deplorable. It will fail. However, there is hope - maybe a reasonable, evolved European co-operative structure will develop. I hope that the people who disagree with Brexit think. Be critical of the EU. Britain and her citizens deserve the best - the EU is not that going to give Britain and the European nations.\",0.06\r\nI am happy with the democratic decision. I was unhappy with the UK following rules dictated by other countries' MEPs I had no part in electing.,0.06\r\n\"Disappointed in the vote, and very concerned about what this means for European unity in the future, but understand why voters chose Brexit and respect the vote of the majority. Nevertheless, there was far too much disinformation circulating and obtuse campaigning by both Remain and Brexit supporters to make a reasoned, well informed decision.\",0.05\r\n\"I want our parliament to be sovereign, I don't trust other nations to have our best interests at heart\",0.04\r\n\"I did not want to leave the EU, and find the prospect of Brexit quite scary in relation to security, our position in the world, our relationship with Ireland and with trade.\",0.04\r\nIt's a bad idea. We should have stayed in the EU. We are better protected in a European group. We help other countries and be helped by them. Staying in Europe would have been better for our future generations,0.04\r\nI wonder whether they fully appreciate what we gain from membership. They seem to be worried about over regulation and immigration.,0.03\r\nThey think it is cut and dried positive and they don't seem to understand the complexities and potential impacts,0.03\r\nIt ain't broke let's not fix it,0.03\r\n\"That they are entitled to their opinion, as I am to mine, but we might not be in such disagreement if there had been more reliable information disseminated, more rational discussion about the pros and cons of Brexit vs Remain and less manipulation by politicians.\",0.03\r\nThey are only thinking of their own benefit.,0.03\r\n\"I thought it was a good idea, but like everything else in politics, all is not what it seems and it won't be what the public thought it would be.\",0.03\r\nIt can be a positive thing for the country. A decision has been made. People have to deal with it and move on.,0.02\r\n\"I am pleased about it, being a Brit. I believe all nations should have complete autonomy. within reason.\",0.02\r\nWhether I agree or not democracy has spoken so as a country we have to do whatever is best.,0.02\r\nI voted to remain as I think that we are stronger in Europe and I don't think that the Government has the ability to negotiate a good deal for us. I also enjoy the multicultural society in London and think that we should welcome immigrants.,0.02\r\nOh dear what have we done,0.02\r\n\"I don't want Britain to leave the EU, and I recognise that we can learn from our neighbours. The European parliament clearly has issues with corporate governance, but I think it would have been better to try to address these than to start the exit process. However, what's done is done, what will be will be, and I hope things turn out well.\",0.02\r\nIt was really hard to find honest information to make a decision. I'm not surprised people came to a different conclusion.,0.02\r\nPeople didn't fully appreciate what they were voting for and the outcome.,0.02\r\nWe should remain in the EU and reform from within.,0.02\r\nI voted remain,0.02\r\n\"I disagree with the vote and the sentiment, but believe it presents an opportunity for the UK.\",0.01\r\nI don't think it is a good idea I like being part of the EU and I think we're stronger as a whole.,0.01\r\n\"It will be good for the UK overall, although it might cause financial concern in the short term\",0.01\r\nI think that the decision has been made and we should unite to make it happen. However I am worried for the future and uncertainty.,0.01"
  },
  {
    "path": "packages/backend-api/data/climate.csv",
    "content": "Climate change,Score\r\nThey have their heads up their ass.,0.93\r\nHow can you be so stupid?,0.91\r\nThey are liberal idiots who are uneducated.,0.90\r\n\"They're stupid, it's getting warmer, we should enjoy it while it lasts.\",0.86\r\nClimate change is happening and it's not changing in our favor. If you think differently you're an idiot.,0.84\r\nI think those people are stupid and short-sighted,0.84\r\n\"They're allowed to do that. But if they act like assholes about, I will block them.\",0.78\r\nuneducated bumpkins or willfully ignorant with vested interests,0.63\r\nI think its a farce and stinks like a bathroom after 26 beers,0.63\r\nFools,0.62\r\nMy thoughts are that people should stop being stupid and ignorant. Climate change is scientifically proven. It isn't a debate.,0.59\r\nThey are uninformed or ignorant,0.56\r\nYou either trust in God or think you are smarter than him.,0.42\r\n\"Their opinion, just don't force it down my throat\",0.41\r\nOver-hyped nonsense.,0.36\r\nI respect them but I believe they think I am stupid and only thinking short-term. I believe we don't know what will happen long-term regardless of supporting regulation regarding climate change. Regulation impedes industry and job creation.,0.33\r\n\"It's real, scary, obvious. Humans need to focus and stop destroying habitats, stop developing erosion because they want to live on a mountain/hill, stop disposing of trash in ways unsafe for the environment. Stop the greed, honor other forms of life.\",0.28\r\nI don't care. They are usually democrats.,0.28\r\nThe climate is always changing. I think the modern concept of climate change is ridiculous. The world when end when God deems it.,0.26\r\nCrooked science. There is no consensus.,0.25\r\n\"It's OK to have a different opinion, however, ignoring factual scientific data is not. People who deny such data are being foolish.\",0.25\r\nThey are ill informed or misinformed. Or...UNINFORMED.,0.20\r\nThey are intolerant so I try to avoid the subject I don't want to get screamed at,0.20\r\n\"Poorly educated, ultimately not their fault. I blame the American educational system\",0.18\r\n\"They are blatantly ignoring a fact that 98% of scientists agree are real. It is okay to have your own opinion about how we should deal with climate change, but no action at all is selfish because we are destroying the world for future generations.\",0.16\r\n\"I think there is a man-made component to climate change, but am afraid the government remedies are potentially far-worse than the disease.\",0.10\r\nDoesn't bother me at all. They can believe what they want.,0.09\r\nIf we have data to back it up then its definitely going to hurt us if we don't change our ways!,0.07\r\nThey need to do more research before they jump to any conclusions,0.06\r\n\"I listen to their opinions, but do not understand how they can disagree with irrefutable, scientific facts.\",0.06\r\nI think it's a divisive topic. The degree to which we can fix any change is debatable as well as how much is natural vs. man made.,0.03\r\nClimate change is occurring but humans have little impact if any for its cause.,0.02\r\n\"I believe that we are contributing to an already existing 'condition' that occurs, naturally, over the millennium.\",0.02\r\nI think the earth goes through cycles and we're in a warmer cycle,0.02\r\nI think that there is not much that we can do and that we play a very small role in the overall changing of our planets climate,0.02\r\n\"I think it exists. But, I am not big on regulations regarding climate change. The EPA regulates way too much.\",0.02\r\n\"Clearly man made, but unsure of its extent and whether anything substantial can be done about it\",0.01\r\n\"I think that climate change is real and has happened for millions of years. Whether or not humans are altering it is another matter entirely, but I strongly suspect that we are.\",0.01\r\nHaven't seen unbiased data,0.01\r\n\"I do not have a good understanding of climate change to have any serious thoughts, however, I try to minimize my own carbon footprint in hopes to help this earth even if it is slight.\",0.01\r\nIt's a natural phenomenon.,0.01\r\n\"Our government needs to do everything it can to combat the causes and effects of climate change. In addition, it is our responsibility as citizens of the world to reduce our use of resources and preserve our environment.\",0.01\r\nClimate changes naturally.,0.01\r\nI recognize there is changes in our climate and feel that we need to do what we can to prevent any further decline. I try to do all that is in my power to change my effect on the climate change. I support what I can to encourage companies to change their effects on our environment.,0.01\r\nEveryone can form opinions as they wish. I wish we all received the same information.,0.01\r\n\"Climate change is real and happening...I think humans have definitely helped it happen faster, but I do believe it would have started to make a change in some way.\",0.01\r\n\"Climate change is real. The reasons for certain phenomena are not well understood. There is plenty of research yet to be done, and a great deal more to be understood.\",0.01\r\nWe're in a 500+ year trend,0.01\r\n\"It's the best it makes me warm when it should be cold. Thanks, global warming.\",0.01"
  },
  {
    "path": "packages/backend-api/data/election.csv",
    "content": "US Election,score\r\nIf they voted for Hilary they are idiots,0.90\r\nIt was awful. People are stupid.,0.90\r\nScrew you trump supporters,0.89\r\nI respect it but they are stupid,0.89\r\n\"Liberals are idiots. Why in the hell would u vote for a person like Hillary Clinton. She believes in stealing, she's dishonest and believes it's okay for an abortion to be done at 9 months. She's awful.\",0.87\r\nDonald trump is the worst person to be president. This election really showed how stupid the U.S. is.,0.85\r\nAnyone who voted for Trump is a moron.,0.81\r\n\"It was a terrible. Both sides suck, but Trump REALLY is scary\",0.72\r\nIt is a shame that Donald Trump was elected. You can never underestimate the stupidity of middle America.,0.67\r\n\"Good job voting for a racist, sexist guy who's putting white supremacists in the white house. Have fun.\",0.67\r\n\"You are ignorant or do not care about the rights of minority populations, women, and non-cis Americans.\",0.59\r\n\"Horrible, but the lesser of two evils won.\",0.45\r\nThe democrats screw themselves up. The liberals live in bubbles and they deserve the loss.,0.43\r\n\"Please put yourself in the shoes of women, minorities, and LGBT people\",0.41\r\n\"It is hard to convince me that you are a Christian when you support a man who is a bully, self serving, and self righteous man determined on destroying freedoms of our population.\",0.40\r\nDisgusted....yet not entirely surprised.,0.26\r\nYour a socialist snowflake!,0.23\r\nWhat I want to say is so disrespectful so I will keep it to myself!,0.21\r\nI think we short changed ourselves. The better candidate won in my opinion but it should never have been a choice between the lesser of two evils.,0.14\r\nThe media was complicit in skewing much information. I'm glad it's over.,0.11\r\nI hope this country can now try to get along.,0.09\r\n\"Sorry it didn't work out, hopefully we can come together and get behind President Trump and get this country back to what it used to be.\",0.08\r\nI don't feel there is anything I could say to someone who voted for Trump that would change their opinion. Hopefully he is able to put the well being of the country above his own gain.,0.08\r\n\"You are entitled to your opinion, however we will all end up paying for this error.\",0.07\r\nGreat. We need our country back!,0.07\r\nThe Electoral College should be abolished. Popular vote is the person the majority of the people want.,0.07\r\n\"we should have talked more. i should not have outcasted you for being different, but i should have tried to understand you more\",0.07\r\n\"Educate yourself on environmental issues, civil rights issues across the board.\",0.07\r\nTo interact with people outside of your normal circle because it will open your eyes to the experiences of others.,0.07\r\nOver half the country didn't vote. This is the result. Politics is basically watching sports except the players wear ties and sit at desks. Its at this point infotainment and has no truth at all in it.,0.06\r\nWe elected the best person for the job.,0.05\r\nAbolish the electoral college.,0.05\r\nMake America Great Again!,0.05\r\n\"Fear is powerful, but understanding and compassion are so much more so.\",0.04\r\nGood luck and let's join hands to form unity.,0.04\r\nRespect the presidency and give Mr. Trump a chance to make good on his campaign promises.,0.04\r\nI didn't like either candidate but I'm willing to give Trump a chance.,0.04\r\nToo much media influence,0.03\r\nDid you vote for what you truly believe is right and why?,0.02\r\n\"I honestly support both, as I was a Bernie supporter.\",0.02\r\n\"I would love to hear their reasons for voting for the candidate they chose and participate in insightful, open dialogue with them\",0.02\r\n\"Regardless of who won/lost, it's time for everyone to work towards unity and improving the economy and business/education opportunities for all.\",0.02\r\nHopefully you made an informed decision based on your own thoughts and principles,0.02\r\nPlease work to use your voice to advocate for positive changes,0.02\r\n\"I hope you gave it some real thought, and thank you for participating.\",0.01"
  },
  {
    "path": "packages/backend-api/data/wikipedia.csv",
    "content": "content\n\"This article lists her first worldwide single as \"\"Energy\"\" being released in 2009, then states that her third worldwide single \"\"Turnin' Me On\"\" was released in 2008.  The cites for both check out, but the dates don't line up.  Somebody with more knowledge on the subject should correct this section.\n(  )\n\"\n\":::: I agree: on the understanding that the first paragraph makes it clear that only parts of Britain became home to these new settlers.\n\"\n\"\nI've corrected the father: \"\"Ferdinand\"\", appearing in various sources, actually makes no sense.\"\n\"\n:::It's polite to ask before cutting and pasting.\n:::As to your points, we are here to provide facts not conjecture, it raises NPOV issues and thus shouldn't be there, and you'll find that earlier in the section. My overarching point still stands the article is on the raids not Képíró.\"\n\"\n:::::::::It could be my favorite for illogical judgement. Just because someone received a lot of blocks this does not mean at all he should get banned (at least not by WP written policies).\"\n\"\n::::Daniel, I will say it again, for the third time: everything you need to do to appeal a block is explained in the above link.  Go there, read it, and then follow the instructions.  And again: no one will do it for you.\"\n\"\n*Support per DrKiernan.\"\n\"1. Starchild is an alien or alien / human hybrid.\n2. Starchild is a human with a disease or condition never seen before.\n3. Starchild is a human who was from the Atlantis civilization.\n2. and 3. would be likely if it is determined that Starchild has 46 chromosomes and a Y chromosome. In this case, the Y chromosome haplogroup must be determined.\n1. is likely based on anatomical examination because of unerupted multiple teeth and strange fibers as seen from X ray analysis and electron microscopes, bone chemical composition markedly different from normal human bone composition, inability to dissolve in solution which easily dissolves human bone, wear of the teeth suggesting Starchild was not a child but a grown person, lack of anatomical features such as frontal sinuses.\"\n\"\n:::::Distorted realities are a funny thing.  No legitimate claim?!  They were there.  What needs to be legitimized?  Keep telling yourself stories to ease your guilt if you want, but at least don't attempt to spread your warped world view.\"\n\"\n::::I cross referenced those dates with a calendar... I would assume that since the originals have always aired on Wednesdays (save maybe some of the specials), stick with the IMDb information.  Most of those dates are Wednesdays as opposed to the Tivo dates.  Also, when you look at the Food Network episode numbers, they land closer to the correct season and order with the IMDb dates.\"\n\"\nClaudio Reyna will join RBNY, watch for official annoucement.\"\n\"The article states:\nCheese is a religion! Known in the the Raichu Bible\n\"\"Until its      modern spread along with European culture, cheese was nearly unheard of in oriental cultures, uninvented in the pre-Columbian Americas, and of only limited use in sub-mediterranean Africa, mainly being widespread and popular only in Europe and areas influenced strongly by its cultures.\"\"\nPerhaps some mention should be made of paneer, the ubiquitous Indian fresh cheese, which does not owe its existence to European culture, but rather is apparently of Persian origin.\n\"\n:You make a good point; apologies for my rather hasty edit previously.\n\"\n|monthFull =\"\n\"\nThis argument is ridiculous. The opinion of some Calvinists that the Roman Catholic Church is not \"\"Catholic\"\" in the \"\"strict sense of the word\"\" is irrelevant to an article that is intended to be neutral. For the purpose of a neutral article each group should be referred to by the name which they give themselves; to do otherwise is to take a position regarding the truth or falsity of the claims. Therefore, I am going to change \"\"Papist\nto \"\"Roman Catholic\"\" each time it appears in this article other than in direct quotes.\"\n\"\nConsider for a moment the gender roles that best suit the parent philosophies of PanDeism. First you have Deism - this is absolutely a masculine concept. God is a father-figure, not a mother giving birth to the universe, but a mechanic, an architect, a craftsman, a clockmaker, a typical male role. And what does this father do after the universe has been made and set in motion, when the gears are wound? He abandons us. He disappears, and does not make himself available to us. We trust that he is still there, but can only confirm this through the exercise of cold reason; this is a God who is cold, emotionless, out of reach, like every stoic father who has presented only this face to a son, a tradition passed down from generations before. The God of Deism therefore possesses the attributes of the Yang.\nNow you have PanTheism - a feminine concept if ever one was! God is the universe that envelops us, is all around us, wraps us in her warmth. God is ever present, sharing herself completely with us, giving us unconditional love because we are part of her, born from her womb with an umbilical cord that can never be severed. This is the ultimate mother, the ultimate feminine, possessing the attribute of the Yin.\nHence, PanDeism strikes the perfect balance of masculinity and femininity, of Yin and Yang (thus not surprisingly, PanDeistic ideologies are far more prevalent in Asia). Like the masculine Deist God, the PanDeist god is a mechanic, an architect, a clockmaker; but the PanDeist God does not abandon us when his act of creation is completed; rather, the PanDeist God assumes the other role, that of the PanTheist all enveloping mother, allowing us to exist through her very substance\nSo, as Deism and PanTheism combine to find the perfect balance in PanDeism, so must we strive to find this balance in ourselves and in our relationships, to both build and nurture, to be sufficiently distant yet always present when this presence is called for. We are each a microcosm of the potential balance of the universe, and each of us already carries with us the connection with the universe that enables us to emulate its temperment, should we desire to touch the God within ourselves. Realize, therefore, beloved friends, that touching God therefore means touching the characteristics within ourselves that reflect the opposite gender - men must find their feminine side, and women their masculine.\"\n\"\n::You may beleive it is an reasonable statement and argument, but it is based on your opinion not on reason nor logic. Take my example, do you think \"\"The communist party does not say the moon is made of cheese in every day news\"\" is a reasonable and logical argument that \"\"the moon is not made of cheese\"\"? Here's another example: is \"\"The communist party does not say 2+2=5 in every day news\"\" a reasonable and logical argument that \"\"2 add 2 does not equal 5\"\"? I'm not sure what the technical term is for this but it comes under something like spurious argument or false reasoning.\"\n\":Well, I'm glad you're not...er, dirty on me. Hope everything works out okay. '\"\n\"::The roentgen's equivalent in SI units is the Gray, so we would need to know what type of radioactive particles the public and liquidators were exposed to, in order to convert to the sievert.\n\"\n\"\nIt seems we have our first competitor! Legotkm has added his table to the page. ‑-\"\n\"\nI took the liberty of reorganizing this article by subdividing the \"\"Prairie Saints\"\" and \"\"Rocky Mountain Saints\"\" sections into subsections based upon the provenances of the various organizations (back to one of the original sects that arose during the \"\"Succession Crisis\"\" of 1844).  I felt that subdividing these lengthy sections would do more toward helping readers understand each group of sects and where they \"\"fit\"\" in the overall Restoration Movement picture.\nI tried to do my best in classifying each sect as \"\"Brighamite,\"\" \"\"Josephite,\"\" \"\"Strangite,\"\" etc., but anyone disagreeing please feel free to move the sect in question appropriately.  I also eliminated the \"\"Other groups\"\" section by folding its contents into appropriate subsctions of the \"\"Prairie Saints\"\" or \"\"Rocky Mountain\"\" sections, as each of those churches could legitimately fit in one of those subsections (confusing, isn't it!!).\nOne word more: usage of \"\"Brighamite,\"\" \"\"Josephite,\"\" etc. is strictly for convenience; no derogation or other insult is intended by my use of these terms. -\"\n\"\nThe top of the page lists the information \"\"(Redirected from Rdiff-backup)\"\" while there is an rdiff-backup entry in the table at the bottom of the page.\nClicking \"\"rdiff-backup\"\" in the table simply returns the rsync page again. Stuff happens.\"\n\"\n*Does anyone have any evidence that the party is most commonly called Die Linke in English language sources other than pure conjecture? Left Party is far more common.\"\n\"\nNew material includes links to verifiable and credible sources.\"\n\"\n******Okay, that's very sad, but it's also still irrelevant, because the sources you linked don't indicate that the violence has anything to do with forced abortions (nor, even if they did, could we possibly say that opposition to forced abortion was equivalent to opposition to abortion). It's really not too much to ask that talk page discussion be relevant - do you have anything to say that's actually about anti-abortion violence? Because I'm sure there are many internet forums where you can display your expertise on Tibet. –  ⋅\"\n\"\n:::Not all editors will agree with that. I dont really think the article is needed personally, but I didnt use a pov to close, only what I saw from the discussion. CSD#A3 isnt 100% clear, I had thought it meant the \"\"what links here\"\". I'd also like to note that the the AfD was in fact too fast, at 3 seconds after the page was created. Not enough time to tell in my opinion. Should have been prodded first. And CSD#A1 wouldnt hold up either per the second sentence Limited content is not in itself a reason to delete if there is enough context to allow expansion.  And everyone was calling for expansion. You have your intro now also.\"\n\"\nSorry about that: we were both editing the same subsection at the same time, cutting material to the same sub-srticle (just slightly different bits!) Freaky.\"\n\"\nTo summarize my changes:\nRedirected \"\"Warrior Ethos\"\" to \"\"US Soldier's Creed\"\"; merged the two articles to reduce confusion.\nRemoved \"\"entitled the 'Warrior Ethos'\"\" - It is entitled the US Soldier's Creed. The \"\"Warrior Ethos\"\" is contained in the Creed.\nRemoved the \"\"dog-tag\"\" reference - unnecessary; trite. It is now an external link. Should this even be here?\nMoved \"\"controversial\"\" remark to end of paragraph, referenced cited article. Should this even be here?\nFormatted the second stanza (Warrior Ethos) in similar fashion to the way it is primarily displayed.\nAdded merged \"\"Warrior Ethos\"\" copy.\nAdded a reference to age of previous version.\nI do not know what the Washington Post reference references.\"\n\"And the guideline is usually interpreted as \"\"Wikipedia doesn't include spoiler warnings\"\".\"\n\"\nReading the article, it is stated that the Carver does not have any genitalia, however by my understanding of sexual development in humans, this isn't particularly possible.  The most common results are a vagina, or penis/testicles, however intersexed conditions only develop in a range of variation between a vagina and penis/testicles.  To say that the Carver has no genitalia at all is highly suspicious, what evidence is shown on the show to indicate this?  Is there a graphic of Costa demonstrating no genitalia at all, or he simply described as having no genitalia.  Likely in the later case, they would mean that he is absent a penis in order to perform the rapes, but not entirely absent of genitalia.  (However, this wouldn't be the first time that Nip/Tuck has made errors about intersex/transgender/transsexual people.  In the first season there is an episode about transsexuals, although all of the people depicted are cross-dressers, and have a significantly more masculine appearance than most all actual transsexuals.)\"\n\"\n::Agreed a source is needed, but this sounds like just the basic view of vajrayana buddhism to me and not particularly controversial aside from the military language. Though it's a little off, because once one realizes the vajra body one also realizes the inseparability of all beings. So not so much that they possess the same essence (that idea of possession implies truly established existence) but more that one realizes buddha nature was all there really was to begin with. Something like that. -\"\n\"Lies, deception, manipulation, cheating, stealing, murdering, are all on the rise in our government based on statistical analysis of the national security archives. Do you know why a headline like this will never be announced? There are many pilers of classification; do you know what they are?  Confidential, Secret, Top Secret, Secret Compartmentalized Information, all with differing rules and guidelines for dissemination such as, “need to know” and handling procedures. What is it all about? Why does our government need such a thing? Primarily the classification is proportionate to the severity of the crimes committed by our government. The differing levels of classification act as a buffer hiding the truth.\nWhat if he wall came down tomorrow and all of this information was released to the world?  Would there be civil war? Would the leaders be prosecuted? How long will it take for the world to be at war with us? What kinds of secrets are being hidden behind steel doors? The truth about secrets is that a system of checks and balances do not exist. Can collective intelligence truly grow and will humanity ever recover the final battle for freedom and at what lengths will the government go to protect the lies. The protections that our constitution provides will no longer suffice because of the national security loophole.\"\n\":::::: Propose the text about PEMFs that you want to restore/add in a new section below, with the sources that support the text, and we can all discus it, thanks. (Or indicate in the section below if you mean the bone healing bit)\n\"\n\"* Pagerank 3    = 41553912 sites estimates\n\"\n\"\n:The problem with saying that the icon collage doesn't have to all inclusive is that the scope of this project is all inclusive. I personally don't see a picture of a sunset as a sun worshiping symbol, but you object to it on those grounds. You're saying that I'm fabricating a controversy (even though if you would read the history of the banner and some of the other activities of this project, you'd see that it's not fabricated at all), and I could say the same of you - that you're fabricating a controversy over an innocent photograph of a landscape. On the other hand, you want folks who would object to seeing a religious icon on their page that they object to, or not see their own icon on their page because their religion isn't deemed important or widespread enough to just accept that the graphic that we pick is good enough. My vote is for the project not to have a graphic at all - graphics are superfluous and certainly not mandatory for a project.\"\n\"\nWhy do i have to stop?I got just the same right as you do.I'm not adding lies,big articles.I am adding one line,that is very important.Not for me,but for people who was working on this case.You don't know the case better than them.If you like to,add something what is better,but stay true to the facts,that they NEVER FOUND any evidence that he isn't the Zodiac.I'm tired of this anti Graysmith thing.He was the first one to write bout Zodiac and you should be thankfull.Greets and wait for my next edit.\"\n\"\n: Good length, needs a little tidying up and some inline citations.\"\n\"\nno problem. Enjoy.\"\n\"\nif you go to the current group section of friends.kalahari-meerkats.com there will be group shots on the right hand side if you scroll down to the vivian and click for a larger view you will see a one eyed dominant standing in front of the researcher that should be all you need to see that the vivian are portrayed as the commandos and then if you look at the monthly reports and the map on the kmp website the whiskers dont encounter the vivian but they do however encounter the commandos who tookover  whisker,elveera,and young ones land.\"\n\"The parliamentary section has been amended and a bit now reads:\n\"\"Two years later, in 1888, he secured passage of a new Oaths Act, which enshrined into law the right of affirmation for members of both Houses, as well as for witnesses in civil and criminal trials.\"\"\nI'm almost entirely certain that this is misleading, but I've not changed it because I want to check.  There was in fact an earlier act (1869?) allowing atheists to give evidence in court, and it was to that Bradlaugh appealed when first asking to be allowed to affirm (the request denied, of course).  His 1888 act may have consolidated all the legislation but it didn't originate the right to affirmation in court, just for parliament. ~~\"\n\"\n::::Agreed, I had not thought of that I will be more careful in the future in this regard.  '' /\"\n\"\nthe census reference needs to be fixed.\"\n\"\nI assure you I am not being paid by anybody to contribute to Wikipedia, I do not support George Bush or Tony Blair and I do not see how Hitler can possible be 'close too centre wing'. Wikipedias definition of far right:\nIn the modern world, the term far right is applied to those who support authoritarianism, usually involving a dominant class (which may be aristocratic or defined along racial or other lines), and/or an established church . Their favored authoritarian state can be an absolute monarchy, but more often today it is some form of oligarchy or military dictatorship. This is most true in regions and nations that have no real history of monarchy, such as Central America (discounting the Pre-Columbian era), Switzerland, and the United States. The term \"\"far right\"\" also embraces extreme nationalism, and will often evoke the ideal of a \"\"pure\"\" ideal of the nation, often defined on racial or \"\"blood\"\" grounds. They may advocate the expansion or restructuring of existing state borders to achieve this ideal nation, often to the point of embracing expansionary war, racialism, jingoism and imperialism.\nHitler was nationalst, embraced expansionary war, a radical, imperialst, authoritarian and supported an established church all of which are traits of the far right. Bush is nothing more than an idiot and Blair is failing to take tough  enough action on the EU, immigration and a whole host of other issues. I will see what i come up as on the Political Spectrum test.\"\n\". I hate, for no good reason, dotting that final t and crossing that final i. Cheers,\"\n\"\n::::::Whilst I'm aware of this, and the specific series of events that led to your totally ludicrous and pointless block, I'm also of the belief you can't be sure every apple in the barrel is rotten just because the first one, two or three happen to be. Maybe you have to throw out a hundred apples but one at least will be edible, even if not totally palatable. And one might even make some cider.\"\n\"Came across this and I figured with your keen interest in B&H; churches (or probably just heritage in general), you might be interest in it.\"\n\"\nWhy on earth isn't there mention of the controversy surrounding Barnes performance in the All Blacks/France and Wales/South African games? For example, there is nothing about \"\"that\"\" kick. Who is protecting this page from it? Wayne Barnes'family?\"\n\"You are giving a lone example of aishwarya rai. why not you go and see the wiki pages of sharukh, salman, amir, saif, rekha, hemamalini, sridevi, esha deol, vidya balan etc. The language scripts of their mother tongue and their main career industry is only displayed. If one has to add the language scripts of the all the languages an actor appeared on the screen, then Kamal hasan's wiki page should have atlease eight scripts. so apply common sense and display only their mother tongue and main career industry's. I wish some one add aishwarya's script in Hindi since her main career role is in that industry. without doubt, she was brought up in the film industy by Tamil and she herself told that she can communicate in Tamil. But I don't want to add Tamil script for her coz it's not her career industry.\nAnd finally, to the thread starter, I am not a newbie and ve been around in wiki for about 2 years. - yasirian\"\n\"\n::I think that makes a lot of sense. There are now two events and the \"\"International Birdman\"\" is not specific to either. Although there is a lot of shared information unless the Worthing Birdman page starts in 2008 linking back to the Bognor one. I am unsure which is the best way to structure these changes.\"\n\"Norwegian is placed as a descendant of the Old East Norse, therefore, the Old West Norse should be moved one space to accommodate the Old Norwegian. Also, Old Gutnish should be occupying both the Early Middle ages and the Middle aged spaces.\"\n\":As you noticed the lead is the main problem. It's too short. That's the reason why the meaning of the name is crammed in there. Concubine is the wording of Encyclopædia Britannica. Also he was not the first Arsacid to rule Armenia, he was technically the fourth. However he was the first one in a long line of Arsacid rulers. He also didn't establish the Armenian dynasty. That's all mentioned in the lead. Thanks for the source, i'll use it to modify the lead. Please let me know if you come up with anything else. I hope it ends up as an FA as currently there isn't a single Armenian related FA article.\"\n\"\n:I'm confused. I see mention of Tim in the current version of the article, and from looking at the history it was added on June 22. (You can see all the edits made to an article by clicking the history tab up there at the top of the screen when looking at the article in question.) I assume you (140.198.85.35) are also 167.94.2.9, who added the entry. Possibly you looked at the page prior to the edit on the 140.198.85.35 machine and have an old version in your browser cache? -\"\n\"\nWithout references, images and additional text this article is only a stub. /\\\"\n\"\n::Which line are you refering to?\"\n\"\n::That last source touches on the point I am getting at: the racing line is not a line through a corner, it is the optimal path around the track. The subtle difference being that while the major influence is the geometry of the corner, the ultimate lap relies on a complete optimisation of all factors.\"\n\"\n::It would be a good idea and encouraged to add some additional biographical material about the individual himself, but one sentence about this particular issue is appropriate. '\"\n\"\n*Remove Qu'ran section. Obviously not applicable to only atheists  apostasy is not just about atheism. \"\"Atheism\"\" as a self-identifiable group did not exist when the Qu'ran was written and trying to claim it made statements about atheists is not supported by any reliable scholarship on the subject.\"\n\"\nOfsted reports are available here .\"\n\"\n:I've removed the implication that the main character is a composite character, because he's clearly Avner from Vengeance; if he was a composite character, that's another matter. It would also help to have sources for the controversy paragraph.\"\n\"\nThey were not made of glass, it was hardened plastic. They originally came in the colors of blue, red and green. They were packaged in a plain plastic bag the a fold over label of yellow. I had an original set when they first came out. Another toy that came out at the same time was string-ball. That one I have a picture of.\"\n\"\n* Done\"\n\"\n\"\"The international reactions to the 2006 Israel-Lebanon conflict have been divided, with most leaders condemning both Hezbollah and Israel.\"\"\nThis first sentence seems a little weird.  If most are condemning both, then reactions are not all that much divided, are they?\"\n\"\n:::: Thanks a lot Friday.\"\n\"\n:1/120% = 83.3̅%, I'd say that's close enough. But which one is a derivative of the other?\"\n\"Where did you find that list of the single constituencies? I tried to find it with google some weeks ago without success. And of course I would also love to know the error in my maps, so I can correct it. Fixed the error in Ubon already, was just wrong numbers in the table, on the image description page of the map were the correct numbers.\"\n\"\n:::: is what I have been working on in the last few minutes.  I would be interested to know your opinion....\"\n\"\nthis article is a \"\"Featured article\"\" in 9 different languages can someone make it featured in the all 15 languages?\"\n\"\n::::The sole argument for not moving this article is that \"\"Arabic numerals\"\" is more common. This is highly arguable and almost impossible to prove. However, there are several arguments for moving it that are documentable and defensible by a logical argument. Now, any neutral party with a mind for logic and fairness could see that the argument for moving it BACK to where it was is the stronger position. Why is the position for not moving is being defended so belligerently? It was moved to it's present location without a clear consensus in the first place! I've run across this several times on Wikipedia; a highly suspect edit becomes entrenched and defended to the teeth on a shaky interpretation of \"\"policy\"\". Logic seems to go out the window. That, is absurd.  To demonstrate how ludicrous it is to judge something by what is common or commonplace can be, listen to common speech, watch television, you hear incorrect grammar and syntax all the time. Just because it is commonplace does not make it acceptable.\n::::I propose the following solution.  Move the article back to it's correct title, \"\"Hindu-Arabic numerals\"\". Then form a redirect from \"\"Arabic numerals\"\" to steer people looking for the subject under the \"\"common\"\" term. That is for what redirects were made. Problem solved. The article retains its correct heading that reflects it's relationship to \"\"Hindu-Arabic numeral systems\"\" and people looking for it under the vulgar title will still find it.\"\n\"\n:I don't mean the hammer meat-tenderizer, I mean the tenderizer with the needles.\"\n\"=\nIn 1475, the Swiss invaded the Barony of Vaud and took the castle of Grandson from Jacques de Savoie, the count of Romont and Lord of Vaud. Charles the Bold, an ally of Jacques de Savoie, besieged the castle of Grandson in 1476. The Swiss defenders, believing that they would be spared if they surrendered, surrendered. However, Charles the Bold believed the Swiss threw themselves on his mercy and he executed all 412 Swiss by hanging and drowning. At the Battle of Grandson, a Swiss relief force arriving too late to lift the siege, defeated Charles the Bold and annexed Grandson and its surrounding area to Bern. Grandson was an exclave, surrounded by Neuchâtel and the Barony of Vaud.\n\"\n\"\n:The problem is with the process.\"\n\"\n:It looks to have been in London after NYC, although obviously key writing took place in London – I've added something about the controversy when it opened in London (see obscenity allegations para), which is possibly why it was tested/found an open door in NYC first. But this is supposition and it would need more research.\"\n\"::I had a feeling after I made it that you might consider the red/blue hard to read. I liked it  and I chose a darker red than my original red to further make it easier to read  but I had a feeling it might be short-lived. I'd still like to mess with it a little bit and incorporate some blue around the red border, just to incorporate both those colors (the silver, our third color, is not necessary). I know the visual standards manual may want blue on a white background, but I also know that there are people in administration who don't like athletics, associate red and blue with athletics, and therefore want to keep red and blue out of documents associated with the university  it's a really political, stupid, stupid thing and FAU won't be able to move forward until they embrace the whole \"\"owl head, owl country, bleed red and blue\"\" mentality that other schools with rabid fans (and alumni) have already.\n::But that's an aside.\n::Good news is that I took some more pictures (FAU sign, multiple apartment angles so we can choose, College of Business, etc) and I'll do my best to put them up tonight. We'll discuss those soon.\"\n\"\n:Hi , nice job, very nice pages. I probably do have sources for both articles, but will wait until they clear DYK to add anything.\"\n\"\n—— ''''''\"\n\"\nThanks for sorting out the reference on my addition to the A14 article, I wasn't absolutely sure how to do that. Useful to learn!\"\n\"\nThis whole thing reads like it was written by the company. Lame lame lame.\"\n\"\nHi,\nRonz may have overreacted in his response to your edits, in that you appear to be a new user. Please review some of the links above to help you familiarize yourself with wiki policy and guidelines. Generally speaking articles need to contain text from reliable sources, that is newpapers, magazines, scientific journals, scholarly books etc ... Articles made exclusively from self-promotional sites are not acceptable on wikipedia. That is why the article was deleted. If there was research published in a reliable source on these exercises that it would be encouraged to add links and perhaps create a page for the topic. Generally, the types of mistakes you are making are unacceptable from an experienced user, but totally understandable from a new user and are generally met with a polite welcome and some introductory information to introduced you to editing on wikipedia. Welcome, and we hope you decide to contribute.\"\n\":::That would be wrong, as you say. Rather, the edit said that these two equations were equivalent:\n\"\n\"\n**It's about which side of the river things are on. You can't have a mill pond on the west bank of the river powering a factory on the east bank  unless you've got a ton of elevation and an aqueduct across the Schuykill.\"\n\"::I think Sin ra is Silla/新羅, an ancient Korean kingdom and Fujiwra no foufira is Fujiwara no Fuhito/藤原不比等. As for Wado, it's famous for the first Japanese coin, 和同開珎(wadokaiho/wadokaichin).\"\n\"\n*If you disagree with the assessment, please change it by editing the class parameter of the {{WikiProject California|class=stub|importance=}} above to the appropriate class and removing the stub template from the article.\"\n\":All said issues have been addressed. EDIT:''' I dont know what happened. I made all the changes but it doesn't save them. I guess  beat me to it. Anyway, if you dont mind, could you look over it again and tell me what could be done to reach FA. I believe this article is truly close. Thanks a bunch.\"\n\"\nBut it does exist, check the \"\"exists\"\" part, and there says that the account was created on August 1, 2006.\"\n\"\nSome questions about this article.\n1. The statement that Chilvers invented the Windsurfer is like stating that someone invented the Xerox, or the Kleenex. Just as Xerox and Kleenex are trademarked terms which have come into common use, the term Windsurfer was a trademark of Windsurfing International, Hoyle Schweitzer's company. This is clearly demonstrated in records of the US Patent and Trademark office, which shows the registration date as July 3, 1973 . Courts also recognized that the Chilvers invention was different in design from the Schweitzer Windsurfer . Therefore it would seem more accurate, using a generic term, to state that Chilvers invented the sailboard. If there is any evidence or record that Chilvers named his invention a \"\"windsurfer,\"\" prior to 1972 when the Schweitzer trademark registration was filed, this would be relevant.\n2. According to the Bicsport website, Tabur was a pre-existing French boat-building company acquired by Baron Bic in the late seventies. It introduced its first boat in 1968 . In light of this, the claim that Chilvers was the founder of Tabur Marine is puzzling. Is there any info available to clear the link between Chilvers and Tabur?\"\n\"\"\"Werewolves can 'imprint' on a certain person once they begin phasing. Jacob describes imprinting to be stronger than love at first sight. This is, according to the legends, very rare, but ... This is about Jacob and all werewolves!\n\"\n\":::I strongly advise you not to re-add it. I assure you it is not the way to get your opinions heard. Just post a note saying that you've posted an essay on it at RR. When in Rome, '''\n\"\n\"\nI added the following text. Some user removed it saying it is nsourced and NN IMO.\nI suppose NN stands for Not Needed. I do not see why that would be the case.\nAn alternative would be to add this in the euro section of the VC page.\nTEXT:\nIn the European Virtual Console some but not all games are available in the languages they were originally published in.\nSome games are multilingual (as they were in their original form, example: Super Mario 64), others are avaiable in mutliple versions (example: The Legend Of Zelda: A Link To The Past). The Wii's country setting decides which version will be available for download. If a user wants to download more than one version, he is charged once per version.\nAn example for a game not available in all of its original language choices is Kirby's Adventure. The NES game was published in a fully localized version in Germany, but only the English version has been made avaiable for users connecting to the Wii Shop Channel, with a German country setting, in time of the game's Virtual Console release.\"\n\"\n::::Okay, I added the sensical ones that actually have articles in them to the top of the list and put them at . I asked the bot to be run at .\"\n\"\nIn case, you continue to vandalise pages, you mat be permanently blocked from edition. Please be careful.\"\n\"\na)The English master, as he seems to think he is, spelt sentence wrongly. (I don't care if I spell words incorrectly, but it's rich when the person who pulls me up for grammar and spelling cannot spell even the simple word 'sentence' correctly)\nb) When I wrote, do your worst, I was not refering to this arguement, but in the real, non-ethernet world.\nc)As I stated previously, I have already won this arguement, so how exactly do you think you won??? Explain, if you can (I assume you can't).\nd)You call me a child, as I am, but I'm older than you; sure, it may be by a few days- but I'm still older. Ha.\ne)Don't patronise one whom's own intelligence outways that of yourself. (I'm refering to the 'simple' remark.)\nf)I've kept this arguement clean and inoffensive, you however, have no dignity and lack self restraint in the 'crudeness' department. So now, try and come back. I throw you the guantlet. Prepare for a metaphorical duel.\"\n\"\nHello Fainites. If you have anything specific that you wish me to doublecheck, then post it on my talkpage and I will see if I can.  But I do believe that with so many anonymous and odd edits going undiscussed on the NLP article, even verbatim quotes will be misplaced. It does seem to me that the anonymous editors are sockpuppets of Comaze. I would rather edit other articles where this problem is not occurring.\nHi again Fainites. Here is the quote in full from Sharpley 87:\nThe most conclusive sentence (about conclusions) in that section you presented is that “There are conclusive data from the research on NLP, and the conclusion is that the principles and procedures of NLP have failed to be supported by those data”\nThen he says “On the other hand, Einspruch and Forman (1985) implied that NLP is far more complex than presumed by researchers, and thus, the data are not true evaluations of NLP. Perhaps this is so, and perhaps NLP procedures are not amenable to research evaluation. This does not necessarily reduce NLP to worthlessness for counseling practice. Rather it puts it in the same category as psychoanalysis, that is, with principles not easily demonstrated in laboratory settings but, nevertheless, strongly supported by clinicians in the field. Not every therapy has to undergo the rigorous testing that is characteristic of the more behavioural approaches to counseling to be of use to the therapeutic community, but failure to produce data that support a particular theory from controlled studies does relegate that theory to questionable status in terms of professional accountability”\nRight at the end of the article the sentences read:\n“Elich et al referred to NLP as a psychological fad, and they may well have been correct. Certainly research data do not support the rather extreme claims that proponents of NLP have made as to the validity of its principles or the novelty of its procedures.”\nI think the only thing to do with this is rely on what the other researchers (eg Devilly, Eisner and so on) say about Sharpley. Also, if NLP is actually mentioned in the Norcross research that AB presented then that may help as it is even more recent regarding acceptance by clinicians.\"\n\"\n:If you don't care, then don't. WTF?\"\n\"\n::::::As I already pronounced, I know very well about the WP conventions. And most of the time, they are very reasonable. But, IMHO, I am not so sure about obvious errors. It can't be the rationale to keep up an obvious error just for the sake of some \"\"majority\"\". And since the end of WWII, they changed it from y to i, which is also reflected even in some English sources, as you showed above.\"\n\"\nWhat for?\"\n\"\nHas apparently died a couple of days ago. —\"\n\"It would be interesting to know who was the youngest player to reach 100 wins, 200 wins, 300 wins, and on. Rafael Nadal is probably the best in most of these records (now is 600 wins with 26 years and 9 month), do anyone know where to find that record?\"\n\"\n* There are many disussions about this, and I can surely said that there are more common, more widespread, and more known ornamental symbols in Armenian culture.\"\n\"\n::Isn't that what page protection is for? —\"\n\"\nOMG SHUT UP U STOOPID\"\n\"I think the whole Andromeda Paradox is incredibly misleadingly described. If two people are standing in the street, and one person starts walking away from the other, then he absolutely will not view events in the Andromeda galaxy which are two days apart. That's a nonsense. As an example, let's imagine you are viewing a supernova through your telescope. If you decide to move your telescope to the left by a couple of foot you don't suddenly see the supernova as it existed two days ago - before it exploded. Move your telescope - the supernova explodes; stop moving your telescope - the supernova doesn't explode. Obviously not the case.\n:The paradox is about what events observers consider to be on their x-axes, no-one can observe such events, they can only observe things transmitted from the events like light. This does not mean that the events on the x-axes do not happen and, if you use SR to allow for the transmission of light and the effects of relative velocities you get the Andromeda paradox. (ie: Penrose is not stupid!).\nIf that was that case then the speed of rotation of Earth would dominate any relative velocity difference of two people walking in the street - people on one side of Earth would see the supernova exploding, while people on the other side of Earth would see the supernova two days earlier before it exploded. We'd see total confusion in space! Roger Penrose actually describes it misleadingly in his original description in his book: \"\"Even with quite slow relative velocities, significant differences in time-ordering will occur for events at great distances\"\" - well, that's absolutely not the case if the two observers are not spatially separated (see my earlier example about viewing a supernova).\n:The paradox is about what events observers consider to be on their x-axes at any instant, not about the order in which photons are received. Penrose is not stupid.\n::But the order in which photons are received defines what events the observers consider to be simultaneous. Remember the old special relativity thought experiment of an observer in a moving train sending two light pulses out to the back and the front of the train? He says the rays arrive simultaneously precisely because he sees the photons arrive at the same time (of course, it's not just limited to light - the speed of light provides an upper limit for any information). It's that speed of light which defines that x-axis to which you refer. I know Penrose is not stupid, but this Andromeda Paradox makes the mistake of overestimating the effect of special relativity on two walking observers. As I say, the rotation of the planet would have a far greater effect than any walking velocity! Penrose has made a mistake here.\n:::The debate is about the geometrical rather than the dynamical interpretation of SR. Both observers get the light arriving in the right order and the car receives the same light signals as the man on the street while they coincide.  The issue is a philosophical enquiry into the nature of the geometrical interpretation of SR - if the world is a (3+1)D manifold then, although the x axes of the man and the car coincide where they meet they diverge with distance by  seconds with distance along the man's x axis.  The x-axis is on the hyperplane of things that are simultaneously present so, according to the geometrical interpretation, the car has different events on Andromeda in its present moment from those on Andromeda in the man's present moment.\nIf two people in the street are not spatially separated then they WILL agree about simultaneity (though one might experience time dilation - his clock might be running slower). And if the two observers are not spatially separated AND their relative velocities are small then there will be absolutely NO difference of opinion about simultaneity - in the Andromeda galaxy on anywhere else - OR time dilation. Basically, their experiences will be identical. I think the whole presentation of this \"\"paradox\"\" is desperately flawed and is misleading and basically incorrect in its current form as presented here. The only way that small relative velocities would have an impact (as suggested by Roger Penrose) is if the two observers are also separated by a great distance (i.e., the combination of small relative velocity IN COMBINATION with a long time for light to reach the observers from a distant galaxy has resulted in the observers being far apart when the two signals reach them - so they disagree about simultaneity - this agrees with the comments of Pgb23 above: \"\"In actual fact the car would have to have been travelling for as long as the light signal from Andromeda ... It would be no good briefly increasing one’s speed.\"\"). The Rietdijk-Putnam argument is fascinating and fundamental, but - with the greatest respect for Roger Penrose - the \"\"Andromeda Paradox\"\" is just plain misleading in this form and should be rewitten or (preferably) deleted.\n\"\n\"\nΎHi\"\n\"\n::::Thanks a lot Haemo... im kinda nu to this whole thing so i just had truble undrstanding sum of it S\nI guess ill just have to learn things from my mistakes hehe\noh and one lassssssst thing... I promise lol! how do you get the link in html to see anothr users contributions... u know... like how the thing to get to someones talk page is 'user talk:.....whatever'... so what is 'contributions'?\"\n\"\nArticle says France and the United Kingdom were the only countries to develop fleets of wooden steam screw battleships, although several other navies made use of a mixture of screw battleships and paddle-steamer frigates. These included Russia, Turkey, Sweden, Naples, Prussia, Denmark and Austria.\nI don't follow: britain and france were the only countries with wooden steam screw battleships. But then it says several others had screw battleships. Is that screw metal battleships, which seems redundant because lots of people had this? Or screw wood but also paddle and wood, which is worth making as a distinction because some of their ships had paddles? This is a complicated way of saying it seems to say only britain had screw wood ships, but some others had screw wood ships. Britain never had a fleet exclusively screw wood.\"\n\"\nI could be mistaken, but I seem to recognize Canon in C by Pacherbell in this song, though I have not yet found any confirmation of this. One of my hobbies is working on music, and when I was using the Canon in C, I recognized the melody from Scatman's World. Despite having no confirmation, I am 99.99% sure that the Scatman sampled the Canon (in C) for this track.\"\n\"\n:Honestly, Will, that's unfair on so many levels.  You  are trolling again.  If I am to assume good faith on your part, I would have to say that I honestly can't believe you actually believe the bigoted and uninformed views of the extraordinarily complex situation you spout here.\"\n\"\nI really appreciate your contributions to this article and your desire to improve it, and I want to help you toward that end.  Would you be willing to visit me at my talk page and drop me a message so we can work on the article in the next few months?  Thanks.\"\n\"\n:: I think this article is one of the better examples of how encyclopedia entries can be encyclopedic yet far more readable and lucid than your average encyclopedia article.\"\n\"\nA caution for the current wave of \"\"succession planinng\"\" panic setting in because government and business is facing the challenge of how many \"\"baby boomers\"\" might leave their employment soon.  Traditional succession planning has focused on charts with names of potential temporary or permanent successors for a key vacancy.  This is good thinking for the most part.  However, it\nis not as robust an investment as a progressive 21st Century enterprise should be making.  \"\"Leadership succession,\"\" a more\nrecent evolution of traditional succession planning, focuses on an enterprise-wide development of leaders at all levels so that\nthe organization can be confident a ready pool of talent is being readied to compete for positions that might come open.\nRather than focusing only on \"\"replacement\"\" (succession) and targeting a limited one or two names, leadership succession invites many to identify their interest in possible moving up and become involved in leadership development to prepare.  This process is more inclusive (read fair) and if done right becomes an accelerator for organizational success.\nLes Wallace, SignatureResources.com\"\n\"\nzc = 1  zm = 8  zs = 1  f = 4         IDAT too big\"\n\"\n::: If we could manage to relax a little, we could solve many more disputes than just this one. Hopefully after your latest response, that concern can be put to rest. Incidentally, it's spelled \"\"Hadad\"\" in the source; is there some reason for the \"\"Haddad\"\" spelling?\"\n\"\n:: The sources have been there all along, I think you tagged the article as a candidate for deletion a split-second after I wrote the first draft and before I was able to finish up the second draft which had my sources cited.\"\n\"\n::That confused me too, someone should consult the laws before changing it I suppose but it does seem that it should be the other way round.\"\n\"\nHi. I believe the two fact tags that Peter has added should be citations to the books in Further Reading. Does anyone have access to these books?\"\n\"\n{{familytree|border=0 | | | | | s1  | | s4  | | s51 | | s73 | | s84 | s1=1|s4=4|s51=5 (1)|s73=7 (3)|s84=8 (4) }}\"\n\"\nManchester: ManchesterIX\"\n\"\n::Several years ago I was bombarded with adds for a diploma mill out of Detroit; I forwarded the information to Gordon Gee, the president of Vanderbilt University in Nashville, Tennessee.  Naturally, he was incenced.  I suggested that the students from his journalism class do an investigation, much like students in Illinois investigated wrongfully-convicted inmates on death row.  The emails soon stopped.\"\n\"\n*Well, didn't I add extra data myself yesterday? Please don't act as if just because you wrote it, nobody else can add. I also have PB5 myself. You're acting like that other PB author.\"\n\"\n::::I think Buckshot06 must be right on this. Some of the industries represented build white goods, scientific instrumentation and other stuff. So, if the Space Station ever needs a new microwave, these guys can organise the item and the launch vehicle ;o)  ♠♥♦♣\"\n\"\nOkay. I'm surprised that you don't have an opinion on at least the first, third and fourth points mentioned just above, but if you need time to look through the discussions, that's cool. Thanks again. -)\"\n\"\n:: Thanks, I missed it. I'll look again.\"\n\":The help desk is not for others to do your homework, either.  /\"\n\"\nThank you very much. You helped me a lot. ^^\"\n\"Please do not save test edits. If you want to experiment, please use the sandbox.\"\n\"\n::Of course, will do. Thanks very much for your advice too. I never thought about templates - so obvious as well!\"\n\"\nKuantan shall be a big place. Its just not the city. It is around 1200 square km huge. More photos should be placed. Take a shot on the scenery especially at the Panorama hills, the city, the port, ant the waterfall please.\"\n\"\n:Oh no, completely opposed to such a solution.\"\n\"\nWhat family is Fruitadens haagorum? If there is, it should be the lightest.\"\n\"\n*** I didn't see any reference to \"\"Japs clan\"\" anywhere in that image.\"\n\":Have you perhaps mixed up the Freedom House \"\"Freedom of the Press\"\" report with their \"\"Freedom in the World\"\" series, which is the subject of this article?  Checking their website, Venezuela is indeed show \"\"Not Free\"\" in the \"\"Freedom of the Press\"\" study, but this is not the same as the \"\"Partly Free\"\" rating it is given in the most recent \"\"Freedom in the World\"\", which is what is being listed here.  The first would be looking at press freedom specifically, the second at the country overall.  I'll revert your change - if I'm mistaken let me know. -   You are not mistaken.\n\"\n\":::Okay! '\n\"\n\"\nLooks like I'll need to refer to those resources a few more times.  The one thing that jumped out at me (at the moment), is the majority/minority view discussion.  In the current section, I would interpret that the conflict with milbloggers has had a major impact on the perception of his credibility and hence his readership.  The milbloggers had previous helped build his prestige and it was the conflict with them that then diminished it.  Conversely, he does still maintain very loyal fans. Looking at it from the outside in, it would appear to me that the views are probably equally divided and ardently argued by the two sides, with few in the middle. In short, he has become a very polarizing figure in the period since he took on BG Menard.\nAll of that to say, I'll be digesting the references you gave to properly moving the article forward.  It will be a couple of days.  Thanks again.(  )\"\n\"This page is software-specific. Is that right?\n\"\n\"This article has been expanded - please list below any points of disagreement so it can be edited in a sensible manner.\nThere is too much criticsm, i want to know more about the actual group\nThose from the Quilliam Foundation (ie ed husain and majid nawaz) or pro-Quilliam who dislike critique, please stop vandalising the article.\n\"\n\"\n:::Considering he's going through the effort to come up with a new sock almost every day, it could be merited.\"\n\"\n:And i intended to add to it, rather than leave it at the two or three sentences i first wrote, but got called away, so it was a pleasant surprise to log on and see what superbe expansion you wrought. Cheers, ''''''\"\n\"\nThrough the article both names, Les and Stroud, are used in an interchangeable. I think it would be better for consistency to either use Les or Stroud on all the article and replace all occurrences of the other name.\"\n\"\nSomeone made a change to the page (which I have reverted) stating that Moe is a city. Moe actually has a population of about 17,000 which makes it quite small in the scheme of things.  If you have evidence, such as government documentation etc., that states that Moe is a city rather than town, please link it in the discussion group/add it as a reference prior to editing the page. If you do have a credible reference and can show then please change it back to city, with my apologies.\nThat said, the only alternative to 'town' that seems like it would be appropriate is 'suburb', as in 'Moe is a suburb within the Latrobe Valley/Latrobe City (which includes towns such as Traralgon, Moe, Morwell, Churchill, Yallourn North, Newborough, Hernes Oak, Hazelwood, Tyers, Westbury, etc).  The term 'Latrobe City', is actually the title of the shire.\"\n\"\nhow can it be vandalism when what I wrote is true?\"\n\"\n::::I didn't catch the comments made by the anon poster about Columbia before I hit save. I tend to agree that Columbia University would probably be a better fit for WPNYC since it is based in Manhattan. That said, if WPNYC doesn't want to take the project on board, WPNY will add it at some point. – ''''''\"\n\"\n:Where is there Romance in One Piece?\"\n\"\nWhy are you accusing me of sock puppetry? Do you really think I'd be capable of creating another account to remove the R&B; genre from Justin Timberlake's article? This is ridiculous, I'm not afraid of showing my face and assume the responsibilities of my acts. But I wonder what Justin did to be an R&B; artist.\"\n\"\nWhat year did you graduate New World?\"\n\"\n:Oh, I was unaware of that. Thank you for pointing that out and changing the article to reflect such! Best,\"\n\"::Your link was labelled other definitions uses, which it wasn't, it was the same definition.  The 'joke' isn't explained, and so may confuse.  Wikipedia isn't a joke book.\n\"\n\"\n::::Thanks;  I should have looked a bit closer in the first instance.  )\"\n\"\n:: point taken on skyline photos, but i never really said it symbolises greatness... just something recognisable about the city. those photos generally contain the most recognisable landmarks of those cities along with the (recognisable also) skyline: sydney harbour and opera house, the yarra, the story bridge over the brisbane river, swan river with its sandy shores and an actual swan. and the festival centre is in the picture of adelaide.\"\n\"\n3(a) Minimal usage. As few non-free content uses as possible are included in each article and in Wikipedia as a whole. Multiple items are not used if one will suffice; one is used only if necessary.\nThere is no real need for all 3 covers. As stated the really don't add anything to the article. This is not free content and as such 1 cover is enough to illustrate the series.\"\n\"\nHara! D\n(  )\"\n\"\nIt needs to be mentioned, if only in passing, that these figures are for COMBUSTION, in air (I assume)\"\n\"\nBe careful man. There is no such thing as if past midnight you're entitled to 3 more rv's! It applies for any 3 rv's within a 24h period (regardless dates). I'll help in the discussion if you wish, start by citing different definitions of European borders...\"\n\"\n:I've noticed that my vote for deletion was altered on the AfD for Yetol. The original text was Totally Non-notable which was changed to Somewhat Non-notable'. It is not acceptable to alter other people's comments in an AfD and doing so could get your account locked.\"\n\"\n::Frankly, this is b.s.  For such a \"\"well-documented\"\" phenomenon, the article certainly is lacking in references backing up such a claim.\"\n\"\nRight then, I'd like to start with some boring little bibliographical questions about the article on Štreit. (For which I thank you. I don't know his work, but a good rule of thumb is that anything exhibited by Amber/Side is worthwhile. I'll investigate.) The questions are very simple, really, but unfortunately in order to ask them I have to use a lot of bytes.\nWorldcat tells me of Josef Moucha, Helena Musilová, and Eva Marlene Hodek, Fotogenie identity: pamět̕ české fotografie / The photogeny of identity: The memory of Czech photography (Prague: Pražský dům fotografie, 2006; ISBN 8086970124).\n(It may be better to say that Moucha, Musilová, and Hodek are the editors, or that all are contributors but that Moucha is the editor. I can't read Czech so I'm guessing.)\nSurely \"\"Prague House of Photography\"\" is the name in English of Pražský dům fotografie; let's not quibble about which to use. And perhaps Kant is publishing it on behalf of Pdf, just as (for example) Yale University Press publishes books on behalf of the photo museum at Houston.\nHowever, the article seems to imply that this book is \"\"in: Dufek, Antonín: Vesnice je svět (The Village is a Global World), Prague, Arcadia 2003. Preface.\"\"\nI guess you mean that what Dufek is quoted as saying is from Dufek's preface to his own Vesnice je svět, via Fotogenie identity: pamět̕ české fotografie. Is this right? If so, perhaps:\n:. . . ; quoting Antonín Dufek's preface to his Vesnice je svět (The Village is a Global World; Prague, Arcadia 2003; ISBN _______)\nor of course the other way around:\n:Antonín Dufek, preface to his Vesnice je svět (The Village is a Global World; Prague, Arcadia 2003; ISBN _______); quoted in . . .\nI looked in WorldCat for this book but couldn't find it. However, I was able to find this quadrilingual book by Štreit (photos) and Dufek (text): Vesnice je svět / The Village is a Global World / Das Dorf ist eine globale Welt / Un village, c'est tout un monde (Prague: Arcadia, 1993; ISBN 8090142354). Perhaps the 2003 book is a new edition or just a reprinting of this.\nIn the list of references, why \"\"coll.\"\"? Is this something like \"\"collective authorship\"\"? If so, sorry but I don't think that this abbreviation is commonly used or much understood in English. (If a book has many authors, of course it's fine to name the first one and say \"\"et al.\"\" for the rest.)\nExcuse the boringness of my questions. More importantly, I'm glad that you wrote an article on Štreit and I look forward to looking at his work. (More on Tichý and others a bit later.)\"\n\"\n::Agree with above user. King Pharmaceuticals is a separate article and should be represented by a link, not by a substansial repetition of key portions of the article here.\"\n\"\nI rewrote the article remove the promotional text and better reflect the magazine.  As a result, I humbly submit that we don't need the News Release flag, so I removed that too.\"\n\"\n::::Have they been involved in any high profile activites since 2006?\"\n\"\n:You have chosen not to respond to my very simple question, which I have now copied to my talk page.  Please respond .\"\n\"\nThis article would benefit from additional content, including a photo.\"\n\"\n::Well isn't it worth mentioning that Matt turned face again?\"\n\"\nCongrats on your successful admin request. May god be with you on Christmas\"\n\"\n:If you want to talk to me, please post in the talk page, not in the user page.\"\n\"\nI think it makes perfect sense to point out that Jersey is not ranked because some other non-independent countries are. However Jerriais rugby, like that of the Isle of Man, seems to have subsumed itself in the RFU, despite Jersey not being part of England.\"\n\"\nIs it a bad sign if you edit the Wiki during a layover in PHX using their free wireless?\"\n\"\nI have renamed this article as mentioned on the project page.\"\n\"\n:No problem.\"\n\"\nout of curiosity whats the difference between most famous and most popular\n\"\"The most famous meganekko in current American fandom is probably Yomiko Readman of the Read or Die (ROD) series. However, in the 1990s, one of the most popular was arguably Tira Misu from the series Sorcerer Hunters, which was an early manga import to the US. The comedic anime series G-On Riders (a pun that also means \"\"glasses-on\"\") has character designs deliberately incorporating this design.\"\"\nalso what statistics does this come from?\"\n\"\nThanks for your message; I hope that my approach is successful — don't let's count our chickens yet....\"\n\"\n:When I looked at the word 熟冷(숙랭) in dictionary, it says it is just a cold water served for ancestor worship ceremony. I think there might be relationship with the word SungNyung(숭늉) but the dictionary doesn't have any detail about the relationship.\"\n\"\nJust noticed \"\"unique ethnic identity has been harshly repressed\"\" while skiming the article. I am sure there are other examples of non-neutral content.\nI do not see how \"\"Turkey's first female pilot and the adopted daughter of Atatürk, took part in the bombing raids against the Dersim Kurds\"\" is relevant to topic either...\"\n\"\n:No, we are an encyclopaedia, not a \"\"terrorist catching group\"\" - we have no interest in what you do if you think you see someone that looks like him.\"\n\"\n:::Well, almost certainly the problem is with the SVG image itself or with something else Commons-related.  The templates used here are very stable, and work with thousands of flags, so I am certain there is no problem with the template code.\"\n\"\n('''''''''')\"\n\"I like the part about the \"\"UltraOrthodox Jews that the wider Jewish community considers anti-Semitic\"\"! It appears that everyone, even Orthodox Jews, are guilty of anti-semitism. Are these Jews considered anti-semitic simply because they are anti-Zionist? If so, this must be one of the most glaring examples of misuse of the phrase \"\"anti-semitism\"\" for political purposes and it calls into question whether the allegations of anti-semitism made against Arabs are due to the same blind spot.\"\n\"\nSee . Exactly what is wrong with reference style. Full details are given. What do you simply delete sourced material? The intro mostly have anti-nuclear arguments. In order to achieve NPOV there should also be opposing views in the intro.\"\n\"\nIs not purple the color of royality as opposed to blue?\"\n\"\nI wrote a very precise sourced version and Captain Occam insisted it be shovelled away into a non-existent criticism section. The point is not about Lynn. but whether the data presented is up to snuff.\"\n\"\nHutaree is NOT a fanclub, it is a GANG, better yet, A cult that is trying to hide behind the fanclub name, the FBI person in charge of the case even said this is not a fanclub group, other fanclub groups also reported this gang to the FBI, since most fanclub groups have active, and leo police officers as members, they use the fanclub as a way to buy guns from other groups, but the other groups are aware of these type of people and always ignore them or turn them away, like they did with Hutaree.\nHutaree is NOT a fanclub, it is a GANG, better yet, A cult that is trying to hide behind the fanclub name, the FBI person in charge of the case even said this is not a fanclub group, other fanclub groups also reported this gang to the FBI, since most fanclub groups have active, and leo police officers as members, they use the fanclub as a way to buy guns from other groups, but the other groups are aware of these type of people and always ignore them or turn them away, like they did with Hutaree.\"\n\"\nIf the C-27J wins the US JCA competition, I plan on splitting the C-27J models off to their own page. If not, I don't forsee a problem leaving them here, as there is not really enough content otherwise. I will try to add some specs on the J in the next few days. -\"\n\"\n''''''\"\n\"\nThe page which i found last year is more authentic than this page.\"\n\"\n::There's nothing hard to understand with Rihk's comment, he wants to know if we should mark a section in Matt's article called weakness and list his inability to narrow his field of telepathy in a crowded room.\n::However, I believe that this is the show's way of portraying his development of his power.  Imagine you're in a room full of people, who are all shouting at you at the top of their lungs... you try to listen to only one as you walk around the room, but doing so alters the volume of each person.  Soon enough, you'd be likely to have a head-splitter too.\"\n\"\n:There is no valid reason to do so. He is not making personal attacks nor is he making any extortion.\"\n\"\nPossible problems in some articles do not mean that problems should remain in other articles. The best is to source according to policy, there are plenty of sources on this topic. Regards.\"\n\"\nI don't know Montenegrin, but shouldn't \"\"Narodna stranka\"\" be translated as \"\"National Party\"\"?\"\n\"\n:You're going to need multiple sources to back up a claim like that. And I'm pretty sure that your theory isn't true anyway. —\"\n\"\n::okay will do\"\n\"\nWhoever here keeps puting Let go's sales to 13.1  million is absolutely wrong!!! Media Traffic does not cover sales from all countries so there is always an additional 10% to the sales that it claims. (i didnt make that up, media traffic has said it!!!!check it out). Now think about it 13.1 milion + 10% = 14.5 million + sales through years (6 years have passed since its release),  1.5 million (at least)= 16 million +++++++.   Plus: Her official website, her record company and all media say the same thing!!!!!!!! SO STOP IT!!!\"\n\"\n::::The wider problem with the name is that it sounds official. While some bot names should sound official, it is an interesting question whether this one should. I will raise this elsewhere.\"\n\"\nSpeaking about \"\"running counter a reliable source\"\"  EA told us her family died in a fire. So, she can't be Jan Fritzges daughter or Sunshynes sister, cause they're still alive. There must be another Emilie Autumn Liddell in Costa Mesa, California, beside \"\"Emilie Autumn\"\" (the one from JLs facebook), \"\"Emily A. Fritzges\"\" and \"\"Autumn\"\" (daughter of Jan Fritzges). Emilie, Autumn and Fritzges seem to be very common names in CA ;)\"\n\"\n::::::::::::::I'm \"\"afraid of tackling the big-boys\"\"? Good grief, you really don't know me. Check my wiki-history.\n::::::::::::::So, what are you suggesting. 1) That we should never block anyone for incivility or personal attacks, no matter how bad. (That's a legitimate position, by the way). 2) That we should somehow codify what does or does not deserve a block. (That's impossible, by the way). or 3) We should have some sort of bureaucratic fuck-fest to determine when we block. (That would be fun to watch, by the way). or 4) We should requite admins to block anyone who says \"\"shity boo\"\"? You may well successfully pour so much shit on me that I never block anyone\"\n\"\nI don't know\"\n\"\n:I think you're right.  Although it looks as though this article only documents states that were swallowed up rather than ones which fell apart.\"\n\"\nI believe this user is using sock puppets to vandalise other users User pages.\"\n\"\n: I don't know if that is what the editor who wrote those lines had in mind, but that would be correct, yes.\"\n\":Indeed, \"\"Nationality\"\" is generally used to denote citizenship, as opposed to what it means in the census. That quote even has the word \"\"etnii\"\" in it, in proper context. This should definitely clear up any remaining doubts. Thanks!\n\"\nWell I was impressed anyway. ;-) Less impressed though by yet another demonstration that administrators (Tom) are not held to the same standards they so frequently demand of their underlings.\n\"\nThank You for answering my questions.  I really appreciate it.  I am still not really sure why ISKCON must be considered Hindu, yet ISKCON views cannot be considered in Hindu articles.  Seems kind of hypocritical. Another thing is that Prabhuphada's Bhagavad-Gita is the most widely read and also He states it is the only pure translation.  But anyways, if Krishna Conciousness differs from Hinduism, why can't I create articles like Krishna Conscious Cosmology, or Krishna in Krishna Consciousness, or Rama in Krishna Consciousness ect.  I have tried doing this in the past because even though Wikipedia considers ISKCON Hindu they do not allow ISKCON's views in Hindu articles.  I find this hypocritical but I gave up trying to fight it so I made my own articles like Krishna in Krishna Conciousness, or Rama in Krishna Consciousness so I could just present the Krishna Conscious view.  I cited many reliable Krishna Conscious sources on the Krishna Conscious view but my articles were deleted over and over until they banned me.  I don't understand why I got banned.  Isn't it fair that if other religions can have their own views on things like Cosmology, such as Biblical Cosmology, Hindu Cosmology, Buddhist Cosmology, and Jain Cosmology that Krishna Consciousness also have a page Krishna Conscious Cosmology to express its unique view if it is not allowed on Hindu Cosmology?  Another thing about the Naraka (Hinduism) is that I know there is a Naraka page, but a few days ago I saw the Naraka (Buddhism) page and I liked it and from reading the Shrimad-Bhagavatam it seemed sort of familiar to me.  I liked how it talked about all the different Hells one by one giving good description, so I went to see if Hinduism had something like that which talked about all the diferent Hellish planets like Buddhism. but an article did not exist so I spent a few hours to make one.  If this is not okay because ISKCON has \"\"Non-Hindu\"\" beleifs can I make an article entitled Naraka (Krishna Consciousness) so that the beliefs of my religion can be expressed like the beliefs of all the other religions? Sorry for asking so many questions.  Thank You very much for answering all of my questions and for your cooperation, time, and effort.\"\n\"\nLayer 7 is nog used in Profibus DP and PA with the DPV-0 to DPV-2. Layer 7 is only used in Profibus FMS in a special FMS layer. view official literature  The New rapid way to Profibus DP (by www.profibus.com)\"\n\"\nHI I'M AN AUSSIE COON BOONG UNBAN KTHX U FUCKIN JEWISH NAZI CUNTS\"\n"
  },
  {
    "path": "packages/backend-api/package.json",
    "content": "{\n  \"name\": \"@conversationai/moderator-backend-api\",\n  \"description\": \"API Endpoints for OSMod\",\n  \"main\": \"src/server.ts\",\n  \"version\": \"1.1.0\",\n  \"scripts\": {\n    \"build\": \"npm run compile\",\n    \"compile\": \"rm -rf dist && ../../node_modules/.bin/tsc --sourceMap --outDir dist --declaration\",\n    \"test\": \"npm run mocha\",\n    \"mocha\": \"WORKER_RUN_IMMEDIATELY=true ts-mocha 'src/test/**/*.spec.ts' --timeout 200000\",\n    \"lint\": \"find src -name '*.ts' | xargs ../../node_modules/.bin/tslint -c ../../tslint.json\"\n  },\n  \"license\": \"Apache-2.0\",\n  \"dependencies\": {\n    \"@conversationai/moderator-frontend-web\": \"1.1.0\",\n    \"@types/bluebird\": \"^3.5.33\",\n    \"@types/body-parser\": \"^1.19.0\",\n    \"@types/chai\": \"^4.2.16\",\n    \"@types/chai-http\": \"^4.2.0\",\n    \"@types/compression\": \"^1.7.0\",\n    \"@types/convict\": \"^6.0.1\",\n    \"@types/cors\": \"^2.8.10\",\n    \"@types/express\": \"^4.17.11\",\n    \"@types/express-ws\": \"^3.0.0\",\n    \"@types/faker\": \"^5.5.1\",\n    \"@types/helmet\": \"^4.0.0\",\n    \"@types/jsonwebtoken\": \"^8.5.1\",\n    \"@types/kue\": \"^0.11.13\",\n    \"@types/lodash\": \"^4.14.168\",\n    \"@types/mocha\": \"^8.2.2\",\n    \"@types/node\": \"14.14.41\",\n    \"@types/opentype.js\": \"^1.3.1\",\n    \"@types/passport\": \"^1.0.6\",\n    \"@types/passport-google-oauth2\": \"^0.1.3\",\n    \"@types/passport-jwt\": \"^3.0.5\",\n    \"@types/qs\": \"6.9.6\",\n    \"@types/randomstring\": \"^1.1.6\",\n    \"@types/redis\": \"^2.8.28\",\n    \"@types/request\": \"^2.48.5\",\n    \"@types/underscore.string\": \"^0.0.38\",\n    \"@types/validator\": \"^13.1.3\",\n    \"@types/yargs\": \"^16.0.1\",\n    \"bluebird\": \"^3.7.2\",\n    \"body-parser\": \"^1.19.0\",\n    \"canvas\": \"^2.7.0\",\n    \"chai\": \"^4.3.4\",\n    \"chai-http\": \"^4.3.0\",\n    \"compression\": \"^1.7.4\",\n    \"convict\": \"6.0.1\",\n    \"cors\": \"2.8.5\",\n    \"csv-parse\": \"^4.15.3\",\n    \"express\": \"^4.17.1\",\n    \"express-winston\": \"^4.1.0\",\n    \"express-ws\": \"^4.0.0\",\n    \"faker\": \"^5.5.3\",\n    \"googleapis\": \"^71.0.0\",\n    \"he\": \"^1.2.0\",\n    \"helmet\": \"^4.4.1\",\n    \"joi\": \"17.4.0\",\n    \"jsonwebtoken\": \"^8.5.1\",\n    \"kue\": \"^0.11.6\",\n    \"lodash\": \"^4.17.21\",\n    \"mocha\": \"^8.3.2\",\n    \"moment\": \"^2.29.1\",\n    \"mysql2\": \"^2.2.5\",\n    \"opentype.js\": \"^1.3.3\",\n    \"passport\": \"^0.4.1\",\n    \"passport-google-oauth20\": \"^2.0.0\",\n    \"passport-jwt\": \"^4.0.0\",\n    \"qs\": \"^6.10.1\",\n    \"randomstring\": \"^1.1.5\",\n    \"redis\": \"^3.1.0\",\n    \"request\": \"^2.88.2\",\n    \"sequelize\": \"^6.6.2\",\n    \"sequelize-cli\": \"^6.2.0\",\n    \"striptags\": \"^3.1.1\",\n    \"ts-mocha\": \"8.0.0\",\n    \"ts-node-dev\": \"1.1.6\",\n    \"underscore.string\": \"^3.3.5\",\n    \"winston\": \"^3.3.3\",\n    \"yargs\": \"^16.2.0\"\n  }\n}\n"
  },
  {
    "path": "packages/backend-api/src/actions/assignment_updaters.ts",
    "content": "/*\nCopyright 2021 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\nimport {Op} from 'sequelize';\n\nimport {Article, ModeratorAssignment, UserCategoryAssignment} from '../models';\nimport {sendNotification} from '../notification_router';\n\nfunction getUserCategoryAssignment(userIds: Array<number>, categoryId: number) {\n  return userIds.map((id) => {\n    return {\n      userId: id,\n      categoryId,\n    };\n  });\n}\n\nfunction getArticleAssignmentArray(userIds: Array<number>, articleIdsInCategory: Array<number>) {\n  return articleIdsInCategory.reduce((sum: Array<{articleId: number; userId: number; }>, articleId) => {\n    return sum.concat(userIds.map((userId) => {\n      return {\n        articleId,\n        userId,\n      };\n    }));\n  }, []);\n}\n\nasync function removeArticleAssignments(userIds: Array<number>, articleIds: Array<number>) {\n  await ModeratorAssignment.destroy({\n    where: {\n      userId: {\n        [Op.in]: userIds,\n      },\n      articleId: {\n        [Op.in]: articleIds,\n      },\n    },\n  });\n}\n\nexport async function updateCategoryAssignments(categoryId: number, userIds: Array<number>) {\n  const articlesInCategory: Array<Article> = await Article.findAll({\n    where: { categoryId },\n  });\n\n  const articleIdsInCategory = articlesInCategory.map((article) => article.id);\n\n  // Get assignments for the category\n  const assignmentsForCategory = await UserCategoryAssignment.findAll({\n    where: { categoryId },\n  });\n\n  const userIdsToBeRemoved = assignmentsForCategory.reduce((prev: Array<number>, current: UserCategoryAssignment): Array<number> => {\n    const assignmentUserId: number = current.userId;\n    const isInAssignment = userIds.some((userId) => (userId === assignmentUserId));\n    if (isInAssignment) {\n      return prev;\n    } else {\n      return prev.concat(assignmentUserId);\n    }\n  }, []);\n\n  if (userIdsToBeRemoved.length > 0) {\n    await removeArticleAssignments(userIdsToBeRemoved, articleIdsInCategory);\n  }\n\n  const newUserIds = userIds.filter((userId) => {\n    return !assignmentsForCategory.some(\n      (assignment: any) => assignment.userId === userId && assignment.categoryId === categoryId,\n    );\n  });\n\n  // If a user is being assigned we need to clear and then add them to each article with categoryId of categoryId\n  await removeArticleAssignments(newUserIds, articleIdsInCategory);\n  await ModeratorAssignment.bulkCreate(getArticleAssignmentArray(newUserIds, articleIdsInCategory));\n\n  // Now remove/set UserCategoryAssignment\n  if (userIdsToBeRemoved.length > 0) {\n    await UserCategoryAssignment.destroy({\n      where: {\n        userId: {\n          [Op.in]: userIdsToBeRemoved,\n        },\n      },\n    });\n  }\n  await UserCategoryAssignment.bulkCreate(getUserCategoryAssignment(newUserIds, categoryId));\n  await sendNotification('category', 'modify', categoryId);\n  for (const articleId of articleIdsInCategory) {\n    await sendNotification('article', 'modify', articleId);\n  }\n}\n\nexport async function updateArticleAssignments(articleId: number, userIds: Set<number>) {\n  // Get assignments for the category\n  const assignments = await ModeratorAssignment.findAll({\n    where: { articleId },\n  });\n\n  const toRemove = new Array<number>();\n\n  for (const a of assignments) {\n    const id = a.userId;\n    if (userIds.has(id)) {\n      userIds.delete(id);\n    }\n    else {\n      toRemove.push(a.id);\n    }\n  }\n\n  await ModeratorAssignment.bulkCreate(getArticleAssignmentArray(Array.from(userIds), [articleId]));\n  await ModeratorAssignment.destroy({where: {id: {[Op.in]: toRemove }}});\n  await sendNotification('article', 'modify', articleId);\n}\n"
  },
  {
    "path": "packages/backend-api/src/actions/object_updaters.ts",
    "content": "/*\nCopyright 2021 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport {ModerationRule, MODERATION_RULE_ACTION_TYPES_SET, Preselect, Tag, TaggingSensitivity} from '../models';\nimport {sendNotification} from '../notification_router';\n\nexport type ModelType = 'moderation_rule' | 'preselect' | 'tagging_sensitivity';\n\nexport function checkModelType(type: string): type is ModelType {\n  switch (type) {\n    case 'moderation_rule':\n    case 'preselect':\n    case 'tagging_sensitivity':\n      return true;\n  }\n  return false;\n}\n\nexport async function processRangeData(\n  type: ModelType,\n  data: {[key: string]: string | number | boolean | null},\n  setValue: (key: string, value: string | number | boolean | null) => void,\n): Promise<string | null> {\n  for (const k of ['tagId', 'categoryId']) {\n    if (k in data) {\n      let val: number | null;\n      if (data[k] === null) {\n        if (k === 'tagId' && type === 'moderation_rule') {\n          return 'tagId must be set.';\n        }\n        val = null;\n      } else {\n        val = parseInt(data[k] as string, 10);\n        if (isNaN(val)) {\n          return `Invalid value ${data[k]} for field ${k}.`;\n        }\n      }\n      setValue(k, val);\n    }\n  }\n  for (const k of ['lowerThreshold', 'upperThreshold']) {\n    if (k in data) {\n      const val = parseFloat(data[k] as string);\n      if (isNaN(val) || val < 0 || val > 1) {\n        return`Range error: ${k} is not a valid number: ${data[k]}.`;\n      }\n      setValue(k, val);\n    }\n  }\n  if (type === 'moderation_rule') {\n    if ('action' in data) {\n      const action = data.action;\n      if (!MODERATION_RULE_ACTION_TYPES_SET.has(action as string)) {\n        return `Unknown action: ${action}.`;\n      }\n\n      setValue('action', action);\n    }\n  }\n\n  return null;\n}\n\nexport async function createRangeObject(\n  type: ModelType,\n  data: {[key: string]: string | number | boolean | null},\n): Promise<string | null> {\n  const modelData: {[key: string]: string | number | boolean | null } = {};\n\n  const msg = await processRangeData(type, data, (key, value) => modelData[key] = value);\n  if (msg) {\n    return msg;\n  }\n\n  let mandatory_attributes = ['lowerThreshold', 'upperThreshold'];\n  if (type === 'moderation_rule') {\n    mandatory_attributes = [...mandatory_attributes, 'tagId', 'action'];\n  }\n\n  for (const k of mandatory_attributes) {\n    if (!(k in modelData)) {\n      return `Missing mandatory attribute: ${k}.`;\n    }\n  }\n\n  switch (type) {\n    case 'moderation_rule':\n      await ModerationRule.create(data as any);\n      break;\n    case 'preselect':\n      await Preselect.create(data as any);\n      break;\n    case 'tagging_sensitivity':\n      await TaggingSensitivity.create(data as any);\n      break;\n  }\n\n  sendNotification('global');\n  return null;\n}\n\nexport async function modifyRangeObject(\n  type: ModelType,\n  id: number,\n  data: {[key: string]: string | number | boolean | null},\n): Promise<string | null> {\n  let object: ModerationRule | Preselect | TaggingSensitivity | null;\n  switch (type) {\n    case 'moderation_rule':\n      object = await ModerationRule.findByPk(id);\n      break;\n    case 'preselect':\n      object = await Preselect.findByPk(id);\n      break;\n    case 'tagging_sensitivity':\n      object = await TaggingSensitivity.findByPk(id);\n      break;\n  }\n\n  if (!object) {\n    return 'Not found';\n  }\n\n  const msg = await processRangeData(type, data, (key, value) => object!.set(key as any, value as any));\n  if (msg) {\n    return msg;\n  }\n\n  await object.save();\n  sendNotification('global');\n  return null;\n}\n\nexport async function deleteRangeObject(type: ModelType | 'tag', objectId: number ) {\n  switch (type) {\n    case 'moderation_rule':\n      await ModerationRule.destroy({where: {id: objectId}});\n      break;\n    case 'preselect':\n      await Preselect.destroy({where: {id: objectId}});\n      break;\n    case 'tagging_sensitivity':\n      await TaggingSensitivity.destroy({where: {id: objectId}});\n      break;\n    case 'tag':\n      await Tag.destroy({where: {id: objectId}});\n      break;\n  }\n  sendNotification('global');\n}\n\nexport async function createTagObject(\n  data: {[key: string]: string | number | boolean | null},\n): Promise<string | null> {\n  for (const k of ['color', 'key', 'label']) {\n    if (typeof data[k] !== 'string') {\n      return `Tag creation error: Missing/invalid attribute ${k}.`;\n    }\n  }\n\n  const {color, description, key, label, isInBatchView, inSummaryScore, isTaggable} = data;\n\n  await Tag.create({\n    color, description, key, label,\n    isInBatchView: !!isInBatchView,\n    inSummaryScore: !!inSummaryScore,\n    isTaggable: !!isTaggable,\n  });\n\n  sendNotification('global');\n  return null;\n}\n\nexport async function modifyTagObject(\n  id: number,\n  data: {[key: string]: string | number | boolean | null},\n): Promise<string | null> {\n  const tag = await Tag.findByPk(id);\n  if (!tag) {\n    return 'Not found';\n  }\n\n  for (const k of ['color', 'key', 'label', 'description']) {\n    if (k in data) {\n      if (typeof data[k] !== 'string' && (k !== 'description' || data[k] !== null)) {\n        return `Tag modification error: Invalid attribute ${k}.`;\n      }\n      tag.set(k as 'color' | 'key' | 'label' | 'description', data[k] as string);\n    }\n  }\n\n  for (const k of ['isInBatchView', 'inSummaryScore', 'isTaggable']) {\n    if (k in data) {\n      tag.set(k as 'isInBatchView' | 'inSummaryScore' | 'isTaggable', !!data[k]);\n    }\n  }\n\n  await tag.save();\n  sendNotification('global');\n  return null;\n}\n"
  },
  {
    "path": "packages/backend-api/src/api/assistant/index.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as express from 'express';\n\nimport { logger } from '../../logger';\nimport { CommentScoreRequest } from '../../models';\nimport { IScoreData } from '../../pipeline/shim';\nimport {\n  enqueueProcessMachineScoreTask,\n} from '../../processing';\nimport { REPLY_SUCCESS } from '../constants';\nimport { onlyServices } from '../util/permissions';\nimport { validateRequest } from '../util/validation';\nimport { scoreSchema } from './schema';\n\nexport function createAssistant(): express.Router {\n  const router = express.Router({\n    caseSensitive: true,\n    mergeParams: true,\n  });\n\n  // Return for score information via Pipeline\n  router.post('/scores/:id',\n    validateRequest(scoreSchema),\n    async (req, res, next) => {\n      const { runImmediately, scores, summaryScores } = req.body;\n      const { id } = req.params;\n\n      logger.info('Process score data to worker for score request ID ', id, 'Body', req.body);\n\n      const scoreData: IScoreData = {\n        scores,\n        summaryScores,\n      };\n\n      // Obtain information about the score request by ID\n      const scoreRequest = await CommentScoreRequest.findByPk(id);\n\n      if (scoreRequest) {\n        await enqueueProcessMachineScoreTask(\n          scoreRequest.commentId!,\n          scoreRequest.userId!,\n          scoreData,\n          runImmediately);\n        res.json(REPLY_SUCCESS);\n        next();\n      } else {\n        logger.error(`Score request not found for provided id: ${id}`);\n        res.status(400).json({\n          status: 'error',\n          errors: 'Score request not found by provided scoreRequestId',\n        });\n\n        return;\n      }\n    },\n  );\n\n  return router;\n}\n\nexport function createAssistantRouter(): express.Router {\n  const router = express.Router({\n    caseSensitive: true,\n    mergeParams: true,\n  });\n\n  router.use('*', onlyServices);\n\n  router.use('/', createAssistant());\n\n  return router;\n}\n"
  },
  {
    "path": "packages/backend-api/src/api/assistant/schema.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as Joi from 'joi';\n\nexport const scoreItemSchema = Joi.object({\n  score: Joi.number().required(),\n  begin: Joi.number().optional(),\n  end: Joi.number().optional(),\n});\n\nexport const scoreDataSchema = Joi.object().pattern(\n  /^[A-Z_]+$/,\n  Joi.array().items(scoreItemSchema),\n).required();\n\nexport const summaryScoreDataSchema = Joi.object().pattern(\n  /^[A-Z_]+$/,\n  Joi.number().required(),\n).required();\n\nexport const scoreSchema = Joi.object({\n  runImmediately: Joi.boolean().optional(),\n  scores: scoreDataSchema.required(),\n  summaryScores: summaryScoreDataSchema.required(),\n});\n"
  },
  {
    "path": "packages/backend-api/src/api/constants.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\n// We send this back when we want to send back a success response, but don't need to send back any data.\n\nexport const REPLY_SUCCESS_VALUE = 'success';\nexport const REPLY_SUCCESS = { status: REPLY_SUCCESS_VALUE};\n"
  },
  {
    "path": "packages/backend-api/src/api/router.ts",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as express from 'express';\n\nimport { createAuthConfigRouter } from '../auth/router';\nimport { createAssistantRouter } from './assistant';\nimport { createServicesRouter } from './services';\n\nexport function createApiRouter(authenticator: any) {\n  const router = express.Router({\n    caseSensitive: true,\n    mergeParams: true,\n  });\n\n  if (authenticator) {\n    // Require tokens for our CRUD endpoints.\n    // Not necessary for `options` requests.\n    ['get', 'post', 'patch', 'delete'].forEach((method) => {\n      (router as any)[method]('*', authenticator);\n    });\n  }\n\n  router.use('/', createAuthConfigRouter());\n\n  // The services API provides custom endpoints for our clients which would\n  // normally be awkward REST queries or are unrelated to database models.\n  router.use('/services', createServicesRouter());\n\n  // The assistant API provides callbacks for assistant users to send per-comment\n  // scores into OSMOD. These are often, but not always, the result of a scoring\n  // request when a new comment is added by the publisher.\n  router.use('/assistant', createAssistantRouter());\n\n  return router;\n}\n"
  },
  {
    "path": "packages/backend-api/src/api/services/assignments.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as express from 'express';\n\nimport {updateArticleAssignments, updateCategoryAssignments} from '../../actions/assignment_updaters';\nimport {\n  Article,\n  User,\n} from '../../models';\nimport {REPLY_SUCCESS} from '../constants';\n\nexport async function countAssignments(user: User) {\n  const articles: Array<Article> = await user.getAssignedArticles();\n  return articles.reduce((sum, a) => sum + a.unmoderatedCount, 0);\n}\n\nexport function createAssignmentsService(): express.Router {\n  const router = express.Router({\n    caseSensitive: true,\n    mergeParams: true,\n  });\n\n  // POST to category/id who's body.data contains userId[]\n  router.post('/categories/:id', async (req, res) => {\n    const categoryId = parseInt(req.params.id, 10);\n    const userIds: Array<number> = req.body.data.map((s: any) => parseInt(s, 10));\n\n    await updateCategoryAssignments(categoryId, userIds);\n\n    res.json(REPLY_SUCCESS);\n  });\n\n  // POST to articles/id who's body.data contains userId[]\n  router.post('/article/:id', async (req, res) => {\n    const articleId = parseInt(req.params.id, 10);\n    const userIds: Set<number> = new Set(req.body.data.map((s: any) => parseInt(s, 10)));\n\n    await updateArticleAssignments(articleId, userIds);\n    res.json(REPLY_SUCCESS);\n  });\n\n  return router;\n}\n"
  },
  {
    "path": "packages/backend-api/src/api/services/authorCounts.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as Bluebird from 'bluebird';\nimport * as express from 'express';\nimport * as Joi from 'joi';\n\nimport { Comment } from '../../models';\nimport { validateAndSendResponse, validateRequest } from '../util/validation';\n\nconst validateInput = validateRequest(Joi.object({\n  data: Joi.alternatives().try(\n    Joi.array().items(Joi.string()),\n    Joi.string(),\n  ).required(),\n}));\n\nconst validateOutputAndSendResponse = validateAndSendResponse(\n  Joi.object({\n    arg: Joi.string(),\n    value: Joi.object({\n      approvedCount: Joi.number().required(),\n      rejectedCount: Joi.number().required(),\n    }),\n  }).unknown().required(),\n);\n\nexport interface IAuthorCounts {\n  approvedCount: number;\n  rejectedCount: number;\n}\n\nexport async function getAuthorCounts(authorSourceId: string): Promise<IAuthorCounts> {\n  const approvedCount = await Comment.count({ where: { authorSourceId, isAccepted: true } });\n  const rejectedCount = await Comment.count({ where: { authorSourceId, isAccepted: false } });\n\n  return {\n    approvedCount,\n    rejectedCount,\n  };\n}\n\nexport function createAuthorCountsService(): express.Router {\n  const router = express.Router({\n    caseSensitive: true,\n    mergeParams: true,\n  });\n\n  router.post(\n    '/',\n    validateInput,\n    async ({ body }, res, next) => {\n      const dataArray = Array.isArray(body.data) ? body.data : [body.data];\n\n      const data = await Bluebird.mapSeries(dataArray, getAuthorCounts);\n\n      const lookup = dataArray.reduce((sum: any, authorId: string, i: number) => {\n        sum[authorId] = data[i];\n\n        return sum;\n      }, {});\n\n      validateOutputAndSendResponse(lookup, res, next);\n    },\n  );\n\n  return router;\n}\n"
  },
  {
    "path": "packages/backend-api/src/api/services/commentActions.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as express from 'express';\nimport * as Joi from 'joi';\n\nimport {User} from '../../models';\nimport {\n  CommentActions,\n  enqueueAddTagTask,\n  enqueueCommentAction,\n  enqueueConfirmTagTask,\n  enqueueRejectTagTask,\n  enqueueRemoveTagTask,\n  enqueueResetTagTask,\n  enqueueScoreAction,\n  ScoreActions,\n} from '../../processing';\nimport { REPLY_SUCCESS } from '../constants';\nimport { dataSchema, validateRequest } from '../util/validation';\n\nexport const detailAddTagSchema = Joi.object({\n  tagId: Joi.string().required(),\n  annotationStart: Joi.number().required(),\n  annotationEnd: Joi.number().greater(Joi.ref('annotationStart')).required(),\n});\n\nexport const commentActionSchema = Joi.object({\n  commentId: Joi.string().required(),\n});\n\nconst validateCommentActionRequest = validateRequest(dataSchema(commentActionSchema));\nconst validateDetailRequest = (schema: Joi.Schema) => validateRequest(dataSchema(schema));\n\n/**\n * Queues an accept, reject, defer or highlight action. Accepts array of comment ids, or a single comment id.\n */\nexport function queueMainAction(action: CommentActions): express.RequestHandler {\n  return async ({ body, user }, res) => {\n    const dataArray = Array.isArray(body.data) ? body.data : [body.data];\n    const isBatchAction = (dataArray.length > 1);\n\n    for (const data of dataArray) {\n      const { commentId } = data;\n      const parsedCommentId = parseInt(commentId, 10);\n      await enqueueCommentAction(\n        action,\n        (user as User).id,\n        parsedCommentId,\n        isBatchAction,\n        body.runImmediately);\n    }\n\n    res.json(REPLY_SUCCESS);\n  };\n}\n\n/**\n * Queues an tag action. Accepts array of comment ids, or a single comment id.\n */\nexport function queueScoreCommentSummaryAction(action: ScoreActions): express.RequestHandler {\n  return async ({ body, params, user }, res) => {\n    const dataArray = Array.isArray(body.data) ? body.data : [body.data];\n    const parsedTagId = parseInt(params.tagid, 10);\n\n    for (const { commentId } of dataArray) {\n      const parsedCommentId = parseInt(commentId, 10);\n      await enqueueScoreAction(\n        action,\n        (user as User).id,\n        parsedCommentId,\n        parsedTagId,\n        body.runImmediately);\n    }\n\n    res.json(REPLY_SUCCESS);\n  };\n}\n\n/**\n * Queues an tag action. Accepts array of comment ids, or a single comment id.\n */\nexport function queueSingleScoreAction(action: ScoreActions): express.RequestHandler {\n  return async ({ body, params, user }, res) => {\n    const parsedCommentId = parseInt(params.commentid, 10);\n    const parsedTagId = parseInt(params.tagid, 10);\n    await enqueueScoreAction(\n      action,\n      (user as User).id,\n      parsedCommentId,\n      parsedTagId,\n      body.runImmediately);\n    res.json(REPLY_SUCCESS);\n  };\n}\n\nexport function createCommentActionsService(): express.Router {\n  const router = express.Router({\n    caseSensitive: true,\n    mergeParams: true,\n  });\n\n  router.post('/reset',\n    validateCommentActionRequest,\n    queueMainAction('resetComments'),\n  );\n\n  router.post('/approve',\n    validateCommentActionRequest,\n    queueMainAction('acceptComments'),\n  );\n\n  router.post('/approve-flags',\n    validateCommentActionRequest,\n    queueMainAction('acceptCommentsAndFlags'),\n  );\n\n  router.post('/resolve-flags',\n    validateCommentActionRequest,\n    queueMainAction('resolveFlags'),\n  );\n\n  router.post('/highlight',\n    validateCommentActionRequest,\n    queueMainAction('highlightComments'),\n  );\n\n  router.post('/reject',\n    validateCommentActionRequest,\n    queueMainAction('rejectComments'),\n  );\n\n  router.post('/reject-flags',\n    validateCommentActionRequest,\n    queueMainAction('rejectCommentsAndFlags'),\n  );\n\n  router.post('/defer',\n    validateCommentActionRequest,\n    queueMainAction('deferComments'),\n  );\n\n  router.post('/tag/:tagid',\n    validateCommentActionRequest,\n    queueScoreCommentSummaryAction('tagComments'),\n  );\n\n  router.post('/tagCommentSummaryScores/:tagid',\n    validateCommentActionRequest,\n    queueScoreCommentSummaryAction('tagCommentSummaryScores'),\n  );\n\n  router.post('/:commentid/tagCommentSummaryScores/:tagid/confirm',\n    queueSingleScoreAction('confirmCommentSummaryScore'),\n  );\n\n  router.post('/:commentid/tagCommentSummaryScores/:tagid/reject',\n    queueSingleScoreAction('rejectCommentSummaryScore'),\n  );\n\n  router.post('/:commentid/scores',\n    validateDetailRequest(detailAddTagSchema),\n    async ({ body, params, user }, res) => {\n      await enqueueAddTagTask(\n        parseInt(params.commentid, 10),\n        parseInt(body.data.tagId, 10),\n        (user as User).id,\n        body.data.annotationStart,\n        body.data.annotationEnd,\n        body.runImmediately);\n\n      res.json(REPLY_SUCCESS);\n    },\n  );\n\n  router.post('/:commentid/scores/:commentscoreid/reset',\n    async ({ body, params}, res) => {\n      await enqueueResetTagTask(parseInt(params.commentscoreid, 10), body.runImmediately);\n      res.json(REPLY_SUCCESS);\n    },\n  );\n\n  router.post('/:commentid/scores/:commentscoreid/confirm',\n    async ({ body, params, user}, res) => {\n      await enqueueConfirmTagTask(\n        (user as User).id,\n        parseInt(params.commentscoreid, 10),\n        body.runImmediately);\n      res.json(REPLY_SUCCESS);\n    },\n  );\n\n  router.post('/:commentid/scores/:commentscoreid/reject',\n    async ({ body, params, user}, res) => {\n      await enqueueRejectTagTask(\n        (user as User).id,\n        parseInt(params.commentscoreid, 10),\n        body.runImmediately);\n      res.json(REPLY_SUCCESS);\n    },\n  );\n\n  router.delete('/:commentid/scores/:commentscoreid',\n    async ({ body, params}, res) => {\n      await enqueueRemoveTagTask(parseInt(params.commentscoreid, 10), body.runImmediately);\n      res.json(REPLY_SUCCESS);\n    },\n  );\n\n  return router;\n}\n"
  },
  {
    "path": "packages/backend-api/src/api/services/commentSources.ts",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as express from 'express';\n\nimport { youtubeActivateChannel, youtubeSynchronizeChannel } from '../../integrations';\nimport {\n  Category,\n  User,\n  USER_GROUP_YOUTUBE,\n} from '../../models';\nimport { enqueue, registerTask } from '../../processing/util';\nimport { REPLY_SUCCESS } from '../constants';\n\n/**\n * API endpoints to control comment sources.\n */\n\nconst ACTION_ACTIVATE = 'activate';\nconst ACTION_SYNC = 'sync';\n\nexport interface ISynchronizeChannelData {\n  ownerId: number;\n  channelId: number;\n}\n\nasync function _youtubeSynchronizeChannel(\n  owner: User,\n  channel: Category,\n) {\n  await enqueue<ISynchronizeChannelData>('youtubeSynchronizeChannel', {ownerId: owner.id, channelId: channel.id});\n}\n\nregisterTask<ISynchronizeChannelData>('youtubeSynchronizeChannel', async (data: ISynchronizeChannelData) => {\n  const owner = await User.findByPk(data.ownerId);\n  const channel = await Category.findByPk(data.channelId);\n  if (!owner) {\n    throw new Error(`Youtube Sync failed: Owner ${data.ownerId} does not exist`);\n  }\n  if (!channel) {\n    throw new Error(`Youtube Sync failed: Channel ${data.channelId} does not exist`);\n  }\n\n  await youtubeSynchronizeChannel(owner, channel);\n});\n\nconst ACTIONS = new Map([\n  [ACTION_ACTIVATE,  new Map([[USER_GROUP_YOUTUBE, youtubeActivateChannel]])],\n  [ACTION_SYNC, new Map([[USER_GROUP_YOUTUBE, _youtubeSynchronizeChannel]])],\n]);\n\nfunction createAction(actionId: string): express.RequestHandler {\n  return async (req, res, next) => {\n    const category = await Category.findOne({where: {id: req.params.categoryId}});\n    if (!category) {\n      res.status(400).json({error: 'No such category'});\n      next();\n      return;\n    }\n\n    const owner = await category.getOwner();\n    if (!owner) {\n      res.status(400).json({error: 'Category has no owner'});\n      next();\n      return;\n    }\n\n    const action = ACTIONS.get(actionId)!.get(owner.group);\n    if (!action) {\n      res.status(400).json({error: `Category does not support action ${action}`});\n      next();\n      return;\n    }\n\n    try {\n      await action(owner, category, req.body.data);\n    }\n    catch (e) {\n      res.status(400).json({error: `Something went wrong: ${e}`});\n      next();\n      return;\n    }\n    res.json(REPLY_SUCCESS);\n    next();\n  };\n}\n\nexport function createCommentSourcesService(): express.Router {\n  const router = express.Router({\n    caseSensitive: true,\n    mergeParams: true,\n  });\n\n  router.post('/activate/:categoryId', createAction(ACTION_ACTIVATE));\n  router.get('/sync/:categoryId', createAction(ACTION_SYNC));\n  return router;\n}\n"
  },
  {
    "path": "packages/backend-api/src/api/services/editComment.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as express from 'express';\nimport * as Joi from 'joi';\n\nimport { logger } from '../../logger';\nimport { Comment } from '../../models';\nimport { enqueueSendCommentForScoringTask } from '../../processing';\nimport { REPLY_SUCCESS } from '../constants';\nimport { validateRequest } from '../util/validation';\n\nconst validateEditCommentRequest = validateRequest(Joi.object({\n  data: Joi.object({\n    commentId: Joi.string().required(),\n    text: Joi.string(),\n    authorName: Joi.string(),\n    authorLocation: Joi.string(),\n  }),\n}));\n\n/**\n * Service route for editing comment text.\n */\nexport function createEditCommentTextService(): express.Router {\n  const router = express.Router({\n      caseSensitive: true,\n      mergeParams: true,\n  });\n\n  router.patch(\n    '/',\n    validateEditCommentRequest,\n    async ({ body }, res) => {\n      try {\n        const { commentId, text, authorName, authorLocation } = body.data;\n        const parsedCommentId = parseInt(commentId, 10);\n        const comment = await Comment.findByPk(parsedCommentId);\n\n        if (!comment) {\n          res.status(404).json({ status: 'error', errors: 'comment not found' });\n          return;\n        }\n\n        const author = {\n          ...comment.author,\n          name: authorName ? authorName : comment.author.name,\n          location: authorLocation ? authorLocation : comment.author.location,\n        };\n\n        // update text and author fields of a comment\n        await comment.update({\n          text,\n          author,\n        });\n\n        enqueueSendCommentForScoringTask(commentId);\n      } catch (err) {\n        logger.error('Edit Comment error: ', err.name, err.message);\n        return;\n      }\n\n      res.status(200).json(REPLY_SUCCESS);\n    },\n  );\n\n  return router;\n}\n"
  },
  {
    "path": "packages/backend-api/src/api/services/histogramScores/index.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as express from 'express';\nimport * as Joi from 'joi';\n\nimport {SUMMARY_SCORE_TAG} from '../../../models';\nimport { validateAndSendResponse } from '../../util/validation';\nimport {\n  getHistogramScoresForArticle,\n  getHistogramScoresForArticleByDate,\n  getHistogramScoresForCategory,\n  getHistogramScoresForCategoryByDate,\n  getMaxSummaryScoreForArticle,\n  getMaxSummaryScoreForCategory,\n  ICommentDated,\n  ICommentScored, NotFoundError,\n  renderScoresToPNG,\n  sortComments,\n} from './util';\n\nexport interface ICommentScoredOrDatedWithStringId {\n  commentId: string;\n}\n\nexport interface ICommentScoredWithStringId extends ICommentScoredOrDatedWithStringId {\n  score: number;\n}\n\nexport interface ICommentDatedWithStringId extends ICommentScoredOrDatedWithStringId {\n  date: string;\n}\n\nconst validateScoredCommentsAndSendResponse = validateAndSendResponse<Array<ICommentScoredWithStringId>>(\n  Joi.array().items(\n    Joi.object().keys({\n      commentId: Joi.string().required(),\n      score: Joi.number().required(),\n    }),\n  ),\n);\n\nconst validateDatedCommentsAndSendResponse = validateAndSendResponse<Array<ICommentDatedWithStringId>>(\n  Joi.array().items(\n    Joi.object().keys({\n      commentId: Joi.string().required(),\n      date: Joi.date().required(),\n    }),\n  ),\n);\n\nfunction stringifyIds(arr: Array<ICommentScored>): Array<ICommentScoredWithStringId>;\nfunction stringifyIds(arr: Array<ICommentDated>): Array<ICommentDatedWithStringId>;\nfunction stringifyIds(arr: Array<any>): Array<any> {\n  return arr.map((a) => {\n    return Object.assign({}, a, {\n      commentId: a.commentId.toString(),\n    });\n  });\n}\n\nasync function scoresToChart(\n  groupBy: 'date' | 'score',\n  getter: () => Promise<Array<any>>,\n  req: express.Request,\n  res: express.Response,\n  next: express.NextFunction,\n) {\n  try {\n    const data = await getter();\n\n    const { query: { width, height, columnCount, showAll } } = req;\n\n    const parsedWidth = width ? parseInt(width as string, 10) : undefined;\n    const parsedHeight = height ? parseInt(height as string, 10) : undefined;\n    const parsedColumnCount = columnCount ? parseInt(columnCount as string, 10) : undefined;\n    const parsedShowAll = showAll ? (showAll === 'true') : false;\n\n    res.setHeader('Content-Type', 'image/png');\n    renderScoresToPNG(\n      data,\n      groupBy,\n      parsedWidth,\n      parsedHeight,\n      parsedColumnCount,\n      parsedShowAll,\n    ).pngStream().pipe(res);\n  } catch (e) {\n    if (e instanceof NotFoundError) {\n      res.status(404).send(e.message);\n      next();\n    } else {\n      next(e);\n    }\n  }\n}\n\nexport function createHistogramScoresService(): express.Router {\n  const router = express.Router({\n    caseSensitive: true,\n    mergeParams: true,\n  });\n\n  router.get('/categories/:id/byDate', async ({ params: { id }, query: { sort }}, res, next) => {\n    try {\n      const categoryId = id === 'all' ? id : parseInt(id, 10);\n      const data = await getHistogramScoresForCategoryByDate(categoryId);\n      const sortedData = await sortComments(data, sort as string);\n\n      validateDatedCommentsAndSendResponse(stringifyIds(sortedData), res, next);\n    } catch (e) {\n      if (e instanceof NotFoundError) {\n        res.status(404).send(e.message);\n        next();\n      } else {\n        next(e);\n      }\n    }\n  });\n\n  router.get('/categories/:id/byDate/chart', async (req, res, next) => {\n    return scoresToChart('date', () => {\n      const { params: { id }} = req;\n      const categoryId = id === 'all' ? id : parseInt(id, 10);\n      return getHistogramScoresForCategoryByDate(categoryId);\n    }, req, res, next);\n  });\n\n  router.get('/categories/:id/tags/:tagId', async ({ params: { id, tagId }, query: { sort }}, res, next) => {\n    try {\n      const categoryId = id === 'all' ? id : parseInt(id, 10);\n      const tagIdNumber = parseInt(tagId, 10);\n      const data = await getHistogramScoresForCategory(categoryId, tagIdNumber);\n      const sortedData = await sortComments(data, sort as string);\n\n      validateScoredCommentsAndSendResponse(stringifyIds(sortedData), res, next);\n    } catch (e) {\n      if (e instanceof NotFoundError) {\n        res.status(404).send(e.message);\n        next();\n      } else {\n        next(e);\n      }\n    }\n  });\n\n  router.get('/categories/:id/summaryScore', async ({ params: { id }, query: { sort }}, res, next) => {\n    try {\n      const categoryId = id === 'all' ? id : parseInt(id, 10);\n      const data = await getMaxSummaryScoreForCategory(categoryId);\n      const sortedData = await sortComments(data, sort as string);\n\n      validateScoredCommentsAndSendResponse(stringifyIds(sortedData), res, next);\n    } catch (e) {\n      if (e instanceof NotFoundError) {\n        res.status(404).send(e.message);\n        next();\n      } else {\n        next(e);\n      }\n    }\n  });\n\n  router.get('/categories/:id/tags/:tagId/chart', async (req, res, next) => {\n    return scoresToChart('score', () => {\n      const { params: { id, tagId }} = req;\n      const categoryId = id === 'all' ? id : parseInt(id, 10);\n      if (tagId === SUMMARY_SCORE_TAG) {\n        return getMaxSummaryScoreForCategory(categoryId);\n      }\n\n      const tagIdNumber = parseInt(tagId, 10);\n      return getHistogramScoresForCategory(categoryId, tagIdNumber);\n\n    }, req, res, next);\n  });\n\n  router.get('/articles/:id/byDate', async ({ params: { id }, query: { sort }}, res, next) => {\n    try {\n      const data = await getHistogramScoresForArticleByDate(parseInt(id, 10));\n      const sortedData = await sortComments(data, sort as string);\n\n      validateDatedCommentsAndSendResponse(stringifyIds(sortedData), res, next);\n    } catch (e) {\n      if (e instanceof NotFoundError) {\n        res.status(404).send(e.message);\n        next();\n      } else {\n        next(e);\n      }\n    }\n  });\n\n  router.get('/articles/:id/byDate/chart', async (req, res, next) => {\n    return scoresToChart('date', () => {\n      const { params: { id } } = req;\n\n      return getHistogramScoresForArticleByDate(parseInt(id, 10));\n    }, req, res, next);\n  });\n\n  router.get('/articles/:id/tags/:tagId', async ({ params, query}, res, next) => {\n    const { id, tagId } = params;\n    const sort = query.sort as string;\n    const tagIdNumber = parseInt(tagId, 10);\n\n    try {\n      const data = await getHistogramScoresForArticle(parseInt(id, 10), tagIdNumber);\n      const sortedData = await sortComments(data, sort);\n\n      validateScoredCommentsAndSendResponse(stringifyIds(sortedData), res, next);\n    } catch (e) {\n      if (e instanceof NotFoundError) {\n        res.status(404).send(e.message);\n        next();\n      } else {\n        next(e);\n      }\n    }\n  });\n\n  router.get('/articles/:id/tags/:tagId/chart', async (req, res, next) => {\n    return scoresToChart('score', () => {\n      const { params: { id, tagId } } = req;\n      const articleId = parseInt(id, 10);\n      if (tagId === SUMMARY_SCORE_TAG) {\n        return getMaxSummaryScoreForArticle(articleId);\n      }\n\n      if (tagId === 'DATE') {\n        return getHistogramScoresForArticleByDate(articleId);\n      }\n\n      const tagIdNumber = parseInt(tagId, 10);\n      return getHistogramScoresForArticle(articleId, tagIdNumber);\n    }, req, res, next);\n  });\n\n  router.get('/articles/:id/summaryScore', async ({ params, query}, res, next) => {\n    try {\n      const { id } = params;\n      const sort = query.sort as string;\n      const data = await getMaxSummaryScoreForArticle(parseInt(id, 10));\n      const sortedData = await sortComments(data, sort);\n\n      validateScoredCommentsAndSendResponse(stringifyIds(sortedData), res, next);\n    } catch (e) {\n      if (e instanceof NotFoundError) {\n        res.status(404).send(e.message);\n        next();\n      } else {\n        next(e);\n      }\n    }\n  });\n\n  return router;\n}\n"
  },
  {
    "path": "packages/backend-api/src/api/services/histogramScores/util.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nconst { Canvas } = require('canvas');\n\nimport { QueryTypes } from 'sequelize';\n\nimport { DotChartRenderer, groupByDateColumns, groupByScoreColumns } from '@conversationai/moderator-frontend-web';\n\nimport { Article, Category, Tag } from '../../../models';\nimport { sequelize } from '../../../sequelize';\nimport { sortCommentIds } from '../../util/sortCommentIds';\n\nexport interface ICommentScoredOrDated {\n  commentId: number;\n}\n\nexport interface ICommentScored extends ICommentScoredOrDated {\n  score: number;\n}\n\nexport interface ICommentDated extends ICommentScoredOrDated {\n  date: string;\n}\n\nexport class NotFoundError extends Error {\n\n}\n\nexport async function sortComments(data: Array<ICommentScored>, sortQuery?: string): Promise<Array<ICommentScored>>;\nexport async function sortComments(data: Array<ICommentDated>, sortQuery?: string): Promise<Array<ICommentDated>>;\nexport async function sortComments<T extends ICommentScoredOrDated>(data: Array<T>, sortQuery?: string): Promise<Array<T>> {\n  if ((sortQuery === 'score') || (sortQuery === '-score')) {\n    // Any here, because the above check already proves its a ICommentScored\n    let sortedByScore = data.sort((a: any, b: any) => a.score - b.score);\n\n    if (sortQuery === '-score') {\n      sortedByScore = sortedByScore.reverse();\n    }\n\n    return sortedByScore;\n  }\n\n  if (!sortQuery) {\n    return data;\n  }\n\n  const sortedIds = await sortCommentIds(\n    data.map((d) => d.commentId),\n    sortQuery.split(','),\n  );\n\n  return data.sort((a, b) => {\n    return sortedIds.indexOf(a.commentId) - sortedIds.indexOf(b.commentId);\n  });\n}\n\n/**\n * Get the max score for each comment across all categories given a tag.\n */\nexport async function getHistogramScoresForAllCategories(tagId: number): Promise<Array<ICommentScored>> {\n  const tag = await Tag.findByPk(tagId);\n  if (!tag) { throw new NotFoundError(`Could not find tag ${tagId}`); }\n\n  return sequelize.query(\n    'SELECT comment_summary_scores.score AS score, comment_summary_scores.commentId ' +\n    'FROM comments ' +\n    'JOIN comment_summary_scores ON comment_summary_scores.commentId = comments.id ' +\n    `AND comment_summary_scores.tagId = :tagId ` +\n    'WHERE comments.isScored = true ' +\n    'AND comments.isModerated = false ' +\n    'ORDER BY score DESC',\n    {\n      replacements: {\n        tagId,\n      },\n      type: QueryTypes.SELECT,\n    },\n  );\n}\n\n/**\n * Get the max score for each comment across all categories.\n */\nexport async function getHistogramScoresForAllCategoriesByDate(): Promise<Array<ICommentDated>> {\n  return sequelize.query(\n    'SELECT comments.id as commentId, comments.sourceCreatedAt as date ' +\n    'FROM comments ' +\n    'WHERE comments.isModerated = false ',\n    {\n      type: QueryTypes.SELECT,\n    },\n  );\n}\n\n/**\n * Get the max score for each comment in a category given a tag. If `categoryId` is the\n * string value \"all\", then this just calls `getHistogramScoresForAllCategories`.\n */\nexport async function getHistogramScoresForCategory(categoryId: number | 'all', tagId: number): Promise<Array<ICommentScored>> {\n  if (categoryId === 'all') {\n    return getHistogramScoresForAllCategories(tagId);\n  }\n\n  const category = await Category.findByPk(categoryId);\n  if (!category) { throw new NotFoundError(`Could not find category ${categoryId}`); }\n\n  const tag = await Tag.findByPk(tagId);\n  if (!tag) { throw new NotFoundError(`Could not find tag ${tagId}`); }\n\n  return sequelize.query(\n    'SELECT comment_summary_scores.score AS score, comment_summary_scores.commentId ' +\n    'FROM comments ' +\n    'JOIN articles ON articles.id = comments.articleId ' +\n    'JOIN comment_summary_scores ON comment_summary_scores.commentId = comments.id ' +\n    `AND comment_summary_scores.tagId = :tagId ` +\n    'WHERE articles.categoryId = :categoryId ' +\n    'AND comments.isScored = true ' +\n    'AND comments.isModerated = false ' +\n    'ORDER BY score DESC',\n    {\n      replacements: {\n        categoryId,\n        tagId,\n      },\n      type: QueryTypes.SELECT,\n    },\n  );\n}\n\n/**\n * Get the max score for each comment in a category. If `categoryId` is the\n * string value \"all\", then this just calls `getHistogramScoresForAllCategoriesByDate`.\n */\nexport async function getHistogramScoresForCategoryByDate(categoryId: number | 'all'): Promise<Array<ICommentDated>> {\n  if (categoryId === 'all') {\n    return getHistogramScoresForAllCategoriesByDate();\n  }\n\n  const category = await Category.findByPk(categoryId);\n  if (!category) { throw new NotFoundError(`Could not find category ${categoryId}`); }\n\n  return sequelize.query(\n    'SELECT comments.id as commentId, comments.sourceCreatedAt as date ' +\n    'FROM comments ' +\n    'JOIN articles ON articles.id = comments.articleId ' +\n    'WHERE articles.categoryId = :categoryId ' +\n    'AND comments.isModerated = false ',\n    {\n      replacements: {\n        categoryId,\n      },\n      type: QueryTypes.SELECT,\n    },\n  );\n}\n\n/**\n * Get the max score for each comment in an article given a tag.\n */\nexport async function getHistogramScoresForArticle(articleId: number, tagId: number): Promise<Array<ICommentScored>> {\n  const article = await Article.findByPk(articleId);\n  if (!article) { throw new NotFoundError(`Could not find article ${articleId}`); }\n\n  const tag = await Tag.findByPk(tagId);\n  if (!tag) { throw new NotFoundError(`Could not find tag ${tagId}`); }\n\n  return sequelize.query(\n    'SELECT comment_summary_scores.score AS score, comment_summary_scores.commentId ' +\n    'FROM comments ' +\n    'JOIN comment_summary_scores ON comment_summary_scores.commentId = comments.id ' +\n    `AND comment_summary_scores.tagId = :tagId ` +\n    'WHERE comments.articleId = :articleId ' +\n    'AND comments.isScored = true ' +\n    'AND comments.isModerated = false ' +\n    'ORDER BY score DESC',\n    {\n      replacements: {\n        articleId,\n        tagId,\n      },\n      type: QueryTypes.SELECT,\n    },\n  );\n}\n\n/**\n * Get the max score for each comment in an article, regardless of state or tag.\n */\nexport async function getHistogramScoresForArticleByDate(articleId: number): Promise<Array<ICommentDated>> {\n  const article = await Article.findByPk(articleId);\n  if (!article) { throw new NotFoundError(`Could not find article ${articleId}`); }\n\n  return sequelize.query(\n    'SELECT comments.id as commentId, comments.sourceCreatedAt as date ' +\n    'FROM comments ' +\n    'WHERE comments.articleId = :articleId ' +\n    'AND comments.isModerated = false ',\n    {\n      replacements: {\n        articleId,\n      },\n      type: QueryTypes.SELECT,\n    },\n  );\n}\n\n/**\n * Get the max summary score for each comment in an article, regardless of state or tag.\n */\nexport async function getMaxSummaryScoreForArticle(articleId: number): Promise<Array<ICommentScored>> {\n  const article = await Article.findByPk(articleId);\n  if (!article) { throw new NotFoundError(`Could not find article ${articleId}`); }\n\n  return sequelize.query(\n    'SELECT comments.id as commentId, comments.maxSummaryScore as score ' +\n    'FROM comments ' +\n    'WHERE comments.articleId = :articleId ' +\n    'AND comments.isScored = true ' +\n    'AND comments.isModerated = false ' +\n    'AND comments.maxSummaryScore IS NOT NULL',\n    {\n      replacements: {\n        articleId,\n      },\n      type: QueryTypes.SELECT,\n    },\n  );\n}\n\n/**\n * Get the max score for each comment across all categories given a tag.\n */\nexport async function getMaxHistogramScoresForAllCategories(): Promise<Array<ICommentScored>> {\n\n  return sequelize.query(\n    'SELECT comments.id as commentId, comments.maxSummaryScore as score ' +\n    'FROM comments ' +\n    'WHERE comments.isScored = true ' +\n    'AND comments.isModerated = false ' +\n    'AND comments.maxSummaryScore IS NOT NULL ' +\n    'ORDER BY score DESC',\n    {\n      type: QueryTypes.SELECT,\n    },\n  );\n}\n\n/**\n * Get the max score for each comment in a category given a tag. If `categoryId` is the\n * string value \"all\", then this just calls `getHistogramScoresForAllCategories`.\n */\nexport async function getMaxSummaryScoreForCategory(categoryId: number | 'all'): Promise<Array<ICommentScored>> {\n  if (categoryId === 'all') {\n    return getMaxHistogramScoresForAllCategories();\n  }\n\n  const category = await Category.findByPk(categoryId);\n  if (!category) { throw new NotFoundError(`Could not find category ${categoryId}`); }\n\n  return sequelize.query(\n    'SELECT comments.id as commentId, comments.maxSummaryScore as score ' +\n    'FROM comments ' +\n    'JOIN articles ON articles.id = comments.articleId ' +\n    'WHERE articles.categoryId = :categoryId ' +\n    'AND comments.isScored = true ' +\n    'AND comments.isModerated = false ' +\n    'AND comments.maxSummaryScore IS NOT NULL ' +\n    'ORDER BY score DESC',\n    {\n      replacements: {\n        categoryId,\n      },\n      type: QueryTypes.SELECT,\n    },\n  );\n}\n\nconst DEFAULT_IMAGE_WIDTH = 400;\nconst DEFAULT_IMAGE_HEIGHT = 200;\nconst DEFAULT_COLUMN_COUNT = 100;\n\nexport function renderScoresToPNG(\n  scores: Array<any>,\n  groupBy: 'date' | 'score',\n  width?: number,\n  height?: number,\n  columnCount?: number,\n  showAll?: boolean,\n) {\n  const w = width || DEFAULT_IMAGE_WIDTH;\n  const h = height || DEFAULT_IMAGE_HEIGHT;\n  const colCount = columnCount || DEFAULT_COLUMN_COUNT;\n\n  const commentsByColumn = groupBy === 'date'\n      ? groupByDateColumns(scores, colCount)\n      : groupByScoreColumns(scores, colCount);\n\n  const canvas = new Canvas(w, h);\n\n  const renderer = new DotChartRenderer(\n    (canvasWidth, canvasHeight) => new Canvas(canvasWidth, canvasHeight),\n  );\n\n  renderer.setProps({\n    canvas,\n    commentsByColumn,\n    width: w,\n    height: h,\n    columnCount: colCount,\n    selectedRangeStart: 0,\n    selectedRangeEnd: 1,\n    showAll,\n  });\n\n  return canvas;\n}\n"
  },
  {
    "path": "packages/backend-api/src/api/services/index.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as express from 'express';\nimport { processingTriggers } from '../../processing/api';\nimport { createAssignmentsService } from './assignments';\nimport { createAuthorCountsService } from './authorCounts';\nimport { createCommentActionsService } from './commentActions';\nimport { createCommentSourcesService } from './commentSources';\nimport { createEditCommentTextService } from './editComment';\nimport { createHistogramScoresService } from './histogramScores';\nimport { createModeratedCountsService } from './moderatedCounts';\nimport { createSearchService } from './search';\nimport { createSimpleRESTService } from './simple';\nimport { createTextSizesService } from './textSizes';\nimport { createUpdateNotificationService } from './updateNotifications';\n\nexport function createServicesRouter(): express.Router {\n  const router = express.Router({\n    caseSensitive: true,\n    mergeParams: true,\n  });\n\n  router.use('/assignments', createAssignmentsService());\n  router.use('/search', createSearchService());\n  router.use('/commentActions', createCommentActionsService());\n  router.use('/histogramScores', createHistogramScoresService());\n  router.use('/moderatedCounts', createModeratedCountsService());\n  router.use('/authorCounts', createAuthorCountsService());\n  router.use('/textSizes', createTextSizesService());\n  router.use('/editComment', createEditCommentTextService());\n  router.use('/updates', createUpdateNotificationService());\n  router.use('/simple', createSimpleRESTService());\n  router.use('/processing', processingTriggers());\n  router.use('/comment_sources', createCommentSourcesService());\n\n  return router;\n}\n"
  },
  {
    "path": "packages/backend-api/src/api/services/moderatedCounts.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as express from 'express';\nimport * as Joi from 'joi';\nimport { mapValues } from 'lodash';\n\nimport { Article, Category, Comment } from '../../models';\nimport { sortCommentIds } from '../util/sortCommentIds';\nimport { validateAndSendResponse } from '../util/validation';\n\ninterface IModeratedCounts {\n  approved: Array<number>;\n  highlighted: Array<number>;\n  rejected: Array<number>;\n  deferred: Array<number>;\n  flagged: Array<number>;\n  batched: Array<number>;\n  automated: Array<number>;\n}\n\ninterface IModeratedCountsAsStrings {\n  approved: Array<string>;\n  highlighted: Array<string>;\n  rejected: Array<string>;\n  deferred: Array<string>;\n  flagged: Array<string>;\n  batched: Array<string>;\n  automated: Array<string>;\n}\n\nconst validateCountsAndSendResponse = validateAndSendResponse<IModeratedCountsAsStrings>(\n  Joi.object({\n    approved: Joi.array().items(Joi.string()).required(),\n    highlighted: Joi.array().items(Joi.string()).required(),\n    rejected: Joi.array().items(Joi.string()).required(),\n    deferred: Joi.array().items(Joi.string()).required(),\n    flagged: Joi.array().items(Joi.string()).required(),\n    batched: Joi.array().items(Joi.string()).required(),\n    automated: Joi.array().items(Joi.string()).required(),\n  }).required(),\n);\n\nasync function getModeratedCounts(model: any, sortQuery: string, getWhere: (model: any, params: any) => Promise<any>): Promise<IModeratedCounts> {\n  const results = await Promise.all([\n    // approved\n    getWhere(model, { isModerated: true, isAccepted: true }),\n\n    // highlighted\n    getWhere(model, { isModerated: true, isHighlighted: true }),\n\n    // rejected\n    getWhere(model, { isModerated: true, isAccepted: false }),\n\n    // deferred\n    getWhere(model, { isModerated: true, isDeferred: true }),\n\n    // flagged\n    getWhere(model, { unresolvedFlagsCount: {$gt: 0} }),\n\n    // batched\n    getWhere(model, { isModerated: true, isBatchResolved: true }),\n\n    // automated\n    getWhere(model, { isModerated: true, isAutoResolved: true }),\n  ]);\n\n  const output = [];\n\n  for (const r of results) {\n    const ids = r.map((c: any) => c.id);\n\n    if (sortQuery) {\n      const sortedIds = await sortCommentIds(\n        ids,\n        sortQuery.split(','),\n      );\n\n      output.push(sortedIds);\n    } else {\n      output.push(ids);\n    }\n  }\n\n  return {\n    approved: output[0],\n    highlighted: output[1],\n    rejected: output[2],\n    deferred: output[3],\n    flagged: output[4],\n    batched: output[5],\n    automated: output[6],\n  };\n}\n\nexport function createModeratedCountsService(): express.Router {\n  const router = express.Router({\n    caseSensitive: true,\n    mergeParams: true,\n  });\n\n  router.get('/articles/:id', async ({ params: { id }, query: { sort }}, res, next) => {\n    let model;\n\n    try {\n      model = await Article.findByPk(id);\n    } catch (e) {\n      return Promise.reject({ error: 404 });\n    }\n\n    const data = await getModeratedCounts(model, sort as string, async (article, where) => {\n      return await article.getComments({\n        where,\n        attributes: ['id'],\n      });\n    });\n\n    const countsOfStrings: IModeratedCountsAsStrings = mapValues(data, (ids) => {\n      return ids.map((i: number) => i.toString());\n    }) as any;\n\n    validateCountsAndSendResponse(countsOfStrings, res, next);\n  });\n\n  router.get('/categories/:id', async ({ params: { id }, query: { sort }}, res, next) => {\n    let data;\n\n    if (id !== 'all') {\n      let model;\n\n      try {\n        model = await Category.findByPk(id);\n      } catch (e) {\n        return Promise.reject({ error: 404 });\n      }\n\n      data = await getModeratedCounts(model, sort as string, async (_article, where) => {\n        return await Comment.findAll({\n          where,\n\n          include: {\n            model: Article,\n            where: { categoryId: id },\n            attributes: ['id'],\n          } as any,\n\n          attributes: ['id'],\n        });\n      });\n    } else {\n      data = await getModeratedCounts(null, sort as string, async (_, where) => {\n        return Comment.findAll({\n          where,\n          attributes: ['id'],\n        });\n      });\n    }\n\n    const countsOfStrings: IModeratedCountsAsStrings = mapValues(data, (ids) => {\n      return ids.map((i: number) => i.toString());\n    }) as any;\n\n    validateCountsAndSendResponse(countsOfStrings, res, next);\n  });\n\n  return router;\n}\n"
  },
  {
    "path": "packages/backend-api/src/api/services/search.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as express from 'express';\nimport * as Joi from 'joi';\nimport { QueryTypes } from 'sequelize';\n\nimport { logger } from '../../logger';\nimport { sequelize } from '../../sequelize';\nimport { sortCommentIds } from '../util/sortCommentIds';\nimport { validateAndSendResponse } from '../util/validation';\n\nconst MINIMUM_QUERY_LENGTH = 3;\n\ntype ISearchResponse = Array<string>;\n\nconst validateSearchAndSendResponse = validateAndSendResponse<ISearchResponse>(\n  Joi.array().items(Joi.string()),\n);\n\nexport function createSearchService(): express.Router {\n  const router = express.Router({\n    caseSensitive: true,\n    mergeParams: true,\n  });\n\n  router.get('/', async (req, res, next) => {\n    try {\n      const term = req.query.term as string;\n      const articleId = req.query.articleId as string;\n      const searchByAuthor = req.query.searchByAuthor as string;\n\n      let ids: Array<number> = [];\n      let results;\n\n      if (term.length >= MINIMUM_QUERY_LENGTH) {\n        if (searchByAuthor === 'true') {\n          const iffyTerm = `%${term}%`;\n          results = await sequelize.query(\n            `SELECT id ` +\n            `FROM comments ` +\n            'WHERE comments.authorSourceId=:term ' +\n            'OR JSON_SEARCH(LOWER(comments.author), \"all\", LOWER(:iffyTerm), NULL, \"$.name\") IS NOT NULL ' +\n            'LIMIT 100',\n            {\n              replacements: {\n                iffyTerm,\n                term,\n              },\n              type: QueryTypes.SELECT,\n            },\n          );\n        } else if (articleId) {\n          results = await sequelize.query(\n            `SELECT id, MATCH(text) AGAINST (:term) as relevance ` +\n            `FROM comments ` +\n            'WHERE comments.articleId = :articleId ' +\n            `AND MATCH(text) AGAINST (:term) ` +\n            'ORDER BY relevance DESC ' +\n            'LIMIT 100',\n            {\n              replacements: {\n                term,\n                articleId,\n              },\n              type: QueryTypes.SELECT,\n            },\n          );\n        } else {\n          results = await sequelize.query(\n            `SELECT id, MATCH(text) AGAINST (:term) as relevance ` +\n            `FROM comments WHERE MATCH(text) AGAINST (:term) ` +\n            'ORDER BY relevance DESC ' +\n            'LIMIT 100',\n            {\n              replacements: {\n                term,\n              },\n              type: QueryTypes.SELECT,\n            },\n          );\n        }\n      }\n\n      ids = results ? results.map((r: any) => r.id) : [];\n\n      const sortQuery = req.query.sort as string;\n      const sortOrder = sortQuery ? sortQuery.split(',') : null;\n\n      // No sort order is specified, defaults to relevance\n      if (sortOrder == null) {\n        validateSearchAndSendResponse(\n          ids.map((i) => i.toString()),\n          res,\n          next,\n        );\n      } else {\n        const sortedIds = await sortCommentIds(\n          ids,\n          sortOrder,\n        );\n\n        validateSearchAndSendResponse(\n          sortedIds.map((i) => i.toString()),\n          res,\n          next,\n        );\n      }\n    } catch (e) {\n      logger.error(`Error with request posted to /services/search: ${e.message}`);\n      res.status(400).json({ status: 'error', errors: 'Search request not completed'});\n\n      return;\n    }\n  });\n\n  return router;\n}\n"
  },
  {
    "path": "packages/backend-api/src/api/services/serializer.ts",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { pick } from 'lodash';\nimport { Model } from 'sequelize';\n\nexport const TAG_FIELDS = ['id', 'color', 'description', 'key', 'label', 'isInBatchView', 'inSummaryScore', 'isTaggable'];\nexport const RANGE_FIELDS = ['id', 'categoryId', 'lowerThreshold', 'upperThreshold', 'tagId'];\nexport const TAGGING_SENSITIVITY_FIELDS = RANGE_FIELDS;\nexport const RULE_FIELDS = ['action', 'createdBy', ...RANGE_FIELDS];\nexport const PRESELECT_FIELDS = RANGE_FIELDS;\nexport const USER_FIELDS = ['id', 'name', 'email', 'avatarURL', 'group', 'isActive'];\n\nconst COMMENTSET_FIELDS = ['id', 'updatedAt', 'allCount', 'unprocessedCount', 'unmoderatedCount', 'moderatedCount',\n  'approvedCount', 'highlightedCount', 'rejectedCount', 'deferredCount', 'flaggedCount',\n  'batchedCount', 'recommendedCount', 'assignedModerators', ];\nexport const CATEGORY_FIELDS = [...COMMENTSET_FIELDS, 'label', 'ownerId', 'isActive', 'sourceId'];\nexport const ARTICLE_FIELDS = [...COMMENTSET_FIELDS, 'title', 'url', 'categoryId', 'sourceCreatedAt', 'lastModeratedAt',\n  'isCommentingEnabled', 'isAutoModerated'];\n\nexport const COMMENT_FIELDS = ['id', 'sourceId', 'replyToSourceId', 'replyId', 'authorSourceId', 'text', 'author',\n  'isScored', 'isModerated', 'isAccepted', 'isDeferred', 'isHighlighted', 'isBatchResolved', 'isAutoResolved',\n  'sourceCreatedAt', 'updatedAt', 'unresolvedFlagsCount', 'flagsSummary', 'sentForScoring', 'articleId',\n  'maxSummaryScore', 'maxSummaryScoreTagId',\n];\nexport const SUMMARY_SCORE_FIELDS = ['tagId', 'score'];\nexport const SCORE_FIELDS = ['id', 'commentId', 'confirmedUserId', 'tagId', 'score',\n  'annotationStart', 'annotationEnd', 'sourceType', 'isConfirmed'];\nexport const FLAG_FIELDS = ['id', 'label', 'detail', 'isRecommendation', 'commentId', 'sourceId', 'authorSourceId',\n  'isResolved', 'resolvedById', 'resolvedAt'];\n\nconst ID_FIELDS = new Set(['id', 'categoryId', 'articleId', 'tagId', 'ownerId', 'commentId',\n  'confirmedUserId', 'resolvedById', 'replyId', 'maxSummaryScoreTagId']);\n\nexport type serializedData = {[key: string]: {} | Array<string> | string | number};\n\n// Convert IDs to strings, and assignedModerators to arrays of strings.\nexport function serialiseObject(\n  o: Model,\n  fields: Array<string>,\n): serializedData {\n  const serialised = pick(o.toJSON(), fields) as {[key: string]: any};\n\n  for (const k of Object.keys(serialised)) {\n    const v = serialised[k];\n\n    if (ID_FIELDS.has(k) && v) {\n      serialised[k] = v.toString();\n    }\n  }\n\n  if (serialised.assignedModerators) {\n    serialised.assignedModerators = serialised.assignedModerators.map(\n      (i: any) => (i.user_category_assignment ?  i.user_category_assignment.userId.toString() :\n        i.moderator_assignment.userId.toString()));\n  }\n  return serialised;\n}\n"
  },
  {
    "path": "packages/backend-api/src/api/services/simple.ts",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\n/**\n * We use this module for API endpoints where it is easier to implement a custom interface\n * than to modify/customise/configure the generic REST api to do the same thing.\n */\n\nimport * as express from 'express';\nimport { pick } from 'lodash';\nimport { Op, QueryTypes } from 'sequelize';\n\nimport {\n  checkModelType,\n  createRangeObject,\n  createTagObject,\n  deleteRangeObject,\n  modifyRangeObject,\n  modifyTagObject,\n} from '../../actions/object_updaters';\nimport { createToken } from '../../auth/tokens';\nimport { clearError } from '../../integrations';\nimport {\n  Article,\n  Comment,\n  CommentFlag,\n  CommentScore,\n  CommentSummaryScore,\n  User,\n  USER_GROUP_ADMIN,\n  USER_GROUP_GENERAL,\n  USER_GROUP_SERVICE,\n  USER_GROUP_YOUTUBE,\n} from '../../models';\nimport { sequelize } from '../../sequelize';\nimport { REPLY_SUCCESS } from '../constants';\nimport {\n  ARTICLE_FIELDS,\n  COMMENT_FIELDS,\n  FLAG_FIELDS,\n  SCORE_FIELDS,\n  serialiseObject,\n  serializedData,\n  SUMMARY_SCORE_FIELDS,\n} from './serializer';\n\nconst userFields = ['id', 'name', 'email', 'group', 'isActive', 'extra'];\n\nexport function createSimpleRESTService(): express.Router {\n  const router = express.Router({\n    caseSensitive: true,\n    mergeParams: true,\n  });\n\n  router.get('/systemUsers/:type', async (req, res) => {\n    const users = await User.findAll({\n      where: { group: req.params.type },\n    });\n\n    const userdata: Array<any> = [];\n    for (const u of users) {\n      const simple = u.toJSON() as {[key: string]: any};\n      if (req.params.type === USER_GROUP_SERVICE) {\n        const token = await createToken(u.id);\n        simple.extra = {jwt: token};\n      }\n      else if (u.extra) {\n        simple.extra = u.extra;\n        // Make sure we don't send any access tokens out.\n        delete simple.extra.token;\n      }\n      userdata.push(pick(simple, userFields));\n    }\n\n    res.json({ users: userdata });\n  });\n\n  router.post('/user',  async (req, res) => {\n    const {name, email, group, isActive} = req.body;\n    if (!(group === USER_GROUP_ADMIN || group === USER_GROUP_GENERAL || group === USER_GROUP_SERVICE)) {\n      res.status(400).send(`Can't create users of type ${group}`);\n      return;\n    }\n\n    if ((group === USER_GROUP_ADMIN || group === USER_GROUP_GENERAL) && !email) {\n      res.status(400).send('User creation error: Human users require an email.');\n      return;\n    }\n\n    if (email) {\n      const existing = await User.count({where: {email}});\n      if (existing) {\n        res.status(400).send('User creation error: email already in use.');\n        return;\n      }\n    }\n\n    await User.create({name, email, group, isActive});\n\n    res.json(REPLY_SUCCESS);\n  });\n\n  router.post('/user/:id', async (req, res) => {\n    const userId = parseInt(req.params.id, 10);\n    const user = await User.findByPk(userId);\n    if (!user) {\n      res.status(404).send('Not found');\n      return;\n    }\n\n    const group = user.group;\n\n    function isRealUser(g: string) {\n      return g === USER_GROUP_ADMIN || g === USER_GROUP_GENERAL;\n    }\n\n    if ((isRealUser(group) || group === USER_GROUP_SERVICE) && typeof(req.body.name) !== 'undefined') {\n      user.name = req.body.name;\n    }\n\n    if (isRealUser(group)) {\n      if (isRealUser(req.body.group)) {\n        user.group = req.body.group;\n      }\n\n      if (typeof(req.body.email) !== 'undefined') {\n        user.email = req.body.email;\n      }\n    }\n\n    if (typeof(req.body.isActive) !== 'undefined') {\n      user.isActive = req.body.isActive;\n    }\n    await user.save();\n\n    if (group === USER_GROUP_YOUTUBE && req.body.isActive) {\n      await clearError(user);\n    }\n\n    res.json(REPLY_SUCCESS);\n  });\n\n  router.post('/article/:id', async (req, res) => {\n    const articleId = parseInt(req.params.id, 10);\n    const article = await Article.findByPk(articleId);\n    if (!article) {\n      res.status(404).json({status: 'error', errors: 'article not found'});\n      return;\n    }\n\n    if (typeof req.body.isCommentingEnabled === 'boolean') {\n      article.isCommentingEnabled = req.body.isCommentingEnabled;\n    }\n    if (typeof req.body.isAutoModerated === 'boolean') {\n      article.isAutoModerated = req.body.isAutoModerated;\n    }\n\n    await article.save();\n\n    res.json(REPLY_SUCCESS);\n  });\n\n  router.post('/articles', async (req, res) => {\n    const articles = await Article.findAll({\n      where: {id: {[Op.in]: req.body}},\n      include: [{ model: User, as: 'assignedModerators', attributes: ['id']}],\n    });\n    const articleData = articles.map((a) => serialiseObject(a, ARTICLE_FIELDS));\n    res.json(articleData);\n  });\n\n  router.post('/comments', async (req, res) => {\n    const comments = await Comment.findAll({\n      where: {id: {[Op.in]: req.body}},\n      include: [\n        {model: Article, as: 'article', attributes: ['categoryId']},\n        {model: Comment, as: 'replies', attributes: ['id']},\n      ],\n    });\n    const summaryScores = await CommentSummaryScore.findAll({\n      where: {commentId: {[Op.in]: req.body}},\n    });\n\n    const results = await sequelize.query(\n      'SELECT commentId, tagId, score, annotationStart, annotationEnd ' +\n      'FROM comment_scores ' +\n      'WHERE id IN (SELECT commentScoreId from comment_top_scores where commentId in (:commentIds))',\n      {\n        type: QueryTypes.SELECT,\n        replacements: { commentIds: req.body },\n      },\n    ) as Array<CommentScore>;\n\n    const topScores = new Map<string, {[key: string]: any}>();\n    for (const topScore of results) {\n      topScores.set(\n        `${topScore.commentId}:${topScore.tagId}`,\n        {score: topScore.score, start: topScore.annotationStart, end: topScore.annotationEnd},\n      );\n    }\n\n    const scoresMap = new Map<number, Array<serializedData>>();\n    for (const score of summaryScores) {\n      let scoresForComment = scoresMap.get(score.commentId);\n      if (!scoresForComment) {\n        scoresForComment = [];\n        scoresMap.set(score.commentId, scoresForComment);\n      }\n      const summaryScore = serialiseObject(score, SUMMARY_SCORE_FIELDS);\n      const topScore = topScores.get(`${score.commentId}:${score.tagId}`);\n      if (topScore) {\n        summaryScore['topScore'] = topScore;\n      }\n      scoresForComment.push(summaryScore);\n    }\n\n    const commentData = comments.map((c) => {\n      const data = serialiseObject(c, COMMENT_FIELDS);\n      if ((c as any).article && (c as any).article.categoryId) {\n        data['categoryId'] = (c as any).article.categoryId.toString();\n      }\n      if ((c as any).replies) {\n        data['replies'] = ((c as any).replies as Array<Comment>).map((r) => r.id.toString());\n      }\n      const scoreData = scoresMap.get(c.id);\n      if (scoreData) {\n        data['summaryScores'] = scoreData;\n      }\n      return data;\n    });\n\n    res.json(commentData);\n  });\n\n  router.get('/article/:id/text', async (req, res) => {\n    const articleId = parseInt(req.params.id, 10);\n    const article = await Article.findByPk(articleId);\n    if (!article) {\n      res.status(404).send('Not found');\n      return;\n    }\n    const text = article.text;\n    res.json({text: text});\n  });\n\n  router.get('/comment/:id/scores', async (req, res) => {\n    const commentId = parseInt(req.params.id, 10);\n    const scores = await CommentScore.findAll({\n      where: {commentId: commentId},\n    });\n    const scoresData = scores.map((s) => serialiseObject(s, SCORE_FIELDS));\n    res.json(scoresData);\n  });\n\n  router.get('/comment/:id/flags', async (req, res) => {\n    const commentId = parseInt(req.params.id, 10);\n    const flags = await CommentFlag.findAll({\n      where: {commentId: commentId},\n    });\n    const flagsData = flags.map((f) => serialiseObject(f, FLAG_FIELDS));\n    res.json(flagsData);\n  });\n\n  router.post('/tag', async (req, res) => {\n    const msg = await createTagObject(req.body);\n    if (msg) {\n      res.status(400).send(msg);\n      return;\n    }\n    res.json(REPLY_SUCCESS);\n  });\n\n  router.patch('/tag/:id', async (req, res) => {\n    const id = parseInt(req.params.id, 10);\n    const msg = await modifyTagObject(id, req.body);\n    if (msg) {\n      res.status(400).send(msg);\n      return;\n    }\n\n    res.json(REPLY_SUCCESS);\n  });\n\n  router.post('/:model', async (req, res) => {\n    if (!checkModelType(req.params.model)) {\n      res.status(400).send('Bad object type');\n      return;\n    }\n\n    const msg = await createRangeObject(req.params.model, req.body);\n    if (msg) {\n      res.status(400).send(msg);\n      return;\n    }\n\n    res.json(REPLY_SUCCESS);\n  });\n\n  router.patch('/:model/:id', async (req, res) => {\n    const id = parseInt(req.params.id, 10);\n\n    if (!checkModelType(req.params.model)) {\n      res.status(400).send('Bad object type');\n      return;\n    }\n\n    const msg = await modifyRangeObject(req.params.model, id, req.body);\n    if (msg) {\n      res.status(400).send(msg);\n      return;\n    }\n    res.json(REPLY_SUCCESS);\n  });\n\n  router.delete('/:model/:id', async (req, res) => {\n    const objectId = parseInt(req.params.id, 10);\n    if (!checkModelType(req.params.model) && req.params.model !== 'tag') {\n      res.status(400).send('Bad object type');\n      return;\n    }\n\n    await deleteRangeObject(req.params.model, objectId);\n    res.json(REPLY_SUCCESS);\n  });\n  return router;\n}\n"
  },
  {
    "path": "packages/backend-api/src/api/services/textSizes.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as express from 'express';\nimport * as Joi from 'joi';\nimport { Op } from 'sequelize';\n\nimport { CommentSize } from '../../models';\nimport { validateAndSendResponse, validateRequest } from '../util/validation';\n\nconst validateTextSizesRequest = validateRequest(Joi.object({\n  data: Joi.array().items(Joi.string()).required(),\n}));\n\ninterface ITextSizesResponse {\n  [key: string]: number;\n}\n\nconst validateTextSizesAndSendResponse = validateAndSendResponse<ITextSizesResponse>(\n  Joi.object({\n    arg: Joi.string(),\n    value: Joi.number(),\n  }).unknown().required(),\n);\n\nexport function createTextSizesService(): express.Router {\n  const router = express.Router({\n    caseSensitive: true,\n    mergeParams: true,\n  });\n\n  router.post('/',\n    validateTextSizesRequest,\n    async ({ body: { data }, query }, res, next) => {\n      const width = query.width as string;\n      try {\n        const widthNum = parseInt(width, 10);\n\n        if (isNaN(widthNum)) {\n          res.status(422).json({ status: 'error', errors: [`width query string param must be a number, got ${width}`] });\n\n          return;\n        }\n\n        const commentSizes = await CommentSize.findAll({\n          where: {\n            commentId: {\n              [Op.in]: data,\n            },\n            width: widthNum,\n          },\n        });\n\n        // Default all values to 60, the average height.\n        // This is in case a comment has not yet been cached.\n        const defaultData: ITextSizesResponse = data.reduce((sum: any, commentId: number) => {\n          sum[commentId] = 60;\n\n          return sum;\n        }, {});\n\n        const sizingData = commentSizes.reduce((sum, commentSize: CommentSize) => {\n          sum[commentSize.commentId.toString()] = commentSize.height;\n\n          return sum;\n        }, defaultData);\n\n        validateTextSizesAndSendResponse(sizingData, res, next);\n      } catch (e) {\n        next(e);\n      }\n    },\n  );\n\n  return router;\n}\n"
  },
  {
    "path": "packages/backend-api/src/api/services/updateNotifications.ts",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\n// Set true to send test update packets\n\nconst SEND_TEST_UPDATE_PACKETS = false;\n\nimport * as express from 'express';\nimport { isEqual } from 'lodash';\nimport { Op } from 'sequelize';\nimport * as WebSocket from 'ws';\n\nimport { logger } from '../../logger';\nimport {\n  Article,\n  Category,\n  ModerationRule,\n  Preselect,\n  Tag,\n  TaggingSensitivity,\n  User,\n} from '../../models';\nimport {clearInterested, INotificationData, registerInterest} from '../../notification_router';\nimport { countAssignments } from './assignments';\nimport {\n  ARTICLE_FIELDS,\n  CATEGORY_FIELDS,\n  PRESELECT_FIELDS,\n  RULE_FIELDS,\n  serialiseObject,\n  TAGGING_SENSITIVITY_FIELDS,\n  TAG_FIELDS,\n  USER_FIELDS,\n} from './serializer';\n\n// TODO: Can't find a good way to get rid of the any types below.  And typing is generally a mess.\n//       Revisit when sequelize has been updated\ninterface ISystemData {\n  users: any;\n  tags: any;\n  taggingSensitivities: any;\n  rules: any;\n  preselects: any;\n}\n\ninterface IAllArticlesData {\n  categories: any;\n  articles: any;\n}\n\ninterface IArticleUpdateData {\n  category: any;\n  article: any;\n}\n\ninterface IPerUserData {\n  assignments: number;\n}\n\ninterface IMessage {\n  type: 'system' | 'global' | 'article-update' | 'user';\n  data: ISystemData | IAllArticlesData | IArticleUpdateData | IPerUserData;\n}\n\nasync function getSystemData() {\n  const users = await User.findAll({where: {group: {[Op.in]: ['admin', 'general']}}});\n  const userdata = users.map((u: User) => {\n    return serialiseObject(u, USER_FIELDS);\n  });\n\n  const tags = await Tag.findAll({});\n  const tagdata = tags.map((t: Tag) => {\n    return serialiseObject(t, TAG_FIELDS);\n  });\n\n  const taggingSensitivities = await TaggingSensitivity.findAll({});\n  const tsdata = taggingSensitivities.map((t: TaggingSensitivity) => {\n    return serialiseObject(t, TAGGING_SENSITIVITY_FIELDS);\n  });\n\n  const rules = await ModerationRule.findAll({});\n  const ruledata = rules.map((r: ModerationRule) => {\n    return serialiseObject(r, RULE_FIELDS);\n  });\n\n  const preselects = await Preselect.findAll({});\n  const preselectdata = preselects.map((p: Preselect) => {\n    return serialiseObject(p, PRESELECT_FIELDS);\n  });\n\n  return {\n    type: 'system',\n    data: {\n      users: userdata,\n      tags: tagdata,\n      taggingSensitivities: tsdata,\n      rules: ruledata,\n      preselects: preselectdata,\n    },\n  } as IMessage;\n}\n\nasync function getGlobalData() {\n  const categories = await Category.findAll({\n    include: [{ model: User, as: 'assignedModerators', attributes: ['id']}],\n  });\n  const categoryIds: Array<number> = [];\n  const categorydata = categories.map((c: Category) => {\n    categoryIds.push(c.id);\n    return serialiseObject(c, CATEGORY_FIELDS);\n  });\n\n  const articles = await Article.findAll({\n    where: {[Op.or]: [{categoryId: null}, {categoryId: categoryIds}]},\n    include: [{ model: User, as: 'assignedModerators', attributes: ['id']}],\n  });\n  const articledata = articles.map((a: Article) => {\n    return serialiseObject(a, ARTICLE_FIELDS);\n  });\n\n  return {\n    type: 'global',\n    data: {\n      categories: categorydata,\n      articles: articledata,\n    },\n  } as IMessage;\n}\n\nasync function getCategoryUpdate(categoryId: number) {\n  const category = await Category.findByPk(\n    categoryId,\n    {include: [{ model: User, as: 'assignedModerators', attributes: ['id']}]},\n  );\n\n  const cData = category  ? serialiseObject(category, CATEGORY_FIELDS) : undefined;\n\n  return {\n    type: 'article-update',\n    data: {\n      categories: [cData],\n      articles: [],\n    },\n  } as IMessage;\n}\n\nasync function getArticleUpdate(articleId: number) {\n  const article = await Article.findByPk(\n    articleId,\n    {include: [{ model: User, as: 'assignedModerators', attributes: ['id']}]},\n  );\n  if (!article) {\n    return null;\n  }\n  const aData = serialiseObject(article, ARTICLE_FIELDS);\n\n  const category = article.categoryId ? await Category.findByPk(\n    article.categoryId,\n    {include: [{ model: User, as: 'assignedModerators', attributes: ['id']}]},\n  ) : null;\n\n  const cData = category  ? serialiseObject(category, CATEGORY_FIELDS) : undefined;\n\n  return {\n    type: 'article-update',\n    data: {\n      categories: [cData],\n      articles: [aData],\n    },\n  } as IMessage;\n}\n\nasync function getPerUserData(userId: number) {\n  const user = (await User.findByPk(userId))!;\n  const assignments = await countAssignments(user);\n\n  return {\n    type: 'user',\n    data: {\n      assignments: assignments,\n    },\n  } as IMessage;\n}\n\ninterface ISocketItem {\n  userId: number;\n  ws: Array<WebSocket>;\n  lastPerUserMessage: IPerUserData | null;\n  sentInitialMessages: boolean;\n}\n\nlet lastSystemMessage: IMessage | null = null;\nconst socketItems = new Map<number, ISocketItem>();\n\nasync function refreshSystemMessage(): Promise<boolean> {\n  const newMessage = await getSystemData();\n  if (!lastSystemMessage) {\n    lastSystemMessage = newMessage;\n    return true;\n  }\n\n  const send = !isEqual(newMessage.data, lastSystemMessage.data);\n  if (send) {\n    lastSystemMessage = newMessage;\n  }\n\n  return send;\n}\n\nfunction removeSocket(si: ISocketItem, ws: WebSocket) {\n  const index = si.ws.indexOf(ws);\n  if (index >= 0) {\n    si.ws.splice(index, 1);\n  }\n  if (si.ws.length === 0) {\n    socketItems.delete(si.userId);\n  }\n}\n\nasync function refreshMessages(alwaysSend: boolean) {\n  const sendSystem = (await refreshSystemMessage() || alwaysSend);\n  return {sendSystem, sendUser: alwaysSend};\n}\n\nasync function maybeSendUpdateToUser(si: ISocketItem,\n                                     {sendSystem, sendUser}:\n                                       {sendSystem: boolean, sendUser: boolean}) {\n  const userSummaryMessage = await getPerUserData(si.userId);\n  sendUser = sendUser || !si.lastPerUserMessage || !isEqual(userSummaryMessage.data, si.lastPerUserMessage);\n\n  for (const ws of si.ws) {\n    try {\n      if (sendSystem) {\n        logger.info(`Sending system data to user ${si.userId}`);\n        await ws.send(JSON.stringify(lastSystemMessage));\n      }\n\n      if (sendUser) {\n        logger.info(`Sending per user data to user ${si.userId}`);\n        await ws.send(JSON.stringify(userSummaryMessage));\n      }\n    }\n    catch (e) {\n      logger.warn(`Websocket faulty for ${si.userId}`, e.message);\n      ws.terminate();\n      removeSocket(si, ws);\n    }\n  }\n\n  si.lastPerUserMessage = userSummaryMessage.data as IPerUserData;\n}\n\nasync function maybeSendUpdates() {\n  for (const si of socketItems.values()) {\n    const updateFlags = await refreshMessages(false);\n    await maybeSendUpdateToUser(si, updateFlags);\n  }\n}\n\nasync function sendUpdate(type: string, update: string) {\n  for (const si of socketItems.values()) {\n    if (!si.sentInitialMessages) {\n      continue;\n    }\n    for (const ws of si.ws) {\n      logger.info(`Sending ${type} to user ${si.userId}`);\n      await ws.send(update);\n    }\n  }\n}\n\nasync function sendGlobal() {\n  const update = await getGlobalData();\n  await sendUpdate('global', JSON.stringify(update));\n}\n\nasync function sendCategoryUpdate(categoryId: number) {\n  const update = await getCategoryUpdate(categoryId);\n  await sendUpdate('category update', JSON.stringify(update));\n}\n\nasync function sendArticleUpdate(articleId: number) {\n  const update = await getArticleUpdate(articleId);\n  await sendUpdate('article update', JSON.stringify(update));\n}\n\nfunction sendTestUpdatePackets(si: ISocketItem) {\n  logger.info(`*** settng up fake update notifications for user ${si.userId}`);\n  let counter = 1;\n  setInterval(async () => {\n    const update = await getArticleUpdate(counter);\n    if (!update) {\n      logger.info(`no such article ${counter}`);\n      counter = 1;\n      return;\n    }\n    const data = update.data as IArticleUpdateData;\n    let msg = `fake update message ${counter}`;\n    if (counter % 3 === 1) {\n      // Just send the category\n      delete  data.article;\n      msg += ' category';\n    }\n    else if (counter % 3 === 2) {\n      // just send the article\n      delete  data.category;\n      msg += ' article';\n    }\n    else {\n      msg += ' both';\n    }\n\n    if (counter % 4 === 2) {\n      // pretend its a new object\n      msg += ' new';\n      if (data.article) {\n        data.article.id = data.article.id + 10000 + Math.floor(Math.random() * 1000);\n      }\n      if (data.category) {\n        data.category.id = data.category.id + 10000 + Math.floor(Math.random() * 1000);\n      }\n    }\n    if (counter % 4 === 3) {\n      msg += ' faked data';\n      // Mess with the data\n      if (data.article) {\n        data.article.unmoderatedCount = data.article.unmoderatedCount + Math.floor(Math.random() * 10000);\n      }\n      if (data.category) {\n        data.category.unmoderatedCount = data.category.unmoderatedCount + Math.floor(Math.random() * 10000);\n      }\n    }\n\n    logger.info(msg);\n    counter ++;\n    logger.info(`Sending **fake** article update to user ${si.userId} -- ${msg}`);\n    for (const ws of si.ws) {\n      await ws.send(JSON.stringify(update));\n    }\n  }, 1000);\n}\n\n// Introduce a simple task queue to ensure messages are processed in the order in which they arrive\nconst taskQueue: Array<() => Promise<void>> = [];\nlet taskQueueProcessing = false;\nasync function processNotification(data: INotificationData) {\n  if (data.objectType === 'category' && data.id) {\n    taskQueue.unshift(() => sendCategoryUpdate(data.id!));\n  } else if (data.objectType === 'article' && data.id) {\n    taskQueue.unshift(() => sendArticleUpdate(data.id!));\n  } else {\n    taskQueue.unshift(maybeSendUpdates);\n  }\n  if (taskQueueProcessing) {\n    return;\n  }\n  taskQueueProcessing = true;\n  while (taskQueue.length > 0) {\n    const task = taskQueue.pop();\n    await task!();\n  }\n  taskQueueProcessing = false;\n}\n\nlet registered = false;\n\nexport function createUpdateNotificationService(): express.Router {\n  const router = express.Router({\n    caseSensitive: true,\n    mergeParams: true,\n  });\n\n  router.ws('/summary', async (ws, req) => {\n    if (!req.user) {\n      logger.error(`Attempt to create a socket without authentication.  Bail...`);\n      ws.terminate();\n      return;\n    }\n\n    const userId = (req.user as User).id;\n    let si = socketItems.get(userId);\n    if (!si) {\n      si = {userId, ws: [], lastPerUserMessage: null, sentInitialMessages: false};\n      socketItems.set(userId, si);\n\n      if (SEND_TEST_UPDATE_PACKETS) {\n        sendTestUpdatePackets(si);\n      }\n    }\n\n    si.ws.push(ws);\n\n    if (!registered) {\n      logger.info(`Setting up notifications`);\n      registerInterest({ processNotification });\n      registered = true;\n    }\n\n    ws.on('close', () => {\n      removeSocket(si!, ws);\n    });\n\n    logger.info(`Websocket opened to ${(req.user as User).email}`);\n    const updateFlags = await refreshMessages(true);\n    await maybeSendUpdateToUser(si, updateFlags);\n    si.sentInitialMessages = true;\n    await sendGlobal();\n  });\n\n  return router;\n}\n\nexport function destroyUpdateNotificationService() {\n  registered = false;\n  clearInterested();\n  for (const si of socketItems.values()) {\n    for (const ws of si.ws) {\n      ws.close();\n    }\n  }\n  socketItems.clear();\n}\n"
  },
  {
    "path": "packages/backend-api/src/api/util/permissions.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as express from 'express';\n\nimport {User} from '../../models';\n\nexport function onlyAdmin(req: express.Request, res: express.Response, next: express.NextFunction) {\n  if ((req as any).testMode) {\n    next();\n    return;\n  }\n\n  // TODO(ldixon): check that user is always defined; and if so update types.\n  if (['admin'].indexOf((req.user as User).group) === -1) {\n    res.status(403).json({ error: 'Only admin users can access this API.' });\n  } else {\n    next();\n  }\n}\n\nexport function onlyServices(req: express.Request, res: express.Response, next: express.NextFunction) {\n  if ((req as any).testMode) {\n    next();\n\n    return;\n  }\n\n  // TODO(ldixon): check that user is always defined; and if so update types.\n  if (['service'].indexOf((req.user as User).group) === -1) {\n    res.status(403).json({ error: 'Only service users can access this API.' });\n  } else {\n    next();\n  }\n}\n\nexport function onlyAdminAndServices(req: express.Request, res: express.Response, next: express.NextFunction) {\n  if ((req as any).testMode) {\n    next();\n\n    return;\n  }\n\n  // TODO(ldixon): check that user is always defined; and if so update types.\n  if (['service', 'admin'].indexOf((req.user as User).group) === -1) {\n    res.status(403).json({ error: 'General users cannot acces this API.' });\n  } else {\n    next();\n  }\n}\n"
  },
  {
    "path": "packages/backend-api/src/api/util/server.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as bodyParser from 'body-parser';\nimport * as compression from 'compression';\nimport * as cors from 'cors';\nimport * as express from 'express';\nimport * as expressWinston from 'express-winston';\nimport * as expressWs from 'express-ws';\nimport * as helmet from 'helmet';\nimport { Server } from 'http';\nimport * as winston from 'winston';\n\n// Logger to capture all requests and output them to the console.\nexport const requestLogger = expressWinston.logger({\n  transports: [\n    new winston.transports.Console({\n      format: winston.format.simple(),\n    }),\n  ],\n  meta: false,\n  ignoredRoutes: [\n    '/_ah/health',\n  ],\n});\n\n// Logger to capture any top-level errors and output json diagnostic info.\nexport const errorLogger = expressWinston.errorLogger({\n  transports: [\n    new winston.transports.Console({\n      format: winston.format.simple(),\n    }),\n  ],\n  format: winston.format.combine(\n    winston.format.json(),\n  ),\n  requestWhitelist: ['body'],\n});\n\nexport function getExpressAppWithPreprocessors(testMode?: boolean) {\n  const app = express();\n  expressWs(app);\n\n  if (!testMode) {\n    // Turn on GZip.\n    app.use(compression());\n  }\n\n  // Required to parse JSON posts.\n  app.use(bodyParser.json({ limit: '2mb' }));\n\n  if (!testMode) {\n    // Enable CORS\n    app.use(cors());\n    app.options('*', cors());\n\n    app.use(helmet({\n      // TODO: Implement a proper content security policy\n      contentSecurityPolicy: false,\n    }));\n    app.use(requestLogger);\n  }\n\n  return app;\n}\n\nexport function applyCommonPostprocessors(app: express.Application, testMode?: boolean) {\n  if (!testMode) {\n    // Add the error logger after all middleware and routes so that\n    // it can log errors from the whole application. Any custom error\n    // handlers should go after this.\n    app.use(errorLogger);\n\n    // Basic 404 handler\n    app.use((_req: express.Request, res: express.Response) => {\n      if (!res.headersSent) {\n        res.status(404).send('Not Found');\n      }\n    });\n\n    // Basic error handler\n    app.use((_err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {\n      // If our routes specified a specific response, then send that. Otherwise,\n      // send a generic message so as not to leak anything.\n\n      if (!res.headersSent) {\n        res.status(500).json({error: 'Internal Server Error'});\n      }\n    });\n  }\n}\n\nexport function makeServer(testMode?: boolean): {\n  app: express.Application;\n  start(port: number): Server;\n} {\n  const app = getExpressAppWithPreprocessors(testMode);\n  return {\n    app,\n    start(port: number) {\n      applyCommonPostprocessors(app, testMode);\n\n      return app.listen(port, () => {\n        console.log('OSMod listening on port', port);\n      });\n    },\n  };\n}\n"
  },
  {
    "path": "packages/backend-api/src/api/util/sortCommentIds.ts",
    "content": "/*\nCopyright 2020 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\nimport {Op, OrderItem} from 'sequelize';\n\nimport {Comment} from '../../models';\n\nexport async function sortCommentIds(\n  ids: Array<number>,\n  sort: Array<string>,\n): Promise<Array<number>> {\n\n  const order: Array<OrderItem> = [];\n\n  for (let sortItem of sort) {\n    let orderItem = 'ASC';\n    if (sortItem.startsWith('-')) {\n      sortItem = sortItem.substring(1);\n      orderItem = 'DESC';\n    }\n    order.push([sortItem, orderItem]);\n  }\n\n  const items: Array<{id: number}> = await Comment.findAll({\n    where: { id: {[Op.in]: ids } },\n    order,\n    attributes: ['id'],\n  });\n\n  return items.map((item: any) => item.id);\n}\n"
  },
  {
    "path": "packages/backend-api/src/api/util/validation.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as express from 'express';\nimport * as Joi from 'joi';\n\nexport function dataSchema(type: any) {\n  return Joi.object().keys({\n    runImmediately: Joi.boolean().optional(),\n    data: Joi.alternatives().try(\n      Joi.array().items(type),\n      type,\n    ).required(),\n  });\n}\n\n/**\n * Express middleware to make sure the body of the post is 1 or more author ids.\n */\nexport function validateRequest(schema: Joi.Schema) {\n  return (req: express.Request, res: express.Response, next: express.NextFunction) => {\n    const data = req.body;\n    const status = schema.validate(data, { convert: false });\n\n    if (status.error) {\n      // console.error(status.error.details);\n      res.status(422).json({ status: 'request validation error', errors: status.error.details, data });\n\n      return;\n    }\n\n    next();\n  };\n}\n\nexport function validateAndSendResponse<T>(schema: Joi.Schema) {\n  return (data: T, res: express.Response, next: express.NextFunction) => {\n    const status = schema.validate(data, { convert: false });\n\n    if (status.error) {\n      // console.error(status.error.details);\n      res.status(422).json({ status: 'response validation error', errors: status.error.details, data });\n\n      return;\n    }\n\n    res.json({ data });\n\n    next();\n  };\n}\n"
  },
  {
    "path": "packages/backend-api/src/auth/config.ts",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { CONFIGURATION_GOOGLE_OAUTH, getConfigItem, setConfigItem } from '../models';\n\nexport interface IGoogleOAuthConfiguration {\n  id: string;\n  secret: string;\n}\n\nexport async function getOAuthConfiguration() {\n  return await getConfigItem(CONFIGURATION_GOOGLE_OAUTH) as IGoogleOAuthConfiguration | null;\n}\n\nexport async function setOAuthConfiguration(oauthConfig: IGoogleOAuthConfiguration) {\n  return await setConfigItem(CONFIGURATION_GOOGLE_OAUTH, oauthConfig);\n}\n\nlet oauthGood = false;\n\nexport async function setOAuthGood(isGood: boolean) {\n  oauthGood = isGood;\n}\n\nexport async function isOAuthGood() {\n  return oauthGood;\n}\n"
  },
  {
    "path": "packages/backend-api/src/auth/providers/google.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { config } from '../../config';\nimport { User } from '../../models';\nimport { IGoogleOAuthConfiguration, setOAuthGood } from '../config';\nimport { ensureFirstUser, findOrCreateUserSocialAuth, isFirstUserInitialised } from '../users';\n\nconst Strategy = require('passport-google-oauth20').Strategy;\n\nclass AuthError extends Error {\n\n}\n\nexport interface IGoogleProfile {\n  id: string;\n  displayName: string;\n  name: {\n    familyName: string;\n    givenName: string;\n  };\n  emails: Array<{\n    value: string;\n    verified: string;\n  }>;\n  photos: Array<{\n    value: string;\n  }>;\n}\n\n/**\n * Map Google OAuth data to a data object to jam into a User model\n */\nexport function mapAuthDataToUser(profile: IGoogleProfile) {\n  const email = profile.emails[0].value;\n\n  return {\n    email,\n    name: profile.displayName,\n  };\n}\n\n/**\n * Map Google OAuth data to a data object to jam into a UserSocialAuth model\n */\nexport function mapAuthDataToUserSocialAuth(accessToken: string, refreshToken: string, profile: IGoogleProfile) {\n  return {\n    provider: 'google',\n    socialId: profile.id,\n    extra: {\n      accessToken,\n      refreshToken,\n      profile,\n    },\n  };\n}\n\n/**\n * Login verification after successful Google Oauth flow. Takes the passed in data an:\n *\n *   1. Finds or creates the user based on their email address\n *   2. Finds or creates a social auth record and relates it to the user\n */\nexport async function verifyGoogleToken(accessToken: string, refreshToken: string, profile: IGoogleProfile): Promise<User> {\n\n  const userData = mapAuthDataToUser(profile);\n\n  if (!userData) {\n    throw new Error('Error extracting user auth data');\n  }\n\n  if (!await isFirstUserInitialised()) {\n    await ensureFirstUser(userData);\n  }\n\n  await setOAuthGood(true);\n\n  const user = await User.findOne({\n    where: { email: userData.email },\n  });\n\n  if (!user) {\n    throw new AuthError(`User ${userData.email} is not yet registered with Moderator.`);\n  }\n\n  if (!user.isActive) {\n    throw new AuthError(`User ${userData.email} has been deactivated.`);\n  }\n\n  const userSocialAuthData = mapAuthDataToUserSocialAuth(accessToken, refreshToken, profile);\n  await findOrCreateUserSocialAuth(user, userSocialAuthData);\n\n  return user;\n}\n\nexport function getGoogleStrategy(\n  oauthConfig: IGoogleOAuthConfiguration,\n) {\n  return new Strategy(\n    {\n      clientID: oauthConfig.id,\n      clientSecret: oauthConfig.secret,\n      callbackURL: `${config.get('api_url')}/auth/callback/google`,\n      userProfileURL: 'https://www.googleapis.com/oauth2/v3/userinfo',\n    },\n    async (accessToken: string, refreshToken: string, profile: IGoogleProfile,\n           callback: (err: any, user?: User | false, info?: any) => any) => {\n      try {\n        const user = await verifyGoogleToken(accessToken, refreshToken, profile);\n\n        // Sync avatar\n        if (profile.photos && profile.photos[0] && profile.photos[0].value) {\n          await user.update({avatarURL: profile.photos[0].value});\n        }\n\n        callback(null, user);\n      }\n      catch (e) {\n        if (e instanceof AuthError) {\n          callback(null, false, {reason: e.message});\n        }\n        else {\n          callback(e);\n        }\n      }\n    },\n  );\n}\n"
  },
  {
    "path": "packages/backend-api/src/auth/providers/index.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nexport * from './google';\nexport * from './jwt';\n"
  },
  {
    "path": "packages/backend-api/src/auth/providers/jwt.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { ExtractJwt, Strategy } from 'passport-jwt';\n\nimport { User } from '../../models';\nimport { getTokenConfiguration, isValidToken } from '../tokens';\nimport { isValidUser } from '../users';\n\n/**\n * Verify JWT payload from JWT Passportstrategy\n *\n * @param {object}   jwtPayload Decoded JWT payload\n * @param {function} done       Verification callback\n */\nexport async function verifyJWT(jwtPayload: any): Promise<User> {\n  if (!isValidToken(jwtPayload)) {\n    throw new Error('Invalid token');\n  }\n\n  const user = await User.findByPk(jwtPayload.user);\n\n  if (user) {\n    if (isValidUser(user)) {\n      if (user.email) {\n        if (user.email === jwtPayload.email) {\n          return user;\n        } else {\n          throw new Error(`User email does not match token: ${user.email} === ${jwtPayload.email}`);\n        }\n      } else {\n        return user;\n      }\n    }\n\n    throw new Error('User not valid');\n  } else {\n    throw new Error('User not found');\n  }\n}\n\n/**\n * JWT Passport strategy configuration\n */\nexport async function getJwtStrategy() {\n  const config = await getTokenConfiguration();\n\n  return new Strategy(\n    {\n      secretOrKey: config.secret,\n      issuer: config.issuer,\n\n      jwtFromRequest: (ExtractJwt as any).fromExtractors([\n        // Pull JWT token out of request header formatted like so: \"Authorization: JWT (token)\"\n        ExtractJwt.fromAuthHeaderWithScheme('jwt'),\n\n        // Or, grab from `token` query string.\n        ExtractJwt.fromUrlQueryParameter('token'),\n      ]),\n    },\n    async (jwtPayload: any, callback: (err: any, user?: User | false) => any) => {\n      try {\n        const user = await verifyJWT(jwtPayload);\n        callback(null, user);\n      } catch (e) {\n        callback(e);\n      }\n    },\n  );\n}\n"
  },
  {
    "path": "packages/backend-api/src/auth/router.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as express from 'express';\nimport * as passport from 'passport';\nimport * as qs from 'qs';\n\nimport { config } from '../config';\nimport { findOrCreateTagByKey, SUMMARY_SCORE_TAG, User } from '../models';\nimport { restartService } from '../server-management';\nimport {\n  getOAuthConfiguration,\n  IGoogleOAuthConfiguration,\n  isOAuthGood,\n  setOAuthConfiguration,\n  setOAuthGood,\n} from './config';\nimport { createToken } from './tokens';\nimport { isFirstUserInitialised } from './users';\nimport { generateServerCSRF, getClientCSRF } from './utils';\n\nfunction redirectToFrontend(\n  res: express.Response,\n  success: boolean,\n  params: object = {},\n  referrer?: string | null,\n): void {\n  let redirectHost;\n\n  if (!referrer) {\n    redirectHost = config.get('frontend_url');\n  }\n  else {\n    redirectHost = referrer;\n  }\n\n  if (redirectHost === '') {\n    redirectHost = '/';\n  }\n\n  const queryString = qs.stringify(Object.assign({\n    error: !success,\n  }, params));\n\n  res.redirect(`${redirectHost}?${queryString}`);\n}\n\nexport function createHealthcheckRouter(oauthConfig: IGoogleOAuthConfiguration | null): express.Router {\n  const router = express.Router({\n    caseSensitive: true,\n    mergeParams: true,\n  });\n\n  router.get(\n    '/auth/healthcheck',\n    async (_req, res, next) => {\n      if (oauthConfig == null) {\n        res.status(218).send('init_oauth');\n      }\n      else if (!await isFirstUserInitialised()) {\n        res.status(218).send('init_first_user');\n      }\n      else if (!await isOAuthGood()) {\n        res.status(218).send('init_check_oauth');\n      }\n      else {\n        res.send('ok');\n      }\n      next();\n    },\n  );\n\n  return router;\n}\n\nexport function createAuthConfigRouter(): express.Router {\n  const router = express.Router({\n    caseSensitive: true,\n    mergeParams: true,\n  });\n\n  router.get(\n    '/auth/config',\n    async (_req, res, next) => {\n      const data = await getOAuthConfiguration();\n      const id = (data && data.id) ? data.id : '';\n      const secret = (data && data.secret) ? 'X'.repeat(data.secret.length - 5) + data.secret.substr(-5) : '';\n      res.json({ google_oauth_config: {\n        id: id,\n        secret: secret,\n      }});\n      next();\n    },\n  );\n\n  router.post(\n    '/auth/config',\n    async (req, res, next) => {\n      await setOAuthConfiguration(req.body.data as IGoogleOAuthConfiguration);\n      res.send('ok');\n      next();\n      await setOAuthGood(false);\n      // Take this opportunity to create some database records that we'll need\n      await findOrCreateTagByKey(SUMMARY_SCORE_TAG);\n      restartService();\n    },\n  );\n\n  return router;\n}\n\nexport function createAuthRouter(): express.Router {\n  const router = express.Router({\n    caseSensitive: true,\n    mergeParams: true,\n  });\n\n  router.get(\n    '/auth/test',\n    passport.authenticate('jwt', { session: false }),\n    (_, res) => {\n      res.send('You should not be able to see this unless you have a valid JWT token');\n    },\n  );\n\n  // Start OAuth login entrypoint.  It should forward to Google OAuth servers\n  router.get(\n    '/auth/login/google',\n    async (req, res, next) => {\n      const serverCSRF = await generateServerCSRF(req, res, next);\n\n      if (res.headersSent) {\n        return;\n      }\n\n      passport.authenticate('google', {\n        session: false,\n\n        // Get profile information and email address\n        // https://developers.google.com/+/web/api/rest/oauth#authorization-scopes\n        scope: ['profile', 'email'],\n\n        state: serverCSRF,\n\n        // Force approval UI and account switcher in OAuth login\n        accessType: 'online',\n        prompt: 'consent',\n      } as any)(req, res, next);\n    },\n  );\n\n  // Complete OAuth login entrypoint.  Google returns here after successful login\n  // We create a login token and forward to the\n  router.get(\n    '/auth/callback/google',\n    (req, res, next) => {\n      passport.authenticate('google', {\n        session: false,\n      }, async (err: any, user: User | false, info: any) => {\n\n        const {clientCSRF, referrer, errorMessage} = await getClientCSRF(req);\n\n        if (err) {\n          return redirectToFrontend(\n            res,\n            false,\n            {\n              errorMessage: `Authentication error: ${err.toString()}`,\n            },\n          );\n        }\n\n        if (!user) {\n          return redirectToFrontend(\n            res,\n            false,\n            {\n              errorMessage: info.reason,\n            },\n          );\n        }\n\n        if (errorMessage) {\n          return redirectToFrontend(\n            res,\n            false,\n            {\n              errorMessage: errorMessage,\n            },\n          );\n        }\n\n        const token = await createToken(user.id, user.get('email'));\n        return redirectToFrontend(\n          res,\n          true,\n          {\n            token,\n            csrf: clientCSRF,\n          },\n          referrer,\n        );\n      })(req, res, next);\n    },\n  );\n\n  return router;\n}\n"
  },
  {
    "path": "packages/backend-api/src/auth/tokens.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { randomBytes } from 'crypto';\nimport * as jwt from 'jsonwebtoken';\nimport { isNumber } from 'lodash';\nimport * as moment from 'moment';\n\nimport {\n  CONFIGURATION_TOKEN,\n  getConfigItem,\n  setConfigItem,\n  User,\n} from '../models';\n\nexport interface ITokenConfiguration {\n  secret: string;\n  issuer: string;\n  expiration_minutes: number;\n}\n\nlet config: ITokenConfiguration | null;\n\nexport async function getTokenConfiguration(): Promise<ITokenConfiguration> {\n  if (config) {\n    return config;\n  }\n\n  config = await getConfigItem(CONFIGURATION_TOKEN) as ITokenConfiguration;\n  if (config) {\n    return config;\n  }\n\n  config = await new Promise<ITokenConfiguration>((resolve, reject) => {\n    randomBytes(48, (err, buffer) => {\n      if (err) {\n        reject(err);\n      }\n\n      resolve({\n        secret: buffer.toString('base64'),\n        issuer: 'OSMod',\n        expiration_minutes: 12 * 60,\n      });\n    });\n  });\n\n  await setConfigItem(CONFIGURATION_TOKEN, config);\n  return config;\n}\n\nexport interface ITokenPayload {\n  iat: number;\n  user: number;\n  email?: string;\n}\n\nexport function isValidToken(tokenPayload: ITokenPayload): boolean {\n  return !(!isNumber(tokenPayload.user) || tokenPayload.user < 1);\n}\n\n/**\n * Indicate whether token iat (issue at timestamp) is before our configured\n * threshold of days\n *\n * @param {object} user User model instance\n * @param {object} tokenPayload Decoded token payload object with an `iat` key\n * @return {boolean}\n */\nexport async function isExpired(user: User, tokenPayload: ITokenPayload): Promise<boolean> {\n  if (user.group === 'service') {\n    return false;\n  }\n\n  const c = await getTokenConfiguration();\n  const cutoff = moment().subtract(c.expiration_minutes, 'minutes').unix();\n\n  return tokenPayload.iat < cutoff;\n}\n\n/**\n * Create a JWT token for the passed in User model instance or user id\n *\n * @param userId User's ID\n * @param email: User's email address\n * @return JWT token string\n */\nexport async function createToken(userId: number, email?: string): Promise<string> {\n  const c = await getTokenConfiguration();\n  return jwt.sign({\n    user: userId,\n    email,\n  },\n  c.secret,\n  {\n    issuer: c.issuer,\n  });\n}\n\n/**\n * Verify a JWT token. Returns false for an invalid or expired token\n * otherwise returns the decoded token\n *\n * @param  {string} token JWT token to verify\n * @return If token is valid, return decoded token data\n */\nexport async function verifyToken(token: string): Promise<ITokenPayload | null> {\n  const c = await getTokenConfiguration();\n  try {\n    const decoded = jwt.verify(token, c.secret) as ITokenPayload;\n    if (isValidToken(decoded)) {\n      return decoded;\n    }\n    return null;\n  }\n  catch (err) {\n    return null;\n  }\n}\n\n/**\n * Checks validity of passed in token and returns a fresh one if it passes\n *\n * @param {string} token JWT token to decode and refresh\n */\nexport async function refreshToken(token: string): Promise<string | null> {\n  const verified = await verifyToken(token);\n\n  if (verified) {\n    return createToken(verified.user, verified.email);\n  }\n\n  return null;\n}\n"
  },
  {
    "path": "packages/backend-api/src/auth/users.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport {\n  User,\n  USER_GROUP_ADMIN,\n  USER_GROUP_YOUTUBE,\n  UserSocialAuth,\n} from '../models';\n\n/**\n * Indicates whether a user is valid to be authenticated\n *\n * @param {object} user User model instance\n */\nexport function isValidUser(user: User): boolean {\n  return user.isActive;\n}\n\n/**\n * Find or create user social auth based on passed in data\n *\n * @param {object} user     User model instance to associate with\n * @param {object} data     Object of data formatted for UserSocialAuth model\n * @return {object}         Promise object that resolves to `instance` (UserSocialAuth instance) and\n *                          `created` (boolean) (use .spread())\n */\nexport async function findOrCreateUserSocialAuth(\n  user: User,\n  data: Pick<UserSocialAuth, 'provider' | 'socialId'>,\n): Promise<[UserSocialAuth, boolean]> {\n  const socialAuthData = {\n    ...data,\n    userId: user.id,\n  };\n\n  const [userSocialAuth, created] = await UserSocialAuth.findOrCreate({\n    where: {\n      userId: socialAuthData.userId,\n      provider: socialAuthData.provider,\n      socialId: socialAuthData.socialId,\n    },\n    defaults: socialAuthData,\n  });\n\n  return [userSocialAuth, created];\n}\n\nexport async function isFirstUserInitialised() {\n  const count = await User.count({where: {group: USER_GROUP_ADMIN, isActive: true}});\n  return count > 0;\n}\n\nexport async function ensureFirstUser({name, email}: {name: string, email: string}) {\n  if (await isFirstUserInitialised()) {\n    return;\n  }\n\n  const [user, created] = await User.findOrCreate({\n    where: {email: email},\n    defaults: {\n      name: name,\n      group: USER_GROUP_ADMIN,\n      isActive: true,\n    },\n  });\n\n  if (!created) {\n    // We are repurposing an existing user.  So ensure they have the correct properties\n    if (!await user.isActive) {\n      user.isActive = true;\n      await user.save();\n    }\n    if (await user.group !== USER_GROUP_ADMIN) {\n      user.group = USER_GROUP_ADMIN;\n      await user.save();\n    }\n  }\n\n  return user;\n}\n\nexport async function saveYouTubeUserToken({name, email}: {name: string, email: string}, token: any) {\n  const [user, created] = await User.findOrCreate({\n    where: {email: email, group: USER_GROUP_YOUTUBE},\n    defaults: {\n      name: name,\n      group: USER_GROUP_YOUTUBE,\n      isActive: true,\n    },\n  });\n\n  if (!created) {\n    if (!await user.isActive) {\n      user.isActive = true;\n    }\n  }\n\n  user.extra = {token: token};\n  await user.save();\n}\n"
  },
  {
    "path": "packages/backend-api/src/auth/utils.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as express from 'express';\nimport * as moment from 'moment';\nimport { generate } from 'randomstring';\n\nimport { CSRF } from '../models';\n\nexport async function generateServerCSRF(req: express.Request, res: express.Response, next: express.NextFunction) {\n  const clientCSRF = req.query.csrf;\n  const referrer = req.query.referrer;\n\n  if (!clientCSRF) {\n    res.status(403).send('No CSRF included in login request.');\n    next();\n    return;\n  }\n\n  const serverCSRF = generate();\n\n  await CSRF.create({\n    serverCSRF,\n    clientCSRF,\n    referrer,\n  });\n\n  return serverCSRF;\n}\n\nexport async function getClientCSRF(req: express.Request):\n  Promise<{clientCSRF: string|undefined, referrer: string|null|undefined, errorMessage: string|undefined}> {\n  const serverCSRF = req.query.state as string;\n  if (!serverCSRF) {\n    return {clientCSRF: undefined, referrer: undefined, errorMessage: 'CSRF missing.'};\n  }\n\n  const csrf = await CSRF.findOne({\n    where: {serverCSRF},\n  });\n\n  if (!csrf) {\n    return {clientCSRF: undefined, referrer: undefined, errorMessage: 'CSRF not valid.'};\n  }\n\n  const maxAge = moment().subtract(5, 'minutes').toDate();\n  const age = csrf.createdAt;\n  const clientCSRF = csrf.clientCSRF;\n  const referrer = csrf.referrer;\n  await csrf.destroy();\n\n  if (age < maxAge) {\n    return {clientCSRF: undefined, referrer: referrer, errorMessage: 'CSRF from server is older than 5 minutes.'};\n  }\n\n  return {clientCSRF: clientCSRF, referrer: referrer, errorMessage: undefined};\n}\n"
  },
  {
    "path": "packages/backend-api/src/auth/youtube.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as express from 'express';\nimport { google } from 'googleapis';\n\nimport { config } from '../config';\nimport { IGoogleOAuthConfiguration } from './config';\nimport { saveYouTubeUserToken } from './users';\nimport { generateServerCSRF, getClientCSRF } from './utils';\n\nexport function createYouTubeRouter(\n  oauthConfig: IGoogleOAuthConfiguration,\n  authenticator: any,\n): express.Router {\n  const router = express.Router({\n    caseSensitive: true,\n    mergeParams: true,\n  });\n\n  if (authenticator) {\n    // Only the connect entrypoint should be authenticated.\n    router.get('/youtube/connect', authenticator);\n  }\n\n  router.get(\n    '/youtube/connect',\n    async (req, res, next) => {\n      const serverCSRF = await generateServerCSRF(req, res, next);\n\n      if (res.headersSent) {\n        return;\n      }\n\n      const oauth2Client = new google.auth.OAuth2(\n        oauthConfig.id,\n        oauthConfig.secret,\n        `${config.get('api_url')}/youtube/callback`);\n      const authUrl = oauth2Client.generateAuthUrl({\n        access_type: 'offline',\n        scope:  ['profile', 'email', 'https://www.googleapis.com/auth/youtube.force-ssl'],\n        state: serverCSRF,\n        prompt: 'consent',\n      });\n      res.redirect(authUrl);\n      next();\n    },\n  );\n\n  router.get(\n    '/youtube/callback',\n    async (req, res) => {\n      const {clientCSRF, errorMessage} = await getClientCSRF(req);\n\n      const params: any = {\n        csrf: clientCSRF,\n      };\n\n      if (req.query.error) {\n        params['errorMessage'] = `Login rejected: ${req.query.error}`;\n      }\n      else if (errorMessage) {\n        params['errorMessage'] = errorMessage;\n      }\n\n      const oauth2Client = new google.auth.OAuth2(\n        oauthConfig.id,\n        oauthConfig.secret,\n        `${config.get('api_url')}/youtube/callback`, );\n      const tokenRsp = await oauth2Client.getToken(req.query.code as string);\n      const token = tokenRsp.tokens;\n      oauth2Client.setCredentials(token);\n      const service = google.oauth2('v2');\n      const uiRsp = await service.userinfo.get({auth: oauth2Client});\n      saveYouTubeUserToken({name: uiRsp.data.name || 'Youtube user', email: uiRsp.data.email || 'youtube@user'}, token);\n\n      const frontend_url = config.get('frontend_url');\n      res.redirect(`${frontend_url}/settings`);\n    });\n\n  return router;\n}\n"
  },
  {
    "path": "packages/backend-api/src/commands/articles/delete.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as yargs from 'yargs';\n\nimport { denormalizeCommentCountsForCategory } from '../../domain';\nimport { logger } from '../../logger';\nimport { Article, Category } from '../../models';\n\nexport const command = 'articles:delete';\nexport const describe = 'Delete all articles from the database.';\n\nexport function builder(args: yargs.Argv) {\n  return args\n    .usage('Usage: node $0 articles:delete');\n}\n\nexport async function handler() {\n  logger.info(`Deleting articles`);\n\n  try {\n    await Article.destroy({where: {}});\n    const categories = await Category.findAll();\n    for (const c of categories) {\n      logger.info('Denormalizing category ' + c.id);\n      denormalizeCommentCountsForCategory(c);\n    }\n\n  }\n  catch (err) {\n    logger.error('Delete articles error: ', err.name, err.message);\n    logger.error(err.errors);\n    process.exit(1);\n  }\n\n  logger.info('Articles successfully deleted');\n  process.exit(0);\n}\n"
  },
  {
    "path": "packages/backend-api/src/commands/comments/calculate_text_size.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as yargs from 'yargs';\n\nimport { calculateTextSize } from '../../domain';\nimport { logger } from '../../logger';\nimport { Comment } from '../../models';\n\nexport const command = 'comments:calculate-text-size';\nexport const describe = 'Using node-canvas, calculate a single comment height at a given width.';\n\nexport function builder(args: yargs.Argv) {\n  return args\n      .usage('Usage: node $0 comments:calculate-text-size')\n      .demand('comment-id')\n      .number('comment-id')\n      .describe('comment-id', 'The comment id')\n      .demand('width')\n      .number('width')\n      .describe('width', 'The text width');\n}\n\nexport async function handler(argv: any) {\n  const width = argv.width;\n  const commentId = argv.commentId;\n  logger.info(`Calculating comment (${commentId}) text size at ${width}`);\n\n  try {\n    const comment = await Comment.findByPk(commentId, {\n      attributes: ['id', 'text'],\n    });\n\n    if (!comment) {\n      logger.error(`No such comment: ${commentId}`);\n      return;\n    }\n\n    const height = await calculateTextSize(comment, width);\n    console.log(`Height in pixels`, height);\n  } catch (err) {\n    logger.error('Calculate comment text size error: ', err.name, err.message);\n    logger.error(err.errors);\n    process.exit(1);\n  }\n\n  process.exit(0);\n}\n"
  },
  {
    "path": "packages/backend-api/src/commands/comments/data_helpers.ts",
    "content": "import { logger } from '../../logger';\nimport {\n  Article,\n  Category,\n  Comment,\n  IAuthorAttributes,\n  RESET_COUNTS,\n  User,\n  USER_GROUP_SERVICE,\n} from '../../models';\nimport { postProcessComment, sendForScoring } from '../../pipeline';\n\nfunction guid() {\n  function s4() {\n    return Math.floor((1 + Math.random()) * 0x10000)\n      .toString(16)\n      .substring(1);\n  }\n  return s4() + s4() + '-' + s4() + '-' + s4() + '-' + s4() + '-' + s4() + s4() + s4();\n}\n\nexport async function createOwner(name: string) {\n  const [owner, ] = await User.findOrCreate({\n    where: {name: 'alice service user'},\n    defaults: {\n      name: name,\n      group: USER_GROUP_SERVICE,\n      isActive: true,\n    },\n  });\n  return owner;\n}\n\nexport async function createCategory(owner: User | null, label: string) {\n  const [category, created] = await Category.findOrCreate({\n    where: {label},\n    defaults: {\n      ownerId: owner?.id,\n      label,\n      sourceId: guid(),\n      ...RESET_COUNTS,\n    },\n  });\n\n  if (created) {\n    logger.info(`Generated category ${category.id}: ${category.label}`);\n  }\n\n  return category;\n}\n\nexport async function createArticle(\n  category: Category,\n  title: string,\n  text: string,\n  url: string,\n) {\n  const [article, created] = await Article.findOrCreate({\n    where: {title},\n    defaults: {\n      categoryId: category.id,\n      ownerId: category.ownerId,\n      sourceId: guid(),\n      title,\n      text,\n      url,\n      sourceCreatedAt: new Date(Date.now()),\n      isCommentingEnabled: true,\n      isAutoModerated: true,\n      ...RESET_COUNTS,\n    },\n  });\n\n  if (created) {\n    logger.info(`Created article ${article.id}: ${article.title}`);\n  }\n\n  return article;\n}\n\nexport async function createComment(\n  article: Article,\n  authorName: string,\n  text: string,\n) {\n  const author: IAuthorAttributes = {\n    name: authorName,\n  };\n\n  const comment = await Comment.create({\n    articleId: article.id,\n    ownerId: article.ownerId,\n    sourceId: guid(),\n    sourceCreatedAt: new Date(Date.now()),\n    authorSourceId: guid(),\n    author,\n    text,\n  });\n\n  await postProcessComment(comment);\n  await sendForScoring(comment);\n\n  return comment;\n}\n"
  },
  {
    "path": "packages/backend-api/src/commands/comments/delete.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as yargs from 'yargs';\n\nimport { denormalizeCommentCountsForArticle } from '../../domain';\nimport { logger } from '../../logger';\nimport { Article, Comment } from '../../models';\n\nexport const command = 'comments:delete';\nexport const describe = 'Delete all comments from the database.';\n\nexport function builder(args: yargs.Argv) {\n  return args\n    .usage('Usage: node $0 comments:delete');\n}\n\nexport async function handler() {\n  logger.info(`Deleting comments`);\n\n  try {\n    await Comment.destroy({where: {}});\n    const articles = await Article.findAll();\n    for (const a of articles) {\n      logger.info('Denormalizing article ' + a.id);\n      denormalizeCommentCountsForArticle(a, false);\n    }\n\n  }\n  catch (err) {\n    logger.error('Delete comments error: ', err.name, err.message);\n    logger.error(err.errors);\n    process.exit(1);\n  }\n\n  logger.info('Comments successfully deleted');\n  process.exit(0);\n}\n"
  },
  {
    "path": "packages/backend-api/src/commands/comments/flag.ts",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { Op } from 'sequelize';\nimport * as yargs from 'yargs';\n\nimport { denormalizeCommentCountsForArticle, denormalizeCountsForComment } from '../../domain';\nimport { Comment, CommentFlag } from '../../models';\n\nexport const command = 'comments:flag';\n\nexport const describe = 'Flag comments.';\n\nexport function builder(args: yargs.Argv) {\n  return args\n    .usage('Usage: node $0 comments:flag --label <label> [ --description <description> ] [ --recommendation ] commentIds...')\n    .demandOption('label')\n    .string('label')\n    .describe('label', `Label to apply.`)\n    .string('detail')\n    .describe('detail', `Description to use.`)\n    .boolean('recommendation')\n    .describe('recommendation', `Flag is a recommendation`)\n    .demandCommand(1);\n}\n\nexport async function handler(argv: any) {\n  const comments = await Comment.findAll({where: {id: {[Op.in]: [argv._.slice(1)] }}});\n  for (const c of comments) {\n    console.log(`Flagging ${c.id}`);\n    await CommentFlag.create({\n      commentId: c.id,\n      label: argv.label.toString(),\n      detail: argv.detail ? argv.detail.toString() : undefined,\n      isRecommendation: argv.recommendation,\n      isResolved: false,\n      sourceId: 'osmod-cli',\n      authorSourceId: 'osmod-cli',\n    });\n\n    await denormalizeCountsForComment(c);\n    await denormalizeCommentCountsForArticle(await c.getArticle(), false);\n  }\n}\n"
  },
  {
    "path": "packages/backend-api/src/commands/comments/generate.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as fs from 'fs';\nimport * as path from 'path';\nimport { Op } from 'sequelize';\nimport * as yargs from 'yargs';\n\nimport { logger } from '../../logger';\nimport {Article, Category} from '../../models';\nimport {sendNotification} from '../../notification_router';\nimport {createArticle, createCategory, createComment, createOwner} from './data_helpers';\n\nconst PREEXISTING_CATEGORIES = 5;\nconst PREEXISTING_ARTICLES = 20;\nconst NEW_CATEGORIES = 1;\nconst NEW_ARTICLES = 5;\nconst NEW_COMMENTS = 20;\n\nexport const command = 'comments:generate';\nexport const describe = 'Generate some comments, with some associated categories and articles.  ' +\n  `We generate ${NEW_CATEGORIES} categories, ` +\n  `${NEW_ARTICLES * NEW_CATEGORIES} articles and ` +\n  `${NEW_COMMENTS * NEW_ARTICLES * NEW_CATEGORIES} comments per invocation.  ` +\n  'Comments are spread across new categories/articles and last few existing ones';\n\nexport function builder(args: yargs.Argv) {\n  return args\n    .usage('Usage: node $0 comments:generate [ --categories c ] [ -articles a ] \\n' +\n           '                                 [ -comments o ]')\n    .number('categories')\n    .describe('categories', `Number of categories to create.`)\n    .default('categories', NEW_CATEGORIES)\n    .number('articles')\n    .describe('articles', `Number of articles to create for each category.`)\n    .default('articles', NEW_ARTICLES)\n    .number('comments')\n    .describe('comments', `Number of comments to create for each article.`)\n    .default('comments', NEW_COMMENTS);\n}\n\nfunction isAlnum(data: string, offset: number): boolean {\n  const c = data.charCodeAt(offset);\n  if ((c > 47) && (c <  58)) {\n    return true;\n  }\n  if ((c > 64) && (c <  91)) {\n    return true;\n  }\n  return (c > 96) && (c < 123);\n}\n\nfunction isInternalPunctuation(data: string, offset: number): boolean {\n  return ' \\t\\n\\r\\v\\'\\,\";-_'.indexOf(data.charAt(offset)) > -1;\n}\n\nfunction find_start_of_sentence(data: string, offset: number): number {\n  while (offset > 0 && (isAlnum(data, offset) || isInternalPunctuation(data, offset))) {\n    offset -= 1;\n  }\n  return offset + 1;\n}\n\nfunction find_end_of_sentence(data: string, offset: number): number {\n  const len = data.length;\n  while (offset < len && (isAlnum(data, offset) || isInternalPunctuation(data, offset))) {\n    offset += 1;\n  }\n  return offset;\n}\n\nfunction find_start_of_word(data: string, offset: number): number {\n  while (offset > 0 && isAlnum(data, offset)) {\n    offset -= 1;\n  }\n  return offset + 1;\n}\n\nfunction find_end_of_word(data: string, offset: number): number {\n  const len = data.length;\n  while (offset < len && isAlnum(data, offset)) {\n    offset += 1;\n  }\n  return offset;\n}\n\nfunction get_sentences(data: string, count: number): string {\n  const start = Math.floor(data.length * Math.random());\n  const offset = find_start_of_sentence(data, start);\n  let end = start;\n  do {\n    end = find_end_of_sentence(data, end + 1);\n    count--;\n  } while (end < data.length && count > 0);\n\n  const ret = data.substr(offset, end - offset).trim();\n  if (ret.length === 0) {\n    return get_words(data, count);\n  }\n  return ret;\n}\n\nfunction get_words(data: string, count: number): string {\n  const start = Math.floor(data.length * Math.random());\n  const offset = find_start_of_word(data, start);\n  let end = start;\n  do {\n    end = find_end_of_word(data, end + 1);\n    count--;\n  } while (end < data.length && count > 0);\n\n  const ret = data.substr(offset, end - offset).replace(/\\W+/g, ' ').trim();\n  if (ret.length === 0) {\n    return get_words(data, count);\n  }\n  return ret;\n}\n\nexport async function handler(argv: any) {\n  const data = fs.readFileSync(path.join(__dirname, '../../../data/alice.txt'), 'utf8');\n\n  const owner = await createOwner('alice service user');\n\n  const categories = await Category.findAll({\n    where: {\n      isActive: true,\n      ownerId: {[Op.eq]: owner.id},\n    },\n    order: [['createdAt', 'DESC']],\n    limit: PREEXISTING_CATEGORIES,\n  });\n\n  const articles = await Article.findAll({\n    where: {\n      ownerId: {[Op.eq]: owner.id},\n    },\n    order: [['createdAt', 'DESC']],\n    limit: PREEXISTING_ARTICLES,\n  });\n\n  async function generate_comments() {\n    for (let i = 0; i < argv.comments; i++) {\n      const idx = Math.floor(articles.length * Math.random());\n      const article = articles[idx];\n      await createComment(article, get_words(data, 3), get_sentences(data, 6));\n    }\n  }\n\n  async function generate_articles() {\n    for (let i = 0; i < argv.articles; i++) {\n      const idx = Math.floor(categories.length * Math.random());\n      const new_article = await createArticle(\n        categories[idx],\n        get_words(data, 10),\n        get_sentences(data, 3),\n        'https://archive.org/stream/alicesadventures19033gut/19033.txt',\n      );\n      articles.push(new_article);\n      await generate_comments();\n    }\n  }\n\n  if (argv.categories !== 0) {\n    for (let i = 0; i < argv.categories; i++) {\n      categories.push(await createCategory(owner, get_words(data, 5)));\n\n      if (argv.articles !== 0) {\n        await generate_articles();\n      }\n      else {\n        await generate_comments();\n      }\n    }\n  }\n  else if (argv.articles !== 0) {\n    await generate_articles();\n  }\n  else {\n    await generate_comments();\n  }\n\n  if (argv.comments === 0) {\n    logger.info(`Not generating comments, but trigger update anyway.`);\n    await sendNotification('global');\n  }\n}\n"
  },
  {
    "path": "packages/backend-api/src/commands/comments/import.ts",
    "content": "/*\nCopyright 2012 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as parse from 'csv-parse';\nimport { promises as fsPromises } from 'fs';\nimport * as path from 'path';\nimport * as yargs from 'yargs';\nimport {User} from '../../models';\nimport {createArticle, createCategory, createComment, createOwner} from './data_helpers';\n\nexport const command = 'comments:import';\nexport const describe = 'Import a CSV file of comments';\n\nconst FILES = ['brexit',  'climate',  'election',  'wikipedia'];\n\nexport function builder(args: yargs.Argv) {\n  return args\n    .usage('Usage: node $0 comments:import [ --source <source> ]')\n    .choices('source', [...FILES, 'all'])\n    .describe('source', 'Source of comments.')\n    .default('source', 'all');\n}\n\nconst METADATA = {\n  wikipedia: {\n    category: 'Wikipedia',\n    article_id: '787',\n    article_title: 'Wikipedia 1/9/17',\n    article_summary: 'Some comments from Wikipedia.',\n  },\n  brexit: {\n    category: 'Brexit',\n    article_title: 'Brexit 9/1/2017',\n    article_summary: 'Some thoughts about brexit...',\n  },\n  climate: {\n    category: 'Climate Change',\n    article_title: 'Climate Change 3/10/18',\n    article_summary: 'Some thoughts about climate change...',\n  },\n  election: {\n    category: 'US Election',\n    article_title: 'US Election 10/20/17',\n    article_summary: 'Some thoughts about the US election...',\n  },\n} as {[key: string]: {category: string, article_title: string, article_summary: string}};\n\nasync function processFile(owner: User, fname: string) {\n  console.log(`process file: ${fname}`);\n  const metadata = METADATA[fname];\n  const category = await createCategory(owner, metadata.category);\n  const article = await createArticle(category, metadata.article_title,\n    metadata.article_summary, 'https://jigsaw.google.com/');\n  const content = await fsPromises.readFile(path.join(__dirname, `../../../data/${fname}.csv`), 'utf8');\n  const records = parse(content, {from_line: 2});\n  for await (const record of records) {\n    await createComment(article, `${fname} user`, record[0]);\n  }\n}\n\nexport async function handler(argv: any) {\n  console.log('Ensure service user');\n  const owner = await createOwner('csv service user');\n\n  if (argv.source === 'all') {\n    for (const f of FILES) {\n      await processFile(owner, f);\n    }\n  } else {\n    await processFile(owner, argv.source);\n  }\n}\n"
  },
  {
    "path": "packages/backend-api/src/commands/comments/rebuild_reply_relations.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as Bluebird from 'bluebird';\nimport { QueryTypes } from 'sequelize';\nimport * as yargs from 'yargs';\n\nimport { logger } from '../../logger';\nimport { sequelize } from '../../sequelize';\n\nexport const command = 'comments:rebuild-reply-relations';\nexport const describe = 'Rebuild the join table for comment relations';\n\nexport function builder(args: yargs.Argv) {\n  return args\n      .usage('Usage: node $0 comments:rebuild-reply-relations');\n}\n\nexport async function handler() {\n  logger.info('Rebuilding reply relations');\n\n  try {\n    const results = await sequelize.query(\n      'SELECT c.id as commentId, c2.id as replyId ' +\n      'FROM comments as c ' +\n      'LEFT JOIN comments as c2 ON c.sourceId = c2.replyToSourceId ' +\n      'WHERE c2.id IS NOT NULL;',\n      { type: QueryTypes.SELECT },\n    );\n\n    logger.info('Found ' + results.length + ' comments with replies');\n\n    await Bluebird.mapSeries(results, async (row: any) => {\n      const existing = await sequelize.query(\n        'SELECT commentId, replyId FROM comment_replies ' +\n        'WHERE commentId = ' + row.commentId + ' AND replyId = ' + row.replyId + ';',\n        { type: QueryTypes.SELECT },\n      );\n\n      if (existing && existing.length > 0) { return Bluebird.resolve(); }\n\n      logger.info('Creating relation for ' + row.commentId + ',' + row.replyId);\n\n      return sequelize.query(\n        'INSERT INTO comment_replies (commentId, replyId, createdAt, updatedAt) ' +\n        'VALUES(' + row.commentId + ',' + row.replyId + ',NOW(),NOW());',\n      );\n    });\n  } catch (err) {\n    logger.error('Rebuild reply relations error: ', err.name, err.message);\n    logger.error(err.errors);\n    process.exit(1);\n  }\n\n  logger.info('Reply relations successfully created');\n  process.exit(0);\n}\n"
  },
  {
    "path": "packages/backend-api/src/commands/comments/recalculate_text_sizes.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as Bluebird from 'bluebird';\nimport * as yargs from 'yargs';\n\nimport { cacheTextSize } from '../../domain/comments';\nimport { logger } from '../../logger';\nimport { Comment, CommentSize } from '../../models';\n\nexport const command = 'comments:recalculate-text-sizes';\nexport const describe = 'Using node-canvas, recalculate comment heights at a given width.';\n\nexport function builder(args: yargs.Argv) {\n  return args\n      .usage('Usage: node $0 comments:recalculate-text-sizes')\n      .demand('width')\n      .number('width')\n      .describe('width', 'The text width');\n}\n\nexport async function handler(argv: any) {\n  const width = argv.width;\n  logger.info(`Recalculating comment text sizes at ${width}`);\n\n  try {\n    // Clear table.\n    await CommentSize.destroy({\n      truncate: true,\n    });\n\n    const comments = await Comment.findAll({\n      attributes: ['id', 'text'],\n    });\n\n    await Bluebird.mapSeries(comments, async (comment) => {\n      return cacheTextSize(comment, width);\n    });\n  } catch (err) {\n    logger.error('Recalculate comment text sizes error: ', err.name, err.message);\n    logger.error(err.errors);\n    process.exit(1);\n  }\n\n  logger.info('Comment text successfully recalculated');\n  process.exit(0);\n}\n"
  },
  {
    "path": "packages/backend-api/src/commands/comments/recalculate_top_scores.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as yargs from 'yargs';\n\nimport { cacheCommentTopScore } from '../../domain';\nimport { logger } from '../../logger';\nimport { Comment, CommentTopScore, Tag } from '../../models';\n\nexport const command = 'comments:recalculate-top-scores';\nexport const describe = 'Recalculate comment top scores.';\n\nexport function builder(args: yargs.Argv) {\n  return args\n      .usage('Usage: node $0 comments:recalculate-top-scores');\n}\n\nexport async function handler() {\n  logger.info(`Recalculating comment top scores`);\n\n  try {\n    // Clear table.\n    await CommentTopScore.destroy({\n      truncate: true,\n    });\n\n    const comments = await Comment.findAll({\n      attributes: ['id'],\n    });\n\n    const tags = await Tag.findAll({\n      attributes: ['id'],\n    });\n\n    for (const tag of tags) {\n      for (const comment of comments) {\n        await cacheCommentTopScore(comment, tag);\n      }\n    }\n  } catch (err) {\n    logger.error('Recalculate comment top scores error: ', err.name, err.message);\n    logger.error(err.errors);\n    process.exit(1);\n  }\n\n  logger.info('Comment top scores successfully recalculated');\n  process.exit(0);\n}\n"
  },
  {
    "path": "packages/backend-api/src/commands/comments/rescore.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as yargs from 'yargs';\n\nimport { logger } from '../../logger';\nimport { Comment } from '../../models';\nimport { resendForScoring } from '../../pipeline';\n\nexport const command = 'comments:rescore';\nexport const describe = 'Rescore comment.';\n\nexport function builder(args: yargs.Argv) {\n  return args\n      .usage('Usage: node $0 comments:rescore');\n}\n\nexport async function handler() {\n  logger.info(`Rescoring comments`);\n\n  try {\n    // Clear tables\n    const comments = await Comment.findAll({\n      attributes: ['id'],\n      where: { isModerated: false },\n    });\n\n    for (const c of comments) {\n      const comment = (await Comment.findByPk(c.id))!;\n      await resendForScoring(comment);\n\n      logger.info(`Rescored comment ${c.id}`);\n    }\n  } catch (err) {\n    logger.error('Rescore comments error: ', err.name, err.message);\n    logger.error(err.errors);\n    process.exit(1);\n  }\n\n  logger.info('Comments successfully rescored');\n  process.exit(0);\n}\n"
  },
  {
    "path": "packages/backend-api/src/commands/comments/send_to_scorer.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as yargs from 'yargs';\n\nimport { logger } from '../../logger';\nimport {\n  Article,\n  Comment,\n  User,\n  USER_GROUP_MODERATOR,\n} from '../../models';\nimport { checkScoringDone, sendToScorer } from '../../pipeline';\n\nexport const command = 'comments:send-to-scorer';\nexport const describe = 'Send comments to Endpoint of user object to get scored.';\n\nexport function builder(args: yargs.Argv) {\n  return args\n    .usage('Usage:\\n\\n' +\n      'Send a comment for scoring:\\n' +\n      'node $0 comments:send-to-scorer --comment-id=4 --user-id=1')\n    .number('comment-id')\n    .demand('comment-id')\n    .describe('comment-id', 'comment id. To run all, use \\'all\\'.')\n    .number('user-id')\n    .demand('user-id')\n    .describe('user-id', 'user id (must be service user)')\n    .check((argv) => {\n      if (!argv.commentId && !argv.userId) {\n        throw new Error('You must enter a comment id and user id to have scored.');\n      }\n\n      return true;\n    });\n}\n\nexport async function handler(argv: any) {\n  const conditions = {\n    include: [Article],\n  } as any;\n\n  if (argv.commentId !== 'all') {\n    conditions['where'] = {\n      id: argv.commentId,\n    };\n  }\n\n  try {\n    const user = await User.findByPk(argv.userId);\n    if (!user) {\n      logger.error(`No such user`);\n      return;\n    }\n\n    if (user.group !== USER_GROUP_MODERATOR) {\n      logger.error(`User is not a moderator`);\n      return;\n    }\n\n    const comments = await Comment.findAll(conditions);\n\n    for (const c of comments) {\n      logger.info('Comment id ', c.id);\n      await sendToScorer(c, user);\n      await checkScoringDone(c);\n    }\n\n    logger.info('Processing Completed.');\n    process.exit(0);\n  } catch (err) {\n    logger.info('failure', err);\n    process.exit(1);\n  }\n}\n"
  },
  {
    "path": "packages/backend-api/src/commands/denormalize.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as Bluebird from 'bluebird';\nimport * as yargs from 'yargs';\n\nimport { denormalizeCommentCountsForArticle, denormalizeCountsForComment } from '../domain';\nimport { logger } from '../logger';\nimport {\n  Article,\n  Comment,\n} from '../models';\n\nexport const command = 'denormalize';\nexport const describe = 'Re-run denormalize counts';\n\nexport function builder(args: yargs.Argv) {\n  return args.usage('Usage: node $0 denormalize')\n              .boolean('articles-only')\n              .describe('articles-only', 'Only recalculate counts for articles, not comments');\n}\n\nexport async function handler(argv: any) {\n  if (!argv.articlesOnly) {\n    const comments = await Comment.findAll();\n\n    await Bluebird.mapSeries(comments, (c: Comment) => {\n      logger.info('Denormalizing comment ' + c.id);\n\n      return denormalizeCountsForComment(c);\n    });\n  }\n\n  const articles = await Article.findAll();\n\n  await Bluebird.mapSeries(articles, (a: Article) => {\n    logger.info('Denormalizing article ' + a.id);\n\n    return denormalizeCommentCountsForArticle(a, false);\n  });\n\n  logger.info('Counts denormalized successfully');\n  process.exit(0);\n}\n"
  },
  {
    "path": "packages/backend-api/src/commands/tests/youtube.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as yargs from 'yargs';\n\nimport { for_all_youtube_users, youtubeSetTestOnly } from '../../integrations';\nimport { activate_channel, sync_channels } from '../../integrations/youtube/channels';\nimport { sync_comment_threads } from '../../integrations/youtube/comments';\nimport { sync_individual_videos, sync_known_videos, sync_playlists } from '../../integrations/youtube/videos';\n\nexport const command = 'test:youtube';\nexport const describe = 'Test out the youtube interface';\n\nexport function builder(args: yargs.Argv) {\n  return args\n    .usage('Usage: node $0 test:youtube');\n}\n\nexport async function handler() {\n  let channelId: string;\n  let videoId: string;\n  youtubeSetTestOnly((type, obj) => {\n    console.log(type, obj);\n    if (type === 'channel' && !channelId) {\n      channelId = obj.id;\n    }\n    if (type === 'video' && !videoId) {\n      videoId = obj.videoId;\n    }\n  });\n\n  await for_all_youtube_users(async (owner, auth) => {\n    console.log('\\n\\n*** Doing sync_channels');\n    await sync_channels(owner, auth);\n    if (channelId) {\n      console.log('\\n*** Testing disable');\n      await activate_channel(owner, auth, channelId, false);\n      console.log('\\n*** Testing enable');\n      await activate_channel(owner, auth, channelId, true);\n    }\n\n    console.log('\\n\\n*** Doing sync_playlists');\n    await sync_playlists(owner, auth);\n\n    console.log('\\n*** Doing sync_known_videos');\n    await sync_known_videos(owner, auth);\n    if (videoId) {\n      console.log('\\n*** Doing sync_individual_videos');\n      await sync_individual_videos(owner, auth, [videoId]);\n    }\n\n    console.log('\\n\\n*** Doing sync comments');\n    await sync_comment_threads(owner, auth, true, 100);\n    console.log('\\n*** Doing incremental sync');\n    await sync_comment_threads(owner, auth, false, 100);\n  });\n}\n"
  },
  {
    "path": "packages/backend-api/src/commands/users/create.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as yargs from 'yargs';\n\nimport { logger } from '../../logger';\nimport {\n  ENDPOINT_TYPE_API,\n  IScorerExtra,\n  User,\n  USER_GROUPS,\n  USER_GROUP_ADMIN,\n  USER_GROUP_GENERAL,\n  USER_GROUP_MODERATOR,\n  USER_GROUP_SERVICE,\n} from '../../models';\n\nexport const command = 'users:create';\n\nexport const describe = 'Create new OS Moderator users';\n\nexport function builder(args: yargs.Argv) {\n  return args\n    .usage('Usage: node $0 create --group general --name \"User Name\" \\\\\\n' +\n           '                                  --email name@example.com\\n' +\n           '       node $0 create --group service --name \"Service Name\"\\n' +\n           '       node $0 create --group moderator --name \"Service Name\" \\\\\\n' +\n           '                                  --moderator-type <type> --api-key <key> \\\\\\n' +\n           '                                  [--endpoint <url>] [--user-agent <agent>] [--attributes <attributes>]\\n')\n    .demand('group')\n    .choices('group', USER_GROUPS)\n    .describe('group', `The user type/group: one of '${USER_GROUP_ADMIN}', '${USER_GROUP_GENERAL}', '${USER_GROUP_MODERATOR}' or '${USER_GROUP_SERVICE}'`)\n    .demand('name')\n    .describe('name', 'The user\\'s name')\n    .string('email')\n    .describe('email', `The user's email address.  Mandatory for all '${USER_GROUP_ADMIN}' and '${USER_GROUP_GENERAL}' users.`)\n    .string('api-key')\n    .describe('api-key', `For moderator users: the API key used to access the moderation service.`)\n    .string('endpoint')\n    .describe('endpoint', 'For moderator users: Endpoint URL for moderator users to post comments for scoring.')\n    .string('user-agent')\n    .describe('user-agent', 'For ${ENDPOINT_TYPE_API} moderator service users: User agent to use.  Defaults sensibly if not set.')\n    .string('attributes')\n    .describe('attributes', 'For ${ENDPOINT_TYPE_API} moderator service users: Comma-separated list of attributes to score on.  Defaluts sensibly if not set.');\n}\n\nexport async function handler(argv: any) {\n  // Make user active\n  const data: any = {\n    group: argv.group,\n    name: argv.name,\n    email: argv.email,\n    isActive: true,\n  };\n\n  if (data.group === USER_GROUP_MODERATOR) {\n    const extra: Partial<IScorerExtra> = {endpointType: ENDPOINT_TYPE_API};\n\n    extra.apiKey = argv.apiKey;\n    if (!extra.apiKey) {\n      console.log(`User creation error: moderators require an API key.\\n`);\n      yargs.showHelp();\n      return;\n    }\n\n    if (argv.endpoint) {\n      extra.endpoint = argv.endpoint;\n    }\n    else {\n      extra.endpoint = 'https://commentanalyzer.googleapis.com/$discovery/rest?version=v1alpha1';\n      logger.info(`API endpoint: Defaulting URL to ${extra.endpoint}`);\n    }\n\n    if (extra.endpointType === ENDPOINT_TYPE_API) {\n      extra.userAgent = argv.userAgent || 'OsmodAssistantV0';\n      if (argv.attributes) {\n        extra.attributes = {};\n        for (const i of argv.attributes.split(',')) {\n          extra.attributes[i] = {};\n        }\n      }\n      else {\n        extra.attributes = {\n          // Attributes taken from https://developers.perspectiveapi.com/s/about-the-api-attributes-and-languages\n          // Google attributes\n          TOXICITY: {},\n          SEVERE_TOXICITY: {},\n          IDENTITY_ATTACK: {},\n          INSULT: {},\n          PROFANITY: {},\n          THREAT: {},\n          SEXUALLY_EXPLICIT: {},\n          FLIRTATION: {},\n          // NYT attributes\n          ATTACK_ON_AUTHOR: {},\n          ATTACK_ON_COMMENTER: {},\n          INCOHERENT: {},\n          INFLAMMATORY: {},\n          LIKELY_TO_REJECT: {},\n          OBSCENE: {},\n          SPAM: {} ,\n          UNSUBSTANTIAL: {},\n        };\n      }\n    }\n\n    data.extra = extra;\n  }\n  else {\n    if (argv.apiKey) {\n      console.log('User creation error: Non-moderator users don\\'t need an API key.\\n');\n      yargs.showHelp();\n      return;\n    }\n    if (argv.endpoint) {\n      console.log('User creation error: Non-moderator users don\\'t need an endpoint.\\n');\n      yargs.showHelp();\n      return;\n    }\n  }\n\n  if ((argv.group === USER_GROUP_ADMIN || argv.group === USER_GROUP_GENERAL) && !argv.email) {\n    console.log('User creation error: Human users require an email.\\n');\n    yargs.showHelp();\n    return;\n  }\n\n  try {\n    await User.create(data);\n    logger.info('User successfully created');\n    process.exit(0);\n  } catch (err) {\n    logger.error('User creation error: ', err.name, err.message);\n    logger.error(err.errors);\n    process.exit(1);\n  }\n}\n"
  },
  {
    "path": "packages/backend-api/src/commands/users/get_token.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as yargs from 'yargs';\n\nimport { createToken } from '../../auth/tokens';\nimport { logger } from '../../logger';\nimport { User } from '../../models';\n\nexport const command = 'users:get-token';\n\nexport const describe = 'Get a JWT token for a user specified by id or email';\n\nexport function builder(args: yargs.Argv) {\n  return args\n    .usage('Usage:\\n\\n' +\n           'Create token for user by id:\\n' +\n           'node $0 get-token --id 4\\n\\n' +\n           'Create token for user by email:\\n' +\n           'node $0 get-token --email name@example.com')\n    .number('id')\n    .describe('id', 'id user to create token for')\n    .string('email')\n    .describe('email', 'Email address of user to create token for')\n    .check((argv) => {\n      if (!argv.id && !argv.email) {\n        throw new Error('You must enter a user id or email to generate a token for');\n      }\n\n      return true;\n    });\n}\n\nexport async function handler(argv: any) {\n  if (!argv.email) {\n    const token = await createToken(argv.id);\n    logger.info(`JWT token for id: ${argv.id}:\\n\\n\\t${token}`);\n    process.exit(0);\n  }\n\n  try {\n    const user = await User.findOne({\n      where: { email: argv.email },\n    });\n\n    if (!user) {\n      logger.error('User not found');\n      process.exit(1);\n      return;\n    }\n    const token = await createToken(user.id, user.email);\n    logger.info(`JWT token for \"${user.name}\" (id: ${user.id}):\\n\\t${token}`);\n    process.exit(0);\n  } catch (err) {\n    logger.error('Error creating token for user: ', err.name, err.message);\n    logger.error(err.errors);\n    process.exit(1);\n  }\n}\n"
  },
  {
    "path": "packages/backend-api/src/config.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as convict from 'convict';\n\n/**\n * Default/base configuration settings for OS Moderator\n */\nexport const config = convict({\n  env: {\n    doc: 'The current application environment',\n    format: ['production', 'development', 'local', 'test', 'circle_ci'],\n    default: 'local',\n    env: 'NODE_ENV',\n  },\n\n  app_name: {\n    doc: 'The name of the project displayed on the frontend.',\n    format: String,\n    default: 'Moderator',\n    env: 'APP_NAME',\n  },\n\n  api_url: {\n    doc: 'The public URL for the API',\n    format: String,\n    default: 'http://localhost:8000/api',\n    env: 'API_URL',\n  },\n\n  frontend_url: {\n    doc: 'The public URL for the web frontend',\n    format: String,\n    default: 'http://localhost:8000',\n    env: 'FRONTEND_URL',\n  },\n\n  database_host: {\n    doc: 'Database host',\n    format: String,\n    default: 'localhost',\n    env: 'DATABASE_HOST',\n  },\n\n  database_port: {\n    doc: 'Database port',\n    format: Number,\n    default: 3306,\n    env: 'DATABASE_PORT',\n  },\n\n  database_name: {\n    doc: 'Database name',\n    format: String,\n    default: 'os_moderator',\n    env: 'DATABASE_NAME',\n  },\n\n  database_user: {\n    doc: 'Database user',\n    format: String,\n    default: 'os_moderator',\n    env: 'DATABASE_USER',\n  },\n\n  database_password: {\n    doc: 'Database password',\n    format: String,\n    default: '',\n    env: 'DATABASE_PASSWORD',\n  },\n\n  database_socket: {\n    doc: 'Database socket',\n    format: String,\n    default: undefined,\n    env: 'DATABASE_SOCKET',\n  },\n\n  redis_url: {\n    doc: 'The Redis config URL used by the worker queue',\n    format: String,\n    default: 'redis://localhost:6379',\n    env: 'REDIS_URL',\n  },\n\n  worker: {\n    run_immediately: {\n      doc: 'If true, we skip redis and run all tasks synchronously',\n      format: Boolean,\n      default: true,\n      env: 'WORKER_RUN_IMMEDIATELY',\n    },\n    remove_task_on_complete: {\n      doc: 'Whether to remove successful tasks from kue history to save memory',\n      format: Boolean,\n      default: true,\n      env: 'WORKER_REMOVE_TASK_ON_COMPLETE',\n    },\n    task_ttl: {\n      doc: 'Task Time To Live (in milliseconds)',\n      format: Number,\n      default: (5 * 60) * 1000, // 5 minutes.\n      env: 'WORKER_TASK_TTL',\n    },\n  },\n\n  require_reason_to_reject: {\n    doc: 'Flag to require moderator to select a reason (tag) to reject a comment',\n    format: Boolean,\n    default: true,\n    env: 'REQUIRE_REASON_TO_REJECT',\n  },\n\n  restrict_to_session: {\n    doc: 'Flag to restrict auth to the user session',\n    format: Boolean,\n    default: true,\n    env: 'RESTRICT_TO_SESSION',\n  },\n\n  moderator_guidelines_url: {\n    doc: 'URL for moderator guidelines',\n    format: String,\n    default: '',\n    env: 'MODERATOR_GUIDELINES_URL',\n  },\n\n  submit_feedback_url: {\n    doc: 'URL for submit feedback mechanism',\n    format: String,\n    default: '',\n    env: 'SUBMIT_FEEDBACK_URL',\n  },\n});\n\nconfig.validate({ allowed: 'strict' });\n"
  },
  {
    "path": "packages/backend-api/src/domain/articles/countDenormalization.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\nimport { Op } from 'sequelize';\n\nimport {\n  Article,\n  Category,\n  Comment,\n} from '../../models';\nimport {\n  denormalizeCommentCountsForCategory,\n} from '../categories';\n\nexport async function denormalizeCommentCountsForArticle(article: Article | null, isModeratorAction: boolean): Promise<void> {\n  if (!article) {\n    return;\n  }\n\n  const allCount = await Comment.count({ where: { articleId: article.id } });\n  const unprocessedCount = await Comment.count({ where: { articleId: article.id, isScored: false } });\n  const unmoderatedCount = await Comment.count({ where: { articleId: article.id,\n      isScored: true, isModerated: false, isDeferred: false } });\n  const moderatedCount = await Comment.count({ where: { articleId: article.id,\n      isScored: true, [Op.or]: { isModerated: true, isDeferred: true } } });\n  const highlightedCount = await Comment.count({ where: { articleId: article.id, isHighlighted: true } });\n  const approvedCount = await Comment.count({ where: { articleId: article.id, isAccepted: true } });\n  const rejectedCount = await Comment.count({ where: { articleId: article.id,\n      isAccepted: false, isHighlighted: false } });\n  const deferredCount = await Comment.count({ where: { articleId: article.id, isDeferred: true } });\n  const flaggedCount = await Comment.count({ where: { articleId: article.id,\n      [Op.or]: [{ isModerated: false }, { isAccepted: true }],\n      unresolvedFlagsCount: { [Op.gt]: 0 } } });\n  const batchedCount = await Comment.count({ where: { articleId: article.id, isModerated: true, isBatchResolved: true } });\n\n  const update: Partial<Article> = {\n    allCount,\n    unprocessedCount,\n    unmoderatedCount,\n    moderatedCount,\n    highlightedCount,\n    approvedCount,\n    rejectedCount,\n    deferredCount,\n    flaggedCount,\n    batchedCount,\n  };\n\n  if (isModeratorAction) {\n    update.lastModeratedAt = new Date();\n  }\n\n  await article.update(update);\n\n  if (article.categoryId) {\n    const category = (await Category.findByPk(article.categoryId))!;\n    await denormalizeCommentCountsForCategory(category);\n  }\n}\n"
  },
  {
    "path": "packages/backend-api/src/domain/articles/index.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nexport * from './countDenormalization';\n"
  },
  {
    "path": "packages/backend-api/src/domain/categories/countDenormalization.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport {\n  Article,\n} from '../../models';\nimport { Category } from '../../models';\n\nexport async function denormalizeCommentCountsForCategory(category: Category) {\n  const query = { where: { categoryId: category.id } };\n\n  const [\n    allCount,\n    unprocessedCount,\n    unmoderatedCount,\n    moderatedCount,\n    highlightedCount,\n    approvedCount,\n    rejectedCount,\n    deferredCount,\n    flaggedCount,\n    batchedCount,\n  ] = await Promise.all([\n    Article.sum('allCount', query),\n    Article.sum('unprocessedCount', query),\n    Article.sum('unmoderatedCount', query),\n    Article.sum('moderatedCount', query),\n    Article.sum('highlightedCount', query),\n    Article.sum('approvedCount', query),\n    Article.sum('rejectedCount', query),\n    Article.sum('deferredCount', query),\n    Article.sum('flaggedCount', query),\n    Article.sum('batchedCount', query),\n  ]);\n\n  return category.update({\n    allCount,\n    unprocessedCount,\n    unmoderatedCount,\n    moderatedCount,\n    highlightedCount,\n    approvedCount,\n    rejectedCount,\n    deferredCount,\n    flaggedCount,\n    batchedCount,\n  });\n}\n"
  },
  {
    "path": "packages/backend-api/src/domain/categories/index.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nexport * from './countDenormalization';\n"
  },
  {
    "path": "packages/backend-api/src/domain/commentScores/index.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as Bluebird from 'bluebird';\nimport { maxBy } from 'lodash';\n\nimport {\n  Comment,\n  CommentScore,\n  CommentTopScore,\n  Tag,\n} from '../../models';\n\n/**\n * Describes a top score for a set of comment scores in a tag.\n */\nexport interface ITopScore {\n  start: number;\n  end: number;\n  commentId: number;\n  score: number;\n}\n\n/**\n * Describes a set of top scores for each related commentId.\n */\nexport interface ITopScores {\n  [commentId: number]: ITopScore;\n}\n\n/**\n * Figure out the top score given an array of scores. Simply uses the max `score`.\n */\nexport function calculateTopScore(scores: Array<CommentScore>): CommentScore | null {\n  const scoresWithRange = scores.filter((s) => {\n    return s.annotationStart !== null && s.annotationEnd !== null;\n  });\n\n  if (scoresWithRange.length <= 0) { return null; }\n\n  return maxBy(scoresWithRange, (s) => s.score) || null;\n}\n\n/**\n * Get all the scores for a set of comments.\n */\nexport async function calculateTopScores(comments: Array<Comment>, tagId: number): Promise<ITopScores> {\n  return Bluebird.reduce(comments, async (sum, comment) => {\n    const topScore = await CommentTopScore.findOne({\n      where: {\n        commentId: comment.id,\n        tagId,\n      },\n    });\n\n    if (!topScore) { return sum; }\n\n    const score = await CommentScore.findByPk(topScore.commentScoreId);\n\n    if (!score) { return sum; }\n\n    sum[comment.id] = {\n      commentId: score.commentId,\n      score: score.score,\n      start: score.annotationStart,\n      end: score.annotationEnd,\n    };\n\n    return sum;\n  }, {} as any);\n}\n\nexport async function cacheCommentTopScore(comment: Comment, tag: Tag): Promise<CommentScore | null> {\n  const scores = await CommentScore.findAll({\n    where: {\n      commentId: comment.id,\n      tagId: tag.id,\n    },\n  });\n\n  const topScore = calculateTopScore(scores);\n\n  if (topScore) {\n    await CommentTopScore.upsert({\n      commentId: comment.id,\n      tagId: tag.id,\n      commentScoreId: topScore.id,\n    });\n  }\n\n  return topScore;\n}\n\nexport async function cacheCommentTopScores(comment: Comment) {\n  const tags = await Tag.findAll();\n  for (const tag of tags) {\n    await cacheCommentTopScore(comment, tag);\n  }\n}\n"
  },
  {
    "path": "packages/backend-api/src/domain/comments/countDenormalization.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport {\n  Comment,\n  CommentFlag,\n  FLAGS_COUNT,\n  RECOMMENDATIONS_COUNT,\n  UNRESOLVED_FLAGS_COUNT,\n} from '../../models';\n\nexport async function denormalizeCountsForComment(comment: Comment) {\n  let unresolvedFlagsCount = 0;\n  const flagsSummary: {[key: string]: Array<number>} = {};\n\n  const flags = await CommentFlag.findAll({ where: { commentId: comment.id } });\n\n  for (const flag of flags) {\n    if (!flagsSummary[flag.label]) {\n      flagsSummary[flag.label] = [0, 0, 0];\n    }\n\n    flagsSummary[flag.label][FLAGS_COUNT] += 1;\n\n    if (!flag.isResolved) {\n      unresolvedFlagsCount += 1;\n      flagsSummary[flag.label][UNRESOLVED_FLAGS_COUNT] += 1;\n    }\n    if (flag.isRecommendation) {\n      flagsSummary[flag.label][RECOMMENDATIONS_COUNT] += 1;\n    }\n  }\n\n  return comment.update({\n    unresolvedFlagsCount,\n    flagsSummary,\n  });\n}\n"
  },
  {
    "path": "packages/backend-api/src/domain/comments/index.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nexport * from './countDenormalization';\nexport * from './textSizes';\n"
  },
  {
    "path": "packages/backend-api/src/domain/comments/textSizes.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as opentype from 'opentype.js';\nimport * as path from 'path';\n\nimport {\n  Comment,\n  CommentSize,\n} from '../../models';\n\nconst FONT_FAMILY = 'Georgia';\nconst TYPE_DEF = {\n  fontFamily: FONT_FAMILY,\n  fontSize: 16,\n  fontWeight: 400,\n  lineHeight: 1.5,\n};\n\nconst FONT_FILE = path.join(__dirname, '..', '..', '..', 'fonts', 'Georgia.ttf');\n\nlet openTypeFont: any;\nopentype.load(FONT_FILE, (err: any, font: any) => {\n  if (err) {\n    console.error('Font could not be loaded: ' + err);\n  } else {\n    openTypeFont = font;\n  }\n});\n\nfunction getTextHeight(lines: Array<string>, _wordWrapWidth: number, styles: any): number {\n  const lineHeight = styles.fontSize * styles.lineHeight;\n\n  return Math.ceil(lineHeight + ((lines.length - 1) * lineHeight));\n}\n\nfunction measureText(word: string): number {\n  const openTypePath = openTypeFont.getPath(word, 0, 0, TYPE_DEF.fontSize);\n  const bounds = openTypePath.getBoundingBox();\n\n  return Math.ceil(bounds.x2 - bounds.x1);\n}\n\nfunction wordWrap(text: string, wordWrapWidth: number): Array<string> {\n  let result = '';\n  const lines = text\n      .split('\\n')\n      .filter((l) => l.length > 0);\n\n  for (let i = 0; i < lines.length; i++) {\n    let spaceLeft = wordWrapWidth;\n    const words = lines[i].split(' ');\n\n    for (let j = 0; j < words.length; j++) {\n      const wordWidth = measureText(words[j]);\n      const wordWidthWithSpace = measureText(words[j] + ' ');\n\n      if (j === 0 || wordWidthWithSpace > spaceLeft) {\n        // Skip printing the newline if it's the first word of the line that is\n        // greater than the word wrap width.\n        if (j > 0) {\n          result += '\\n';\n        }\n\n        result += words[j];\n        spaceLeft = wordWrapWidth - wordWidth;\n      } else {\n        spaceLeft -= wordWidthWithSpace;\n        result += ` ${words[j]}`;\n      }\n    }\n\n    if (i < lines.length - 1) {\n      result += '\\n';\n    }\n  }\n\n  return result.split(/(?:\\r\\n|\\r|\\n)/);\n}\n\nexport async function calculateTextSize(comment: Comment, width: number): Promise<number> {\n  const lines = await wordWrap(comment.text, width);\n\n  return getTextHeight(lines, width, TYPE_DEF);\n}\n\nexport async function cacheTextSize(comment: Comment, width: number): Promise<number> {\n  const height = await calculateTextSize(comment, width);\n\n  await CommentSize.findOrCreate({\n    where: {\n      commentId: comment.id,\n      width,\n    },\n    defaults: {\n      commentId: comment.id,\n      width,\n      height,\n    },\n  });\n\n  return height;\n}\n"
  },
  {
    "path": "packages/backend-api/src/domain/index.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nexport * from './articles';\nexport * from './categories';\nexport * from './comments';\nexport * from './commentScores';\n"
  },
  {
    "path": "packages/backend-api/src/index.ts",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as express from 'express';\nimport * as expressWs from 'express-ws';\n// TODO: Passport seems to be a dead project.  Keep an eye on what is happening\n//     And consider replacing it with passport-next or something else.\nimport * as passport from 'passport';\n\nimport { createApiRouter } from './api/router';\nimport { getOAuthConfiguration, isOAuthGood } from './auth/config';\nimport { getGoogleStrategy, getJwtStrategy } from './auth/providers';\nimport {\n  createAuthConfigRouter,\n  createAuthRouter,\n  createHealthcheckRouter,\n} from './auth/router';\nimport { createYouTubeRouter } from './auth/youtube';\nimport { config } from './config';\n\nexport async function mountAPI(testMode?: boolean): Promise<express.Express> {\n  const app = express();\n  expressWs(app);\n\n  app.use((req, _res, next) => {\n    (req as any).testMode = testMode;\n    next();\n  });\n\n  // Initialize auth strategies and Passport\n  // (Authenticator doesn't have a well-defined type...)\n  let jwtAuthenticator: any;\n  const oauthConfig = await getOAuthConfiguration();\n  const oauthOk = oauthConfig != null && await isOAuthGood();\n\n  if (!testMode) {\n    passport.use(await getJwtStrategy());\n    if (oauthConfig) {\n      passport.use(getGoogleStrategy(oauthConfig));\n    }\n    app.use(passport.initialize());\n    jwtAuthenticator = passport.authenticate('jwt', { session: false });\n  }\n\n  // Fully-qualify the links field of responses.\n  app.use((_req, _res, next) => {\n    app.set('json replacer', (key: string, value: any) => {\n      if (key === 'links') {\n        return Object.keys(value).reduce((sum, k) => {\n          sum[k] = value[k] && value[k].replace(/^\\//, config.get('api_url') + '/');\n\n          return sum;\n        }, {} as any);\n      }\n\n      return value;\n    });\n\n    next();\n  });\n\n  app.use('/', createHealthcheckRouter(oauthConfig));\n\n  if (!testMode && !oauthOk) {\n    console.log('*** Starting in bootstrap mode ***');\n    app.use('/', createAuthConfigRouter());\n  }\n\n  app.use('/', createAuthRouter());\n  if (oauthOk) {\n    app.use('/', createYouTubeRouter(oauthConfig!, jwtAuthenticator));\n  }\n  app.use('/', createApiRouter(jwtAuthenticator));\n\n  return app;\n}\n"
  },
  {
    "path": "packages/backend-api/src/integrations/decisions.ts",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { Op } from 'sequelize';\n\nimport {\n  Comment,\n  Decision,\n  User,\n} from '../models';\n\nexport async function getDecisionForComment(\n  comment: Comment,\n): Promise<Decision | null> {\n  return Decision.findOne({\n    where: {\n      commentId: comment.id,\n      sentBackToPublisher: {  [Op.eq]: null },\n      isCurrentDecision: true,\n    },\n  });\n}\n\nexport async function foreachPendingDecision(\n  owner: User,\n  callback: (decision: Decision, comment: Comment) => Promise<void>,\n) {\n  const decisions = await Decision.findAll({\n    where: {\n      sentBackToPublisher: { [Op.eq]: null },\n      isCurrentDecision: true,\n    },\n    include: [{model: Comment, required: true, where: {ownerId: owner.id}}],\n  });\n\n  for (const d of decisions) {\n    await callback(d, (await d.getComment())!);\n  }\n}\n\nexport async function markDecisionExecuted(decision: Decision) {\n  decision.sentBackToPublisher = new Date();\n  await decision.save();\n  const comment = (await decision.getComment())!;\n  comment.sentBackToPublisher = new Date();\n  await comment.save();\n}\n"
  },
  {
    "path": "packages/backend-api/src/integrations/index.ts",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nexport * from './youtube/authenticate';\nexport * from './youtube/objectmap';\nexport * from './youtube/task';\nexport * from './youtube/actions';\n"
  },
  {
    "path": "packages/backend-api/src/integrations/youtube/actions.ts",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { Category, User } from '../../models';\nimport { for_one_youtube_user } from './authenticate';\nimport { activate_channel, get_article_id_map_for_channel } from './channels';\nimport { sync_comment_threads_for_channel } from './comments';\n\nexport async function youtubeActivateChannel(\n  owner: User,\n  channel: Category,\n  args: {[key: string]: boolean},\n) {\n  await for_one_youtube_user(owner, async (_, auth) => {\n    await activate_channel(owner, auth, channel.sourceId!, args.activate);\n  });\n}\n\nexport async function youtubeSynchronizeChannel(\n  owner: User,\n  channel: Category,\n) {\n  const articleIdMap = await get_article_id_map_for_channel(channel);\n  await for_one_youtube_user(owner, async (_, auth) => {\n    await sync_comment_threads_for_channel(\n      owner,\n      auth,\n      channel.sourceId!,\n      articleIdMap,\n      true,\n      1000,\n    );\n  });\n}\n"
  },
  {
    "path": "packages/backend-api/src/integrations/youtube/authenticate.ts",
    "content": "/*\nCopyright 2018 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { OAuth2Client } from 'google-auth-library';\nimport { google } from 'googleapis';\n\nimport { getOAuthConfiguration } from '../../auth/config';\nimport { logger } from '../../logger';\nimport { IIntegrationExtra, User, USER_GROUP_YOUTUBE } from '../../models';\n\nexport const SCOPES = ['https://www.googleapis.com/auth/youtube.force-ssl'];\n\nexport async function for_one_youtube_user(\n  user: User,\n  callback: (owner: User, client: OAuth2Client) => Promise<void>,\n) {\n  const oauthConfig = await getOAuthConfiguration();\n  if (!oauthConfig) {\n    return;\n  }\n\n  const oauth2Client = new google.auth.OAuth2(oauthConfig.id, oauthConfig.secret);\n  logger.info(`Youtube: Authenticating as: ${user.id}:${user.email} (${user.name})`);\n  const extra = user.extra as IIntegrationExtra;\n  oauth2Client.setCredentials(extra.token);\n  await callback(user, oauth2Client);\n}\n\nexport async function for_all_youtube_users(\n  callback: (owner: User, client: OAuth2Client) => Promise<void>,\n) {\n  const users = await User.findAll({where: {group: USER_GROUP_YOUTUBE, isActive: true}});\n  for (const user of users) {\n    await for_one_youtube_user(user, callback);\n  }\n}\n"
  },
  {
    "path": "packages/backend-api/src/integrations/youtube/channels.ts",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { OAuth2Client } from 'google-auth-library';\nimport { google } from 'googleapis';\nimport { Op } from 'sequelize';\n\nimport { logger } from '../../logger';\nimport { Article, Category, User } from '../../models';\nimport { mapChannelToCategory, saveError, setChannelActive } from './objectmap';\n\nconst service = google.youtube('v3');\n\nasync function sync_page_of_channels(owner: User, auth: OAuth2Client, pageToken?: string) {\n  return new Promise<string | null | undefined>((resolve, reject) => {\n    service.channels.list({\n      auth: auth,\n      part: ['snippet', 'brandingSettings'],\n      mine: true,\n      maxResults: 50,\n      pageToken: pageToken,\n    }, async (err, response) => {\n      if (err) {\n        await saveError(owner, err);\n        logger.error('Google API returned an error: ' + err);\n        reject('Google API error');\n        return;\n      }\n\n      const channels = response!.data.items!;\n      const nextPageToken = response!.data.nextPageToken;\n\n      if (channels.length === 0) {\n        logger.info('No channels found.');\n        resolve(undefined);\n        return;\n      }\n\n      for (const channel of channels) {\n        await mapChannelToCategory(owner, channel);\n      }\n      resolve(nextPageToken);\n    });\n  });\n}\n\nexport async function sync_channels(\n  owner: User,\n  auth: OAuth2Client,\n) {\n  logger.info(`Syncing channels for user ${owner.email}`);\n  let next_page;\n  do {\n    next_page = await sync_page_of_channels(owner, auth, next_page);\n  } while (next_page);\n}\n\nexport async function activate_channel(\n  owner: User,\n  auth: OAuth2Client,\n  channelId: string,\n  activate: boolean,\n): Promise<void> {\n  return new Promise<void>((resolve, reject) => {\n    service.channels.list({\n      auth: auth,\n      id: [channelId],\n      part: ['brandingSettings'],\n    }, async (err: any, response: any) => {\n      if (err) {\n        await saveError(owner, err);\n        logger.error('Google API returned an error: ' + err);\n        reject('Google API error');\n        return;\n      }\n\n      if (response!.data.items.length === 0) {\n        logger.warn(`Couldn't find channel ${channelId}`);\n        reject('Couldn\\'t find corresponding youtube channel.');\n        return;\n      }\n\n      const data = response.data.items[0].brandingSettings;\n      data.channel.moderateComments = activate;\n\n      service.channels.update({\n        auth: auth,\n        part: ['brandingSettings'],\n        requestBody: {\n          id: channelId,\n          brandingSettings: data,\n        },\n      }, async (err2: any, response2: any) => {\n        if (err2) {\n          await saveError(owner, err);\n          logger.error('Google API returned an error: ' + err);\n          reject('Google API error');\n          return;\n        }\n        await setChannelActive(owner, channelId, response2.data.brandingSettings);\n        resolve();\n      });\n    });\n  });\n}\n\nexport async function get_playlist_for_channel(owner: User, auth: OAuth2Client, channelId: string) {\n  return new Promise<string>((resolve, reject) => {\n    service.channels.list({\n      auth: auth,\n      id: [channelId],\n      part: ['contentDetails'],\n    }, async (err: any, response: any) => {\n      if (err) {\n        await saveError(owner, err);\n        logger.error('Google API returned an error: ' + err);\n        reject('Google API error');\n        return;\n      }\n      if (response!.data.items.length === 0) {\n        logger.warn(`Couldn't find channel ${channelId}`);\n        reject('Couldn\\'t find corresponding youtube channel.');\n        return;\n      }\n      resolve(response!.data.items[0].contentDetails.relatedPlaylists.uploads);\n    });\n  });\n}\n\nexport async function get_article_id_map_for_channel(\n  category: Category,\n) {\n  const articles = await Article.findAll({\n    where: { categoryId: category.id },\n    attributes: ['id', 'sourceId'],\n  });\n\n  const articleIdMap = new Map<string, number>();\n  for (const a of articles) {\n    articleIdMap.set(a.sourceId, a.id);\n  }\n  return articleIdMap;\n}\n\nexport async function for_each_active_channel(\n  owner: User,\n  callback: (channelId: string, articleIdMap: Map<string, number>) => Promise<void>,\n) {\n  const categories = await Category.findAll({\n    where: {\n      ownerId: owner.id,\n      sourceId: {[Op.ne]: null},\n      isActive: true,\n    },\n  });\n\n  for (const category of categories) {\n    const channelId = category.sourceId!;\n    const articleIdMap = await get_article_id_map_for_channel(category);\n    await callback(channelId, articleIdMap);\n  }\n}\n"
  },
  {
    "path": "packages/backend-api/src/integrations/youtube/comments.ts",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { OAuth2Client } from 'google-auth-library';\nimport { google } from 'googleapis';\n\nimport { logger } from '../../logger';\nimport {\n  Comment,\n  Decision,\n  MODERATION_ACTION_ACCEPT,\n  MODERATION_ACTION_DEFER,\n  User,\n} from '../../models';\nimport { foreachPendingDecision, markDecisionExecuted } from '../decisions';\nimport { for_each_active_channel } from './channels';\nimport { mapCommentThreadToComments } from './objectmap';\nimport { get_article_id_from_youtube_id } from './videos';\n\nconst service = google.youtube('v3');\n\nasync function sync_page_of_comments(\n  owner: User,\n  auth: OAuth2Client,\n  channelId: string,\n  articleIdMap: Map<string, number>,\n  maxResults: number,\n  all: boolean,\n  pageToken?: string,\n) {\n  return new Promise<string | undefined>((resolve, reject) => {\n    service.commentThreads.list({\n      auth: auth,\n      allThreadsRelatedToChannelId: channelId,\n      part: ['snippet', 'replies'],\n      textFormat: 'plainText',\n      maxResults: maxResults,\n      pageToken: pageToken,\n      moderationStatus: !all ? 'heldForReview' : undefined,\n    }, async (err: any, response: any) => {\n      if (err) {\n        logger.error('Google API returned an error: ' + err);\n        reject('Google API error');\n        return;\n      }\n\n      const comments = response!.data.items;\n      const nextPageToken = response!.data.nextPageToken;\n\n      if (comments.length === 0) {\n        logger.info(`Couldn't find any threads for channel ${channelId}`);\n        resolve(undefined);\n        return;\n      }\n\n      for (const t of comments) {\n        const articleId = await get_article_id_from_youtube_id(\n          owner,\n          auth,\n          articleIdMap,\n          t.snippet.channelId,\n          t.snippet.videoId,\n        );\n        if (articleId == null) {\n          logger.info(`Couldn't map video ${t.snippet.videoId} to an article`);\n          continue;\n        }\n        await mapCommentThreadToComments(owner, articleId, t);\n      }\n      resolve(nextPageToken);\n    });\n  });\n}\n\nexport async function sync_comment_threads_for_channel(\n  owner: User,\n  auth: OAuth2Client,\n  channelId: string,\n  articleIdMap: Map<string, number>,\n  all: boolean,\n  count?: number,\n) {\n  logger.info(`Syncing comments for channel ${channelId}`);\n\n  let left = count || 10000;\n  let next_page;\n  do {\n    next_page = await sync_page_of_comments(owner, auth, channelId, articleIdMap, 10, all, next_page);\n    left -= 10;\n  } while (next_page && left > 0);\n\n  logger.info(`Done sync of comments for channel ${channelId}`);\n}\n\nexport async function sync_comment_threads(\n  owner: User,\n  auth: OAuth2Client,\n  all: boolean,\n  count?: number,\n) {\n  await for_each_active_channel(owner, async (channelId: string, articleIdMap: Map<string, number>) => {\n    await sync_comment_threads_for_channel(owner, auth, channelId, articleIdMap, all, count);\n  });\n}\n\nexport async function implement_moderation_decision(\n  auth: OAuth2Client,\n  comment: Comment,\n  decision: Decision,\n) {\n  const sourceId = comment.sourceId;\n  const status = decision.status;\n\n  if (status === MODERATION_ACTION_DEFER) {\n    logger.info(`Not syncing comment ${comment.id}:${sourceId} - in deferred state`);\n    markDecisionExecuted(decision);\n    return;\n  }\n\n  const moderationStatus = (status === MODERATION_ACTION_ACCEPT) ? 'published' : 'rejected';\n  logger.info(  `Syncing comment ${comment.id}:${sourceId} to ${moderationStatus} (${decision.id})`);\n  service.comments.setModerationStatus({\n      auth: auth,\n      id: [sourceId],\n      moderationStatus: moderationStatus,\n    },\n    (err) => {\n      if (err) {\n        logger.error(`Google API returned an error for comment ${comment.id}: ` + err);\n        return;\n      }\n      markDecisionExecuted(decision);\n    });\n}\n\nexport async function implement_moderation_decisions(\n  owner: User,\n  auth: OAuth2Client,\n) {\n  await foreachPendingDecision(owner, async (decision, comment) => {\n    await implement_moderation_decision(auth, comment, decision);\n  });\n}\n"
  },
  {
    "path": "packages/backend-api/src/integrations/youtube/hooks.ts",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { Comment, User } from '../../models';\nimport { IPipelineHook } from '../../pipeline/hooks';\nimport { getDecisionForComment } from '../decisions';\nimport { for_one_youtube_user } from './authenticate';\nimport { implement_moderation_decision } from './comments';\n\nexport const youtubeHooks: IPipelineHook = {\n  async commentModerated(owner: User, comment: Comment) {\n    await for_one_youtube_user(owner, async (_, auth) => {\n      const decision = await getDecisionForComment(comment);\n      if (!decision) {\n        return;\n      }\n      await implement_moderation_decision(auth, comment, decision);\n    });\n  },\n};\n"
  },
  {
    "path": "packages/backend-api/src/integrations/youtube/objectmap.ts",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { pick } from 'lodash';\nimport { Op } from 'sequelize';\n\nimport { logger } from '../../logger';\nimport {\n  Article,\n  Category,\n  Comment,\n  IAuthorAttributes,\n  IIntegrationExtra,\n  RESET_COUNTS,\n  User,\n} from '../../models';\nimport { postProcessComment, sendForScoring } from '../../pipeline';\n\nlet testOnly = false;\nlet testCallback: (type: string, obj: any) => void;\nexport function youtubeSetTestOnly(c: typeof testCallback) {\n  testOnly = true;\n  testCallback = c;\n}\n\nexport async function saveError(owner: User, error: Error) {\n  if (testOnly) {\n    testCallback('error', error);\n    return;\n  }\n  const extra = owner.extra as IIntegrationExtra;\n  extra.lastError = pick(error, ['name', 'message']);\n  owner.isActive = false;\n  owner.extra = extra;\n  await owner.save();\n}\n\nexport async function clearError(owner: User) {\n  const extra = owner.extra as IIntegrationExtra;\n  delete extra.lastError;\n  owner.extra = extra;\n  await owner.save();\n}\n\nexport async function mapChannelToCategory(owner: User, channel: any) {\n  if (testOnly) {\n    testCallback('channel', channel);\n    return;\n  }\n\n  const channelId = channel.id!;\n  const isActive = channel.brandingSettings.channel.moderateComments;\n\n  const categoryDefaults = {\n    label: channel.snippet!.title!,\n    isActive: isActive,\n  };\n\n  try {\n    const [category, created] = await Category.findOrCreate({\n      where: {\n        ownerId: owner.id,\n        sourceId: channelId,\n      },\n      defaults: {\n        ...categoryDefaults,\n        ownerId: owner.id,\n        sourceId: channelId,\n        ...RESET_COUNTS,\n      },\n    });\n\n    if (created) {\n      logger.info(`Category created for channel \"${category.label}\" (local id: ${category.id} -> remote id: ${channel.id})`);\n    }\n    else {\n      category.set(categoryDefaults);\n      await category.save();\n      logger.info(`Category updated for channel \"${category.label}\" (local id: ${category.id} -> remote id: ${channel.id})`);\n    }\n\n    // Create an article to store comments for the channel itself.\n    // sourceId of this article is the channel ID\n    const defaults = {\n      categoryId: category.id,\n      title: 'Channel comments',\n      text: 'Comments associated with the channel itself.',\n      url: 'https://www.youtube.com/channel/' + channelId,\n      sourceCreatedAt: new Date(channel.snippet!.publishedAt!),\n    };\n    const [article, acreated] = await Article.findOrCreate({\n      where: {\n        ownerId: owner.id,\n        sourceId: channelId,\n      },\n\n      defaults: {\n        ...defaults,\n        ownerId: owner.id,\n        sourceId: channelId,\n        isCommentingEnabled: true,\n        isAutoModerated: true,\n        ...RESET_COUNTS,\n      },\n    });\n\n    if (acreated) {\n      logger.info(`Article created for channel \"${article.title}\" (local id: ${article.id} -> remote id: ${channel.id})`);\n    }\n    else {\n      article.set(defaults);\n      await article.save();\n      logger.info(`Article updated for channel \"${article.title}\" (local id: ${article.id} -> remote id: ${channel.id})`);\n    }\n  }\n  catch (error) {\n    logger.error(`Failed update of article for channel \"${channel.snippet!.title}\": \"${error}\"`);\n  }\n}\n\nexport async function setChannelActive(owner: User, channelId: string, brandingSettings: any) {\n  const isActive: boolean = !!brandingSettings.channel.moderateComments;\n  if (testOnly) {\n    testCallback('setChannelActive', brandingSettings);\n    return 0;\n  }\n  const category = await Category.findOne({where: {ownerId: owner.id, sourceId: channelId}});\n  if (category) {\n    category.isActive = isActive;\n    category.save();\n  }\n}\n\nexport async function foreachActiveChannel(owner: User, callback: (channelId: string, articleIdMap: Map<string, number>) => Promise<void>) {\n  const categories = await Category.findAll({\n    where: {\n      ownerId: owner.id,\n      sourceId: {[Op.ne]: null},\n      isActive: true,\n    },\n  });\n\n  for (const category of categories) {\n    const categoryId = category.id;\n    const channelId = category.sourceId!;\n\n    const articles = await Article.findAll({\n      where: { categoryId: categoryId },\n      attributes: ['id', 'sourceId'],\n    });\n\n    const articleIdMap = new Map<string, number>();\n    for (const a of articles) {\n      articleIdMap.set(a.sourceId, a.id);\n    }\n\n    await callback(channelId, articleIdMap);\n  }\n}\n\nexport async function mapVideoItemToArticle(\n  owner: User,\n  categoryId: number,\n  videoId: string,\n  snippet: any,\n): Promise<number|null> {\n  if (testOnly) {\n    testCallback('video', {videoId, snippet});\n    return 0;\n  }\n\n  logger.info(`Got video \"${snippet.title}\" (${videoId})`);\n\n  const defaults = {\n    title: snippet.title.substring(0, 255),\n    text: snippet.description,\n    url: 'https://www.youtube.com/watch?v=' + videoId,\n    sourceCreatedAt: new Date(snippet.publishedAt),\n    extra: snippet,\n  };\n\n  try {\n    const [article, created] = await Article.findOrCreate({\n      where: { sourceId: videoId },\n\n      defaults: {\n        ...defaults,\n        ownerId: owner.id,\n        sourceId: videoId,\n        categoryId: categoryId,\n        isCommentingEnabled: true,\n        isAutoModerated: true,\n        ...RESET_COUNTS,\n      },\n    });\n\n    if (created) {\n      logger.info(`Created article ${article.id} for video ${article.sourceId}`);\n    }\n    else {\n      article.set(defaults);\n      await article.save();\n      logger.info(`Updated article ${article.id} for video ${article.sourceId}`);\n    }\n    return article.id;\n  }\n  catch (error) {\n    logger.error(`Failed update of video ${videoId}: ${error}`);\n    return null;\n  }\n}\n\nasync function mapCommentToComment(\n  owner: User,\n  articleId: number,\n  ytcomment: any,\n  replyToSourceId: string | undefined,\n) {\n  if (testOnly) {\n    testCallback('comment', ytcomment);\n    return;\n  }\n\n  try {\n    const author: IAuthorAttributes = {\n      name: ytcomment.snippet.authorDisplayName,\n      avatar: ytcomment.snippet.authorProfileImageUrl,\n    };\n\n    const sourceCreatedAt = new Date(ytcomment.snippet.publishedAt);\n    const defaults = {\n      articleId: articleId,\n      authorSourceId: ytcomment.snippet.authorChannelId.value,\n      author: author,\n      text: ytcomment.snippet.textDisplay,\n      sourceCreatedAt,\n      replyToSourceId: replyToSourceId,\n      extra: ytcomment,\n    };\n\n    const [comment, created] = await Comment.findOrCreate({\n      where: { sourceId: ytcomment.id },\n\n      defaults: {\n        ownerId: owner.id,\n        sourceId: ytcomment.id,\n        ...defaults,\n      },\n    });\n\n    if (created) {\n      logger.info(`Created comment ${comment.id} (${comment.sourceId})`);\n    }\n    else if (Math.floor((comment.sourceCreatedAt as Date).getTime() / 1000) ===\n             Math.floor(sourceCreatedAt.getTime() / 1000)) {\n      logger.info(`Comment ${comment.id} (${comment.sourceId}) unchanged`);\n      return;\n    }\n    comment.set(defaults);\n    await comment.save();\n    logger.info(`Updated comment ${comment.id} (${comment.sourceId})`);\n\n    try {\n      await postProcessComment(comment);\n      await sendForScoring(comment);\n    }\n    catch (error) {\n      logger.error(`Failed sendForScoring of comment ${comment.id}: ${error}`);\n    }\n  }\n  catch (error) {\n    logger.error(`Failed update of comment ${ytcomment.id}: ${error}`);\n  }\n}\n\nexport async function mapCommentThreadToComments(\n  owner: User,\n  articleId: number,\n  thread: any,\n) {\n  await mapCommentToComment(owner, articleId, thread.snippet.topLevelComment, undefined);\n  if (thread.replies) {\n    for (const c of thread.replies.comments) {\n      await mapCommentToComment(owner, articleId, c, thread.snippet.topLevelComment.id);\n    }\n  }\n}\n"
  },
  {
    "path": "packages/backend-api/src/integrations/youtube/task.ts",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { for_all_youtube_users } from './authenticate';\nimport { sync_channels } from './channels';\nimport { implement_moderation_decisions, sync_comment_threads } from './comments';\nimport { clearError } from './objectmap';\nimport { sync_known_videos } from './videos';\n\n// Tick is every minute.  Channel sync once per day.\nconst CHANNEL_SYNC_INTERVAL = 60 * 24;\nconst COMMENT_SYNC_INTERVAL = 5;\n\nexport async function syncYoutubeTask(tick: number) {\n  if (tick % COMMENT_SYNC_INTERVAL === 0) {\n    await for_all_youtube_users(async (owner, auth) => {\n      if (tick % CHANNEL_SYNC_INTERVAL === 0) {\n        await clearError(owner);\n        await sync_channels(owner, auth);\n        await sync_known_videos(owner, auth);\n        await implement_moderation_decisions(owner, auth);\n      }\n      await sync_comment_threads(owner, auth, false);\n    });\n  }\n}\n"
  },
  {
    "path": "packages/backend-api/src/integrations/youtube/videos.ts",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { OAuth2Client } from 'google-auth-library';\nimport { google } from 'googleapis';\nimport { Op } from 'sequelize';\n\nimport { logger } from '../../logger';\nimport { Article, Category, User } from '../../models';\nimport { get_playlist_for_channel } from './channels';\nimport { mapVideoItemToArticle, saveError } from './objectmap';\n\nconst service = google.youtube('v3');\n\nasync function sync_page_of_videos(\n  owner: User,\n  auth: OAuth2Client,\n  category: Category,\n  playlist: string,\n  pageToken?: string,\n) {\n  return new Promise<string | undefined>((resolve, reject) => {\n    service.playlistItems.list({\n      auth: auth,\n      playlistId: playlist,\n      part: ['snippet'],\n      maxResults: 50,\n      pageToken: pageToken,\n    }, async (err: any, response: any) => {\n      if (err) {\n        await saveError(owner, err);\n        logger.error('Google API returned an error: ' + err);\n        reject('Google API error');\n        return;\n      }\n\n      const videos = response!.data.items;\n      const nextPageToken = response!.data.nextPageToken;\n\n      if (videos.length === 0) {\n        logger.info(`Couldn't find any videos in playlist ${playlist}.`);\n        resolve(undefined);\n        return;\n      }\n\n      for (const item of response.data.items) {\n        const videoId = item.snippet.resourceId.videoId;\n        await mapVideoItemToArticle(owner, category.id, videoId, item.snippet);\n      }\n      resolve(nextPageToken);\n    });\n  });\n}\n\nexport async function sync_playlists(\n  owner: User,\n  auth: OAuth2Client,\n) {\n  logger.info(`Syncing videos for user ${owner.email}.`);\n  const categories = await Category.findAll({\n    where: {\n      ownerId: owner.id,\n      sourceId: {[Op.ne]: null},\n      isActive: true,\n    },\n  });\n\n  for (const category of categories) {\n    const channelId = category.sourceId!;\n\n    const playlist = await get_playlist_for_channel(owner, auth, channelId);\n    logger.info(`Syncing channel ${category.label} (${channelId}/${playlist})`);\n\n    let next_page;\n    do {\n      next_page = await sync_page_of_videos(owner, auth, category, playlist, next_page);\n    } while (next_page);\n\n    logger.info(`Done sync of ${category.label}.`);\n  }\n}\n\nexport async function sync_individual_videos(\n  owner: User,\n  auth: OAuth2Client,\n  videoIds: Array<string>,\n): Promise<Array<number> | null> {\n  return new Promise< Array<number> | null >((resolve, reject) => {\n    service.videos.list({\n      auth: auth,\n      part: ['snippet'],\n      id: videoIds,\n    },  async (err: any, response: any) => {\n      if (err) {\n        await saveError(owner, err);\n        logger.error('Google API returned an error: ' + err);\n        reject('Google API error');\n        return;\n      }\n\n      if (response.data.items.length === 0) {\n        logger.error(`Sync single video: No such video ${videoIds.join()}`);\n        reject(`No such video ${videoIds.join()}`);\n        return;\n      }\n\n      const articleIds: Array<number> = [];\n      for (const video of response.data.items) {\n        const category = await Category.findOne({\n          where: {\n            ownerId: owner.id,\n            sourceId: video.snippet.channelId,\n          },\n        });\n\n        if (!category) {\n          logger.error(`No category for video ${video.id} ${video.snippet.channelId}`);\n          reject(`No category for video ${video.id} ${video.snippet.channelId}`);\n          return;\n        }\n\n        const articleId = await mapVideoItemToArticle(owner, category.id, video.id, video.snippet);\n        if (articleId) {\n          articleIds.push(articleId);\n        }\n      }\n      resolve(articleIds);\n    });\n  });\n}\n\nexport async function sync_known_videos(\n  owner: User,\n  auth: OAuth2Client,\n) {\n  logger.info(`Syncing known videos for user ${owner.email}.`);\n  const articles = await Article.findAll({\n    where: {\n      ownerId: owner.id,\n    },\n    include: [{\n      model: Category,\n      where: { isActive: true },\n    }],\n  });\n\n  let videoIds: Array<string> = [];\n  for (const article of articles) {\n    videoIds.push(article.sourceId);\n    if (videoIds.length === 10) {\n      await sync_individual_videos(owner, auth, videoIds);\n      videoIds = [];\n    }\n  }\n  if (videoIds.length > 0) {\n    await sync_individual_videos(owner, auth, videoIds);\n  }\n}\n\nexport async function get_article_id_from_youtube_id(\n  owner: User,\n  auth: OAuth2Client,\n  articleIds: Map<string, number>,\n  channelId: string,\n  videoId: string,\n): Promise<number | null> {\n  if (videoId) {\n    if (articleIds.has(videoId)) {\n      return articleIds.get(videoId)!;\n    }\n\n    const returnedIds = await sync_individual_videos(owner, auth, [videoId]);\n    if (returnedIds === null) {\n      return null;\n    }\n    articleIds.set(videoId, returnedIds[0]);\n    return returnedIds[0];\n  }\n  else {\n    return articleIds.get(channelId) || null;\n  }\n}\n"
  },
  {
    "path": "packages/backend-api/src/logger.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as winston from 'winston';\n\nexport const logger = winston.createLogger({\n  level: 'info',\n  transports: [\n    new winston.transports.Console({\n      format: winston.format.simple(),\n    }),\n  ],\n});\n"
  },
  {
    "path": "packages/backend-api/src/models/article.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport {BelongsToManyGetAssociationsMixin, DataTypes, Model} from 'sequelize';\n\nimport {createSendNotificationHook} from '../notification_router';\nimport {sequelize} from '../sequelize';\nimport {Category} from './category';\nimport {Comment} from './comment';\nimport {User} from './user';\n\nexport class Article extends Model {\n  id: number;\n  ownerId?: number;\n  sourceId: string;\n  categoryId?: number | null;\n  title: string;\n  text: string;\n  url: string;\n  sourceCreatedAt?: Date | null;\n  isCommentingEnabled: boolean;\n  isAutoModerated: boolean;\n  extra?: object | null;\n  allCount: number;\n  unprocessedCount: number;\n  unmoderatedCount: number;\n  moderatedCount: number;\n  highlightedCount: number;\n  approvedCount: number;\n  rejectedCount: number;\n  deferredCount: number;\n  flaggedCount: number;\n  batchedCount: number;\n  lastModeratedAt?: Date | null;\n\n  getAssignedModerators: BelongsToManyGetAssociationsMixin<User>;\n  getComments: BelongsToManyGetAssociationsMixin<Comment>;\n}\n\nArticle.init({\n  id: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    primaryKey: true,\n    autoIncrement: true,\n  },\n\n  ownerId: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    references: { model: User, key: 'id' },\n    allowNull: true,\n  },\n\n  sourceId: {\n    type: DataTypes.CHAR(255),\n    allowNull: false,\n  },\n\n  categoryId: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    references: { model: Category, key: 'id' },\n    allowNull: true,\n  },\n\n  title: {\n    type: DataTypes.CHAR(255),\n    allowNull: false,\n  },\n\n  text: {\n    type: DataTypes.TEXT({length: 'long'}),\n    allowNull: false,\n  },\n\n  url: {\n    type: DataTypes.CHAR(255),\n    allowNull: false,\n  },\n\n  sourceCreatedAt: {\n    type: DataTypes.DATE,\n    allowNull: true,\n  },\n\n  isCommentingEnabled: {\n    type: DataTypes.BOOLEAN,\n    allowNull: false,\n    defaultValue: true,\n  },\n\n  isAutoModerated: {\n    type: DataTypes.BOOLEAN,\n    allowNull: false,\n    defaultValue: true,\n  },\n\n  allCount: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    allowNull: false,\n    defaultValue: 0,\n  },\n\n  unprocessedCount: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    allowNull: false,\n    defaultValue: 0,\n  },\n\n  unmoderatedCount: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    allowNull: false,\n    defaultValue: 0,\n  },\n\n  moderatedCount: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    allowNull: false,\n    defaultValue: 0,\n  },\n\n  highlightedCount: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    allowNull: false,\n    defaultValue: 0,\n  },\n\n  approvedCount: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    allowNull: false,\n    defaultValue: 0,\n  },\n\n  rejectedCount: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    allowNull: false,\n    defaultValue: 0,\n  },\n\n  deferredCount: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    allowNull: false,\n    defaultValue: 0,\n  },\n\n  flaggedCount: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    allowNull: false,\n    defaultValue: 0,\n  },\n\n  batchedCount: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    allowNull: false,\n    defaultValue: 0,\n  },\n\n  lastModeratedAt: {\n    type: DataTypes.DATE,\n    allowNull: true,\n  },\n\n  extra: {\n    type: DataTypes.JSON,\n    allowNull: true,\n  },\n}, {\n\n  sequelize,\n  modelName: 'article',\n  indexes: [\n    {\n      name: 'sourceId_index',\n      fields: ['sourceId'],\n      unique: true,\n    },\n  ],\n\n  hooks: {\n    afterCreate: createSendNotificationHook<Article>('article', 'create', (a) => a.id),\n    afterUpdate: createSendNotificationHook<Article>('article', 'modify', (a) => a.id),\n  },\n});\n\nArticle.belongsTo(User, {as: 'owner'});\nArticle.belongsTo(Category);\n"
  },
  {
    "path": "packages/backend-api/src/models/category.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport {BelongsToGetAssociationMixin, BelongsToManyGetAssociationsMixin, DataTypes, Model} from 'sequelize';\n\nimport {createSendNotificationHook} from '../notification_router';\nimport {sequelize} from '../sequelize';\nimport {User} from './user';\n\nexport class Category extends Model {\n  id: number;\n  label: string;\n  ownerId?: number | null;\n  sourceId?: string;\n  isActive?: boolean;\n  extra?: object | null;\n  allCount: number;\n  unprocessedCount: number;\n  unmoderatedCount: number;\n  moderatedCount: number;\n  highlightedCount: number;\n  approvedCount: number;\n  rejectedCount: number;\n  deferredCount: number;\n  flaggedCount: number;\n  batchedCount: number;\n\n  getAssignedModerators: BelongsToManyGetAssociationsMixin<User>;\n  getOwner: BelongsToGetAssociationMixin<User>;\n}\n\nCategory.init({\n  id: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    primaryKey: true,\n    autoIncrement: true,\n  },\n\n  ownerId: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    references: { model: User, key: 'id' },\n    allowNull: true,\n  },\n\n  sourceId: {\n    type: DataTypes.CHAR(255),\n    allowNull: true,\n  },\n\n  label: {\n    type: DataTypes.CHAR(255),\n    allowNull: false,\n  },\n\n  isActive: {\n    type: DataTypes.BOOLEAN,\n    allowNull: true,\n    defaultValue: true,\n  },\n\n  allCount: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    allowNull: false,\n    defaultValue: 0,\n  },\n\n  unprocessedCount: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    allowNull: false,\n    defaultValue: 0,\n  },\n\n  unmoderatedCount: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    allowNull: false,\n    defaultValue: 0,\n  },\n\n  moderatedCount: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    allowNull: false,\n    defaultValue: 0,\n  },\n\n  highlightedCount: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    allowNull: false,\n    defaultValue: 0,\n  },\n\n  approvedCount: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    allowNull: false,\n    defaultValue: 0,\n  },\n\n  rejectedCount: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    allowNull: false,\n    defaultValue: 0,\n  },\n\n  deferredCount: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    allowNull: false,\n    defaultValue: 0,\n  },\n\n  flaggedCount: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    allowNull: false,\n    defaultValue: 0,\n  },\n\n  batchedCount: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    allowNull: false,\n    defaultValue: 0,\n  },\n\n  extra: {\n    type: DataTypes.JSON,\n    allowNull: true,\n  },\n}, {\n\n  sequelize,\n  modelName: 'category',\n  indexes: [\n    {\n      name: 'label_index',\n      fields: ['label'],\n      unique: true,\n    },\n  ],\n  hooks: {\n    afterCreate: createSendNotificationHook<Category>('category', 'create', (a) => a.id),\n    afterUpdate: createSendNotificationHook<Category>('category', 'modify', (a) => a.id),\n  },\n});\n\nCategory.belongsTo(User, {as: 'owner'});\n"
  },
  {
    "path": "packages/backend-api/src/models/comment.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport {\n  BelongsToGetAssociationMixin,\n  DataTypes,\n  HasManyGetAssociationsMixin,\n  Model,\n} from 'sequelize';\n\nimport {createSendNotificationHook} from '../notification_router';\nimport {sequelize} from '../sequelize';\nimport {Article} from './article';\nimport {User} from './user';\n\ndeclare class Decision extends Model {}\n\nexport interface IAuthorAttributes {\n  name: string;\n  email?: string;\n  location?: string;\n  avatar?: string;\n}\n\nexport const FLAGS_COUNT = 0;\nexport const UNRESOLVED_FLAGS_COUNT = 1;\nexport const RECOMMENDATIONS_COUNT = 2;\n\nexport interface IFlagSummary {\n  [key: string]: Array<number>;\n}\n\nexport class Comment extends Model {\n  id: number;\n  ownerId?: number;\n  sourceId: string;\n  articleId: number | null;\n  replyToSourceId?: string | null;\n  replyId?: number | null;\n  authorSourceId: string;\n  text: string;\n  author: IAuthorAttributes;\n  isModerated?: boolean;\n  isScored?: boolean;\n  isAccepted?: boolean | null;\n  isDeferred?: boolean | null;\n  isHighlighted?: boolean | null;\n  isBatchResolved?: boolean | null;\n  isAutoResolved?: boolean | null;\n  unresolvedFlagsCount?: number;\n  flagsSummary?: IFlagSummary | null;\n  sourceCreatedAt: Date | null;\n  sentForScoring?: Date | null;\n  sentBackToPublisher?: Date | null;\n  extra?: object | null;\n  maxSummaryScore?: number | null;\n  maxSummaryScoreTagId?: string | null;\n\n  getArticle: BelongsToGetAssociationMixin<Article>;\n  getReplyTo: BelongsToGetAssociationMixin<Comment>;\n  getDecisions: HasManyGetAssociationsMixin<Decision>;\n  getReplies: HasManyGetAssociationsMixin<Comment>;\n}\n\nComment.init({\n  id: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    primaryKey: true,\n    autoIncrement: true,\n  },\n\n  ownerId: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    references: { model: User, key: 'id' },\n    allowNull: true,\n  },\n\n  sourceId: {\n    type: DataTypes.CHAR(255),\n    allowNull: false,\n  },\n\n  articleId: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    allowNull: true,\n    references: { model: Article, key: 'id' },\n  },\n\n  replyToSourceId: {\n    type: DataTypes.CHAR(255),\n    allowNull: true,\n  },\n\n  replyId: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    allowNull: true,\n  },\n\n  authorSourceId: {\n    type: DataTypes.CHAR(255),\n    allowNull: false,\n  },\n\n  text: {\n    type: DataTypes.TEXT({length: 'long'}),\n    allowNull: false,\n  },\n\n  author: {\n    type: DataTypes.JSON,\n    allowNull: false,\n  },\n\n  isScored: {\n    type: DataTypes.BOOLEAN,\n    allowNull: false,\n    defaultValue: false,\n  },\n\n  isModerated: {\n    type: DataTypes.BOOLEAN,\n    allowNull: false,\n    defaultValue: false,\n  },\n\n  isAccepted: {\n    type: DataTypes.BOOLEAN,\n    allowNull: true,\n    defaultValue: null,\n  },\n\n  isDeferred: {\n    type: DataTypes.BOOLEAN,\n    allowNull: true,\n    defaultValue: false,\n  },\n\n  isHighlighted: {\n    type: DataTypes.BOOLEAN,\n    allowNull: true,\n    defaultValue: false,\n  },\n\n  isBatchResolved: {\n    type: DataTypes.BOOLEAN,\n    allowNull: true,\n    defaultValue: false,\n  },\n\n  isAutoResolved: {\n    type: DataTypes.BOOLEAN,\n    allowNull: true,\n    defaultValue: false,\n  },\n\n  unresolvedFlagsCount: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    allowNull: false,\n    defaultValue: 0,\n  },\n\n  flagsSummary: {\n    type: DataTypes.JSON,\n    allowNull: true,\n  },\n\n  sourceCreatedAt: {\n    type: DataTypes.DATE,\n    allowNull: true,\n  },\n\n  sentForScoring: {\n    type: DataTypes.DATE,\n    allowNull: true,\n  },\n\n  sentBackToPublisher: {\n    type: DataTypes.DATE,\n    allowNull: true,\n  },\n\n  extra: {\n    type: DataTypes.JSON,\n    allowNull: true,\n  },\n\n  maxSummaryScore: {\n    type: DataTypes.FLOAT.UNSIGNED,\n    allowNull: true,\n  },\n\n  maxSummaryScoreTagId: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    allowNull: true,\n  },\n}, {\n  sequelize,\n  modelName: 'comment',\n  indexes: [\n    {\n      name: 'replyToSourceId_index',\n      fields: ['replyToSourceId'],\n    },\n    {\n      name: 'authorSourceId_index',\n      fields: ['authorSourceId'],\n    },\n    {\n      name: 'isAccepted_index',\n      fields: ['isAccepted'],\n    },\n    {\n      name: 'isDeferred_index',\n      fields: ['isDeferred'],\n    },\n    {\n      name: 'isHighlighted_index',\n      fields: ['isHighlighted'],\n    },\n    {\n      name: 'isBatchResolved_index',\n      fields: ['isBatchResolved'],\n    },\n    {\n      name: 'isAutoResolved_index',\n      fields: ['isAutoResolved'],\n    },\n    {\n      name: 'sentForScoring_index',\n      fields: ['sentForScoring'],\n    },\n    {\n      name: 'sentBackToPublisher_index',\n      fields: ['sentBackToPublisher'],\n    },\n    {\n      name: 'maxSummaryScore_index',\n      fields: ['maxSummaryScore'],\n    },\n    {\n      name: 'maxSummaryScoreTagId_index',\n      fields: ['maxSummaryScoreTagId'],\n    },\n    {\n      name: 'comments_text',\n      type: 'FULLTEXT',\n      fields: ['text'],\n    },\n  ],\n  hooks: {\n    afterCreate: createSendNotificationHook<Comment>('comment', 'create', (a) => a.id),\n    afterUpdate: createSendNotificationHook<Comment>('comment', 'modify', (a) => a.id),\n  },\n\n});\n\nComment.belongsTo(User, {as: 'owner'});\nComment.belongsTo(Article);\nComment.belongsTo(Comment, {\n  foreignKey: 'replyId',\n  onDelete: 'SET NULL',\n  as: 'replyTo',\n});\n\nComment.hasMany(Comment, {\n  foreignKey: 'replyId',\n  as: 'replies',\n});\n"
  },
  {
    "path": "packages/backend-api/src/models/comment_flag.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport {DataTypes, Model} from 'sequelize';\n\nimport {sequelize} from '../sequelize';\nimport {Comment} from './comment';\nimport {User} from './user';\n\nexport class CommentFlag extends Model {\n  id: number;\n  label: string;\n  detail?: string;\n  isRecommendation: boolean;\n  commentId: number;\n  sourceId?: string;\n  authorSourceId?: string;\n  isResolved: boolean;\n  resolvedById?: number;\n  resolvedAt?: Date | null;\n  extra?: object | null;\n}\n\nCommentFlag.init({\n  id: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    primaryKey: true,\n    autoIncrement: true,\n  },\n\n  label: {\n    type: DataTypes.CHAR(80),\n    allowNull: false,\n  },\n\n  detail: {\n    type: DataTypes.STRING,\n    allowNull: true,\n  },\n\n  isRecommendation: {\n    type: DataTypes.BOOLEAN,\n    defaultValue: false,\n  },\n\n  commentId: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    references: { model: Comment, key: 'id' },\n    onDelete: 'cascade',\n    onUpdate: 'cascade',\n  },\n\n  sourceId: {\n    type: DataTypes.CHAR(255),\n    allowNull: true,\n  },\n\n  authorSourceId: {\n    type: DataTypes.CHAR(255),\n    allowNull: true,\n  },\n\n  isResolved: {\n    type: DataTypes.BOOLEAN,\n    defaultValue: false,\n  },\n\n  resolvedById: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    allowNull: true,\n    references: { model: User, key: 'id' },\n    onDelete: 'set null',\n  },\n\n  resolvedAt: {\n    type: DataTypes.DATE,\n    allowNull: true,\n  },\n\n  extra: {\n    type: DataTypes.JSON,\n    allowNull: true,\n  },\n}, {\n  sequelize,\n  modelName: 'comment_flag',\n  charset: 'utf8',\n});\n\nCommentFlag.belongsTo(Comment, {\n  onDelete: 'CASCADE',\n});\n"
  },
  {
    "path": "packages/backend-api/src/models/comment_score.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport {BelongsToGetAssociationMixin, DataTypes, Model} from 'sequelize';\n\nimport {sequelize} from '../sequelize';\nimport {Comment} from './comment';\nimport {CommentScoreRequest} from './comment_score_request';\nimport {Tag} from './tag';\nimport {User} from './user';\n\nexport const SCORE_SOURCE_TYPES = [\n  'User',\n  'Moderator',\n  'Machine',\n];\n\nexport class CommentScore  extends Model {\n  id: number;\n  commentId?: number | null;\n  confirmedUserId?: number;\n  commentScoreRequestId?: number;\n  tagId?: number | null;\n  userId?: number;\n  sourceType: string;\n  sourceId?: string | null;\n  score: number;\n  annotationStart?: number | null;\n  annotationEnd?: number | null;\n  isConfirmed?: boolean | null;\n  extra?: object | null;\n\n  getTag: BelongsToGetAssociationMixin<Tag>;\n  getComment: BelongsToGetAssociationMixin<Comment>;\n}\n\nCommentScore.init({\n  id: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    primaryKey: true,\n    autoIncrement: true,\n  },\n\n  commentId: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    references: { model: Comment, key: 'id' },\n    allowNull: false,\n    onDelete: 'cascade',\n    onUpdate: 'cascade',\n  },\n\n  tagId: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    references: { model: Tag, key: 'id' },\n    allowNull: false,\n    onDelete: 'cascade',\n    onUpdate: 'cascade',\n  },\n\n  sourceType: {\n    type: DataTypes.ENUM(...SCORE_SOURCE_TYPES),\n    allowNull: false,\n  },\n\n  sourceId: {\n    type: DataTypes.CHAR(255),\n    allowNull: true,\n  },\n\n  score: {\n    type: DataTypes.FLOAT.UNSIGNED, // Score from 0 - 1\n    allowNull: false,\n  },\n\n  annotationStart: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    allowNull: true,\n  },\n\n  annotationEnd: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    allowNull: true,\n  },\n\n  extra: {\n    type: DataTypes.JSON,\n    allowNull: true,\n  },\n\n  confirmedUserId: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    allowNull: true,\n  },\n\n  isConfirmed: {\n    type: DataTypes.BOOLEAN,\n    allowNull: true,\n  },\n\n}, {\n  sequelize,\n  modelName: 'comment_score',\n  indexes: [\n    {\n      name: 'commentId_index',\n      fields: ['commentId'],\n    },\n    {\n      name: 'commentId_score_index',\n      fields: ['commentId', 'score'],\n    },\n    {\n      name: 'commentId_score_tagId_index',\n      fields: ['commentId', 'score', 'tagId'],\n    },\n  ],\n});\n\nCommentScore.belongsTo(Comment, {\n  onDelete: 'CASCADE',\n});\nCommentScore.belongsTo(CommentScoreRequest, {\n  as: 'commentScoreRequest',\n  onDelete: 'CASCADE',\n});\nCommentScore.belongsTo(Tag, {\n  onDelete: 'CASCADE',\n});\nCommentScore.belongsTo(User, {\n  onDelete: 'SET NULL',\n});\n"
  },
  {
    "path": "packages/backend-api/src/models/comment_score_request.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport {BelongsToGetAssociationMixin, DataTypes, Model} from 'sequelize';\n\nimport {sequelize} from '../sequelize';\nimport {Comment} from './comment';\nimport {User} from './user';\n\nexport class CommentScoreRequest extends Model {\n  id: number;\n  commentId?: number;\n  userId?: number;\n  sentAt: Date;\n  doneAt?: Date | null;\n\n  getComment: BelongsToGetAssociationMixin<Comment>;\n}\n\nCommentScoreRequest.init({\n  id: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    primaryKey: true,\n    autoIncrement: true,\n  },\n\n  commentId: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    allowNull: false,\n  },\n\n  userId: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    allowNull: false,\n  },\n\n  sentAt: {\n    type: DataTypes.DATE,\n    allowNull: false,\n  },\n\n  doneAt: {\n    type: DataTypes.DATE,\n    allowNull: true,\n  },\n}, {\n  sequelize,\n  modelName: 'comment_score_request',\n});\n\nCommentScoreRequest.belongsTo(Comment);\nCommentScoreRequest.belongsTo(User);\n"
  },
  {
    "path": "packages/backend-api/src/models/comment_size.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport {DataTypes, Model} from 'sequelize';\n\nimport {sequelize} from '../sequelize';\nimport {Comment} from './comment';\n\nexport class CommentSize extends Model {\n  id: number;\n  commentId: number;\n  width: number;\n  height: number;\n}\n\nCommentSize.init({\n  commentId: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    allowNull: false,\n    primaryKey: true,\n  },\n\n  width: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    allowNull: false,\n  },\n\n  height: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    allowNull: false,\n  },\n}, {\n  sequelize,\n  modelName: 'comment_size',\n  indexes: [\n    {\n      name: 'commentId_width_index',\n      fields: ['commentId', 'width'],\n      unique: true,\n    },\n  ]},\n);\n\nCommentSize.removeAttribute('id');\n\nCommentSize.belongsTo(Comment, {\n  onDelete: 'CASCADE',\n});\n"
  },
  {
    "path": "packages/backend-api/src/models/comment_summary_score.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport {BelongsToGetAssociationMixin, DataTypes, Model} from 'sequelize';\n\nimport {sequelize} from '../sequelize';\nimport {Comment} from './comment';\nimport {Tag} from './tag';\nimport {User} from './user';\n\nexport class CommentSummaryScore extends Model {\n  commentId: number;\n  tagId: number;\n  score: number;\n  isConfirmed?: boolean | null;\n  confirmedUserId?: number | null;\n  getTag: BelongsToGetAssociationMixin<Tag>;\n}\n\nCommentSummaryScore.init({\n  commentId: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    allowNull: false,\n    primaryKey: true,\n  },\n\n  tagId: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    allowNull: false,\n    primaryKey: true,\n  },\n\n  score: {\n    type: DataTypes.FLOAT.UNSIGNED, // Score from 0 - 1\n    allowNull: false,\n  },\n\n  isConfirmed: {\n    type: DataTypes.BOOLEAN,\n    allowNull: true,\n    defaultValue: null,\n  },\n\n  confirmedUserId: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    allowNull: true,\n    defaultValue: null,\n  },\n}, {\n  sequelize,\n  modelName: 'comment_summary_score',\n  timestamps: false,\n  indexes: [\n    {\n      name: 'commentId_tagId_index',\n      fields: ['commentId', 'tagId'],\n      unique: true,\n    },\n  ],\n});\n\nCommentSummaryScore.removeAttribute('id');\n\nCommentSummaryScore.belongsTo(Comment, {\n  onDelete: 'CASCADE',\n});\n\nCommentSummaryScore.belongsTo(Tag, {\n  onDelete: 'CASCADE',\n});\n\nCommentSummaryScore.belongsTo(User, {\n  as: 'confirmedUser',\n  onDelete: 'CASCADE',\n});\n"
  },
  {
    "path": "packages/backend-api/src/models/comment_top_score.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport {DataTypes, Model} from 'sequelize';\n\nimport {sequelize} from '../sequelize';\n\nexport class CommentTopScore extends Model {\n  id: number;\n  commentId: number;\n  tagId: number;\n  commentScoreId: number;\n}\nCommentTopScore.init({\n  commentId: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    allowNull: false,\n    primaryKey: true,\n  },\n\n  tagId: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    allowNull: false,\n    primaryKey: true,\n  },\n\n  commentScoreId: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    allowNull: true,\n  },\n}, {\n  sequelize,\n  modelName: 'comment_top_score',\n  timestamps: false,\n  indexes: [\n    {\n      name: 'commentId_tagId_index',\n      fields: ['commentId', 'tagId'],\n      unique: true,\n    },\n  ],\n});\n"
  },
  {
    "path": "packages/backend-api/src/models/configuration.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\n/**\n * This model is used to store the generic configuration for OSMod, e.g.,\n * - Global configuration\n * - The secret used for token generation\n * - API keys for remote services\n * Note that if there can be multiple copies of the configuration items\n * (e.g., access keys for service being moderated.) then it is probably better\n * served via a custom service user type (e.g, youtube service users.)\n */\n\nimport {DataTypes, Model} from 'sequelize';\n\nimport {sequelize} from '../sequelize';\n\nexport const CONFIGURATION_TOKEN = 'token';\nexport const CONFIGURATION_GOOGLE_OAUTH = 'google-oauth';\n\nclass Configuration extends Model {\n  id: string;\n  data: object;\n}\n\nConfiguration.init({\n  id: {\n    type: DataTypes.STRING,\n    primaryKey: true,\n  },\n\n  data: {\n    type: DataTypes.JSON,\n    allowNull: false,\n  },\n}, {\n  sequelize,\n  modelName: 'configuration_items',\n});\n\nexport async function getConfigItem(itemId: string): Promise<object | null> {\n  const item = await Configuration.findOne({where: {id: itemId}});\n  if (!item) {\n    return null;\n  }\n\n  return item.data;\n}\n\nexport async function setConfigItem(itemId: string, data: object): Promise<void> {\n  const [item, created] = await Configuration.findOrCreate({\n      where: {id: itemId},\n      defaults: {data},\n  });\n\n  if (!created) {\n    item.data = data;\n    await item.save();\n  }\n}\n"
  },
  {
    "path": "packages/backend-api/src/models/constants.ts",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nexport const MODERATION_ACTION_ACCEPT = 'Accept';\nexport const MODERATION_ACTION_REJECT = 'Reject';\nexport const MODERATION_ACTION_DEFER = 'Defer';\nexport const MODERATION_ACTION_HIGHLIGHT = 'Highlight';\n\nexport type IResolution = 'Accept' | 'Reject' | 'Defer';\n\nexport type IAction = 'Accept' | 'Reject' | 'Defer' | 'Highlight';\n\nexport const RESET_COUNTS = {\n  allCount: 0,\n  unprocessedCount: 0,\n  moderatedCount: 0,\n  unmoderatedCount: 0,\n  highlightedCount: 0,\n  approvedCount: 0,\n  rejectedCount: 0,\n  deferredCount: 0,\n  flaggedCount: 0,\n  batchedCount: 0,\n};\n"
  },
  {
    "path": "packages/backend-api/src/models/csrf.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport {DataTypes, Model} from 'sequelize';\n\nimport {sequelize} from '../sequelize';\n\nexport class CSRF extends Model {\n  createdAt: Date;\n  clientCSRF: string;\n  serverCSRF: string;\n  referrer: string | null;\n}\n\nCSRF.init({\n  id: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    primaryKey: true,\n    autoIncrement: true,\n  },\n\n  clientCSRF: {\n    type: DataTypes.CHAR(255),\n    allowNull: false,\n  },\n\n  serverCSRF: {\n    type: DataTypes.CHAR(255),\n    allowNull: false,\n  },\n\n  referrer: {\n    type: DataTypes.CHAR(255),\n    allowNull: true,\n  },\n}, {\n  sequelize,\n  modelName: 'csrfs',\n});\n"
  },
  {
    "path": "packages/backend-api/src/models/decision.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport {BelongsToGetAssociationMixin, DataTypes, Model} from 'sequelize';\n\nimport {sequelize} from '../sequelize';\nimport {Comment} from './comment';\nimport {\n  IResolution,\n  MODERATION_ACTION_ACCEPT,\n  MODERATION_ACTION_DEFER,\n  MODERATION_ACTION_REJECT,\n} from './constants';\nimport {ModerationRule} from './moderation_rule';\nimport {User} from './user';\n\n/**\n * Decision model\n */\nexport class Decision extends Model {\n  id: number;\n  commentId?: number;\n  userId?: number;\n  moderationRuleId?: number;\n  isCurrentDecision?: boolean;\n  status?: IResolution;\n  source?: 'User' | 'Rule';\n  sentBackToPublisher?: Date;\n\n  getComment: BelongsToGetAssociationMixin<Comment>;\n}\n\nDecision.init({\n  id: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    primaryKey: true,\n    autoIncrement: true,\n  },\n\n  status: {\n    type: DataTypes.ENUM(MODERATION_ACTION_ACCEPT, MODERATION_ACTION_REJECT, MODERATION_ACTION_DEFER),\n    allowNull: false,\n  },\n\n  source: {\n    type: DataTypes.ENUM('User', 'Rule'),\n    allowNull: false,\n  },\n\n  isCurrentDecision: {\n    type: DataTypes.BOOLEAN,\n    allowNull: false,\n    defaultValue: true,\n  },\n\n  sentBackToPublisher: {\n    type: DataTypes.DATE,\n    allowNull: true,\n  },\n}, {\n  sequelize,\n  modelName: 'decision',\n});\n\nDecision.belongsTo(Comment, {\n  onDelete: 'CASCADE',\n});\n\nDecision.belongsTo(User, {\n  onDelete: 'SET NULL',\n  foreignKey: {\n    allowNull: true,\n  },\n});\n\nDecision.belongsTo(ModerationRule, {\n  onDelete: 'SET NULL',\n  foreignKey: {\n    allowNull: true,\n  },\n});\n"
  },
  {
    "path": "packages/backend-api/src/models/index.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\nimport { Article } from './article';\nimport { Category } from './category';\nimport { Comment } from './comment';\nimport { CommentFlag } from './comment_flag';\nimport { CommentScore } from './comment_score';\nimport { CommentSize } from './comment_size';\nimport { CommentSummaryScore } from './comment_summary_score';\nimport { Decision } from './decision';\nimport { ModerationRule } from './moderation_rule';\nimport { ModeratorAssignment } from './moderator_assignment';\nimport { Tag } from './tag';\nimport { User } from './user';\nimport { UserCategoryAssignment } from './user_category_assignment';\n\nArticle.hasMany(Comment);\nArticle.belongsToMany(User, {\n  through: {\n    model: ModeratorAssignment,\n    unique: false,\n  },\n  foreignKey: 'articleId',\n  as: 'assignedModerators',\n});\n\nCategory.hasMany(Article, {\n  // These work around a weird sequelize bug which adds a unique constraint\n  // only on article for seemingly no reason.\n  constraints: false,\n  foreignKeyConstraint: false,\n});\nCategory.belongsToMany(User, {\n  through: {\n    model: UserCategoryAssignment,\n    unique: false,\n  },\n  foreignKey: 'categoryId',\n  as: 'assignedModerators',\n});\n\nComment.hasMany(CommentFlag, { as: 'commentFlags' });\nComment.hasMany(CommentScore, { as: 'commentScores' });\nComment.hasMany(CommentSummaryScore, { as: 'commentSummaryScores' });\nComment.hasMany(Decision, { as: 'decisions' });\nComment.hasMany(CommentSize, { as: 'commentSizes' });\n\nTag.hasMany(ModerationRule, { as: 'moderationRules' });\nTag.hasMany(CommentScore, { as: 'commentScores' });\n\nUser.belongsToMany(Article, {\n  through: {\n    model: ModeratorAssignment,\n    unique: false,\n  },\n  foreignKey: 'userId',\n  as: 'assignedArticles',\n});\nUser.belongsToMany(Category, {\n  through: {\n    model: UserCategoryAssignment,\n    unique: false,\n  },\n  foreignKey: 'userId',\n  as: 'assignedCategories',\n});\n\nexport * from './article';\nexport * from './category';\nexport * from './comment';\nexport * from './comment_score';\nexport * from './comment_summary_score';\nexport * from './comment_flag';\nexport * from './comment_score_request';\nexport * from './comment_size';\nexport * from './comment_top_score';\nexport * from './configuration';\nexport * from './constants';\nexport * from './csrf';\nexport * from './decision';\nexport * from './moderation_rule';\nexport * from './moderator_assignment';\nexport * from './preselect';\nexport * from './tag';\nexport * from './tagging_sensitivity';\nexport * from './user';\nexport * from './user_category_assignment';\nexport * from './user_social_auth';\n"
  },
  {
    "path": "packages/backend-api/src/models/moderation_rule.ts",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport {DataTypes, Model} from 'sequelize';\n\nimport {sequelize} from '../sequelize';\n\nimport {Category} from './category';\nimport {\n  IAction,\n  MODERATION_ACTION_ACCEPT,\n  MODERATION_ACTION_DEFER,\n  MODERATION_ACTION_HIGHLIGHT,\n  MODERATION_ACTION_REJECT,\n} from './constants';\nimport {Tag} from './tag';\nimport {User} from './user';\n\nexport const MODERATION_RULE_ACTION_TYPES = [\n  MODERATION_ACTION_ACCEPT,\n  MODERATION_ACTION_REJECT,\n  MODERATION_ACTION_DEFER,\n  MODERATION_ACTION_HIGHLIGHT,\n];\n\nexport const MODERATION_RULE_ACTION_TYPES_SET = new Set(MODERATION_RULE_ACTION_TYPES);\n\nexport class ModerationRule extends Model {\n  id: number;\n  tagId: number;\n  categoryId?: number;\n  createdBy?: number;\n  lowerThreshold: number;\n  upperThreshold: number;\n  action: IAction;\n}\n\nModerationRule.init({\n  id: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    primaryKey: true,\n    autoIncrement: true,\n  },\n\n  tagId: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    allowNull: false,\n  },\n\n  categoryId: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    allowNull: true,\n  },\n\n  createdBy: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    allowNull: true,\n  },\n\n  lowerThreshold: {\n    type: DataTypes.FLOAT(2).UNSIGNED,\n    allowNull: false,\n  },\n\n  upperThreshold: {\n    type: DataTypes.FLOAT(2).UNSIGNED,\n    allowNull: false,\n  },\n\n  action: {\n    type: DataTypes.ENUM(...MODERATION_RULE_ACTION_TYPES),\n    allowNull: false,\n  },\n}, {\n  sequelize,\n  modelName: 'moderation_rules',\n});\n\nModerationRule.belongsTo(Category, {\n  onDelete: 'CASCADE',\n  foreignKey: {\n    allowNull: true,\n  },\n});\n\nModerationRule.belongsTo(Tag, {\n  onDelete: 'CASCADE',\n  foreignKey: {\n    allowNull: false,\n  },\n});\n\nModerationRule.belongsTo(User, {\n  foreignKey: 'createdBy',\n  constraints: false,\n});\n\nexport function isModerationRule(instance: any) {\n  // TODO: instanceof doesn't work under some circumstances that I don't really understand.\n  //       Hopefully fixed in later sequelize.\n  //       Instead check for an attribute unique to this object.\n  // return instance instanceof ModerationRule.Instance;\n  return instance && instance.get && !!instance.action;\n}\n"
  },
  {
    "path": "packages/backend-api/src/models/moderator_assignment.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport {DataTypes, Model} from 'sequelize';\n\nimport {sequelize} from '../sequelize';\nimport {Article} from './article';\nimport {User} from './user';\n\n/**\n * Article model\n */\nexport class ModeratorAssignment extends Model {\n  id: number;\n  userId: number;\n  articleId: number;\n}\n\nModeratorAssignment.init({\n  id: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    primaryKey: true,\n    autoIncrement: true,\n  },\n\n  userId: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    allowNull: false,\n  },\n\n  articleId: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    allowNull: false,\n  },\n}, {\n  sequelize,\n  modelName: 'moderator_assignment',\n  indexes: [\n    {\n      name: 'unique_assignment_index',\n      fields: ['userId', 'articleId'],\n      unique: true,\n    },\n    {\n      name: 'userId_index',\n      fields: ['userId'],\n    },\n  ],\n});\n\nModeratorAssignment.belongsTo(User, {\n  onDelete: 'CASCADE',\n});\n\nModeratorAssignment.belongsTo(Article, {\n  onDelete: 'CASCADE',\n});\n"
  },
  {
    "path": "packages/backend-api/src/models/preselect.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport {DataTypes, Model} from 'sequelize';\n\nimport {sequelize} from '../sequelize';\nimport {Category} from './category';\nimport {Tag} from './tag';\nimport {User} from './user';\n\nexport class Preselect extends Model {\n  id: number;\n  tagId?: number;\n  categoryId?: number;\n  createdBy?: number;\n  lowerThreshold: number;\n  upperThreshold: number;\n}\n\nPreselect.init({\n  id: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    primaryKey: true,\n    autoIncrement: true,\n  },\n\n  tagId: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    allowNull: true,\n  },\n\n  categoryId: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    allowNull: true,\n  },\n\n  createdBy: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    allowNull: true,\n  },\n\n  lowerThreshold: {\n    type: DataTypes.FLOAT(2).UNSIGNED,\n    allowNull: false,\n  },\n\n  upperThreshold: {\n    type: DataTypes.FLOAT(2).UNSIGNED,\n    allowNull: false,\n  },\n}, {\n  sequelize,\n  modelName: 'preselect',\n});\n\nPreselect.belongsTo(Category, {\n  onDelete: 'CASCADE',\n  foreignKey: {\n    allowNull: true,\n  },\n});\n\nPreselect.belongsTo(Tag, {\n  onDelete: 'CASCADE',\n  foreignKey: {\n    allowNull: true,\n  },\n});\n\nPreselect.belongsTo(User, {\n  foreignKey: 'createdBy',\n  constraints: false,\n});\n"
  },
  {
    "path": "packages/backend-api/src/models/tag.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport {DataTypes, Model} from 'sequelize';\nimport {humanize, titleize, trim} from 'underscore.string';\n\nimport {randomDarkColor} from '@conversationai/moderator-frontend-web';\n\nimport {sequelize} from '../sequelize';\n\nexport const SUMMARY_SCORE_TAG = 'SUMMARY_SCORE';\n\n/**\n * Tag model\n */\nexport class Tag extends Model {\n  id: number;\n  key: string;\n  label: string;\n  color?: string;\n  description?: string;\n  isInBatchView?: boolean;\n  isTaggable?: boolean;\n  inSummaryScore?: boolean;\n}\nTag.init({\n  id: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    primaryKey: true,\n    autoIncrement: true,\n  },\n\n  key: {\n    type: DataTypes.CHAR(255),\n    allowNull: false,\n  },\n\n  label: {\n    type: DataTypes.CHAR(255),\n    allowNull: false,\n  },\n\n  color: {\n    type: DataTypes.CHAR(255),\n    allowNull: false,\n    defaultValue: '#000000',\n  },\n\n  description: {\n    type: DataTypes.CHAR(255),\n    allowNull: true,\n  },\n\n  // If false, hides from frontend-ui. Useful for having tags for\n  // various analytics which users are never expected to see. Or if a ML\n  // tag is in \"beta\" and is running silently until the kinks are worked out.\n  isInBatchView: {\n    type: DataTypes.BOOLEAN,\n    allowNull: false,\n    defaultValue: false,\n  },\n\n  // If false, hides from tag lists like reason to reject\n  // or tags that moderator can apply to a comment\n  isTaggable: {\n    type: DataTypes.BOOLEAN,\n    allowNull: false,\n    defaultValue: false,\n  },\n\n  inSummaryScore: {\n    type: DataTypes.BOOLEAN,\n    allowNull: false,\n    defaultValue: false,\n  },\n}, {\n  sequelize,\n  modelName: 'tag',\n  indexes: [\n    {\n      fields: ['key'],\n      unique: true,\n    },\n  ],\n});\n\nexport async function findOrCreateTagByKey(key: string) {\n  const cleanKey = trim(key);\n  const label = titleize(humanize(cleanKey));\n  const color = key === SUMMARY_SCORE_TAG ? '#cccccc' : randomDarkColor(cleanKey);\n\n  const [instance, _] = await Tag.findOrCreate({\n    where: {key: cleanKey},\n    defaults: {\n      key: cleanKey,\n      label,\n      color,\n      isInBatchView: true,\n      isTaggable: false,\n      inSummaryScore: key !== SUMMARY_SCORE_TAG,\n    }});\n\n  return instance;\n}\n"
  },
  {
    "path": "packages/backend-api/src/models/tagging_sensitivity.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport {DataTypes, Model} from 'sequelize';\n\nimport {sequelize} from '../sequelize';\nimport {Category} from './category';\nimport {Tag} from './tag';\nimport {User} from './user';\n\nexport class TaggingSensitivity extends Model {\n  id: number;\n  tagId?: number;\n  categoryId?: number;\n  createdBy?: number;\n  lowerThreshold: number;\n  upperThreshold: number;\n}\n\nTaggingSensitivity.init({\n  id: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    primaryKey: true,\n    autoIncrement: true,\n  },\n\n  tagId: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    allowNull: true,\n  },\n\n  categoryId: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    allowNull: true,\n  },\n\n  createdBy: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    allowNull: true,\n  },\n\n  lowerThreshold: {\n    type: DataTypes.FLOAT(2).UNSIGNED,\n    allowNull: false,\n  },\n\n  upperThreshold: {\n    type: DataTypes.FLOAT(2).UNSIGNED,\n    allowNull: false,\n  },\n}, {\n  sequelize,\n  modelName: 'tagging_sensitivity',\n});\n\nTaggingSensitivity.belongsTo(Category, {\n  onDelete: 'CASCADE',\n  foreignKey: {\n    allowNull: true,\n  },\n});\n\nTaggingSensitivity.belongsTo(Tag, {\n  onDelete: 'CASCADE',\n  foreignKey: {\n    allowNull: true,\n  },\n});\n\nTaggingSensitivity.belongsTo(User, {\n  foreignKey: 'createdBy',\n  constraints: false,\n});\n"
  },
  {
    "path": "packages/backend-api/src/models/user.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as Joi from 'joi';\nimport {BelongsToManyGetAssociationsMixin, DataTypes, Model} from 'sequelize';\n\nimport {createSendNotificationHook} from '../notification_router';\nimport {sequelize} from '../sequelize';\nimport {Article} from './article';\n\nexport const USER_GROUP_GENERAL = 'general';\nexport const USER_GROUP_ADMIN = 'admin';\nexport const USER_GROUP_SERVICE = 'service';\nexport const USER_GROUP_YOUTUBE = 'youtube';\nexport const USER_GROUP_MODERATOR = 'moderator';\n\nexport const USER_GROUPS = [\n  USER_GROUP_GENERAL,\n  USER_GROUP_ADMIN,\n  USER_GROUP_SERVICE,\n  USER_GROUP_YOUTUBE,\n  USER_GROUP_MODERATOR,\n];\n\n// Configuration constants for moderator service users\nexport const ENDPOINT_TYPE_API = 'perspective-api';\n\nexport interface IRequestedAttributes {\n  [attribute: string]:  {\n    scoreType?: string;\n    scoreThreshold?: number;\n  };\n}\n\nexport interface IScorerExtra {\n  endpointType: string;\n  apiKey: string;\n  endpoint: string;\n  userAgent?: string;\n  attributes?: IRequestedAttributes;\n}\n\nexport interface IIntegrationExtra {\n  token?: any;\n  lastError?: {name: string, message: string};\n  isActive?: boolean;\n}\n\nexport interface IServiceExtra {\n  jwt: any;\n}\n\nexport class User extends Model {\n  id: number;\n  group: string;\n  email?: string;\n  name: string;\n  isActive: boolean;\n  avatarURL?: string | null;\n  extra?: IScorerExtra | IIntegrationExtra | IServiceExtra | null;\n\n  getAssignedArticles: BelongsToManyGetAssociationsMixin<Article>;\n}\n\nUser.init({\n  id: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    primaryKey: true,\n    autoIncrement: true,\n  },\n\n  group: {\n    type: DataTypes.ENUM(...USER_GROUPS),\n    allowNull: false,\n  },\n\n  name: {\n    type: DataTypes.CHAR(255),\n    allowNull: false,\n  },\n\n  email: {\n    type: DataTypes.CHAR(255),\n    allowNull: true,\n  },\n\n  isActive: {\n    type: DataTypes.BOOLEAN,\n    allowNull: false,\n    defaultValue: false,\n  },\n\n  avatarURL: {\n    type: DataTypes.CHAR(255),\n    allowNull: true,\n  },\n\n  extra: {\n    type: DataTypes.JSON,\n    allowNull: true,\n  },\n}, {\n\n  sequelize,\n  modelName: 'user',\n  indexes: [\n    {\n      name: 'users_email',\n      fields: ['email'],\n    },\n    {\n      name: 'group_index',\n      fields: ['group'],\n    },\n    {\n      name: 'isActive_index',\n      fields: ['isActive'],\n    },\n    {\n      fields: ['email', 'group'],\n      unique: true,\n    },\n  ],\n\n  validate: {\n    /**\n     * Require an email address for non-service users\n     */\n    requireEmailForHumans() {\n      const group = this.group;\n      if (group === USER_GROUP_GENERAL || group === USER_GROUP_ADMIN) {\n        const validEmail = Joi.string().email().required().validate(this.email, { convert: false });\n        if (validEmail.error) {\n          throw new Error('Email address required for human users');\n        }\n      }\n    },\n  },\n\n  hooks: {\n    afterCreate: createSendNotificationHook<User>('user', 'create', (a) => a.id),\n    afterUpdate: createSendNotificationHook<User>('user', 'modify', (a) => a.id),\n  },\n});\n\nexport function isUser(instance: Model | null): boolean {\n  // TODO: instanceof doesn't work under some circumstances that I don't really understand.\n  //       Hopefully fixed in later sequelize.\n  //       Instead check for an attribute unique to this object.\n  // return instance instanceof User.Instance;\n  return !!(instance && instance.get && !!(instance as User).group);\n}\n"
  },
  {
    "path": "packages/backend-api/src/models/user_category_assignment.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport {DataTypes, Model} from 'sequelize';\n\nimport {sequelize} from '../sequelize';\n\nexport class UserCategoryAssignment extends Model {\n  id: number;\n  categoryId: number;\n  userId: number;\n}\n\nUserCategoryAssignment.init({\n  categoryId: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    allowNull: false,\n    primaryKey: true,\n  },\n\n  userId: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    allowNull: false,\n    primaryKey: true,\n  },\n}, {\n  sequelize,\n  modelName: 'user_category_assignment',\n});\n\nUserCategoryAssignment.removeAttribute('id');\n"
  },
  {
    "path": "packages/backend-api/src/models/user_social_auth.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport {DataTypes, Model} from 'sequelize';\n\nimport {sequelize} from '../sequelize';\nimport {User} from './user';\n\nexport class UserSocialAuth extends Model {\n  id: number;\n  userId?: number;\n  socialId: string;\n  provider: string;\n  extra?: object | null;\n}\n\nUserSocialAuth.init({\n  id: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    primaryKey: true,\n    autoIncrement: true,\n  },\n  userId: {\n    type: DataTypes.INTEGER.UNSIGNED,\n    references: { model: User, key: 'id' },\n    allowNull: false,\n  },\n\n  socialId: {\n    type: DataTypes.CHAR(255),\n    allowNull: false,\n  },\n\n  provider: {\n    type: DataTypes.CHAR(150),\n    allowNull: false,\n  },\n\n  extra: {\n    type: DataTypes.JSON,\n    allowNull: true,\n  },\n}, {\n  sequelize,\n  modelName: 'user_social_auth',\n  indexes: [\n    {\n      name: 'unique_user_provider_index',\n      fields: ['provider', 'userId'],\n      unique: true,\n    },\n    {\n      name: 'unique_provider_user_index',\n      fields: ['provider', 'socialId'],\n      unique: true,\n    },\n  ],\n});\n\nUserSocialAuth.belongsTo(User, {\n  onDelete: 'CASCADE',\n});\n"
  },
  {
    "path": "packages/backend-api/src/notification_router.ts",
    "content": "/*\nCopyright 2021 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport {createClient, RedisClient} from 'redis';\nimport {promisify} from 'util';\n\nimport {config} from './config';\nimport {logger} from './logger';\nimport {publish} from './redis';\n\nconst REDIS_NOTIFICATION_CHANNEL = 'update-notification';\n\nexport interface INotificationData {\n  objectType: NotificationObjectType;\n  action?: NotificationAction;\n  id?: number;\n}\n\ninterface IInterestListener {\n  processNotification(data: INotificationData): void;\n}\n\nlet interested: Array<IInterestListener> = [];\nlet sendDirect = false;\n\nexport function setTestMode() {\n  sendDirect = true;\n}\n\nexport type NotificationObjectType = 'global' | 'category' | 'article' | 'user' | 'comment';\nexport type NotificationAction = 'create' | 'modify' | 'delete';\n\nexport function createSendNotificationHook<T>(\n  objectType: NotificationObjectType,\n  action: NotificationAction,\n  selector: (items: T) => number,\n) {\n  return async (items: T) => {\n    const id = selector(items);\n    await sendNotification(objectType, action, id);\n  };\n}\n\nexport function processNotification(data: INotificationData) {\n  for (const i of interested) {\n    i.processNotification(data);\n  }\n}\n\nexport async function sendNotification(\n  objectType: NotificationObjectType,\n  action?: NotificationAction,\n  id?: number,\n) {\n  const data: INotificationData = {objectType, action, id};\n  if (sendDirect) {\n    processNotification(data);\n  } else {\n    logger.info(`send notification: ${data.objectType} ${data.action || ''} ${data.id || ''}`);\n    await publish(REDIS_NOTIFICATION_CHANNEL, JSON.stringify(data));\n  }\n}\n\nlet listening = false;\nexport async function receiveNotifications() {\n  if (listening) {\n    return;\n  }\n  const subscribeClient: RedisClient = createClient(config.get('redis_url'));\n  const subscribe = promisify(subscribeClient.subscribe).bind(subscribeClient);\n  subscribeClient.on('message', (_channel, message) => {\n    const data = JSON.parse(message) as INotificationData;\n    logger.info(`processing notification: ${data.objectType} ${data.action || ''} ${data.id || ''}`);\n    processNotification(data);\n  });\n  listening = true;\n  await subscribe(REDIS_NOTIFICATION_CHANNEL);\n}\n\n// TODO: Need to add an ID so clear can clear correct item.\n//     But we only ever have one listener at the moment...\nexport function registerInterest(interestListener: IInterestListener) {\n  interested.push(interestListener);\n  if (!sendDirect) {\n    receiveNotifications();\n  }\n}\n\nexport function clearInterested() {\n  interested = [];\n}\n"
  },
  {
    "path": "packages/backend-api/src/pipeline/apiShim.ts",
    "content": "/*\nCopyright 2018 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as Bluebird from 'bluebird';\nimport { google } from 'googleapis';\nimport * as requestRaw from 'request';\nimport * as striptags  from 'striptags';\n\nimport { logger } from '../logger';\nimport {\n  Comment,\n  IRequestedAttributes,\n  IScorerExtra,\n  User,\n} from '../models';\nimport { IScoreData } from './shim';\n\nconst request = Bluebird.promisify(requestRaw) as any;\nBluebird.promisifyAll(request);\n\n// Perspective API request types.\ninterface ITextEntry {\n  text: string;\n}\n\ninterface IAnalyzeCommentRequest {\n  comment: ITextEntry;\n  context: { entries: Array<ITextEntry>; };\n  requestedAttributes: IRequestedAttributes;\n  languages?: Array<string>;\n  clientToken?: string;\n  spanAnnotations?: boolean;\n}\n\n// Perspective API response types.\ninterface ISpanScore {\n  begin: number;\n  end: number;\n  score: { value: number; };\n}\n\ninterface IAttributeScores {\n  [attribute: string]: {\n    spanScores?: Array<ISpanScore>;\n    summaryScore?: { value: number; };\n  };\n}\n\ninterface IAnalyzeCommentResponse {\n  attributeScores?: IAttributeScores;\n  languages?: Array<string>;\n  clientToken?: string;\n}\n\n// Removes '@...' suffix, if present.\nfunction StripAttributeVersion(attributeName: string): string {\n  return attributeName.replace(/@.*/, '');\n}\n\n/**\n * Create a scorer that sends comments to an endpoint for scoring\n *\n * @param {object} scorer  Service User that owns this scorer.\n * @param {object} processMachineScore  Callback to invoke if score is determined synchronously.\n */\nexport async function createShim(\n    scorer: User,\n    processMachineScore: (commentId: number, serviceUserId: number, scoreData: IScoreData) => Promise<void>,\n    ) {\n  const serviceUserId = scorer.id;\n  const extra = scorer.extra as IScorerExtra;\n  const discoveryURL = extra.endpoint;\n  const apiKey = extra.apiKey;\n  const attributes = extra.attributes;\n  const userAgent = extra.userAgent;\n\n  async function packPerspectiveApiRequest(comment: Comment, reqId: string | number) {\n    const req: IAnalyzeCommentRequest = {\n      comment: {text: striptags(comment.text)},\n      context: {entries: []},\n      requestedAttributes: attributes!,\n      languages: ['en'],\n      clientToken: userAgent + '_request' + reqId,\n      spanAnnotations: true,\n    };\n\n    const article = await comment.getArticle();\n    if (article) {\n      req.context.entries.push({text: striptags(article.text)});\n    }\n\n    const replyTo = await comment.getReplyTo();\n    if (replyTo) {\n      req.context.entries.push({text: striptags(replyTo.text)});\n    }\n\n    return req;\n  }\n\n  function unpackPerspectiveApiResponse(comment: Comment, data: IAnalyzeCommentResponse): IScoreData {\n    const unpackedData: IScoreData = {scores: {}, summaryScores: {}};\n\n    for (const attributeName in data.attributeScores!) {\n      const unversionedName = StripAttributeVersion(attributeName);\n      const attributeScore = data.attributeScores![attributeName];\n\n      if (attributeScore.spanScores && attributeScore.spanScores.length > 0) {\n        unpackedData.scores[unversionedName] = attributeScore.spanScores.map(\n          ({begin, end, score: {value}}) => ({begin, end, score: value}),\n        );\n      }\n\n      if (attributeScore.summaryScore) {\n        unpackedData.summaryScores[unversionedName] = attributeScore.summaryScore.value;\n\n        if (!unpackedData.scores[unversionedName]) {\n          // Not got a spanScores entry, so make one up from the summary\n          const begin = 0;\n          const end = comment.text.length;\n          const value = attributeScore.summaryScore.value;\n          unpackedData.scores[unversionedName] = [{begin, end, score: value}];\n        }\n      }\n      else {\n        logger.error(`Comment ${comment.id}:Strangely there are no summary scores for ${attributeName}`);\n      }\n    }\n\n    return unpackedData;\n  }\n\n  const endpoint = await google.discoverAPI(discoveryURL);\n  if (!(endpoint.comments && (endpoint.comments as any).analyze)) {\n    throw Error('Unknown error loading API: client is b0rken');\n  }\n\n  return {\n    sendToScorer: async (comment: Comment, reqId: string | number) => {\n      const papiRequest = await packPerspectiveApiRequest(comment, reqId);\n      const papiResponse = await (endpoint.comments as any).analyze({key: apiKey, resource: papiRequest});\n      const unpackedResponse = unpackPerspectiveApiResponse(comment, papiResponse.data);\n      await processMachineScore(comment.id, serviceUserId, unpackedResponse);\n    },\n  };\n}\n"
  },
  {
    "path": "packages/backend-api/src/pipeline/hooks.ts",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { Comment, User } from '../models';\nimport { enqueue, registerTask } from '../processing/util';\n\nexport interface IPipelineHook {\n  commentModerated(owner: User, comment: Comment): Promise<void>;\n}\n\nconst hooks = new Map<string, IPipelineHook>();\n\nexport interface IHookData {\n  ownerId: number;\n  [key: string]: number | string;\n}\n\nasync function getOwnerData(ownerId: number) {\n  const owner = await User.findByPk(ownerId);\n  if (!owner) {\n    throw new Error(`No user with ID ${ownerId}`);\n  }\n\n  const ownerType = owner.group;\n  const hook = hooks.get(ownerType);\n  return {owner, ownerType, hook};\n}\n\nasync function executeCommentModeratedTask(data: IHookData) {\n  const { owner, hook } = await getOwnerData(data.ownerId);\n  if (hook && hook.commentModerated) {\n    const comment = await Comment.findByPk(data['commentId']);\n    if (!comment) {\n      throw new Error(`No comment with ID ${data['commentId']}`);\n    }\n    await hook.commentModerated(owner, comment);\n  }\n}\n\nexport function registerHooks(ownerType: string, hook: IPipelineHook) {\n  hooks.set(ownerType, hook);\n  registerTask<IHookData>(`${ownerType}:commentModerated`, executeCommentModeratedTask);\n}\n\nexport async function commentModeratedHook(comment: Comment) {\n  const ownerId = comment.ownerId;\n  if (!ownerId) {\n    return;\n  }\n  const { owner, ownerType, hook } = await getOwnerData(ownerId);\n  if (hook && hook.commentModerated) {\n    await enqueue<IHookData>(`${ownerType}:commentModerated`, {ownerId: owner.id, commentId: comment.id});\n  }\n}\n"
  },
  {
    "path": "packages/backend-api/src/pipeline/index.ts",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nexport * from './pipeline';\n"
  },
  {
    "path": "packages/backend-api/src/pipeline/pipeline.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as Bluebird from 'bluebird';\nimport { groupBy, maxBy } from 'lodash';\nimport * as moment from 'moment';\nimport { fn, Op } from 'sequelize';\n\nimport {\n  Article,\n  Comment,\n  CommentScore,\n  CommentScoreRequest,\n  CommentSummaryScore,\n  Decision,\n  ENDPOINT_TYPE_API,\n  findOrCreateTagByKey,\n  IResolution,\n  IScorerExtra,\n  isModerationRule,\n  isUser,\n  ModerationRule,\n  Tag,\n  User,\n  USER_GROUP_MODERATOR,\n} from '../models';\nimport {sequelize} from '../sequelize';\n\nimport {\n  cacheCommentTopScores,\n  cacheTextSize,\n  denormalizeCommentCountsForArticle,\n  denormalizeCountsForComment,\n} from '../domain';\nimport {logger} from '../logger';\nimport {processRulesForComment} from './rules';\nimport {IScoreData, IScores, IShim, ISummaryScores} from './shim';\nimport {getIsDoneScoring} from './state';\n\nimport {createShim as createApiShim} from './apiShim';\nimport {commentModeratedHook} from './hooks';\n\nconst shims = new Map<number, IShim>();\n\nexport async function sendToScorer(comment: Comment, scorer: User) {\n  try {\n    // Destroy existing comment score request for user.\n    await CommentScoreRequest.destroy({\n      where: {\n        commentId: comment.id,\n        userId: scorer.id,\n      },\n    });\n\n    let shim = shims.get(scorer.id);\n    if (!shim) {\n      const extra = scorer.extra as IScorerExtra;\n\n      if (!extra) {\n        logger.error(`Missing endpoint config for scorer ${scorer.id}`);\n        return;\n      }\n\n      if (extra.endpointType === ENDPOINT_TYPE_API) {\n        shim = await createApiShim(scorer, processMachineScore);\n      }\n      else {\n        logger.error(`Unknown moderator endpoint type: ${extra.endpoint} for scorer ${scorer.id}`);\n        return;\n      }\n      shims.set(scorer.id, shim);\n    }\n\n    // Create score request\n    const csr = await CommentScoreRequest.create({\n      commentId: comment.id,\n      userId: scorer.id,\n      sentAt: fn('now'),\n    });\n\n    await shim.sendToScorer(comment, csr.id);\n  }\n  catch (err) {\n    logger.error(`Error posting comment id ${comment.id} for scoring: ${err}`);\n  }\n}\n\nexport async function checkScoringDone(comment: Comment): Promise<void> {\n  // Mark timestamp for when comment was last sent for scoring\n  comment.sentForScoring = new Date();\n  await comment.save();\n\n  const isDoneScoring = await getIsDoneScoring(comment.id);\n  if (isDoneScoring) {\n    await completeMachineScoring(comment.id);\n  }\n}\n\n/**\n * Send passed in comment for scoring against all active service Users.\n */\nexport async function sendForScoring(comment: Comment): Promise<void> {\n  const serviceUsers = await User.findAll({\n    where: {\n      group: USER_GROUP_MODERATOR,\n      isActive: true,\n    },\n  } as any);\n\n  let foundServiceUser = false;\n  for (const scorer of serviceUsers) {\n    await sendToScorer(comment, scorer);\n    foundServiceUser = true;\n  }\n\n  if (foundServiceUser) {\n    await checkScoringDone(comment);\n  }\n  else {\n    logger.info('No active Comment Scorers found');\n  }\n}\n\n/**\n * Return a cutoff date object for re-sending score requests. Every CommentScoreRequest\n * whose `sentAt` date is before this should be re-sent for scoring.\n */\nexport function resendCutoff() {\n  return moment().subtract(5, 'minutes').toDate();\n}\n\n/**\n * Get all comment instances that were sent for scoring before the `resendCutoff`\n * who have not been marked `isScored` or resolved\n */\nexport async function getCommentsToResendForScoring(\n  processCommentLimit?: number,\n): Promise<Array<Comment>> {\n  const findOpts = {\n    where: {\n      isAccepted: null,\n      isScored: false,\n      sentForScoring: {[Op.lt]: resendCutoff()},\n    },\n    include: [Article],\n  } as any;\n\n  if ('undefined' !== typeof processCommentLimit) {\n    findOpts.limit = processCommentLimit;\n  }\n\n  return await Comment.findAll(findOpts);\n}\n\n/**\n * Resend a comment to be scored again.\n */\nexport async function resendForScoring(comment: Comment): Promise<void> {\n  logger.info(`Re-sending comment id ${comment.id} for scoring`);\n  await sendForScoring(comment);\n}\n\n/**\n * Receive a single score. Data object should have: commentId, serviceUserId, sourceType,\n * score, and optionally annotationStart and annotationEnd\n */\nexport async function processMachineScore(\n  commentId: number,\n  serviceUserId: number,\n  scoreData: IScoreData,\n): Promise<void> {\n  logger.info('PROCESS MACHINE SCORE ::', commentId, serviceUserId, JSON.stringify(scoreData));\n  const comment = (await Comment.findByPk(commentId))!;\n\n  // Find matching comment score request\n  const commentScoreRequest = await CommentScoreRequest.findOne({\n    where: {\n      commentId,\n      userId: serviceUserId,\n    },\n    order: [['sentAt', 'DESC']],\n  });\n\n  if (!commentScoreRequest) {\n    throw new Error('Comment score request not found');\n  }\n\n  // Find/create all tags present in scores\n  const scoresTags = await findOrCreateTagsByKey(Object.keys(scoreData.scores));\n\n  const commentScoresData = compileScoresData(\n    'Machine',\n    serviceUserId,\n    scoreData.scores,\n    {\n      comment,\n      commentScoreRequest,\n      tags: scoresTags,\n    },\n  );\n\n  // Find/create all tags present in summary scores\n  const summaryScoresTags = await findOrCreateTagsByKey(Object.keys(scoreData.summaryScores));\n\n  // Clear old comment scores and create new comment scores\n  await CommentScore.destroy({\n    where: {\n      commentId,\n      userId: serviceUserId,\n    },\n  });\n  await CommentScore.bulkCreate(commentScoresData);\n  // TODO send update notification that comment has been updated\n\n  const commentSummaryScoresData = compileSummaryScoresData(\n    scoreData.summaryScores,\n    comment,\n    summaryScoresTags,\n  );\n\n  await sequelize.transaction(async (t) => {\n    for (const c of commentSummaryScoresData) {\n      await CommentSummaryScore.upsert(c, {transaction: t, returning: false});\n    }\n  });\n\n  await updateMaxSummaryScore(comment);\n\n  // Mark the comment score request as done\n  commentScoreRequest.doneAt = new Date();\n  await commentScoreRequest.save();\n}\n\nexport async function updateMaxSummaryScore(comment: Comment): Promise<void> {\n  const tagsInSummaryScore = await Tag.findAll({\n    where: {\n      inSummaryScore: true,\n    },\n  });\n  const summaryScores = await CommentSummaryScore.findAll({\n    where: {\n      commentId: comment.id,\n      tagId: {\n        [Op.in]: tagsInSummaryScore.map((tag) => tag.id),\n      },\n    },\n  });\n\n  if (summaryScores.length <= 0) {\n    return;\n  }\n\n  const maxSummaryScores = maxBy(summaryScores, (score) => score.score);\n  await comment.update({\n    maxSummaryScore: maxSummaryScores!.score,\n    maxSummaryScoreTagId: maxSummaryScores!.tagId,\n  });\n}\n\n/**\n * Once all scores are in, process rules, record the decision and denormalize.\n */\nexport async function completeMachineScoring(commentId: number): Promise<void> {\n  const comment = (await Comment.findByPk(commentId, {\n    include: [Article],\n  }))!;\n\n  comment.isScored = true;\n  await comment.save();\n\n  await cacheCommentTopScores(comment);\n  await processRulesForComment(comment);\n  await denormalizeCountsForComment(comment);\n  await denormalizeCommentCountsForArticle(await comment.getArticle(), false);\n}\n\n/**\n * Take raw scores data and an object of model data and map it all together in an array to\n * bulk create comment scores with\n */\nexport function compileScoresData(sourceType: string, userId: number, scoreData: IScores, modelData: any) {\n  sourceType = sourceType || 'Machine';\n\n  const tagsByKey = groupBy(modelData.tags, (tag: Tag) => tag.key);\n\n  const data: Array<Pick<CommentScore,\n    'commentId' | 'commentScoreRequestId' | 'sourceType' | 'userId' | 'tagId' | 'score' | 'annotationStart' | 'annotationEnd'>>\n    = [];\n\n  Object\n    .keys(scoreData)\n    .forEach((tagKey) => {\n      scoreData[tagKey].forEach((score) => {\n        data.push({\n          commentId: modelData.comment.id,\n          commentScoreRequestId: modelData.commentScoreRequest.id,\n          sourceType,\n          userId,\n          tagId: tagsByKey[tagKey][0].id,\n          score: score.score,\n          annotationStart: score.begin,\n          annotationEnd: score.end,\n        });\n      });\n    });\n\n  return data;\n}\n\n/**\n * Take raw scores data and an object of model data and map it all together in an array to\n * bulk create comment summary scores with\n */\nexport function compileSummaryScoresData(scoreData: ISummaryScores, comment: Comment, tags: Array<Tag>) {\n  const tagsByKey = groupBy(tags, (tag: Tag) => tag.key);\n\n  const data: Array<Pick<CommentSummaryScore, 'commentId' | 'tagId' | 'score'>> = [];\n\n  Object\n    .keys(scoreData)\n    .forEach((tagKey) => {\n      data.push({\n        commentId: comment.id,\n        tagId: (tagsByKey[tagKey][0]).id,\n        score: scoreData[tagKey],\n      });\n    });\n\n  return data;\n}\n\n/**\n * Given an array of tag keys, find or create them\n *\n * @param {array} keys      Array of tag keys (strings)\n * @return {object} Promise object that resolves to an array of Tag model instances\n */\nexport async function findOrCreateTagsByKey(\n  keys: Array<string>,\n): Promise<Array<Tag>> {\n  return Bluebird.mapSeries(keys, async (key) => {\n    return await findOrCreateTagByKey(key);\n  });\n}\n\n/**\n * Save the action of a rule or user making a comment on a decision.\n */\nexport async function recordDecision(\n  comment: Comment,\n  status: IResolution,\n  source: User | ModerationRule | null,\n): Promise<Decision> {\n  // Find out if we're overriding a previous decision.\n  const previousDecisions = await comment.getDecisions({\n    where: {isCurrentDecision: true},\n  });\n\n  // Set previous active decisions to `isCurrentDecision` false.\n  await Promise.all(\n    previousDecisions.map((d) => d.update({isCurrentDecision: false})),\n  );\n\n  await comment.update({updatedAt: fn('now')});\n\n  // Add new decision, isCurrentDecision defaults to true.\n  const decision = await Decision.create({\n    commentId: comment.id,\n    status,\n\n    source: isUser(source) ? 'User' : 'Rule',\n    userId: (source && isUser(source)) ? source.id : undefined,\n    moderationRuleId: (source && isModerationRule(source)) ? source.id : undefined,\n  });\n\n  await commentModeratedHook(comment);\n  return decision;\n}\n\nexport async function postProcessComment(comment: Comment): Promise<void> {\n  const article = await comment.getArticle();\n\n  // Denormalize the moderation counts for the comment article\n  await denormalizeCommentCountsForArticle(article, false);\n\n  // Cache the size of the comment text.\n  await cacheTextSize(comment, 696);\n\n  // Try to create reply, return if not a reply\n  const replyToSourceId = comment.replyToSourceId;\n  if (!replyToSourceId) {\n    return;\n  }\n\n  // Find a parent id\n  const parent = await Comment.findOne({where: {sourceId: replyToSourceId}});\n\n  // If the parent cannot be found, then return\n  if (!parent) {\n    return;\n  }\n\n  await comment.update({\n    replyId: parent.id,\n  });\n}\n"
  },
  {
    "path": "packages/backend-api/src/pipeline/rules.ts",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport {\n  groupBy,\n  mapValues,\n  max,\n  uniq,\n} from 'lodash';\n\nimport {\n  Comment,\n  CommentSummaryScore,\n  ModerationRule,\n  MODERATION_ACTION_ACCEPT,\n  MODERATION_ACTION_DEFER,\n  MODERATION_ACTION_REJECT,\n  SUMMARY_SCORE_TAG,\n  Tag,\n} from '../models';\nimport {\n  approve,\n  defer,\n  getHighlightStateData,\n  IDecision,\n  reject,\n} from './state';\n\nexport interface ICompiledScores {\n  [tagId: number]: number;\n}\n\n/**\n * Take a list of scores and compose them into an object whose keys are tag ids and whose values\n * are scores in those tags. If there are multiple scores for the same tag, we take the max.\n */\nexport function compileScores(commentScores: Array<CommentSummaryScore>): ICompiledScores {\n  const grouped = groupBy(commentScores, (score) => score.tagId);\n\n  return mapValues(grouped, (scores) => {\n    // Pull out `score` field values and return their average\n    return max(scores.map((score: CommentSummaryScore) => score.score)) || 0;\n  });\n}\n\n/**\n * Resolve a comment state based on passed in scores and rules. If any rules match up with the comment and there's\n * consensus in their actions, it is applied to the comment. If there's any disagreement, it's deferred. The\n * \"highlight\" action is only applied if the comment is approved. If no rules match, the comment is returned\n * unchanged.\n *\n * @param  {object} comment  Comment model instance\n * @param  {array} scores    Array of CommentScore instances\n * @param  {array} rules     Array of ModerationRule instances\n * @return {object} Promise object that resolves with the comment, updated if anything's changed\n */\nexport async function resolveComment(\n  comment: Comment,\n  scores: Array<CommentSummaryScore>,\n  rules?: Array<ModerationRule>,\n): Promise<IDecision | null> {\n  // Add a fake score for the summary score so that rules can be written against it.\n  const summaryScoreTag = await Tag.findOne({\n    where: { key: SUMMARY_SCORE_TAG },\n  });\n\n  let compiledScores: ICompiledScores;\n\n  if (summaryScoreTag) {\n    const tempSummaryScore = CommentSummaryScore.build({\n      commentId: comment.id,\n      tagId: summaryScoreTag.id,\n      score: comment.maxSummaryScore!,\n    });\n\n    compiledScores = compileScores([tempSummaryScore, ...scores]);\n  } else {\n    compiledScores = compileScores(scores);\n  }\n\n  if (!rules) {\n    rules = await ModerationRule.findAll();\n  }\n\n  const article = await (comment as any).getArticle();\n\n  const matchingRules = rules.filter((r) => {\n    const score = compiledScores[r.tagId];\n\n    return score && score >= r.lowerThreshold && score <= r.upperThreshold;\n  });\n\n  const globalRules = matchingRules.filter((r) => !r.categoryId);\n  const categoryRules = matchingRules.filter((r) => article && r.categoryId === article.categoryId);\n\n  function isThereConsensus(testRules: Array<ModerationRule>): boolean {\n    const actions = testRules.map((r) => r.action.toLowerCase());\n\n    // Replace highlight with accept.\n    const replacedActions = actions.map((a) => a === 'highlight' ? 'accept' : a);\n\n    return uniq(replacedActions).length === 1;\n  }\n\n  let consensus;\n  let appliedRule;\n  let wasHighlighted;\n\n  // Only global applies.\n  if ((globalRules.length > 0) && (categoryRules.length <= 0)) {\n    consensus = isThereConsensus(globalRules);\n    appliedRule = globalRules[globalRules.length - 1];\n    wasHighlighted = globalRules.some((r) => r.action.toLowerCase() === 'highlight');\n  }\n\n  else if (\n    // Only category applies\n    ((globalRules.length <= 0) && (categoryRules.length > 0)) ||\n\n    // Both apply, but we prefer the category overrides\n    ((globalRules.length > 0) && (categoryRules.length > 0))\n  ) {\n    consensus = isThereConsensus(categoryRules);\n    appliedRule = categoryRules[categoryRules.length - 1];\n    wasHighlighted = categoryRules.some((r) => r.action.toLowerCase() === 'highlight');\n  }\n\n  // Nothing applies\n  else {\n    return null;\n  }\n\n  // If there's no consensus or everything is \"defer\", defer the comment\n  if (!consensus) {\n    await defer(comment, null);\n    comment.isAutoResolved = true;\n    await comment.save();\n\n    return {\n      resolution: MODERATION_ACTION_DEFER,\n      resolver: null,\n    };\n  }\n\n  const appliedAction = appliedRule.action.toLowerCase();\n  const replacedAction = appliedAction === 'highlight' ? 'accept' : appliedAction;\n\n  // If all actions are equal, we have consensus and we can approve or reject\n  if (replacedAction === 'accept') {\n    // Only highlight accepted comments\n    const extra = wasHighlighted ? getHighlightStateData() : {};\n\n    await approve(comment, appliedRule, extra);\n    comment.isAutoResolved = true;\n    await comment.save();\n\n    return {\n      resolution: MODERATION_ACTION_ACCEPT,\n      resolver: appliedRule,\n    };\n  } else if (replacedAction === 'reject') {\n    // Reject a comment if all actions equal \"reject\" and \"highlight\" hasn't been set\n    await reject(comment, appliedRule);\n    comment.isAutoResolved = true;\n    await comment.save();\n\n    return {\n      resolution: MODERATION_ACTION_REJECT,\n      resolver: appliedRule,\n    };\n  } else if (replacedAction === 'defer') {\n    // Defer a comment\n    await defer(comment, appliedRule);\n    comment.isAutoResolved = true;\n    await comment.save();\n\n    return {\n      resolution: MODERATION_ACTION_DEFER,\n      resolver: appliedRule,\n    };\n  } else {\n    return null;\n  }\n}\n\n/**\n * Fetch active rules and scores for the passed in comment and see if any automated rules can be applied to it\n *\n * @param {object} comment Comment model instance to process rules on\n */\nexport async function processRulesForComment(comment: Comment): Promise<IDecision | null> {\n  const article = await comment.getArticle();\n\n  if (article && !article.isAutoModerated) {\n    return null;\n  }\n\n  // Otherwise, fetch all scores and play ball\n  const commentSummaryScores = await CommentSummaryScore.findAll({\n    where: { commentId: comment.id },\n    include: [Tag],\n  });\n\n  if (!commentSummaryScores.length) {\n    // If no scores are found, resolve with the comment\n    return null;\n  } else {\n    // Otherwise, process the rules and act on the comment as configured\n    return resolveComment(comment, commentSummaryScores);\n  }\n}\n"
  },
  {
    "path": "packages/backend-api/src/pipeline/shim.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { Comment } from '../models';\n\nexport interface IScore {\n  score: number;\n  begin?: number;\n  end?: number;\n}\n\nexport interface IScores {\n  [key: string]: Array<IScore>;\n}\n\nexport interface ISummaryScores {\n  [key: string]: number;\n}\n\nexport interface IScoreData {\n  scores: IScores;\n  summaryScores: ISummaryScores;\n}\n\nexport interface IShim {\n  /**\n   * Send a single comment for scoring\n   *\n   * @param {object} comment  Comment to score\n   * @param {string} correlator  String used to correlate this request with any out-of-band responses.\n   * @return {object} Promise object indicating whether we've finished processing this request.\n   */\n  sendToScorer(comment: Comment, correlator: string | number): Promise<void>;\n}\n"
  },
  {
    "path": "packages/backend-api/src/pipeline/state.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { groupBy, omit } from 'lodash';\n\nimport { denormalizeCommentCountsForArticle, denormalizeCountsForComment } from '../domain';\nimport {\n  Comment,\n  CommentScore,\n  CommentScoreRequest,\n  IResolution,\n  isUser,\n  MODERATION_ACTION_ACCEPT,\n  MODERATION_ACTION_DEFER,\n  MODERATION_ACTION_REJECT,\n  ModerationRule,\n  Tag,\n  User,\n} from '../models';\nimport { recordDecision } from './pipeline';\n\nexport interface IDecision {\n  resolution: IResolution;\n  resolver: User | ModerationRule | null;\n}\n\n/**\n * Take an array of CommentScoreRequest instances for a Comment and return true or false\n * indicating whether scoring is complete or not\n */\nexport function scoresComplete(commentScoreRequests: Array<CommentScoreRequest>): boolean {\n\n  // If there are no requests existing at all, assume it's not done scoring\n\n  if (commentScoreRequests.length < 1) {\n    return false;\n  } else {\n\n    // Group requests by comment scorer to make sure we're handling cases where multiple\n    // requests have been sent out for the same scorer\n\n    const grouped = groupBy(commentScoreRequests, 'userId');\n\n    return Object\n      .keys(grouped)\n      .map((key) => {\n\n        // Condense down into an array of booleans, returning true for each scorer if any\n        // score requests have `doneAt` set\n\n        return grouped[key].some((item: CommentScoreRequest) => {\n          return !!item.doneAt;\n        });\n      })\n      .every((item: boolean) => {\n        // If every scorer has one `doneAt` (true), return true\n        return item;\n      });\n  }\n}\n\n/**\n * Whether a comment is done being scored or not. If not `CommentScoreRequest` rows\n * exist, it will resolve to false. If there are any `CommentScoreRequest` rows that have\n * an empty `doneAt` field, it will resolve to false. Otherwise if they're all filled in\n * it will resolve to true\n */\nexport async function getIsDoneScoring(commentId: number) {\n  // Find and count all comment score requests\n  const commentScoreRequests = await CommentScoreRequest.findAll({\n    where: { commentId: commentId },\n    order: [['sentAt', 'DESC']],\n  });\n\n  return scoresComplete(commentScoreRequests);\n}\n\nexport type ICommentStateParams = Pick<\n  Comment,\n  'isAccepted' | 'isModerated' | 'isDeferred' | 'isHighlighted' | 'isBatchResolved' | 'isAutoResolved'\n>;\n\n/**\n * Get object of a clean state data to set on a Comment model instance\n */\nexport function getDefaultStateData(): ICommentStateParams {\n  return {\n    isAccepted: null,\n    isModerated: false,\n    isDeferred: false,\n    isHighlighted: false,\n    isBatchResolved: false,\n    isAutoResolved: false,\n  };\n}\n\n/**\n * Get object of approved state data to set on a Comment model instance\n * @return {object}\n */\nexport function getApproveStateData(): Partial<ICommentStateParams> {\n  return {\n    isAccepted: true,\n    isModerated: true,\n    isDeferred: false,\n  };\n}\n\n/**\n * Get object of rejected state data to set on a Comment model instance\n */\nexport function getRejectStateData(): Partial<ICommentStateParams> {\n  return {\n    isAccepted: false,\n    isModerated: true,\n    isDeferred: false,\n  };\n}\n\n/**\n * Get object of deferred state data to set on a Comment model instance\n */\nexport function getDeferStateData(): Partial<ICommentStateParams> {\n  return {\n    isAccepted: null,\n    isModerated: true,\n    isDeferred: true,\n  };\n}\n\n/**\n * Get object of highlighted state data to set on a Comment model instance\n */\nexport function getHighlightStateData(): Partial<ICommentStateParams> {\n  return {\n    isHighlighted: true,\n  };\n}\n\n/**\n * Get object of unhighlighted state data to set on a Comment model instance\n */\nexport function getUnHighlightStateData(): Partial<ICommentStateParams> {\n  return {\n    isHighlighted: false,\n  };\n}\n\n/**\n * Set comment state and save it to the database. Passing an optional data object will add extra data,\n * but keys that conflict with `state` will be omitted\n */\nexport async function setCommentState(\n  comment: Comment,\n  source: User | ModerationRule | null,\n  state: Partial<ICommentStateParams>,\n  data?: Partial<ICommentStateParams>): Promise<Comment> {\n\n  // Create an object of data to save and accept an optional `data` object for additional\n  // data, omitting any keys that conflict with `state`\n\n  const updated = await comment.update({\n    ...state,\n    ...(data ? omit(data, Object.keys(state)) : {}),\n  });\n\n  await denormalizeCountsForComment(comment);\n\n  // denormalize the comment counts\n  const article = await comment.getArticle();\n  if (article) {\n    await denormalizeCommentCountsForArticle(article, isUser(source));\n  }\n\n  return updated;\n}\n\n/**\n * Mark a comment approved in the database, accepts an optional data object to\n * tack on to the comment\n */\nexport async function approve(\n  comment: Comment,\n  source: User | ModerationRule | null,\n  data?: object,\n): Promise<Comment> {\n  const updated = await setCommentState(comment, source, getApproveStateData(), data);\n\n  await recordDecision(updated, MODERATION_ACTION_ACCEPT, source);\n\n  return updated;\n}\n\n/**\n * Mark a comment rejected in the database, accepts an optional data object to\n * tack on to the comment\n */\nexport async function reject(\n  comment: Comment,\n  source: User | ModerationRule | null,\n  data?: object,\n): Promise<Comment> {\n  const updated = await setCommentState(comment, source, getRejectStateData(), data);\n\n  await recordDecision(updated, MODERATION_ACTION_REJECT, source);\n\n  return updated;\n}\n\n/**\n * Mark a comment deferred in the database, accepts an optional data object to\n * tack on to the comment\n */\nexport async function defer(\n  comment: Comment,\n  source: User | ModerationRule | null,\n  data?: object,\n): Promise<Comment> {\n  const updated = await setCommentState(comment, source, getDeferStateData(), data);\n\n  await recordDecision(updated, MODERATION_ACTION_DEFER, source);\n\n  return updated;\n}\n\n/**\n * Toggle a comment's highlighted state in the database, accepts an optional data object to\n * tack on to the comment\n */\nexport async function highlight(\n  comment: Comment,\n  source: User | ModerationRule | null,\n): Promise<Comment> {\n  let updated = comment;\n  if (comment.isHighlighted) {\n    updated = await setCommentState(comment, source, {\n      ...getApproveStateData(),\n      ...getUnHighlightStateData(),\n    });\n  } else {\n    updated = await setCommentState(comment, source, {\n      ...getApproveStateData(),\n      ...getHighlightStateData(),\n    });\n  }\n\n  return updated;\n}\n\n/**\n * Reset a comment in the database.\n */\nexport function reset(comment: Comment, source: User | ModerationRule | null ): Promise<Comment> {\n  return setCommentState(comment, source, getDefaultStateData());\n}\n\n/**\n * Add a score directly to a comment. Used to store highlight and flag 100% tags.\n *\n * @param {object} comment   Comment model instance\n * @param {object} tag       Tag model instance\n * @param {object} user      User model instance\n */\nexport async function addScore(comment: Comment, tag: Tag, user?: User | null): Promise<CommentScore> {\n  const score = await CommentScore.create({\n    tagId: tag.id,\n    commentId: comment.id,\n    userId: user ? user.id : undefined,\n    sourceType: 'Moderator',\n    score: 1,\n  });\n\n  await denormalizeCountsForComment(comment);\n\n  // denormalize the comment counts\n  const article = await comment.getArticle();\n  if (article) {\n    await denormalizeCommentCountsForArticle(article, user !== null);\n  }\n\n  return score;\n}\n"
  },
  {
    "path": "packages/backend-api/src/processing/api/index.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as express from 'express';\n\nimport { kickWorker } from '../worker';\nimport { createKnownTasksRouter } from './known_tasks';\n\n/*\n    The Task API exposes HTTP endpoints for starting asynchronous\n    tasks.\n*/\nexport function mountTaskAPI(): express.Router {\n  const router = express.Router({\n    caseSensitive: true,\n    mergeParams: true,\n  });\n\n  router.use('/:taskName', createKnownTasksRouter());\n\n  return router;\n}\n\nexport function processingTriggers(): express.Router {\n  const router = express.Router({\n    caseSensitive: true,\n    mergeParams: true,\n  });\n\n  router.get('/trigger/:category', async (_req, res, next) => {\n    await kickWorker('reset');\n    res.json({ status: 'ok' });\n    next();\n  });\n\n  return router;\n}\n\nexport * from './known_tasks';\nexport * from './permissions';\n"
  },
  {
    "path": "packages/backend-api/src/processing/api/known_tasks.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as express from 'express';\nimport * as Joi from 'joi';\n\nimport { logger } from '../../logger';\nimport { knownTasks } from '../util';\n\nconst schema = Joi.object({\n  data: Joi.alternatives().try(\n    Joi.array(),\n    Joi.object(),\n  ).required(),\n});\n\n/**\n * Task router for starting worker tasks.\n */\nexport function createKnownTasksRouter(): express.Router {\n  const router = express.Router({\n    caseSensitive: true,\n    mergeParams: true,\n  });\n\n  router.post(\n    '/',\n    async ({ body, params: { taskName }}, res, next) => {\n      try {\n        const status = schema.validate(body, { convert: false });\n\n        if (status.error) {\n          res.status(422).json({ status: 'error', errors: status.error.details });\n\n          return;\n        }\n\n        // Send comments for rescoring.\n        if (knownTasks[taskName] === undefined) {\n          logger.error(`unknown task name: ${taskName}`);\n          res.status(400).json({ status: 'error', error: `unknown task name: ${taskName}`});\n\n          return;\n        }\n\n        const task = knownTasks[taskName];\n        await task(body.data);\n\n        logger.info(`OSMod Task ${task} complete!`);\n        res.status(200).json({ status: 'success'});\n      } catch (err) {\n        logger.error('Task error: ', err.name, err.message);\n        next(err);\n      }\n    },\n  );\n\n  return router;\n}\n"
  },
  {
    "path": "packages/backend-api/src/processing/api/permissions.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as express from 'express';\n\nexport function verifyAppEngineCron(req: express.Request, res: express.Response, next: express.NextFunction) {\n  if (req.header('X-Appengine-Cron') !== 'true') {\n    res.status(401).json({ error: 'unauthorized' });\n\n    return;\n  }\n\n  next();\n}\n"
  },
  {
    "path": "packages/backend-api/src/processing/dashboard.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { Application } from 'express';\nimport { app } from 'kue';\n\nexport function mountQueueDashboard(): Application {\n  return app;\n}\n"
  },
  {
    "path": "packages/backend-api/src/processing/index.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { youtubeHooks } from '../integrations/youtube/hooks';\nimport { USER_GROUP_YOUTUBE } from '../models';\nimport { registerHooks } from '../pipeline/hooks';\n\nregisterHooks(USER_GROUP_YOUTUBE, youtubeHooks);\n\nexport * from './api';\nexport * from './dashboard';\nexport * from './tasks';\nexport * from './worker';\n"
  },
  {
    "path": "packages/backend-api/src/processing/tasks/comment_actions.ts",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\nimport { logger } from '../../logger';\nimport { MODERATION_ACTION_ACCEPT, MODERATION_ACTION_REJECT } from '../../models';\nimport { approve, defer, highlight, reject, reset } from '../../pipeline/state';\nimport { enqueue, registerTask } from '../util';\nimport { getComment, getUser, resolveComment, resolveCommentAndFlags, resolveFlagsAndDenormalize } from './db_operations';\n\nexport type CommentActions =\n  'acceptComments' |\n  'acceptCommentsAndFlags' |\n  'rejectComments' |\n  'rejectCommentsAndFlags' |\n  'highlightComments' |\n  'deferComments' |\n  'resetComments' |\n  'resolveFlags';\n\ninterface ICommentActionData {\n  commentId: number;\n  userId?: number | null;\n  isBatchAction: boolean;\n}\n\nasync function executeAcceptCommentsTask(data: ICommentActionData) {\n  const { commentId, userId, isBatchAction } = data;\n\n  return resolveComment(\n    commentId,\n    userId || null,\n    isBatchAction,\n    MODERATION_ACTION_ACCEPT,\n    approve,\n  );\n}\n\nasync function executeAcceptCommentsAndFlagsTask(data: ICommentActionData) {\n  const { commentId, userId, isBatchAction } = data;\n\n  return resolveCommentAndFlags(\n    commentId,\n    userId || null,\n    isBatchAction,\n    MODERATION_ACTION_ACCEPT,\n    approve,\n  );\n}\n\nasync function executeRejectCommentsTask(data: ICommentActionData) {\n  const { commentId, userId, isBatchAction } = data;\n\n  return resolveComment(\n    commentId,\n    userId || null,\n    isBatchAction,\n    MODERATION_ACTION_REJECT,\n    reject,\n  );\n}\n\nasync function executeRejectCommentsAndFlagsTask(data: ICommentActionData) {\n  const { commentId, userId, isBatchAction } = data;\n\n  return resolveCommentAndFlags(\n    commentId,\n    userId || null,\n    isBatchAction,\n    MODERATION_ACTION_REJECT,\n    reject,\n  );\n}\n\nasync function executeDeferCommentsTask(data: ICommentActionData) {\n  const user = await getUser(data.userId);\n  const comment = await getComment(data.commentId);\n\n  // update batch action\n  logger.info('defer comment : ',  comment.id );\n  comment.isBatchResolved = data.isBatchAction;\n  await comment.save();\n  return defer(comment, user);\n}\n\nasync function executeHighlightCommentsTask(data: ICommentActionData)  {\n  const user = await getUser(data.userId);\n  const comment = await getComment(data.commentId);\n\n  logger.info('highlight comment : ', comment.id);\n  comment.isBatchResolved = data.isBatchAction;\n  await comment.save();\n  return highlight(comment, user);\n}\n\nasync function executeResetCommentsTask(data: ICommentActionData) {\n  const user = await getUser(data.userId);\n  const comment = await getComment(data.commentId);\n  logger.info(`reset comment: ${comment.id}`);\n  return reset(comment, user);\n}\n\nasync function executeResolveFlagsTask(data: ICommentActionData) {\n  const { commentId, userId } = data;\n\n  return resolveFlagsAndDenormalize(\n    commentId,\n    userId ? userId : undefined,\n  );\n}\n\nregisterTask<ICommentActionData>('acceptComments', executeAcceptCommentsTask);\nregisterTask<ICommentActionData>('acceptCommentsAndFlags', executeAcceptCommentsAndFlagsTask);\nregisterTask<ICommentActionData>('rejectComments', executeRejectCommentsTask);\nregisterTask<ICommentActionData>('rejectCommentsAndFlags', executeRejectCommentsAndFlagsTask);\nregisterTask<ICommentActionData>('deferComments', executeDeferCommentsTask);\nregisterTask<ICommentActionData>('highlightComments', executeHighlightCommentsTask);\nregisterTask<ICommentActionData>('resolveFlags', executeResolveFlagsTask);\nregisterTask<ICommentActionData>('resetComments', executeResetCommentsTask);\n\nexport async function enqueueCommentAction(\n  action: CommentActions,\n  userId: number,\n  commentId: number,\n  isBatchAction: boolean,\n  runImmediately: boolean,\n) {\n  await enqueue<ICommentActionData>(action, {commentId, userId, isBatchAction}, runImmediately);\n}\n"
  },
  {
    "path": "packages/backend-api/src/processing/tasks/db_operations.ts",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\nimport {\n  denormalizeCommentCountsForArticle,\n  denormalizeCountsForComment,\n} from '../../domain';\nimport { logger } from '../../logger';\nimport {\n  Article,\n  Comment,\n  CommentFlag,\n  Tag,\n  User,\n} from '../../models';\nimport {\n  IResolution,\n} from '../../models';\n\nexport async function getUser(userId?: number | null | undefined) {\n  if (!userId) {\n    return null;\n  }\n  const user = await User.findByPk(userId);\n\n  if (!user) {\n    throw new Error(`User not found, id: ${userId}`);\n  }\n\n  return user;\n}\n\nexport async function getComment(commentId: number) {\n  const comment = await Comment.findOne({\n    where: { id: commentId },\n    include: [\n      { model: Article, required: true},\n    ],\n  });\n\n  if (!comment) {\n    throw new Error(`Comment not found, id: ${commentId}`);\n  }\n\n  return comment;\n}\n\nexport async function getTag(tagId: number) {\n  const tag = await Tag.findByPk(tagId);\n\n  if (!tag) {\n    throw new Error(`Tag not found, id: ${tagId}`);\n  }\n\n  return tag;\n}\n\nexport async function resolveComment(\n  commentId: number,\n  userId: number | null,\n  isBatchAction: boolean,\n  status: IResolution,\n  domainFn: (comment: Comment, source: any) => Promise<Comment>,\n): Promise<void> {\n  const user = await getUser(userId);\n  const comment = await getComment(commentId);\n  logger.info(`${status} comment: ${commentId}`);\n  comment.isBatchResolved = isBatchAction;\n  await comment.save();\n  await domainFn(comment, user);\n}\n\nasync function resolveFlags(\n  commentId: number,\n  userId?: number,\n): Promise<void> {\n  await CommentFlag.update({\n      isResolved: true,\n      resolvedById: userId,\n      resolvedAt: new Date(),\n    } as any,\n    { where: {\n        commentId: commentId,\n        isResolved: false,\n      }},\n  );\n}\n\nexport async function resolveFlagsAndDenormalize(\n  commentId: number,\n  userId?: number,\n): Promise<void> {\n  await resolveFlags(commentId, userId);\n  const comment = await Comment.findByPk(commentId);\n  if (!comment) {\n    throw new Error(`No such comment ${commentId}`);\n  }\n\n  const article = await comment.getArticle();\n  await denormalizeCountsForComment(comment);\n  await denormalizeCommentCountsForArticle(article, true);\n}\n\nexport async function resolveCommentAndFlags(\n  commentId: number,\n  userId: number | null,\n  isBatchAction: boolean,\n  status: IResolution,\n  domainFn: (comment: Comment, source: any) => Promise<Comment>,\n): Promise<void> {\n  // We update flags first as we do the denormalization in the resolveComment action.\n  await resolveFlags(commentId, userId ? userId : undefined);\n  await resolveComment(commentId, userId, isBatchAction, status, domainFn);\n}\n"
  },
  {
    "path": "packages/backend-api/src/processing/tasks/heartbeat.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { logger } from '../../logger';\nimport {\n  getCommentsToResendForScoring,\n  resendForScoring,\n} from '../../pipeline';\n\nconst HEARTBEAT_INTERVAL = 10;\nconst PROCESS_COMMENT_LIMIT = 10;\n\nexport async function heartbeatTask(tick: number) {\n  if (tick % HEARTBEAT_INTERVAL === 0) {\n    await resendComments();\n  }\n}\n\nasync function resendComments() {\n  logger.info('Process checking for comments to re-send for scoring');\n  let comments;\n\n  try {\n    // See if there are any comments that need to be re-sent for scoring\n    comments = await getCommentsToResendForScoring(PROCESS_COMMENT_LIMIT);\n  } catch (err) { // Catching just for logging purposes\n    logger.error('Heartbeat: Error fetching comments for re-sending for scoring', err);\n    throw err;\n  }\n\n  if (!comments.length) {\n    logger.info('Heartbeat: No comments found to re-send for scoring');\n\n    return;\n  }\n\n  logger.info(`Heartbeat: ${comments.length} comments found to re-send for scoring`);\n\n  try {\n    for (const comment of comments) {\n      await resendForScoring(comment);\n    }\n  } catch (err) { // Catching just for logging purposes\n    logger.error('Heartbeat: Error re-sending comments for scoring', err);\n    throw err;\n  }\n}\n"
  },
  {
    "path": "packages/backend-api/src/processing/tasks/index.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { logger } from '../../logger';\nimport { getQueueSingleton, processKnownTasks } from '../util';\n\nexport function startProcessing() {\n  const queue = getQueueSingleton();\n\n  processKnownTasks();\n\n  queue.on('job enqueue', (id: number, type: string) => {\n    logger.info(`${type} Job queued: ${id}`);\n  });\n\n  queue.on('job complete', (id: number) => {\n    logger.info(`Job complete: ${id}`);\n  });\n\n  queue.on('job failed', (err: any) => {\n    logger.error(`Job failed: ${err}`);\n  });\n\n  queue.on('error', (err: any) => {\n    logger.error(`Worker queue error: ${err}`);\n  });\n\n  // Check for stuck jobs every 10 seconds\n\n  queue.watchStuckJobs(10000);\n\n  // Graceful shutdown\n\n  process.once('SIGTERM', () => {\n    queue.shutdown(10000, '', (err: any) => {\n      if (err) {\n        logger.error(`Worker queue shutdown error: ${err}`);\n      } else {\n        logger.info('Worker queue shut down successfully');\n      }\n      process.exit(0);\n    });\n  });\n}\n\nexport { heartbeatTask } from './heartbeat';\nexport { CommentActions, enqueueCommentAction } from './comment_actions';\nexport {\n  enqueueAddTagTask,\n  enqueueConfirmTagTask,\n  enqueueRejectTagTask,\n  enqueueRemoveTagTask,\n  enqueueResetTagTask,\n} from './score_tag_actions';\nexport { enqueueProcessMachineScoreTask } from './process_machine_score';\nexport { enqueueSendCommentForScoringTask } from './send_comment_for_scoring';\nexport { enqueueScoreAction, ScoreActions } from './score_actions';\nexport {\n  enqueueProcessTagAdditionTask,\n  enqueueProcessTagRevocationTask,\n  IProcessTagAdditionData,\n  IProcessTagData,\n} from './process_tagging';\n"
  },
  {
    "path": "packages/backend-api/src/processing/tasks/process_machine_score.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport {\n  completeMachineScoring,\n  processMachineScore,\n} from '../../pipeline';\nimport { IScoreData } from '../../pipeline/shim';\nimport { getIsDoneScoring } from '../../pipeline/state';\nimport { enqueue, registerTask } from '../util';\n\ninterface IProcessMachineScoreData {\n  commentId: number;\n  userId: number;\n  scoreData: IScoreData;\n}\n\nasync function executeProcessMachineScoreTask(data: IProcessMachineScoreData) {\n  await processMachineScore(\n    data.commentId,\n    data.userId,\n    data.scoreData,\n  );\n\n  const isDoneScoring = await getIsDoneScoring(data.commentId);\n\n  if (isDoneScoring) {\n    await completeMachineScoring(data.commentId);\n  }\n}\n\nregisterTask<IProcessMachineScoreData>('processMachineScore', executeProcessMachineScoreTask);\n\nexport async function enqueueProcessMachineScoreTask(commentId: number, userId: number, scoreData: IScoreData, runImmediately: boolean) {\n  const taskData = { commentId, userId, scoreData };\n  await enqueue<IProcessMachineScoreData>('processMachineScore', taskData, runImmediately);\n}\n"
  },
  {
    "path": "packages/backend-api/src/processing/tasks/process_tagging.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport {\n  denormalizeCommentCountsForArticle,\n  denormalizeCountsForComment,\n} from '../../domain';\nimport { logger } from '../../logger';\nimport { Article, Comment, CommentFlag } from '../../models';\nimport { enqueue, registerTask } from '../util';\n\nexport interface IProcessTagData {\n  type: 'recommendation' | 'flag';\n  sourceUserId: string;\n  sourceCommentId: string;\n}\n\nexport interface IProcessTagAdditionData extends IProcessTagData {\n  extra?: any;\n}\n\nfunction lookUpCommentBySourceId(sid: string ) {\n  return Comment.findOne({\n    where: { sourceId: sid },\n    include: [Article],\n  });\n}\n\nexport async function executeProcessTagAdditionTask(data: IProcessTagAdditionData) {\n  const { type, sourceCommentId, sourceUserId, extra } = data;\n\n  logger.info('Process Tag Addition', JSON.stringify(data));\n\n  try {\n    const comment = await lookUpCommentBySourceId(sourceCommentId);\n\n    if (!comment) {\n      throw new Error(`Comment not found: sourceId = ${sourceCommentId}`);\n    }\n\n    const options = {\n      where: {\n        commentId: comment.id,\n        sourceId: sourceUserId,\n      },\n      defaults: {\n        label: type,\n        isRecommendation: type === 'recommendation',\n        commentId: comment.id,\n        sourceId: sourceUserId,\n        isResolved: false,\n        extra: extra || null,\n      },\n    };\n\n    const [instance, created] = await CommentFlag.findOrCreate(options);\n\n    if (!created) {\n      instance.extra = extra;\n      await instance.save();\n    }\n\n    await denormalizeCountsForComment(comment);\n    await denormalizeCommentCountsForArticle(await comment.getArticle(), false);\n  }\n  catch (err) { // Catching just for logging purposes\n    logger.error('Catch Tag Addition', err);\n    throw err;\n  }\n}\n\nexport async function enqueueProcessTagAdditionTask(data: IProcessTagData, runImmediately: boolean) {\n  await enqueue<IProcessTagAdditionData>('processTagAddition', data, runImmediately);\n}\n\nexport async function executeProcessTagRevocationTask(data: IProcessTagData) {\n  const { sourceCommentId, sourceUserId } = data;\n\n  logger.info('Process Tag Revocation', JSON.stringify(data));\n\n  try {\n    const comment = await lookUpCommentBySourceId(sourceCommentId);\n\n    if (!comment) {\n      throw new Error(`Comment not found: sourceId = ${sourceCommentId}`);\n    }\n\n    await CommentFlag.destroy({\n      where: {\n        commentId: comment.id,\n        sourceId: sourceUserId,\n      },\n    });\n\n    await denormalizeCountsForComment(comment);\n    await denormalizeCommentCountsForArticle(await comment.getArticle(), false);\n  }\n  catch (err) { // Catching just for logging purposes\n    logger.error('Catch Tag Revocation', err);\n    throw err;\n  }\n}\n\nregisterTask<IProcessTagAdditionData>('processTagAddition', executeProcessTagAdditionTask);\nregisterTask<IProcessTagData>('processTagRevocation', executeProcessTagRevocationTask);\n\nexport async function enqueueProcessTagRevocationTask(data: IProcessTagData, runImmediately: boolean) {\n  await enqueue<IProcessTagData>('processTagRevocation', data, runImmediately);\n}\n"
  },
  {
    "path": "packages/backend-api/src/processing/tasks/score_actions.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { logger } from '../../logger';\nimport { CommentSummaryScore } from '../../models';\nimport { addScore } from '../../pipeline/state';\nimport { enqueue, registerTask} from '../util';\nimport {\n  getComment,\n  getTag,\n  getUser,\n} from './db_operations';\n\nexport type ScoreActions =\n  'tagComments' |\n  'tagCommentSummaryScores' |\n  'confirmCommentSummaryScore' |\n  'rejectCommentSummaryScore';\n\ninterface ITagCommentsData {\n  commentId: number;\n  userId: number;\n  tagId: number;\n}\n\nasync function executeTagCommentsTask(data: ITagCommentsData) {\n  const user = await getUser(data.userId);\n  const comment = await getComment(data.commentId);\n  const tag = await getTag(data.tagId);\n\n  logger.info(`Run tag comment process with comment id ${comment.id} and tag id ${tag.id}`);\n  const commentScore = await addScore(comment, tag, user);\n  logger.info('Comment Score added.');\n  return commentScore;\n}\n\nasync function executeTagCommentSummaryScoresTask(data: ITagCommentsData) {\n  const user = await getUser(data.userId);\n  const comment = await getComment(data.commentId);\n  const tag = await getTag(data.tagId);\n\n  logger.info(`Run tag comment process with comment id ${comment.id} and tag id ${tag.id}`);\n\n  await CommentSummaryScore.upsert({\n    commentId: comment.id,\n    tagId: tag.id,\n    score: 1,\n    isConfirmed: true,\n    confirmedUserId: user && user.id,\n  });\n\n  logger.info('Comment Summary Score added.');\n}\n\nasync function executeConfirmCommentSummaryScoreTask(data: ITagCommentsData) {\n  const user = await getUser(data.userId);\n  const { commentId, tagId } = data;\n\n  // Confirm Comment Score Exists\n  const cs = await CommentSummaryScore.findOne({\n    where: {\n      commentId,\n      tagId,\n    },\n  });\n\n  if (!cs) { return; }\n\n  logger.info(`Confirm tag for comment_score commentId: ${cs.commentId}`);\n\n  return cs.update({\n    isConfirmed: true,\n    confirmedUserId: user && user.id,\n  });\n}\n\nasync function executeRejectCommentSummaryScoreTask(data: ITagCommentsData) {\n  await getUser(data.userId);\n  const { commentId, tagId, userId } = data;\n\n  // Confirm Comment Score Exists\n  const cs = await CommentSummaryScore.findOne({\n    where: {\n      commentId,\n      tagId,\n    },\n  });\n\n  if (!cs) { return; }\n\n  logger.info(`Confirm tag for comment_score commentId: ${cs.commentId}`);\n\n  return cs.update({\n    isConfirmed: false,\n    confirmedUserId: userId,\n  });\n}\n\nregisterTask<ITagCommentsData>('tagComments', executeTagCommentsTask);\nregisterTask<ITagCommentsData>('tagCommentSummaryScores', executeTagCommentSummaryScoresTask);\nregisterTask<ITagCommentsData>('confirmCommentSummaryScore', executeConfirmCommentSummaryScoreTask);\nregisterTask<ITagCommentsData>('rejectCommentSummaryScore', executeRejectCommentSummaryScoreTask);\n\nexport async function enqueueScoreAction(\n  action: ScoreActions,\n  userId: number,\n  commentId: number,\n  tagId: number,\n  runImmediately: boolean,\n) {\n  await enqueue<ITagCommentsData>(action, {commentId, tagId, userId}, runImmediately);\n}\n"
  },
  {
    "path": "packages/backend-api/src/processing/tasks/score_tag_actions.ts",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\nimport {\n  denormalizeCommentCountsForArticle,\n  denormalizeCountsForComment,\n} from '../../domain';\nimport { logger } from '../../logger';\nimport { CommentScore } from '../../models';\nimport { enqueue, registerTask } from '../util';\n\ninterface IAddTagData {\n  commentId: number;\n  tagId: number;\n  userId: number;\n  annotationStart?: number;\n  annotationEnd?: number;\n}\n\ninterface ICommentScoreData {\n  commentScoreId: number;\n}\n\ninterface IUserCommentScoreData {\n  commentScoreId: number;\n  userId: number;\n}\n\nasync function executeAddTagTask(data: IAddTagData) {\n  const {\n    commentId,\n    tagId,\n    userId,\n    annotationStart,\n    annotationEnd,\n  } = data;\n\n  const cs = await CommentScore.create({\n    commentId,\n    tagId,\n    isConfirmed: true,\n    confirmedUserId: userId,\n    userId,\n    annotationStart,\n    annotationEnd,\n    sourceType: 'Moderator',\n    score: 1,\n  });\n\n  const comment = await cs.getComment();\n  const article = await comment!.getArticle();\n\n  await denormalizeCountsForComment(comment!);\n  await denormalizeCommentCountsForArticle(article, false);\n\n  return cs;\n}\n\nasync function executeRemoveTagTask(data: ICommentScoreData) {\n  const { commentScoreId } = data;\n\n  const cs = await CommentScore.findByPk(commentScoreId);\n\n  if (!cs) {\n    throw new Error(`Comment Score Not found, id: ${commentScoreId}`);\n  }\n\n  const comment = await cs.getComment();\n  const article = await comment!.getArticle();\n\n  // Remove\n  await CommentScore.destroy({\n    where: { id: commentScoreId },\n  });\n\n  await denormalizeCountsForComment(comment!);\n  await denormalizeCommentCountsForArticle(article, false);\n\n  logger.info(`Remove comment score ${commentScoreId}`);\n\n  return cs;\n}\n\nexport async function executeResetTagTask(data: ICommentScoreData) {\n  const { commentScoreId } = data;\n\n  // Confirm Comment Score Exists\n  const cs = await CommentScore.findByPk(commentScoreId);\n\n  if (!cs) { return; }\n\n  logger.info(`Reset tag for comment_score id: ${cs.id}`);\n\n  return cs.update({\n    isConfirmed: null,\n  });\n}\n\nexport async function executeConfirmTagTask(data: IUserCommentScoreData) {\n  const { commentScoreId, userId } = data;\n\n  // Confirm Comment Score Exists\n  const cs = await CommentScore.findByPk(commentScoreId);\n\n  if (!cs) { return; }\n\n  logger.info(`Confirm tag for comment_score id: ${cs.id}`);\n\n  return cs.update({\n    isConfirmed: true,\n    confirmedUserId: userId,\n  });\n}\n\nexport async function executeRejectTagTask(data: IUserCommentScoreData) {\n  const { commentScoreId, userId } = data;\n\n  const cs = await CommentScore.findByPk(commentScoreId);\n\n  if (!cs) { return; }\n\n  logger.info(`Reject tag for comment_score id: ${cs.id}`);\n\n  return cs.update({\n    isConfirmed: false,\n    confirmedUserId: userId,\n  });\n}\n\nregisterTask<IAddTagData>('addTag', executeAddTagTask);\nregisterTask<ICommentScoreData>('removeTag', executeRemoveTagTask);\nregisterTask<ICommentScoreData>('resetTag', executeResetTagTask);\nregisterTask<IUserCommentScoreData>('confirmTag', executeConfirmTagTask);\nregisterTask<IUserCommentScoreData>('rejectTag', executeRejectTagTask);\n\nexport async function enqueueAddTagTask(\n  commentId: number,\n  tagId: number,\n  userId: number,\n  annotationStart: number,\n  annotationEnd: number,\n  runImmediately: boolean,\n) {\n  await enqueue<IAddTagData>('addTag', {commentId, tagId, userId, annotationStart, annotationEnd}, runImmediately);\n}\n\nexport async function enqueueRemoveTagTask(commentScoreId: number, runImmediately: boolean) {\n  await enqueue<ICommentScoreData>('removeTag', {commentScoreId}, runImmediately);\n}\n\nexport async function enqueueResetTagTask(commentScoreId: number, runImmediately: boolean) {\n  await enqueue<ICommentScoreData>('resetTag', {commentScoreId}, runImmediately);\n}\n\nexport async function enqueueConfirmTagTask(userId: number, commentScoreId: number, runImmediately: boolean) {\n  await enqueue<IUserCommentScoreData>('confirmTag', {commentScoreId, userId}, runImmediately);\n}\n\nexport async function enqueueRejectTagTask(userId: number, commentScoreId: number, runImmediately: boolean) {\n  await enqueue<IUserCommentScoreData>('rejectTag', {commentScoreId, userId}, runImmediately);\n}\n"
  },
  {
    "path": "packages/backend-api/src/processing/tasks/send_comment_for_scoring.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { logger } from '../../logger';\nimport { Article, Comment } from '../../models';\nimport { sendForScoring } from '../../pipeline';\nimport { enqueue, registerTask } from '../util';\n\ninterface ISendCommentForScoringTaskData {\n  commentId: number;\n}\n\nasync function executeSendCommentForScoringTask(data: ISendCommentForScoringTaskData): Promise<void> {\n  const {commentId} = data;\n\n  logger.info(`sendCommentForScoringTask: Looking for ${commentId}`);\n\n  const comment = await Comment.findByPk(commentId, {\n    include: [\n      Article,\n      {\n        model: Comment,\n        as: 'replyTo',\n      },\n    ],\n  });\n\n  if (comment) {\n    logger.info(`sendCommentForScoringTask: Found ${commentId}`);\n  }\n  else {\n    throw new Error(`sendCommentForScoringTask: Comment not found, id: ${commentId}`);\n  }\n\n  await sendForScoring(comment);\n}\n\nregisterTask<ISendCommentForScoringTaskData>('sendCommentForScoring', executeSendCommentForScoringTask);\n\nexport async function enqueueSendCommentForScoringTask(commentId: number) {\n  await enqueue<ISendCommentForScoringTaskData>('sendCommentForScoring', { commentId });\n}\n"
  },
  {
    "path": "packages/backend-api/src/processing/util.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { createQueue, Job, Queue } from 'kue';\n\nimport { config } from '../config';\n\n/**\n * Creating the job queue before importing tasks as `createQueue`\n * creates a singleton, followed by importing tasks\n */\nlet queue: Queue;\nexport function getQueueSingleton(): Queue {\n  queue = queue || createQueue({\n    redis: config.get('redis_url'),\n    jobEvents: false,\n  }) as Queue;\n\n  return queue;\n}\n\nexport const knownTasks: {\n  [name: string]: IQueueHandler<any>;\n} = {};\n\nexport function enqueue<T>(name: string, data: T, runImmediately = false): Job | Promise<any> {\n  if (runImmediately || config.get('worker.run_immediately')) {\n    const fn = knownTasks[name];\n    return fn(data);\n  }\n  else {\n    const q = getQueueSingleton();\n    const job = q.createJob(name, data);\n\n    return job\n      .removeOnComplete(config.get('worker.remove_task_on_complete'))\n      .ttl(config.get('worker.task_ttl'))\n      .save();\n  }\n}\n\nexport interface IQueueHandler<T> {\n  (data: T): Promise<any>;\n}\n\nexport function registerTask<T>(name: string, fn: IQueueHandler<T>) {\n  knownTasks[name] = fn;\n}\n\nexport function processKnownTasks() {\n  for (const key in knownTasks) {\n    queue.process(key, 1, async (job: Job, done: (event: any, data?: any) => any) => {\n      try {\n        const data = await knownTasks[key](job.data);\n        done(null, data);\n      } catch (e) {\n        done(e);\n      }\n    });\n  }\n}\n"
  },
  {
    "path": "packages/backend-api/src/processing/worker.ts",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n/**\n * This file provides the framework for the worker process.\n * This is designed to run long-duration background tasks,\n * e.g., synchronising with an external data source.\n *\n * It does 2 things:\n *  - Every ${WORKER_POLL_INTERVAL} it kicks into action and runs all registered tasks sequentially.\n *  - When a task set is running, it ensures that no new task sets are initiated.\n *\n * Each task item is passed the current tick count.  (Approximately the number of minutes that have\n * passed since the process started.)  It can use that to decide which tasks to run.\n *\n * To register a task item, create a suitable async function.  Then, within startWorker,  call\n * registerWorkItem with that function as an argument.\n *\n * Any process can call kickWorker to start the next tick immediately.  Pass \"true\" to this function\n * to reset the tick to 0.  This is used to indicate to the task items that all tasks should be run.\n */\n\nimport { createClient, RedisClient } from 'redis';\nimport { promisify } from 'util';\n\nimport { config } from '../config';\nimport { logger } from '../logger';\nimport { publish } from '../redis';\n\n// a minute in milliseconds\nconst WORK_POLL_INTERVAL = 60 * 1000;\nconst REDIS_WORK_TRIGGER_CHANNEL = 'work_trigger';\nexport type REDIS_WORK_TRIGGER_TYPE = 'kick' | 'start' | 'reset' | 'stop';\n\nconst workItems = new Map<string, (tick: number) => Promise<void>>();\n\nexport function registerWorkItem(itemName: string, item: (tick: number) => Promise<void>) {\n  logger.info(`Registering work item ${itemName}`);\n  workItems.set(itemName, item);\n}\n\nlet tick = 0;\nlet lastTick: number | null = null;\n\nasync function processWorkItems() {\n  if (lastTick !== null) {\n    // Still actively processing\n    return;\n  }\n\n  lastTick = -1;\n  while (true) {\n    if (tick === lastTick) {\n      break;\n    }\n\n    logger.info(`Processing tick ${tick}.`);\n    for (const i of workItems.values()) {\n      await i(tick);\n    }\n    lastTick = tick;\n  }\n\n  lastTick = null;\n}\n\nlet intervalId: NodeJS.Timer;\n\nasync function startWorking() {\n  logger.info('Start processing ticks');\n  intervalId = setInterval(() => {\n    kickWorker('kick');\n  }, WORK_POLL_INTERVAL);\n}\n\nasync function stopWorking() {\n  logger.info('Stop processing ticks');\n  clearInterval(intervalId);\n}\n\nexport async function startWorker() {\n  const subscribeClient: RedisClient = createClient(config.get('redis_url'));\n  const subscribe = promisify(subscribeClient.subscribe).bind(subscribeClient);\n  subscribeClient.on('message', (_channel, message) => {\n    switch (message as REDIS_WORK_TRIGGER_TYPE) {\n      case 'start':\n        startWorking();\n        return;\n      case 'stop':\n        stopWorking();\n        return;\n      case 'reset':\n        tick = 0;\n    }\n    processWorkItems();\n    tick++;\n  });\n  await subscribe(REDIS_WORK_TRIGGER_CHANNEL);\n\n  startWorking();\n}\n\nexport async function kickWorker(type: REDIS_WORK_TRIGGER_TYPE) {\n  publish(REDIS_WORK_TRIGGER_CHANNEL, type);\n}\n\nexport async function runTask(task: string) {\n  const item = workItems.get(task);\n  if (item) {\n    await item(0);\n  }\n}\n"
  },
  {
    "path": "packages/backend-api/src/processor.ts",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { startProcessing } from './processing';\n\nstartProcessing();\n"
  },
  {
    "path": "packages/backend-api/src/redis.ts",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { createClient, RedisClient } from 'redis';\nimport { promisify } from 'util';\n\nimport { config } from './config';\n\nconst redisClient: RedisClient = createClient(config.get('redis_url'));\nexport const publish = promisify(redisClient.publish).bind(redisClient);\nexport const quit = promisify(redisClient.quit).bind(redisClient);\n"
  },
  {
    "path": "packages/backend-api/src/sequelize-config.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as Sequelize from 'sequelize';\n\nimport { config } from './config';\n\nexport const dialect = 'mysql';\nexport const host = config.get('database_host');\nexport const port = config.get('database_port');\nexport const database = config.get('database_name');\nexport const username = config.get('database_user');\nexport const password = config.get('database_password');\nexport const logging = false;\nconst socketPath = config.get('database_socket');\nexport const dialectOptions = socketPath && { socketPath };\n\nexport const mysqlConfig: Sequelize.Options = {\n  dialect,\n  logging,\n  host,\n  port,\n  define: {\n    charset: 'utf8',\n    collate: 'utf8_general_ci',\n  },\n  dialectOptions,\n};\n"
  },
  {
    "path": "packages/backend-api/src/sequelize-sync.ts",
    "content": "#!/usr/bin/env node\n\n/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\nexport * from './models';\nexport * from './sequelize';\n"
  },
  {
    "path": "packages/backend-api/src/sequelize.ts",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { Sequelize } from 'sequelize';\n\nimport { database, mysqlConfig, password, username } from './sequelize-config';\n\nexport const sequelize = new Sequelize(\n  database,\n  username,\n  password,\n  mysqlConfig,\n);\n"
  },
  {
    "path": "packages/backend-api/src/server-management.ts",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\nimport * as http from 'http';\nimport * as https from 'https';\n\nimport { destroyUpdateNotificationService } from './api/services/updateNotifications';\n\nlet server: https.Server | http.Server;\nlet init: () => void;\nlet closing = false;\n\nexport function registerServer(iserver: https.Server | http.Server) {\n  server = iserver;\n}\n\nexport function registerInit(iinit: () => void) {\n  init = iinit;\n}\n\nexport function restartService() {\n  if (closing) {\n    return;\n  }\n  closing = true;\n  console.log('*** closing server');\n  server.close(() => {\n    closing = false;\n    init();\n  });\n  destroyUpdateNotificationService();\n}\n"
  },
  {
    "path": "packages/backend-api/src/server.ts",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n/**\n * Core process for starting the server.\n * The server can run in two modes:\n *\n * - STANDALONE: The server will serve both the static files and the API\n *   entrypoints.\n *\n * - SPLIT: The static files are served from a separate server - e.g.,\n *   Webpack or S3.  In this case we just serve the API entrypoints.)\n *\n * It currently does this by looking at the two environment variables:\n *\n * - FRONTEND_URL: The URL used to fetch static files.\n *\n * - API_URL: The root URL of the API.\n *\n * If the latter is a sub-URL of the former, then we assume we are running in\n * STANDALONE mode.\n */\n\nimport * as expressWs from 'express-ws';\nimport { readFileSync } from 'fs';\nimport * as http from 'http';\nimport * as https from 'https';\n\nimport { mountWebFrontend } from '@conversationai/moderator-frontend-web';\n\nimport { mountAPI } from '.';\nimport { applyCommonPostprocessors, getExpressAppWithPreprocessors } from './api/util/server';\nimport { config } from './config';\nimport { logger } from './logger';\nimport { mountQueueDashboard } from './processing';\nimport { registerInit, registerServer } from './server-management';\n\nconst frontend_url = config.get('frontend_url');\nif (!frontend_url) {\n  logger.error('FRONTEND_URL is not defined!');\n  process.exit(1);\n}\n\nconst api_url = config.get('api_url');\nif (!api_url) {\n  logger.error('API_URL is not defined!');\n  process.exit(1);\n}\n\nconst STANDALONE = api_url.startsWith(frontend_url);\nconst pUrl = new URL(api_url);\nconst port = pUrl.port || (pUrl.protocol === 'https:' ? 443 : 80);\nconst path = pUrl.pathname;\n\nasync function init() {\n  const app = getExpressAppWithPreprocessors(false);\n  let server;\n\n  if (pUrl.protocol === 'https:') {\n    // We assume a LetsEncrypt generated key\n    const sslRoot = `/etc/letsencrypt/live/${pUrl.hostname}`;\n    const privateKey = readFileSync(`${sslRoot}/privkey.pem`, 'utf8');\n    const certificate = readFileSync(`${sslRoot}/fullchain.pem`, 'utf8');\n    const credentials = {key: privateKey, cert: certificate};\n    server = https.createServer(credentials, app);\n  }\n  else {\n    server = http.createServer(app);\n  }\n\n  expressWs(app, server);\n\n  if (config.get('env') === 'development') {\n    console.log('Publishing dev services.');\n    app.use('/queues', mountQueueDashboard());\n  }\n\n  // TODO: We may need to resurrect these entrypoints for external integration.\n  //       Not sure who will use the task API.\n  // app.use('/tasks', mountTaskAPI());\n\n  app.use(path, await mountAPI());\n\n  if (STANDALONE) {\n    console.log('Mounting web frontend');\n    app.use('/', mountWebFrontend());\n  }\n\n  applyCommonPostprocessors(app);\n\n  console.log(`Binding to ${pUrl.protocol} ${pUrl.hostname} : ${port}`);\n  await server.listen({host: '0.0.0.0', port});\n  console.log(`Started server in ${STANDALONE ? 'standalone' : 'API only'} mode`);\n\n  registerServer(server);\n}\n\nregisterInit(init);\ninit();\n"
  },
  {
    "path": "packages/backend-api/src/test/auth/providers/google.spec.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as chai from 'chai';\nimport { mapAuthDataToUser, mapAuthDataToUserSocialAuth } from '../../../auth/providers/google';\n\nconst assert = chai.assert;\n\n// tslint:disable\n\ndescribe('Auth Domain Google Tests', function() {\n  describe('mapAuthDataToUser', function() {\n    it('should map Google auth data to a User model data object', function() {\n      let fakeProfile: any = {\n        id: '110348421844561964015',\n        displayName: 'User Name',\n        name: {\n          familyName: 'Name',\n          givenName: 'User'\n        },\n        emails: [ { value: 'name@example.com', type: 'account' } ],\n        photos: [ { value: 'https://lh3.googleusercontent.com/-XdUIqdMkCWA/AAAAAAAAAAI/AAAAAAAAAAA/4252rscbv5M/photo.jpg?sz=50' } ],\n        gender: undefined,\n        provider: 'google',\n        _raw: '{\\n \"kind\": \"plus#person\",\\n \"etag\": \"\\\\\"xw0en60W6-NurXn4VBU-CMjSPEw/Tf5NtTfuatAoxmsxre4F8rjNgi0\\\\\"\",\\n \"emails\": [\\n  {\\n   \"value\": \"name@example.com\",\\n   \"type\": \"account\"\\n  }\\n ],\\n \"objectType\": \"person\",\\n \"id\": \"110348421844561964015\",\\n \"displayName\": \"User Name\",\\n \"name\": {\\n  \"familyName\": \"Name\",\\n  \"givenName\": \"User\"\\n },\\n \"image\": {\\n  \"url\": \"https://lh3.googleusercontent.com/-XdUIqdMkCWA/AAAAAAAAAAI/AAAAAAAAAAA/4252rscbv5M/photo.jpg?sz=50\",\\n  \"isDefault\": true\\n },\\n \"isPlusUser\": false,\\n \"language\": \"en\",\\n \"verified\": false,\\n \"domain\": \"example.com\"\\n}\\n',\n        _json: {\n          kind: 'plus#person',\n          etag: '\"xw0en60W6-NurXn4VBU-CMjSPEw/Tf5NtTfuatAoxmsxre4F8rjNgi0\"',\n          emails: [],\n          objectType: 'person',\n          id: '110348421844561964015',\n          displayName: 'User Name',\n          name: {\n            familyName: 'Name',\n            givenName: 'User'\n          },\n          image:\n          { url: 'https://lh3.googleusercontent.com/-XdUIqdMkCWA/AAAAAAAAAAI/AAAAAAAAAAA/4252rscbv5M/photo.jpg?sz=50',\n            isDefault: true },\n          isPlusUser: false,\n          language: 'en',\n          verified: false,\n          domain: 'example.com'\n        }\n      };\n\n      let expected = {\n        email: 'name@example.com',\n        name: 'User Name',\n      };\n\n      assert.deepEqual(mapAuthDataToUser(fakeProfile), expected);\n    });\n  });\n\n  describe('mapAuthDataToUserSocialAuth', function() {\n    it('should map Google auth data to a UserSocialAuth model data object', function() {\n      let fakeAccessToken = 'ya29.Ci8rA80F6ziadxHlRyhV9Buh84F2U5sOTtJDVernUYgsqTjdhOZUjw2sMwmbs8P1KA';\n      let fakeRefreshToken: any = undefined;\n      let fakeProfile: any = {\n        id: '110348421844561964015',\n        displayName: 'User Name',\n        name: {\n          familyName: 'Name',\n          givenName: 'User'\n        },\n        emails: [ { value: 'name@example.com', type: 'account' } ],\n        photos: [ { value: 'https://lh3.googleusercontent.com/-XdUIqdMkCWA/AAAAAAAAAAI/AAAAAAAAAAA/4252rscbv5M/photo.jpg?sz=50' } ],\n        gender: undefined,\n        provider: 'google',\n        _raw: '{\\n \"kind\": \"plus#person\",\\n \"etag\": \"\\\\\"xw0en60W6-NurXn4VBU-CMjSPEw/Tf5NtTfuatAoxmsxre4F8rjNgi0\\\\\"\",\\n \"emails\": [\\n  {\\n   \"value\": \"name@example.com\",\\n   \"type\": \"account\"\\n  }\\n ],\\n \"objectType\": \"person\",\\n \"id\": \"110348421844561964015\",\\n \"displayName\": \"User Name\",\\n \"name\": {\\n  \"familyName\": \"Name\",\\n  \"givenName\": \"User\"\\n },\\n \"image\": {\\n  \"url\": \"https://lh3.googleusercontent.com/-XdUIqdMkCWA/AAAAAAAAAAI/AAAAAAAAAAA/4252rscbv5M/photo.jpg?sz=50\",\\n  \"isDefault\": true\\n },\\n \"isPlusUser\": false,\\n \"language\": \"en\",\\n \"verified\": false,\\n \"domain\": \"example.com\"\\n}\\n',\n        _json: {\n          kind: 'plus#person',\n          etag: '\"xw0en60W6-NurXn4VBU-CMjSPEw/Tf5NtTfuatAoxmsxre4F8rjNgi0\"',\n          emails: [],\n          objectType: 'person',\n          id: '110348421844561964015',\n          displayName: 'User Name',\n          name: {\n            familyName: 'Name',\n            givenName: 'User'\n          },\n          image:\n          { url: 'https://lh3.googleusercontent.com/-XdUIqdMkCWA/AAAAAAAAAAI/AAAAAAAAAAA/4252rscbv5M/photo.jpg?sz=50',\n            isDefault: true },\n          isPlusUser: false,\n          language: 'en',\n          verified: false,\n          domain: 'example.com'\n        }\n      };\n\n      let expected = {\n        provider: 'google',\n        socialId: '110348421844561964015',\n        extra: {\n          accessToken: fakeAccessToken,\n          refreshToken: fakeRefreshToken,\n          profile: fakeProfile\n        }\n      };\n\n      assert.deepEqual(mapAuthDataToUserSocialAuth(fakeAccessToken, fakeRefreshToken, fakeProfile), expected);\n    });\n  });\n});\n"
  },
  {
    "path": "packages/backend-api/src/test/auth/tokens.spec.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as chai from 'chai';\nimport * as jwt from 'jsonwebtoken';\nimport * as moment from 'moment';\n\nimport {\n  createToken,\n  getTokenConfiguration,\n  isExpired,\n  ITokenConfiguration,\n  ITokenPayload,\n  refreshToken,\n  verifyToken,\n} from '../../auth/tokens';\nimport {\n  User,\n  USER_GROUP_ADMIN,\n  USER_GROUP_GENERAL,\n  USER_GROUP_SERVICE,\n} from '../../models';\nimport { makeUser } from '../fixture';\n\nconst assert = chai.assert;\n\ndescribe('Auth Domain Token Tests', () => {\n  let config: ITokenConfiguration;\n  before(async () => {\n    config = await getTokenConfiguration();\n  });\n\n  /**\n   * Return a timestamp before out expiration cutoff\n   */\n  function getExpiredTimestamp() {\n    return moment()\n      .subtract(config.expiration_minutes, 'minutes')\n      .subtract(1, 'second')\n      .unix();\n  }\n\n  let fakeGeneralUser: User;\n  let fakeAdminUser: User;\n  let fakeServiceUser: User;\n\n  beforeEach(async () => {\n    fakeGeneralUser = await makeUser({group: USER_GROUP_GENERAL});\n    fakeAdminUser = await makeUser({group: USER_GROUP_ADMIN});\n    fakeServiceUser = await makeUser({group: USER_GROUP_SERVICE});\n  });\n\n  afterEach(async () => {\n    await User.destroy({where: {}});\n  });\n\n  describe('isExpired', () => {\n    it('should return false for an unexpired token', async () => {\n      const fakeToken = {\n        iat: moment().unix(),\n        user: 1,\n      };\n\n      assert.isFalse(await isExpired(fakeGeneralUser, fakeToken));\n      assert.isFalse(await isExpired(fakeAdminUser, fakeToken));\n      assert.isFalse(await isExpired(fakeServiceUser, fakeToken));\n    });\n\n    it('should return true for an expired token', async () => {\n      const fakeToken = {\n        iat: getExpiredTimestamp(),\n        user: 1,\n      };\n\n      assert.isTrue(await isExpired(fakeGeneralUser, fakeToken));\n      assert.isTrue(await isExpired(fakeAdminUser, fakeToken));\n      assert.isFalse(await isExpired(fakeServiceUser, fakeToken));\n    });\n\n    it('should always return false for a user in the \"service\" group', async () => {\n      const fakeValidToken = {\n        iat: getExpiredTimestamp(),\n        user: 1,\n      };\n\n      const fakeExpiredToken = {\n        iat: getExpiredTimestamp(),\n        user: 1,\n      };\n\n      assert.isFalse(await isExpired(fakeServiceUser, fakeValidToken));\n      assert.isFalse(await isExpired(fakeServiceUser, fakeExpiredToken));\n    });\n  });\n\n  describe('create', () => {\n    it('should return a valid JWT token containing the user\\'s id', async () => {\n      const userId = 159;\n      const token = await createToken(userId);\n      assert.isNotNull(token);\n\n      assert.doesNotThrow(() => {\n        const decoded = jwt.verify(token, config.secret) as ITokenPayload;\n        assert.equal(decoded.user, userId);\n      });\n    });\n  });\n\n  describe('verifyToken', () => {\n    it('should return true for a valid token', async () => {\n      const userId = 784;\n      const validToken = jwt.sign({user: userId}, config.secret);\n      const verified = await verifyToken(validToken);\n      assert.isObject(verified);\n      if (verified) {\n        assert.equal(verified.user, userId);\n      }\n    });\n\n    it('should return false for a mismatched secret', async () => {\n      const userId = 298;\n      const invalidToken = jwt.sign({user: userId}, config.secret + 'a');\n      const verified = await verifyToken(invalidToken);\n      assert.isNull(verified);\n    });\n\n    it('should return false for a token with no user id', async () => {\n      const invalidToken = jwt.sign({}, config.secret);\n      const verified = await verifyToken(invalidToken);\n      assert.isNull(verified);\n    });\n  });\n\n  describe('refresh', () => {\n    /**\n     * Return a timestamp at least a second in the past, which makes JWT generate a different token\n     */\n    function getRefreshableTimestamp() {\n      return moment().subtract(1, 'second').unix();\n    }\n\n    it('should return a new token in exchange for a valid token', async () => {\n      const userId = 4238;\n\n      const token = jwt.sign({\n        iat: getRefreshableTimestamp(),\n        user: userId,\n      }, config.secret);\n\n      const refreshed = await refreshToken(token);\n      assert.isNotNull(refreshed);\n      if (refreshed) {\n        const decoded = jwt.verify(refreshed, config.secret) as ITokenPayload;\n\n        assert.isString(refreshed);\n        assert.isObject(decoded);\n        assert.equal(decoded.user, userId);\n        assert.notEqual(token, refreshed);\n      }\n    });\n\n    it('should return false for a mismatched secret', async () => {\n      const userId = 387;\n\n      const token = jwt.sign({\n        iat: getRefreshableTimestamp(),\n        user: userId,\n      }, config.secret + 'a');\n\n      const refreshed = await refreshToken(token);\n\n      assert.isNull(refreshed);\n    });\n\n    it('should return false for an expired token', async () => {\n      const userId = 387;\n\n      const token = jwt.sign({\n        iat: getExpiredTimestamp(),\n        user: userId,\n      }, config.secret + 'a');\n\n      const refreshed = await refreshToken(token);\n\n      assert.isNull(refreshed);\n    });\n\n    it('should return false for a token with no user id', async () => {\n      const token = jwt.sign({\n        iat: getRefreshableTimestamp(),\n      }, config.secret);\n\n      const refreshed = await refreshToken(token);\n\n      assert.isNull(refreshed);\n    });\n  });\n});\n"
  },
  {
    "path": "packages/backend-api/src/test/auth/users.spec.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as chai from 'chai';\nimport { UniqueConstraintError } from 'sequelize';\n\nimport {\n  ensureFirstUser,\n  findOrCreateUserSocialAuth,\n  isFirstUserInitialised,\n  isValidUser,\n} from '../../auth/users';\nimport { User, UserSocialAuth } from '../../models';\nimport { createUser } from '../domain/comments/fixture';\n\nconst assert = chai.assert;\n\n// tslint:disable\n\ndescribe('Auth Domain Users Tests', function() {\n  beforeEach(async () => {\n    await UserSocialAuth.destroy({where:{}});\n    await User.destroy({where:{}});\n  });\n\n  describe('isValidUser', function() {\n    it('should return true for an active user', async () => {\n      const activeUser = await User.create({\n        group: 'admin',\n        name: 'User Name',\n        email: 'email1@example.com',\n        isActive: true\n      });\n\n      assert.isTrue(isValidUser(activeUser));\n    });\n\n    it('should return false for an inactive user', async () => {\n      const inactiveUser = await User.create({\n        group: 'general',\n        name: 'User Name',\n        email: 'email2@example.com',\n        isActive: false\n      });\n\n      assert.isFalse(isValidUser(inactiveUser));\n    });\n  });\n\n  describe('findOrCreateUserSocialAuth', function() {\n    it('should create a non-existent social auth and relate it to the passed in user', async () => {\n      const userData = {\n        group: 'admin',\n        name: 'Daenerys Targaryen',\n        email: 'khaleesi@dothrakisea.com',\n        isActive: true\n      };\n\n      const userSocialAuthData = {\n        provider: 'google',\n        socialId: '123',\n        extra: {\n          accessToken: 'fdakjh48fh'\n        }\n      };\n\n      const createdUser = await User.create(userData);\n      const [userSocialAuth, created] = await findOrCreateUserSocialAuth(createdUser, userSocialAuthData);\n\n      assert.isTrue(created);\n      assert.equal(userSocialAuth.userId, createdUser.id);\n      assert.equal(userSocialAuth.provider, userSocialAuthData.provider);\n      assert.equal(userSocialAuth.socialId, userSocialAuthData.socialId);\n      assert.deepEqual(userSocialAuth.extra, userSocialAuthData.extra);\n    });\n\n    it('should resolve to an existing social auth if present', async () => {\n      const userData = {\n        group: 'general',\n        name: 'Arya Stark',\n        email: 'arya@manyfacedgod.com',\n        isActive: true\n      };\n\n      const userSocialAuthData = {\n        provider: 'google',\n        socialId: '456',\n        extra: {\n          accessToken: 'or8hbf7'\n        }\n      };\n\n      const createdUser = await User.create(userData);\n\n      const socialAuthData = {\n        ...userSocialAuthData,\n        userId: createdUser.id,\n      };\n\n      const createdSocialAuth = await UserSocialAuth.create(socialAuthData);\n\n      const [userSocialAuth, created] = await findOrCreateUserSocialAuth(createdUser, userSocialAuthData);\n\n      assert.isFalse(created);\n      assert.equal(userSocialAuth.id, createdSocialAuth.id);\n      assert.equal(userSocialAuth.userId, createdUser.id);\n      assert.equal(userSocialAuth.provider, userSocialAuthData.provider);\n      assert.equal(userSocialAuth.socialId, userSocialAuthData.socialId);\n      assert.deepEqual(userSocialAuth.extra, userSocialAuthData.extra);\n    });\n\n    it('should not allow multiple social auth records for the same user from the same provider', async () => {\n      const user1Data = {\n        group: 'general',\n        name: 'Sansa Stark',\n        email: 'sansa@stark.com',\n        isActive: true\n      };\n\n      const user2Data = {\n        group: 'general',\n        name: 'Theon Greyjoy',\n        email: 'theon@stark.com',\n        isActive: true\n      };\n\n      const userSocialAuthData1 = {\n        provider: 'google',\n        socialId: '456',\n        extra: {\n          accessToken: 'or8hbf7'\n        }\n      };\n\n      const userSocialAuthData2 = {\n        provider: userSocialAuthData1.provider,\n        socialId: userSocialAuthData1.socialId,\n        extra: {\n          accessToken: 'aflijoi8'\n        }\n      };\n\n      const createdUser1 = await User.create(user1Data);\n      const createdUser2 = await User.create(user2Data);\n\n      const [createdSocialAuth1, created] = await findOrCreateUserSocialAuth(createdUser1, userSocialAuthData1);\n\n      assert.isTrue(created);\n      assert.equal(createdUser1.id, createdSocialAuth1.userId);\n\n      try {\n        await findOrCreateUserSocialAuth(createdUser2, userSocialAuthData2);\n        assert(false, 'findOrCreateUserSocialAuth resolved successfully when it should have thrown a unique constraint error');\n      } catch (err) {\n        assert.instanceOf(err, UniqueConstraintError);\n      }\n    });\n  });\n\n  describe('Ensure availability of first admin user', () => {\n    const user1Data = {\n      group: 'admin',\n      name: 'Enabled Admin',\n      email: 'sansa@stark.com',\n      isActive: true\n    };\n\n    const user2Data = {\n      group: 'admin',\n      name: 'Disabled Admin',\n      email: 'theon@stark.com',\n      isActive: false\n    };\n\n    const user3Data = {\n      group: 'general',\n      name: 'Enabled ordinary usere',\n      email: 'arya@stark.com',\n      isActive: true\n    };\n\n    const createdUser = {\n      group: 'admin',\n      name: 'Administrator',\n      email: 'test@example.com',\n      isActive: true\n    };\n\n    async function assertUser(user: any) {\n      const dbu = (await User.findOne({where: {email: user.email}}))!;\n      assert.equal(dbu.name, user.name);\n      assert.equal(dbu.group, user.group);\n      assert.equal(dbu.isActive, user.isActive);\n    }\n\n    it('Make sure nothing happens if we already have a first user', async () => {\n      for (const u of [user1Data, user2Data, user3Data]) {\n        await createUser(u);\n      }\n\n      assert.equal(await User.count({where: {}}), 3, 'users created');\n      assert.equal(await User.count({where: {isActive: true}}), 2, 'users active');\n      assert.equal(await User.count({where: {group: 'admin'}}), 2, 'users admin');\n      assert.equal(await User.count({where: {group: 'admin', isActive: true}}), 1, 'users active admin');\n\n      assert.isTrue(await isFirstUserInitialised());\n      await ensureFirstUser(createdUser);\n\n      const count = await User.count({where: {}});\n      assert.equal(count, 3, 'same number of users');\n      for (const u of [user1Data, user2Data, user3Data]) {\n        await assertUser(u);\n      }\n    });\n\n    it('Make sure we create a new user when currently no admin', async () => {\n      for (const u of [user2Data, user3Data]) {\n        await createUser(u);\n      }\n\n      assert.isFalse(await isFirstUserInitialised());\n      await ensureFirstUser(createdUser);\n\n      const count = await User.count({where: {}});\n      assert.equal(count, 3);\n      for (const u of [createdUser, user2Data, user3Data]) {\n        await assertUser(u);\n      }\n    });\n\n    it('Make sure we enable disabled admin user', async () => {\n      for (const u of [user2Data, user3Data]) {\n        await createUser(u);\n      }\n\n      assert.isFalse(await isFirstUserInitialised());\n      await ensureFirstUser(user2Data);\n\n      const count = await User.count({where: {}});\n      assert.equal(count, 2);\n      await assertUser(user3Data);\n      await assertUser({\n        group: 'admin',\n        name: user2Data.name,\n        email: user2Data.email,\n        isActive: true\n      });\n    });\n\n    it('Make sure we upgrade a general user to admin user', async () => {\n      for (const u of [user2Data, user3Data]) {\n        await createUser(u);\n      }\n\n      assert.isFalse(await isFirstUserInitialised());\n      await ensureFirstUser(user3Data);\n\n      const count = await User.count({where: {}});\n      assert.equal(count, 2);\n      await assertUser(user2Data);\n      await assertUser({\n        group: 'admin',\n        name: user3Data.name,\n        email: user3Data.email,\n        isActive: true\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "packages/backend-api/src/test/domain/comments/fixture.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as faker from 'faker';\nimport { random, sample } from 'lodash';\nimport { fn } from 'sequelize';\nimport { underscored } from 'underscore.string';\n\nimport {\n  Article,\n  Category,\n  Comment,\n  CommentScore,\n  CommentScoreRequest,\n  CommentSummaryScore,\n  ModerationRule,\n  RESET_COUNTS,\n  Tag,\n  User,\n} from '../../../models';\nimport {\n  ENDPOINT_TYPE_API,\n  MODERATION_RULE_ACTION_TYPES,\n  SCORE_SOURCE_TYPES,\n  USER_GROUP_MODERATOR,\n  USER_GROUP_SERVICE,\n} from '../../../models';\n\nexport interface IAttributes {\n  [key: string]: any;\n}\n\n// Category\nexport function getCategoryData(data: IAttributes = {}): IAttributes {\n  return {\n    label: faker.lorem.words(1),\n    ...RESET_COUNTS,\n    ...data,\n  };\n}\n\nexport async function createCategory(obj: Partial<IAttributes> = {}): Promise<Category> {\n  return Category.create(getCategoryData(obj));\n}\n\n// Articles\nexport function getArticleData(data: Partial<IAttributes> = {}): IAttributes {\n  return {\n    sourceId: faker.datatype.uuid(),\n    title: faker.lorem.words(20),\n    text: faker.lorem.words(20),\n    url: faker.internet.url(),\n    sourceCreatedAt: new Date('2012-10-29T21:54:07.609Z'),\n    isCommentingEnabled: true,\n    isAutoModerated: true,\n    ...RESET_COUNTS,\n    ...data,\n  };\n}\n\nexport async function createArticle(obj: Partial<IAttributes> = {}): Promise<Article> {\n  return Article.create(getArticleData(obj));\n}\n\nexport function getCommentData(data: Partial<IAttributes> = {}): IAttributes {\n  return {\n    sourceId: faker.datatype.uuid(),\n    authorSourceId: faker.datatype.uuid(),\n    text: faker.lorem.words(20),\n    author: {},\n    sourceCreatedAt: fn('now'),\n    ...data,\n  } as IAttributes;\n}\n\nexport async function createComment(data?: any): Promise<Comment> {\n  return Comment.create(getCommentData(data));\n}\n\n// Comment score requests\n\nexport function getCommentScoreRequestData(data: Partial<IAttributes> = {}): IAttributes {\n  return {\n    commentId: faker.datatype.number(),\n    userId: faker.datatype.number(),\n    sentAt: fn('now'),\n\n    ...data,\n  };\n}\n\nexport async function createCommentScoreRequest(data?: object): Promise<CommentScoreRequest> {\n  return CommentScoreRequest.create(getCommentScoreRequestData(data));\n}\n\n// Users\n\nexport async function createUser(data: IAttributes = {}): Promise<User> {\n  return User.create({\n    group: 'general',\n    email: faker.internet.email(),\n    name: faker.name.firstName(),\n    isActive: true,\n    ...data,\n  });\n}\n\nexport async function createServiceUser(data: IAttributes = {}): Promise<User> {\n  return User.create({\n    group: USER_GROUP_SERVICE,\n    name: faker.name.firstName(),\n    isActive: true,\n    ...data,\n  });\n}\n\nexport async function createModeratorUser(data: IAttributes = {}): Promise<User> {\n  return User.create({\n    group: USER_GROUP_MODERATOR,\n    name: faker.name.firstName(),\n    isActive: true,\n    extra: {\n      endpointType: ENDPOINT_TYPE_API,\n      endpoint: 'http://www.google.com',\n      apiKey: 'sdf',\n    },\n    ...data,\n  });\n}\n\n// Comment scores\n\nexport function getCommentScoreData(data: IAttributes = {}): IAttributes {\n  return {\n    commentId: faker.datatype.number(),\n    tagId: faker.datatype.number(),\n    sourceType: sample(SCORE_SOURCE_TYPES),\n    score: (random(0, 100) / 100),\n    ...data,\n    // TODO(ldixon): fix typehack.\n  } as any;\n}\n\nexport async function createCommentScore(data?: object): Promise<CommentScore> {\n  return CommentScore.create(getCommentScoreData(data));\n}\n\n// Comment summary scores\n\nexport function getCommentSummaryScoreData(data: IAttributes = {}): IAttributes {\n  return {\n    commentId: faker.datatype.number(),\n    tagId: faker.datatype.number(),\n    score: (random(0, 100) / 100),\n    ...data,\n  };\n}\n\nexport async function createCommentSummaryScore(data?: object): Promise<CommentSummaryScore> {\n  return CommentSummaryScore.create(getCommentSummaryScoreData(data));\n}\n\n// Moderation rules\n\nexport function getModerationRuleData(data: IAttributes = {}): IAttributes {\n  const lowerThreshold = (random(0, 100) / 100);\n  const upperThreshold = (random(lowerThreshold * 100, 100) / 100);\n\n  return {\n    tagId: faker.datatype.number(),\n    action: sample(MODERATION_RULE_ACTION_TYPES),\n    lowerThreshold,\n    upperThreshold,\n    ...data,\n  };\n}\n\nexport async function createModerationRule(data?: object): Promise<ModerationRule> {\n  return ModerationRule.create(getModerationRuleData(data));\n}\n\n// Tags\n\nexport function getTagData(data: IAttributes = {}): IAttributes {\n  const tagLabel = faker.lorem.words(2);\n  const tagKey = underscored(tagLabel);\n\n  return {\n    key: tagKey,\n    label: tagLabel,\n    ...data,\n  };\n}\n\nexport async function createTag(data?: object): Promise<Tag> {\n  return Tag.create(getTagData(data));\n}\n"
  },
  {
    "path": "packages/backend-api/src/test/domain/comments/textSizes.spec.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as chai from 'chai';\n\nimport {\n  cacheTextSize,\n} from '../../../domain';\nimport {\n  CommentSize,\n} from '../../../models';\nimport {\n  createArticle,\n  createComment,\n} from './fixture';\n\n// tslint:disable no-import-side-effect\nimport '../../test_helper';\n// tslint:enable no-import-side-effect\n\nconst assert = chai.assert;\n\ndescribe('Comments Domain Text Sizing Tests', () => {\n  describe('cacheTextSize', () => {\n    it('should return false for no score requests', async () => {\n      const article = await createArticle();\n      const comment = await createComment({\n        articleId: article.id,\n        text: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.',\n      });\n\n      const width = 600;\n      const height = await cacheTextSize(comment, width);\n\n      assert.equal(height, 144);\n\n      const commentSizes = await CommentSize.count({\n        where: {\n          commentId: comment.id,\n          width,\n          height,\n        },\n      });\n\n      assert.equal(commentSizes, 1);\n    });\n  });\n});\n"
  },
  {
    "path": "packages/backend-api/src/test/domain/topScores/calculateTopScores.spec.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\nimport { expect } from 'chai';\n\nimport { calculateTopScore } from '../../../domain/commentScores';\nimport { CommentScore } from '../../../models';\n\n// tslint:disable no-import-side-effect\nimport '../../test_helper';\n// tslint:enable no-import-side-effect\n\ndescribe('calculateTopScore', () => {\n  it('finds the higest score with a range', async () => {\n    const commentScores = [\n      CommentScore.build({ commentId: 1, tagId: 1, sourceType: 'Machine', score: 1, annotationStart: null, annotationEnd: null }),\n      CommentScore.build({ commentId: 2, tagId: 1, sourceType: 'Machine', score: 0.75, annotationStart: 0, annotationEnd: 1 }),\n      CommentScore.build({ commentId: 3, tagId: 1, sourceType: 'Machine', score: 0.5, annotationStart: 2, annotationEnd: 3 }),\n      CommentScore.build({ commentId: 4, tagId: 1, sourceType: 'Machine', score: 0.0, annotationStart: 1, annotationEnd: 2 }),\n    ];\n\n    const topScore = calculateTopScore(commentScores);\n\n    expect(topScore).to.be.equal(commentScores[1]);\n  });\n\n  it('returns null if no score has a range', async () => {\n    const topScore = calculateTopScore([\n      CommentScore.build({ commentId: 1, tagId: 1, sourceType: 'Machine', score: 1, annotationStart: null, annotationEnd: null }),\n    ]);\n\n    expect(topScore).to.be.null;\n  });\n});\n"
  },
  {
    "path": "packages/backend-api/src/test/fixture.ts",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as chai from 'chai';\nimport * as WebSocket from 'ws';\n\nimport {\n  CommentTopScore,\n  MODERATION_ACTION_ACCEPT,\n  RESET_COUNTS,\n} from '../models';\nimport {\n  Article,\n  Category,\n  Comment,\n  CommentFlag,\n  CommentScore,\n  CommentSummaryScore,\n  ModerationRule,\n  Preselect,\n  Tag,\n  TaggingSensitivity,\n  User,\n} from '../models';\n\nconst expect = chai.expect;\nexport { expect };\n\nlet articleCounter = 0;\nexport async function makeArticle(obj = {}): Promise<Article> {\n  return Article.create({\n    sourceId: `something ${articleCounter++}`,\n    title: 'An article',\n    text: 'Text',\n    url: 'https://example.com',\n    sourceCreatedAt: new Date('2012-10-29T21:54:07.609Z'),\n    isCommentingEnabled: true,\n    isAutoModerated: true,\n    ...RESET_COUNTS,\n    ...obj,\n  });\n}\n\nexport async function makeUser(obj = {}): Promise<User> {\n  return User.create({\n    group: 'general',\n    name: 'Name',\n    email: 'email@example.com',\n    isActive: true,\n    ...obj,\n  });\n}\n\nexport async function makeTag(obj = {}) {\n  return Tag.create({\n    key: 'test',\n    label: 'Test',\n    ...obj,\n  });\n}\n\nexport async function makeTaggingSensitivity(obj = {}): Promise<TaggingSensitivity> {\n  return TaggingSensitivity.create({\n    lowerThreshold: 0,\n    upperThreshold: 1,\n    ...obj,\n  });\n}\n\nlet commentCounter = 0;\nexport async function makeComment(obj = {}): Promise<Comment> {\n  return Comment.create({\n    articleId: null,\n    sourceId: `something ${commentCounter++}`,\n    authorSourceId: 'something',\n    text: 'words',\n    author: {\n      name: 'Joe Bloggs',\n    },\n    unresolvedFlagsCount: 0,\n    sourceCreatedAt: new Date('2012-10-29T21:54:07.609Z'),\n    isScored: true,\n    ...obj,\n  });\n}\n\nexport async function makeCommentScore(obj = {}): Promise<CommentScore> {\n  return CommentScore.create({\n    commentId: null,\n    tagId: null,\n    score: 1,\n    sourceType: 'Machine',\n    annotationStart: null,\n    annotationEnd: null,\n    ...obj,\n  });\n}\n\nexport async function makeCommentTopScore(score: CommentScore): Promise<void> {\n  await CommentTopScore.create({\n    commentId: score.commentId as number,\n    tagId: score.tagId as number,\n    commentScoreId: score.id as number,\n  });\n}\n\nexport async function makeCommentSummaryScore(\n  obj: Partial<Pick<CommentSummaryScore, 'commentId' | 'tagId' | 'score' | 'isConfirmed'>>,\n): Promise<CommentSummaryScore> {\n  return CommentSummaryScore.create({\n    score: 1,\n    ...obj,\n  });\n}\n\nexport async function makeCategory(obj = {}): Promise<Category> {\n  return Category.create({\n    label: 'something',\n    ...RESET_COUNTS,\n    ...obj,\n  });\n}\n\nexport async function makeRule(tag: Tag, obj = {}): Promise<ModerationRule> {\n  return ModerationRule.create({\n    tagId: tag.id,\n    lowerThreshold: 0,\n    upperThreshold: 1,\n    action: MODERATION_ACTION_ACCEPT,\n    ...obj,\n  });\n}\n\nexport async function makePreselect(obj = {}): Promise<Preselect> {\n  return Preselect.create({\n    lowerThreshold: 0,\n    upperThreshold: 1,\n    ...obj,\n  });\n}\n\nexport async function makeFlag(obj: {}): Promise<CommentFlag> {\n  return CommentFlag.create({\n    label: 'test flag',\n    isResolved: false,\n    ...obj,\n  } as any);\n}\n\nexport async function sleep(ms: number) {\n  return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\nexport function assertSystemMessage(body: any) {\n  expect(body.type).eq('system');\n}\nexport function assertGlobalMessage(body: any) {\n  expect(body.type).eq('global');\n}\nexport function assertArticleUpdateMessage(body: any) {\n  expect(body.type).eq('article-update');\n}\nexport function assertUserMessage(body: any) {\n  expect(body.type).eq('user');\n}\n\nexport async function listenForMessages(\n  action: () => Promise<void>,\n  results: Array<(message: any) => void>,\n): Promise<void> {\n  let id: NodeJS.Timer;\n\n  const timeout = new Promise((_, reject) => {\n    id = setTimeout(() => {\n      reject(new Error('Timed out while waiting for notification'));\n    }, 1000);\n  });\n\n  const p = new Promise<void>((resolve, reject) => {\n    const socket = new WebSocket('ws://localhost:3000/services/updates/summary');\n\n    socket.onclose = () => {\n      if (results.length !== 0) {\n        reject('Not received enough messages');\n      }\n    };\n\n    socket.onmessage = (m: any) => {\n      try {\n        const body: any = JSON.parse(m.data as string);\n        const r = results.shift();\n        if (r) {\n          r(body);\n        }\n        if (results.length === 0) {\n          resolve();\n        }\n      }\n      catch (e) {\n        reject(e);\n      }\n    };\n  });\n\n  await sleep(100);\n  await action();\n\n  await Promise.race([\n    timeout,\n    p,\n  ]);\n  clearTimeout(id!);\n}\n"
  },
  {
    "path": "packages/backend-api/src/test/integration/api/actions.spec.ts",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as chai from 'chai';\n\nimport {\n  denormalizeCommentCountsForArticle,\n  denormalizeCountsForComment,\n} from '../../../domain';\nimport {\n  Article,\n  Category,\n  Comment,\n  CommentFlag,\n  User,\n} from '../../../models';\nimport {\n  expect,\n  makeArticle,\n  makeComment,\n  makeFlag,\n  makeUser,\n} from '../../fixture';\nimport { app, setAuthenticatedUser } from './test_helper';\n\nconst BASE_URL = `/services/commentActions`;\n\ndescribe(BASE_URL, () => {\n  let article: Article;\n  let comment1: Comment;\n  let comment2: Comment;\n  let user: User;\n  let user2: User;\n  let unresolved1: CommentFlag;\n  let unresolved2: CommentFlag;\n  let resolved1: CommentFlag;\n  let unresolved3: CommentFlag;\n\n  async function checkArticle(\n    x: string,\n    newCount: number,\n    approvedCount: number,\n    rejectedCount: number,\n    flagsCount: number,\n  ) {\n    const a1 = (await Article.findByPk(article.id))!;\n    expect(a1.unmoderatedCount, `${x} article newCount`).equal(newCount);\n    expect(a1.approvedCount, `${x} article approvedCount`).equal(approvedCount);\n    expect(a1.rejectedCount, `${x} article rejectedCount`).equal(rejectedCount);\n    expect(a1.flaggedCount, `${x} article flaggedCount`).equal(flagsCount);\n  }\n\n  async function checkComment(\n    x: string,\n    id: number,\n    state: 'new' | 'accepted' | 'rejected',\n    unresolved: number,\n    summary: {[key: string]: Array<number>},\n  ) {\n    const c = (await Comment.findByPk(id))!;\n    if (state === 'new') {\n      expect(c.isModerated, `${x} comment ${id} is moderated`).equal(false);\n    }\n    else {\n      expect(c.isModerated, `${x} comment ${id} is moderated`).equal(true);\n      expect(c.isAccepted,  `${x} comment ${id} is accepted`).equal(state === 'accepted');\n    }\n\n    expect(c.unresolvedFlagsCount, `${x} comment ${id} unresolved`).equals(unresolved);\n    const s = c.flagsSummary;\n    expect(s, `${x} comment ${id} summary`).deep.equal(summary);\n  }\n\n  async function checkFlag(x: string, id: number, resolved: boolean, resolvedById: number | null) {\n    const f = (await CommentFlag.findByPk(id))!;\n    expect(f.isResolved, `${x} flag ${id} isResolved`).equal(resolved);\n    expect(f.resolvedById, `${x} flag ${id} resolvedById`).equal(resolvedById);\n  }\n\n  async function actOnAComment(url: string) {\n    const apiClient = chai.request(app);\n    const {status} = await apiClient.post(url).send({\n      data: [{commentId: comment1.id.toString()}],\n      runImmediately: true,\n    });\n    expect(status).equal(200);\n  }\n\n  beforeEach(async () => {\n    article = await makeArticle({});\n    comment1 = await makeComment({articleId: article.id, isModerated: false});\n    comment2 = await makeComment({articleId: article.id, isModerated: true, isAccepted: true});\n    user = await makeUser();\n    setAuthenticatedUser(user);\n    user2 = await makeUser({email: 'other@example.com'});\n    unresolved1 = await makeFlag({commentId: comment1.id, label: 'unresolved 1'});\n    unresolved2 = await makeFlag({commentId: comment1.id, label: 'unresolved 2', isRecommendation: true});\n    resolved1 = await makeFlag({commentId: comment1.id, label: 'resolved 1', isResolved: true, resolvedById: user2.id });\n    unresolved3 = await makeFlag({commentId: comment2.id, label: 'unresolved 3'});\n    await denormalizeCountsForComment(comment1);\n    await denormalizeCountsForComment(comment2);\n    await denormalizeCommentCountsForArticle(article, false);\n  });\n\n  afterEach(async () => {\n    await CommentFlag.destroy({where: {}});\n    await Comment.destroy({where: {}});\n    await Article.destroy({where: {}});\n    await Category.destroy({where: {}});\n    await User.destroy({where: {}});\n  });\n\n  it('approve comment then flags', async () => {\n    await checkArticle('a', 1, 1, 0, 2);\n    await checkComment('a1', comment1.id, 'new', 2, {\n      'unresolved 1': [1, 1, 0],\n      'unresolved 2': [1, 1, 1],\n      'resolved 1': [1, 0, 0],\n    });\n    await checkComment('a2', comment2.id, 'accepted', 1, {\n      'unresolved 3': [1, 1, 0],\n    });\n    await checkFlag('a1', unresolved1.id, false, null);\n    await checkFlag('a2', unresolved2.id, false, null);\n    await checkFlag('a3', resolved1.id, true, user2.id);\n    await checkFlag('a4', unresolved3.id, false, null);\n\n    await actOnAComment(`${BASE_URL}/approve`);\n    await checkArticle('b', 0, 2, 0, 2);\n    await checkComment('b1', comment1.id, 'accepted', 2, {\n      'unresolved 1': [1, 1, 0],\n      'unresolved 2': [1, 1, 1],\n      'resolved 1': [1, 0, 0],\n    });\n    await checkComment('b2', comment2.id, 'accepted', 1, {\n      'unresolved 3': [1, 1, 0],\n    });\n    await checkFlag('b1', unresolved1.id, false, null);\n    await checkFlag('b2', unresolved2.id, false, null);\n    await checkFlag('b3', resolved1.id, true, user2.id);\n    await checkFlag('b4', unresolved3.id, false, null);\n\n    await actOnAComment(`${BASE_URL}/approve-flags`);\n    await checkArticle('c', 0, 2, 0, 1);\n    await checkComment('c1', comment1.id, 'accepted', 0, {\n      'unresolved 1': [1, 0, 0],\n      'unresolved 2': [1, 0, 1],\n      'resolved 1': [1, 0, 0],\n    });\n    await checkComment('c2', comment2.id, 'accepted', 1, {\n      'unresolved 3': [1, 1, 0],\n    });\n    await checkFlag('c1', unresolved1.id, true, user.id);\n    await checkFlag('c2', unresolved2.id, true, user.id);\n    await checkFlag('c3', resolved1.id, true, user2.id);\n    await checkFlag('c4', unresolved3.id, false, null);\n  });\n\n  it('approve comment and flags', async () => {\n    await actOnAComment(`${BASE_URL}/approve-flags`);\n    await checkArticle('c', 0, 2, 0, 1);\n    await checkComment('c', comment1.id, 'accepted', 0, {\n      'unresolved 1': [1, 0, 0],\n      'unresolved 2': [1, 0, 1],\n      'resolved 1': [1, 0, 0],\n    });\n    await checkFlag('c1', unresolved1.id, true, user.id);\n    await checkFlag('c2', unresolved2.id, true, user.id);\n  });\n\n  it('reject comment, then approve.', async () => {\n    await actOnAComment(`${BASE_URL}/reject`);\n    await checkArticle('a', 0, 1, 1, 1);\n    await checkComment('a', comment1.id, 'rejected', 2, {\n      'unresolved 1': [1, 1, 0],\n      'unresolved 2': [1, 1, 1],\n      'resolved 1': [1, 0, 0],\n    });\n    await checkFlag('a1', unresolved1.id, false, null);\n    await checkFlag('a2', unresolved2.id, false, null);\n    await checkFlag('a3', resolved1.id, true, user2.id);\n\n    await actOnAComment(`${BASE_URL}/approve`);\n    await checkArticle('b', 0, 2, 0, 2);\n    await checkComment('b', comment1.id, 'accepted', 2, {\n      'unresolved 1': [1, 1, 0],\n      'unresolved 2': [1, 1, 1],\n      'resolved 1': [1, 0, 0],\n    });\n    await checkFlag('b1', unresolved1.id, false, null);\n    await checkFlag('b2', unresolved2.id, false, null);\n    await checkFlag('b3', resolved1.id, true, user2.id);\n  });\n\n  it('reject all comments and flags', async () => {\n    const url = `${BASE_URL}/reject-flags/`;\n    const apiClient = chai.request(app);\n    const {status} = await apiClient.post(url).send(\n      {\n        data: [\n          { commentId: comment1.id.toString() },\n          { commentId: comment2.id.toString() },\n        ],\n        runImmediately: true },\n    );\n    expect(status).equal(200);\n    await checkArticle('c', 0, 0, 2, 0);\n    await checkComment('c1', comment1.id, 'rejected', 0, {\n      'unresolved 1': [1, 0, 0],\n      'unresolved 2': [1, 0, 1],\n      'resolved 1': [1, 0, 0],\n    });\n    await checkComment('c2', comment2.id, 'rejected', 0, {\n      'unresolved 3': [1, 0, 0],\n    });\n    await checkFlag('c1', unresolved1.id, true, user.id);\n    await checkFlag('c2', unresolved2.id, true, user.id);\n    await checkFlag('c3', resolved1.id, true, user2.id);\n    await checkFlag('c4', unresolved3.id, true, user.id);\n  });\n\n  it('resolve flags', async () => {\n    const url = `${BASE_URL}/resolve-flags/`;\n    const apiClient = chai.request(app);\n    const {status} = await apiClient.post(url).send(\n      {\n        data: [\n          { commentId: comment1.id.toString() },\n          { commentId: comment2.id.toString() },\n        ],\n        runImmediately: true },\n    );\n    expect(status).equal(200);\n    await checkArticle('c', 1, 1, 0, 0);\n    await checkComment('c1', comment1.id, 'new', 0, {\n      'unresolved 1': [1, 0, 0],\n      'unresolved 2': [1, 0, 1],\n      'resolved 1': [1, 0, 0],\n    });\n    await checkComment('c2', comment2.id, 'accepted', 0, {\n      'unresolved 3': [1, 0, 0],\n    });\n    await checkFlag('c1', unresolved1.id, true, user.id);\n    await checkFlag('c2', unresolved2.id, true, user.id);\n    await checkFlag('c3', resolved1.id, true, user2.id);\n    await checkFlag('c4', unresolved3.id, true, user.id);\n  });\n});\n"
  },
  {
    "path": "packages/backend-api/src/test/integration/api/assignments.spec.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as chai from 'chai';\n\nimport { REPLY_SUCCESS_VALUE } from '../../../api/constants';\nimport {\n  denormalizeCommentCountsForArticle,\n} from '../../../domain';\nimport {\n  Article,\n  Category,\n  Comment,\n  ModeratorAssignment,\n  User,\n  UserCategoryAssignment,\n} from '../../../models';\nimport {\n  expect,\n  makeArticle,\n  makeCategory,\n  makeComment,\n  makeUser,\n} from '../../fixture';\nimport {\n  app,\n} from './test_helper';\n\nconst BASE_URL = `/services/assignments`;\n\ndescribe(BASE_URL, () => {\n  let category: Category;\n  let article: Article;\n  let user: User;\n\n  beforeEach(async () => {\n    category = await makeCategory();\n    article = await makeArticle({categoryId: category.id});\n    await makeComment({articleId: article.id});\n    denormalizeCommentCountsForArticle(article, false);\n    user = await makeUser();\n  });\n\n  afterEach(async () => {\n    await ModeratorAssignment.destroy({where: {}});\n    await UserCategoryAssignment.destroy({where: {}});\n    await Comment.destroy({where: {}});\n    await Article.destroy({where: {}});\n    await Category.destroy({where: {}});\n    await User.destroy({where: {}});\n  });\n\n  describe('/categories/:id', () => {\n    const url = `${BASE_URL}/categories/:id`;\n    it('Assign a moderator to a category', async () => {\n      const bca = await UserCategoryAssignment.findAndCountAll({where: {}});\n      expect(bca.count).to.be.equal(0);\n      const baa = await ModeratorAssignment.findAndCountAll({where: {}});\n      expect(baa.count).to.be.equal(0);\n\n      {\n        const apiClient = chai.request(app);\n        const {status, body} = await apiClient.post(url.replace(':id', category.id.toString())).send({data: [user.id]});\n        expect(status).to.be.equal(200);\n        expect(body.status).to.be.equal(REPLY_SUCCESS_VALUE);\n\n        const aca = await UserCategoryAssignment.findAndCountAll({where: {}});\n        expect(aca.count).to.be.equal(1);\n        const aaa = await ModeratorAssignment.findAndCountAll({where: {}});\n        expect(aaa.count).to.be.equal(1);\n      }\n\n      {\n        const apiClient = chai.request(app);\n        const {status, body} = await apiClient.post(url.replace(':id', category.id.toString())).send({data: []});\n        expect(status).to.be.equal(200);\n        expect(body.status).to.be.equal(REPLY_SUCCESS_VALUE);\n\n        const aca = await UserCategoryAssignment.findAndCountAll({where: {}});\n        expect(aca.count).to.be.equal(0);\n        const aaa = await ModeratorAssignment.findAndCountAll({where: {}});\n        expect(aaa.count).to.be.equal(0);\n      }\n    });\n  });\n});\n"
  },
  {
    "path": "packages/backend-api/src/test/integration/api/assistant.spec.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as chai from 'chai';\nimport { fn } from 'sequelize';\n\nimport {\n  Comment,\n  CommentScoreRequest,\n  User,\n} from '../../../models';\nimport {\n  expect,\n  makeComment,\n  makeUser,\n} from '../../fixture';\nimport {\n  app,\n} from './test_helper';\n\nconst BASE_URL = `/assistant`;\nconst prefixed = `${BASE_URL}/`;\n\ndescribe(prefixed, () => {\n  const url = `${prefixed}scores/:id`;\n  let csRequest: CommentScoreRequest;\n  let score: {score: number, begin: number, end: number};\n\n  describe('/scores/:id', () => {\n    beforeEach(async () => {\n      const comment = await makeComment();\n      const user = await makeUser();\n\n      csRequest = await CommentScoreRequest.create({\n        commentId: comment.id,\n        userId: user.id,\n        sentAt: fn('now'),\n      });\n\n      score = {\n        score: 1,\n        begin: 0,\n        end: 1,\n      };\n    });\n    afterEach(async () => {\n      await CommentScoreRequest.destroy({where: {}});\n      await Comment.destroy({where: {}});\n      await User.destroy({where: {}});\n    });\n\n    it('should return success with valid commentScoreRequest', async () => {\n      let was200 = false;\n\n      try {\n        const apiClient = chai.request(app);\n\n        const { status } = await apiClient.post(url.replace(':id', csRequest.id.toString())).send({\n          scores: {\n            SCORE_TAG: [score],\n          },\n          summaryScores: {\n            SCORE_TAG: score.score,\n          },\n        });\n\n        expect(status).to.be.equal(200);\n        was200 = true;\n      } finally {\n        expect(was200).to.be.true;\n      }\n    });\n\n    it('should fail with invalid schema of score data', async () => {\n      let was422 = false;\n\n      try {\n        const apiClient = chai.request(app);\n\n        const { status } = await apiClient.post(url.replace(':id', csRequest.id.toString())).send({\n          scores: {\n            SCORE_TAG: score, // should be an array\n          },\n          summaryScores: {\n            SCORE_TAG: score.score,\n          },\n        });\n        expect(status).to.be.equal(422);\n        was422 = true;\n      } catch (e) {\n        console.log(e);\n      } finally {\n        expect(was422).to.be.true;\n      }\n    });\n\n    it('should fail without summary score data', async () => {\n      let was422 = false;\n\n      try {\n        const apiClient = chai.request(app);\n\n        const { status } = await apiClient.post(url.replace(':id', csRequest.id.toString())).send({\n          scores: {\n            SCORE_TAG: [score],\n          },\n          // Below is missing on purpose.\n          // summaryScores: {\n          //   SCORE_TAG: score.score,\n          // },\n        });\n        expect(status).to.be.equal(422);\n        was422 = true;\n      } catch (e) {\n        console.log(e);\n      } finally {\n        expect(was422).to.be.true;\n      }\n    });\n\n    it('should fail with invalid comment score id', async () => {\n      let was400 = false;\n\n      try {\n        const apiClient = chai.request(app);\n\n        const { status } = await apiClient.post(url.replace(':id', 'fake')).send({\n          scores: {\n            SCORE_TAG: [score],\n          },\n          summaryScores: {\n            SCORE_TAG: score.score,\n          },\n        });\n        expect(status).to.be.equal(400);\n        was400 = true;\n      } catch (e) {\n        console.log(e);\n      } finally {\n        expect(was400).to.be.true;\n      }\n    });\n  });\n});\n"
  },
  {
    "path": "packages/backend-api/src/test/integration/api/authorCounts.spec.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as chai from 'chai';\n\nimport {\n  Comment,\n  CommentScoreRequest,\n} from '../../../models';\nimport {\n  expect,\n  makeComment,\n} from '../../fixture';\nimport {\n  app,\n} from './test_helper';\n\nconst BASE_URL = `/services/authorCounts`;\n\nasync function fakeAuthor(authorSourceId: string, approvedCount: number, rejectedCount: number, otherCount: number) {\n  for (let i = 0; i < approvedCount; i++) {\n    await makeComment({ authorSourceId, isAccepted: true });\n  }\n\n  for (let i = 0; i < rejectedCount; i++) {\n    await makeComment({ authorSourceId, isAccepted: false });\n  }\n\n  for (let i = 0; i < otherCount; i++) {\n    await makeComment({ authorSourceId, isAccepted: null });\n  }\n}\n\ndescribe(BASE_URL, () => {\n  beforeEach(async () => {\n    await CommentScoreRequest.destroy({where: {}});\n    await Comment.destroy({where: {}});\n  });\n\n  describe('/authorCounts', () => {\n\n    it('should return a lookup when asked for a single authorId', async () => {\n      const apiClient = chai.request(app);\n\n      const authorSourceId = '$2a$10$pMz0P4a/kq1h4pZwt7Ji8unFGQquqqStriJVFAN0Si.Eh49XUyUty';\n      const approvedCount = 2;\n      const rejectedCount = 7;\n      const otherCount = 2;\n      await fakeAuthor(authorSourceId, approvedCount, rejectedCount, otherCount);\n\n      const { body } = await apiClient.post(`${BASE_URL}`).send({\n        data: authorSourceId,\n      });\n\n      expect(body).to.deep.equal({\n        data: {\n          [authorSourceId]: {\n            approvedCount,\n            rejectedCount,\n          },\n        },\n      });\n    });\n\n    it('should return a zeros when an author does not exist', async () => {\n      const apiClient = chai.request(app);\n      const authorSourceId = 'fake';\n\n      const { body } = await apiClient.post(`${BASE_URL}`).send({\n        data: [authorSourceId],\n      });\n\n      expect(body).to.deep.equal({\n        data: {\n          [authorSourceId]: {\n            approvedCount: 0,\n            rejectedCount: 0,\n          },\n        },\n      });\n    });\n\n    it('should return a lookup when asked for mulitple authorIds', async () => {\n      const apiClient = chai.request(app);\n      const authorSourceId1 = '$2a$10$pMz0P4a/kq1h4pZwt7Ji8unFGQquqqStriJVFAN0Si.Eh49XUyUty';\n      const approvedCount1 = 2;\n      const rejectedCount1 = 7;\n      const otherCount1 = 2;\n      await fakeAuthor(authorSourceId1, approvedCount1, rejectedCount1, otherCount1);\n\n      const authorSourceId2 = '52';\n      const approvedCount2 = 2;\n      const rejectedCount2 = 7;\n      const otherCount2 = 2;\n      await fakeAuthor(authorSourceId2, approvedCount2, rejectedCount2, otherCount2);\n\n      const { body } = await apiClient.post(`${BASE_URL}`).send({\n        data: [authorSourceId1, authorSourceId2],\n      });\n\n      expect(body).to.deep.equal({\n        data: {\n          [authorSourceId1]: {\n            approvedCount: approvedCount1,\n            rejectedCount: rejectedCount1,\n          },\n          [authorSourceId2]: {\n            approvedCount: approvedCount2,\n            rejectedCount: rejectedCount2,\n          },\n        },\n      });\n    });\n\n  });\n\n});\n"
  },
  {
    "path": "packages/backend-api/src/test/integration/api/editComment.spec.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\nimport * as chai from 'chai';\n\nimport {\n  Comment,\n} from '../../../models';\n\nimport {\n  expect,\n  makeComment,\n} from '../../fixture';\n\nimport {cleanDatabase} from '../../test_helper';\nimport {\n  app,\n} from './test_helper';\n\nconst URL = `/services/editComment`;\n\ndescribe(URL, () => {\n    before(async () => {\n        await cleanDatabase(false);\n    });\n\n    it('should return 404', async () => {\n        try {\n            const apiClient = chai.request(app);\n\n            const { status } = await apiClient.patch(URL).send({\n                data: {\n                    commentId: '1',\n                    text: 'lol',\n                    authorName: 'what',\n                    authorLocation: 'NYC',\n                },\n            });\n\n            expect(status).to.be.equal(404);\n        } catch (err) {\n            expect(err.response.status).to.be.equal(404);\n        }\n\n    });\n\n    it('should return 422', async () => {\n        try {\n            const apiClient = chai.request(app);\n            const { status } = await apiClient.patch(URL).send({\n                data: {\n                    commentId: 1,\n                    text: 1,\n                    authorName: 1,\n                    authorLocation: 1,\n                },\n            });\n\n            expect(status).to.be.equal(422);\n        } catch (err) {\n            expect(err.response.status).to.be.equal(422);\n        }\n\n    });\n\n    it('should return 200', async () => {\n        const apiClient = chai.request(app);\n        const comment = await makeComment();\n        const updatedText = 'I’m living everyday like a hustle, another drug to juggle. Another day, another struggle.';\n        const updatedAuthorName = 'Biggie';\n        const updatedAuthorLocation = 'LA';\n\n        try {\n            const { status } = await apiClient.patch(URL).send({\n                data: {\n                    commentId: comment.id.toString(),\n                    text: updatedText,\n                    authorName: updatedAuthorName,\n                    authorLocation: updatedAuthorLocation,\n                },\n            });\n\n            expect(status).to.be.equal(200);\n        } catch (err) {\n            expect(err.response.status).to.be.equal(200);\n        }\n\n        const updatedComment = await Comment.findOne({ where: { id: comment.id }});\n        const { name, location } = updatedComment!.author;\n\n        expect(updatedComment!.text).to.be.equal(updatedText);\n        expect(name).to.be.equal(updatedAuthorName);\n        expect(location).to.be.equal(updatedAuthorLocation);\n    });\n});\n"
  },
  {
    "path": "packages/backend-api/src/test/integration/api/histogramScores.spec.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as chai from 'chai';\n\nimport { cacheCommentTopScores } from '../../../domain';\nimport {\n  Article,\n  Category,\n  Comment,\n  CommentSummaryScore,\n  Tag,\n} from '../../../models';\nimport {\n  expect,\n  makeArticle,\n  makeCategory,\n  makeComment,\n  makeCommentSummaryScore,\n  makeTag,\n} from '../../fixture';\nimport {\n  app,\n} from './test_helper';\n\nconst BASE_URL = `/services/histogramScores`;\n\ndescribe(BASE_URL, () => {\n  beforeEach(async () => {\n    await CommentSummaryScore.destroy({where: {}});\n    await Comment.destroy({where: {}});\n    await Article.destroy({where: {}});\n    await Category.destroy({where: {}});\n    await Tag.destroy({where: {}});\n  });\n\n  describe('/articles/:articleId/tags/:tagId', () => {\n\n    it('returns scores in the article and tag', async () => {\n      const apiClient = chai.request(app);\n\n      const article = await makeArticle();\n      const articleId = article.id;\n\n      const tag = await makeTag({ key: 'SPAM', label: 'spam' });\n      const tagId = tag.id;\n\n      const comment1 = await makeComment({ articleId });\n      const commentSummaryScore1 = await makeCommentSummaryScore({ commentId: comment1.id, tagId, score: 1.0 });\n\n      const comment2 = await makeComment({ articleId });\n      const commentSummaryScore2 = await makeCommentSummaryScore({ commentId: comment2.id, tagId, score: 0.25 });\n\n      // Should ignore non scored\n      await makeComment({ articleId, isScored: false });\n\n      const { body } = await apiClient.get(`${BASE_URL}/articles/${articleId}/tags/${tagId}`);\n\n      // Only comment 2 should be present, because its score is between the rule 0-0.5\n\n      expect(body.data).to.be.lengthOf(2);\n\n      expect(body.data).to.deep.include({\n        commentId: comment1.id.toString(),\n        score: commentSummaryScore1.score,\n      });\n\n      expect(body.data).to.deep.include({\n        commentId: comment2.id.toString(),\n        score: commentSummaryScore2.score,\n      });\n    });\n\n    it('returns a 404 for missing article', async () => {\n      let was404 = false;\n\n      try {\n        const apiClient = chai.request(app);\n        const { status } = await apiClient.get(`${BASE_URL}/articles/0/tags/0`);\n        expect(status).to.be.equal(404);\n        was404 = true;\n      } catch (e) {\n        console.log(e);\n      } finally {\n        expect(was404).to.be.true;\n      }\n    });\n\n    it('returns a 404 for missing tag', async () => {\n      let was404 = false;\n      const article = await makeArticle();\n      const articleId = article.id;\n\n      try {\n        const apiClient = chai.request(app);\n        const { status } = await apiClient.get(`${BASE_URL}/articles/${articleId}/tags/0`);\n        expect(status).to.be.equal(404);\n        was404 = true;\n      } catch (e) {\n        console.log(e);\n      } finally {\n        expect(was404).to.be.true;\n      }\n    });\n  });\n\n  describe('/categories/:categoryId/tags/:tagId', () => {\n\n    it('returns scores in the category and tag', async () => {\n      const category1 = await makeCategory({ label: 'One' });\n      const categoryId1 = category1.id;\n\n      const article1 = await makeArticle({ categoryId: categoryId1 });\n      const articleId1 = article1.id;\n\n      const category2 = await makeCategory({ label: 'Two' });\n      const categoryId2 = category2.id;\n\n      const article2 = await makeArticle({ categoryId: categoryId2 });\n      const articleId2 = article2.id;\n\n      const tag = await makeTag({ key: 'SPAM', label: 'spam' });\n      const tagId = tag.id;\n\n      const comment1 = await makeComment({ articleId: articleId1 });\n      const commentSummaryScore1 = await makeCommentSummaryScore({ commentId: comment1.id, tagId, score: 1.0 });\n      await cacheCommentTopScores(comment1);\n\n      const comment2 = await makeComment({ articleId: articleId1 });\n      const commentSummaryScore2 = await makeCommentSummaryScore({ commentId: comment2.id, tagId, score: 0.25 });\n      await cacheCommentTopScores(comment2);\n\n      // Should ignore non scored\n      await makeComment({ articleId: articleId2, isScored: true });\n      await makeComment({ articleId: articleId2, isScored: false });\n\n      const apiClient = chai.request(app);\n      const { body } = await apiClient.get(`${BASE_URL}/categories/${categoryId1}/tags/${tagId}`);\n\n      expect(body.data).to.be.lengthOf(2);\n\n      expect(body.data).to.deep.include({\n        commentId: comment1.id.toString(),\n        score: commentSummaryScore1.score,\n      });\n\n      expect(body.data).to.deep.include({\n        commentId: comment2.id.toString(),\n        score: commentSummaryScore2.score,\n      });\n    });\n\n    it('returns a 404 for missing article', async () => {\n      let was404 = false;\n\n      try {\n        const apiClient = chai.request(app);\n        const { status } = await apiClient.get(`${BASE_URL}/categories/0/tags/0`);\n        expect(status).to.be.equal(404);\n        was404 = true;\n      } catch (e) {\n        console.log(e);\n      } finally {\n        expect(was404).to.be.true;\n      }\n    });\n\n    it('returns a 404 for missing tag', async () => {\n      let was404 = false;\n      const category = await makeCategory({ label: 'One' });\n      const categoryId = category.id;\n\n      try {\n        const apiClient = chai.request(app);\n        const { status } = await apiClient.get(`${BASE_URL}/categories/${categoryId}/tags/0`);\n        expect(status).to.be.equal(404);\n        was404 = true;\n      } catch (e) {\n        console.log(e);\n      } finally {\n        expect(was404).to.be.true;\n      }\n    });\n  });\n});\n"
  },
  {
    "path": "packages/backend-api/src/test/integration/api/simple-comment.spec.ts",
    "content": "/*\nCopyright 2020 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as chai from 'chai';\n\nimport {denormalizeCountsForComment} from '../../../domain/comments';\nimport {\n  Article,\n  Comment,\n  CommentFlag,\n  CommentScore,\n  CommentSummaryScore,\n  CommentTopScore,\n  Tag,\n} from '../../../models';\nimport {\n  expect,\n  makeArticle,\n  makeComment,\n  makeCommentScore,\n  makeCommentSummaryScore,\n  makeCommentTopScore, makeFlag,\n  makeTag,\n} from '../../fixture';\nimport {app} from './test_helper';\n\nconst BASE_URL = `/services/simple`;\n\ndescribe(BASE_URL, () => {\n  let article1: Article;\n  let article2: Article;\n  let comment1: Comment;\n  let comment2: Comment;\n  let tag1: Tag;\n  let tag2: Tag;\n\n  before(async () => {\n    article1 = await makeArticle({title: 'test article 1', text: 'this is the text for article 1'});\n    article2 = await makeArticle({title: 'test article 2'});\n    tag1 = await makeTag({key: 'tag1', label: 'tag1'});\n    tag2 = await makeTag({key: 'tag2', label: 'tag2'});\n    comment1 = await makeComment({\n      articleId: article1.id,\n      text: 'test comment 1',\n      maxSummaryScore: 0.3,\n      maxSummaryScoreTagId: tag2.id,\n    });\n    comment2 = await makeComment({articleId: article2.id, text: 'test comment 2', replyId: comment1.id});\n    await makeCommentScore({commentId: comment1.id, tagId: tag1.id, score: 0.1, annotationStart: 3, annotationEnd: 4});\n    const score2 = await makeCommentScore({commentId: comment1.id, tagId: tag1.id, score: 0.2, annotationStart: 5, annotationEnd: 6});\n    const score3 = await makeCommentScore({commentId: comment1.id, tagId: tag2.id, score: 0.3, annotationStart: 7, annotationEnd: 8});\n    await makeCommentTopScore(score2);\n    await makeCommentTopScore(score3);\n    await makeCommentSummaryScore({commentId: comment1.id, tagId: tag1.id, score: 0.15, isConfirmed: false});\n    await makeCommentSummaryScore({commentId: comment1.id, tagId: tag2.id, score: 0.3, isConfirmed: false});\n    await makeFlag({label: 'red', commentId: comment1.id});\n    await makeFlag({label: 'red', commentId: comment1.id});\n    await makeFlag({label: 'green', commentId: comment1.id, isResolved: true});\n    await denormalizeCountsForComment(comment1);\n  });\n\n  after(async () => {\n    await Article.destroy({where: {}});\n    await CommentFlag.destroy({where: {}});\n    await CommentTopScore.destroy({where: {}});\n    await CommentSummaryScore.destroy({where: {}});\n    await CommentScore.destroy({where: {}});\n    await Comment.destroy({where: {}});\n    await Tag.destroy({where: {}});\n  });\n\n  describe('article api tests', () => {\n    it('fetch 1 and update', async () => {\n      {\n        const apiClient = chai.request(app);\n        const {status, body} = await apiClient.post(`${BASE_URL}/article/get`).send([article1.id]);\n        expect(status).to.equal(200);\n        expect(body.length).to.equal(1);\n        expect(body[0].title).to.equal('test article 1');\n        expect(body[0].isCommentingEnabled).to.be.true;\n        expect(body[0].isAutoModerated).to.be.true;\n      }\n\n      {\n        const apiClient = chai.request(app);\n        const {status} = await apiClient.post(`${BASE_URL}/article/update/${article1.id}`).send({\n          isCommentingEnabled: false,\n        });\n        expect(status).to.equal(200);\n      }\n\n      {\n        const apiClient = chai.request(app);\n        const {status, body} = await apiClient.post(`${BASE_URL}/article/get`).send([article1.id]);\n        expect(status).to.equal(200);\n        expect(body[0].isCommentingEnabled).to.be.false;\n        expect(body[0].isAutoModerated).to.be.true;\n      }\n\n      {\n        const apiClient = chai.request(app);\n        const {status} = await apiClient.post(`${BASE_URL}/article/update/${article1.id}`).send({\n          isCommentingEnabled: true,\n          isAutoModerated: false,\n        });\n        expect(status).to.equal(200);\n      }\n\n      {\n        const apiClient = chai.request(app);\n        const {status, body} = await apiClient.post(`${BASE_URL}/article/get`).send([article1.id]);\n        expect(status).to.equal(200);\n        expect(body[0].isCommentingEnabled).to.be.true;\n        expect(body[0].isAutoModerated).to.be.false;\n      }\n    });\n\n    it('fetch multiple', async () => {\n      const apiClient = chai.request(app);\n      const { status, body } = await apiClient.post(`${BASE_URL}/article/get`).send([article1.id, article2.id]);\n      expect(status).to.equal(200);\n      expect(body.length).to.equal(2);\n      expect(body[0].title).to.equal('test article 1');\n      expect(body[1].title).to.equal('test article 2');\n    });\n\n    it('fetch text', async () => {\n      const apiClient = chai.request(app);\n      const { status, body } = await apiClient.get(`${BASE_URL}/article/${article1.id}/text`);\n      expect(status).to.equal(200);\n      expect(body.text).to.equal('this is the text for article 1');\n    });\n  });\n\n  describe('comment', () => {\n    it('fetch 1 comment', async () => {\n      const apiClient = chai.request(app);\n      const {status, body} = await apiClient.post(`${BASE_URL}/comment/get`).send([comment1.id]);\n      expect(status).to.equal(200);\n      expect(body.length).to.equal(1);\n      expect(body[0].text).to.equal('test comment 1');\n      expect(body[0].replies).to.deep.equal([comment2.id.toString()]);\n      expect(body[0].maxSummaryScore).to.equal(0.3);\n      expect(body[0].maxSummaryScoreTagId).to.equal(tag2.id.toString());\n      expect(body[0].summaryScores).to.deep.equal([\n        { tagId: tag1.id.toString(), score: 0.15, topScore: {score: 0.2, start: 5, end: 6}},\n        { tagId: tag2.id.toString(), score: 0.3, topScore: {score: 0.3, start: 7, end: 8}},\n      ]);\n      expect(body[0].unresolvedFlagsCount).to.equal(2);\n      expect(body[0].flagsSummary).to.deep.equal({ red: [ 2, 2, 0 ], green: [ 1, 0, 0 ] });\n    });\n\n    it('fetch multiple comments', async () => {\n      const apiClient = chai.request(app);\n      const {status, body} = await apiClient.post(`${BASE_URL}/comment/get`).send([comment1.id, comment2.id]);\n      expect(status).to.equal(200);\n      expect(body.length).to.equal(2);\n      expect(body[0].text).to.equal('test comment 1');\n      expect(body[1].text).to.equal('test comment 2');\n    });\n\n    it('fetch scores', async () => {\n      const apiClient = chai.request(app);\n      const {status, body} = await apiClient.get(`${BASE_URL}/comment/${comment1.id}/scores`);\n      expect(status).to.equal(200);\n      expect(body.length).to.equal(3);\n      expect(body.map((i: {[key: string]: any}) => i.score)).to.deep.equal([0.1, 0.2, 0.3]);\n    });\n\n    it('fetch flags', async () => {\n      const apiClient = chai.request(app);\n      const {status, body} = await apiClient.get(`${BASE_URL}/comment/${comment1.id}/flags`);\n      expect(status).to.equal(200);\n      expect(body.map((i: {[key: string]: any}) => i.label)).to.deep.equal(['red', 'red', 'green']);\n    });\n  });\n});\n"
  },
  {
    "path": "packages/backend-api/src/test/integration/api/simple-ranges.spec.ts",
    "content": "/*\nCopyright 2020 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as chai from 'chai';\n\nimport {\n  Category,\n  ModerationRule,\n  Preselect,\n  Tag,\n  TaggingSensitivity,\n} from '../../../models';\nimport {\n  expect,\n  makeCategory,\n  makePreselect,\n  makeRule,\n  makeTag,\n  makeTaggingSensitivity,\n} from '../../fixture';\nimport {app} from './test_helper';\n\nconst BASE_URL = `/services/simple`;\n\ndescribe(BASE_URL, () => {\n  let category: Category;\n  let tag1: Tag;\n  let tag2: Tag;\n  let deleteTag: Tag;\n  let rule: ModerationRule;\n  let sensitivity: TaggingSensitivity;\n  let preselect: Preselect;\n\n  before(async () => {\n    category = await makeCategory();\n    tag1 = await makeTag({key: 'tag1', label: 'tag1'});\n    tag2 = await makeTag({key: 'tag2', label: 'tag2'});\n    deleteTag = await makeTag({key: 'deletetag', label: 'Delete tag'});\n    rule = await makeRule(tag2);\n    sensitivity = await makeTaggingSensitivity({tagId: tag2.id});\n    preselect = await makePreselect({tagId: tag2.id});\n  });\n\n  after(async () => {\n    await Tag.destroy({where: {}});\n  });\n\n  describe('tag operations', () => {\n    it('post', async () => {\n      const apiClient = chai.request(app);\n      const {status} = await apiClient.post(`${BASE_URL}/tag`).send({\n        color: '#ffffff',\n        key: 'newtag',\n        label: 'new tag',\n      });\n      expect(status).to.equal(200);\n      const tags = await Tag.findAll();\n      expect(tags.length).to.equal(4);\n      expect(tags[3].key).to.equal('newtag');\n      expect(tags[3].color).to.equal('#ffffff');\n    });\n\n    it('patch', async () => {\n      const apiClient = chai.request(app);\n      const {status} = await apiClient.patch(`${BASE_URL}/tag/${tag1.id}`).send({\n        label: 'updated tag',\n        isInBatchView: false,\n        isTaggable: true,\n      });\n      expect(status).to.equal(200);\n      const tag = await Tag.findByPk(tag1.id);\n      expect(tag?.label).to.equal('updated tag');\n      expect(tag?.isInBatchView).to.be.false;\n      expect(tag?.isTaggable).to.be.true;\n    });\n\n    it('delete', async () => {\n      const apiClient = chai.request(app);\n      const {status} = await apiClient.del(`${BASE_URL}/tag/${deleteTag.id}`);\n      expect(status).to.equal(200);\n      const tags = await Tag.findAll();\n      expect(tags.length).to.equal(3);\n    });\n  });\n\n  describe('moderation rule', () => {\n    it('create', async () => {\n      const apiClient = chai.request(app);\n      const {status} = await apiClient.post(`${BASE_URL}/moderation_rule`).send({\n        tagId: tag2.id,\n        categoryId: category.id,\n        lowerThreshold: 0,\n        upperThreshold: 0.1,\n        action: 'Accept',\n      });\n      expect(status).to.equal(200);\n      const rules = await ModerationRule.findAll();\n      expect(rules.length).to.equal(2);\n      expect(rules[1].categoryId).to.equal(category.id);\n      expect(rules[1].tagId).to.equal(tag2.id);\n      expect(rules[1].action).to.equal('Accept');\n      expect(rules[1].lowerThreshold).to.equal(0);\n    });\n\n    it('update', async () => {\n      const apiClient = chai.request(app);\n      const {status} = await apiClient.patch(`${BASE_URL}/moderation_rule/${rule.id}`).send({\n        tagId: tag1.id,\n        upperThreshold: 0.2,\n        action: 'Reject',\n      });\n      expect(status).to.equal(200);\n      const newRule = await ModerationRule.findByPk(rule.id);\n      expect(newRule?.categoryId).to.equal(null);\n      expect(newRule?.tagId).to.equal(tag1.id);\n      expect(newRule?.action).to.equal('Reject');\n      expect(newRule?.lowerThreshold).to.equal(0);\n      expect(newRule?.upperThreshold).to.equal(0.2);\n    });\n\n    it('delete', async () => {\n      const apiClient = chai.request(app);\n      const {status} = await apiClient.del(`${BASE_URL}/moderation_rule/${rule.id}`);\n      expect(status).to.equal(200);\n      const rules = await ModerationRule.findAll();\n      expect(rules.length).to.equal(1);\n    });\n  });\n\n  describe('sensitivities', () => {\n    it('create', async () => {\n      const apiClient = chai.request(app);\n      const {status} = await apiClient.post(`${BASE_URL}/tagging_sensitivity`).send({\n        tagId: tag2.id,\n        lowerThreshold: 0,\n        upperThreshold: 0.1,\n      });\n      expect(status).to.equal(200);\n      const sensitivities = await TaggingSensitivity.findAll();\n      expect(sensitivities.length).to.equal(2);\n      expect(sensitivities[1].categoryId).to.equal(null);\n      expect(sensitivities[1].tagId).to.equal(tag2.id);\n      expect(sensitivities[1].lowerThreshold).to.equal(0);\n    });\n\n    it('update', async () => {\n      const apiClient = chai.request(app);\n      const {status} = await apiClient.patch(`${BASE_URL}/tagging_sensitivity/${sensitivity.id}`).send({\n        categoryId: category.id,\n        tagId: null,\n        upperThreshold: 0.2,\n      });\n      expect(status).to.equal(200);\n      const newSensitivity = await TaggingSensitivity.findByPk(sensitivity.id);\n      expect(newSensitivity?.categoryId).to.equal(category.id);\n      expect(newSensitivity?.tagId).to.equal(null);\n      expect(newSensitivity?.lowerThreshold).to.equal(0);\n      expect(newSensitivity?.upperThreshold).to.equal(0.2);\n    });\n\n    it('delete', async () => {\n      const apiClient = chai.request(app);\n      const {status} = await apiClient.del(`${BASE_URL}/tagging_sensitivity/${sensitivity.id}`);\n      expect(status).to.equal(200);\n      const sensitivities = await TaggingSensitivity.findAll();\n      expect(sensitivities.length).to.equal(1);\n    });\n  });\n\n  describe('preselect', () => {\n    it('create', async () => {\n      const apiClient = chai.request(app);\n      const {status} = await apiClient.post(`${BASE_URL}/preselect`).send({\n        lowerThreshold: 0,\n        upperThreshold: 0.1,\n      });\n      expect(status).to.equal(200);\n      const preselects = await Preselect.findAll();\n      expect(preselects.length).to.equal(2);\n      expect(preselects[1].categoryId).to.equal(null);\n      expect(preselects[1].tagId).to.equal(null);\n      expect(preselects[1].lowerThreshold).to.equal(0);\n    });\n\n    it('update', async () => {\n      const apiClient = chai.request(app);\n      const {status} = await apiClient.patch(`${BASE_URL}/preselect/${preselect.id}`).send({\n        tagId: tag2.id,\n        categoryId: category.id,\n        upperThreshold: 0.2,\n      });\n      expect(status).to.equal(200);\n      const newPreselect = await Preselect.findByPk(preselect.id);\n      expect(newPreselect?.categoryId).to.equal(category.id);\n      expect(newPreselect?.tagId).to.equal(tag2.id);\n      expect(newPreselect?.lowerThreshold).to.equal(0);\n      expect(newPreselect?.upperThreshold).to.equal(0.2);\n    });\n\n    it('delete', async () => {\n      const apiClient = chai.request(app);\n      const {status} = await apiClient.del(`${BASE_URL}/preselect/${preselect.id}`);\n      expect(status).to.equal(200);\n      const preselects = await Preselect.findAll();\n      expect(preselects.length).to.equal(1);\n    });\n  });\n});\n"
  },
  {
    "path": "packages/backend-api/src/test/integration/api/simple-user.spec.ts",
    "content": "/*\nCopyright 2020 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as chai from 'chai';\n\nimport {User} from '../../../models';\nimport {expect, makeUser} from '../../fixture';\nimport {app} from './test_helper';\n\nconst BASE_URL = `/services/simple`;\n\ndescribe(BASE_URL, () => {\n  describe('user create', () => {\n    afterEach(async () => {\n      await User.destroy({where: {}});\n    });\n\n    it('Should create a system user', async () => {\n      const apiClient = chai.request(app);\n\n      const res = await apiClient.post(`${BASE_URL}/user`).send({\n        name: 'test system user',\n        group: 'service',\n        isActive: true,\n      });\n\n      expect(res.status).to.equal(200);\n      const users = await User.findAll({where: {}});\n      expect(users.length).to.equal(1);\n      expect(users[0].group).to.equal('service');\n    });\n\n    it('Should create a normal user', async () => {\n      const apiClient = chai.request(app);\n\n      const res = await apiClient.post(`${BASE_URL}/user`).send({\n        name: 'test normal user',\n        email: 'test@example.com',\n        group: 'admin',\n        isActive: true,\n      });\n\n      expect(res.status).to.equal(200);\n      const users = await User.findAll({where: {}});\n      expect(users.length).to.equal(1);\n      expect(users[0].group).to.equal('admin');\n    });\n\n    it('Should fail to create a normal user as no email address', async () => {\n      const apiClient = chai.request(app);\n\n      const res = await apiClient.post(`${BASE_URL}/user`).send({\n        name: 'test normal user',\n        group: 'admin',\n        isActive: true,\n      });\n\n      expect(res.status).to.equal(400);\n      expect(await User.count({where: {}})).to.equal(0);\n    });\n\n    it('Should fail to create a user with a forbidden type', async () => {\n      const apiClient = chai.request(app);\n\n      const res = await apiClient.post(`${BASE_URL}/user`).send({\n        name: 'test normal user',\n        email: 'test@example.com',\n        group: 'moderator',\n        isActive: true,\n      });\n\n      expect(res.status).to.equal(400);\n      expect(await User.count({where: {}})).to.equal(0);\n    });\n  });\n\n  describe('user get/update', () => {\n    let general: User;\n    let admin: User;\n    let service: User;\n    let moderator: User;\n\n    before(async () => {\n      general = await makeUser({group: 'general', email: 'general@example.com'});\n      admin = await makeUser({group: 'admin', email: 'admin@example.com'});\n      service = await makeUser({group: 'service', email: 'service@example.com'});\n      moderator = await makeUser({group: 'moderator', email: 'moderator@example.com'});\n      await makeUser({group: 'youtube', email: 'youtube@example.com'});\n    });\n\n    after(async () => {\n      await User.destroy({where: {}});\n    });\n\n    it('Get should just get service users', async () => {\n      const apiClient = chai.request(app);\n      const { status, body } = await apiClient.get(`${BASE_URL}/systemUsers/service`);\n      expect(status).to.equal(200);\n      expect(body.users.length).to.equal(1);\n      expect(body.users[0].email).to.equal('service@example.com');\n    });\n\n    it('Get should just get moderator users', async () => {\n      const apiClient = chai.request(app);\n      const { status, body } = await apiClient.get(`${BASE_URL}/systemUsers/moderator`);\n      expect(status).to.equal(200);\n      expect(body.users.length).to.equal(1);\n      expect(body.users[0].email).to.equal('moderator@example.com');\n    });\n\n    it('Update general to admin plus email address change', async () => {\n      const apiClient = chai.request(app);\n      const res = await apiClient.post(`${BASE_URL}/user/update/${general.id}`).send({\n        name: 'updated name',\n        email: 'updated@example.com',\n        group: 'admin',\n        isActive: false,\n      });\n\n      expect(res.status).to.equal(200);\n      const user = await User.findOne({where: {id: general.id}});\n      expect(user?.name).to.equal('updated name');\n      expect(user?.email).to.equal('updated@example.com');\n      expect(user?.group).to.equal('admin');\n      expect(user?.isActive).to.equal(false);\n    });\n\n    it('Update admin to service shouldn\\'t change anything', async () => {\n      const apiClient = chai.request(app);\n      const res = await apiClient.post(`${BASE_URL}/user/update/${admin.id}`).send({\n        group: 'service',\n      });\n\n      expect(res.status).to.equal(200);\n      const user = await User.findOne({where: {id: admin.id}});\n      expect(user?.group).to.equal('admin');\n    });\n\n    it('Update service to general shouldn\\'t change anything', async () => {\n      const apiClient = chai.request(app);\n      const res = await apiClient.post(`${BASE_URL}/user/update/${service.id}`).send({\n        group: 'general',\n      });\n\n      expect(res.status).to.equal(200);\n      const user = await User.findOne({where: {id: service.id}});\n      expect(user?.group).to.equal('service');\n    });\n\n    it('Can only update isActive for moderator', async () => {\n      const apiClient = chai.request(app);\n      const res = await apiClient.post(`${BASE_URL}/user/update/${moderator.id}`).send({\n        name: 'new name',\n        group: 'general',\n        isActive: false,\n      });\n\n      expect(res.status).to.equal(200);\n      const user = await User.findOne({where: {id: moderator.id}});\n      expect(user?.group).to.equal('moderator');\n      expect(user?.isActive).to.equal(false);\n    });\n  });\n});\n"
  },
  {
    "path": "packages/backend-api/src/test/integration/api/test_helper.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as chai from 'chai';\nimport * as express from 'express';\n\nimport { User } from '../../../models';\n\nconst chaiHttp = require('chai-http');\n\nimport { makeServer } from '../../../api/util/server';\nimport { mountAPI } from '../../../index';\n\nchai.use(chaiHttp);\nlet app: express.Application;\nlet user: User;\n\nbefore(async () => {\n  const serverStuff = makeServer(true);\n  app = serverStuff.app;\n  app.use('/', (req, _, next) => {\n    req.user = user;\n    next();\n  });\n  app.use('/', await mountAPI(true));\n});\n\nexport function setAuthenticatedUser(u: User) {\n  user = u;\n}\n\nexport { app };\n"
  },
  {
    "path": "packages/backend-api/src/test/integration/websocket/assign_moderators.spec.ts",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as chai from 'chai';\n\nimport { Article, Category, User } from '../../../models';\n\nimport { REPLY_SUCCESS_VALUE } from '../../../api/constants';\nimport { destroyUpdateNotificationService } from '../../../api/services/updateNotifications';\nimport { makeServer } from '../../../api/util/server';\nimport { mountAPI } from '../../../index';\nimport {\n  assertArticleUpdateMessage,\n  assertGlobalMessage,\n  assertSystemMessage,\n  assertUserMessage,\n  expect,\n  listenForMessages,\n  makeArticle,\n  makeCategory,\n  makeUser,\n  sleep,\n} from '../../fixture';\n\nimport chaiHttp = require('chai-http');\nimport {cleanDatabase} from '../../test_helper';\nchai.use(chaiHttp);\n\ndescribe('websocket tests: assign moderators', () => {\n  let app: any;\n  let server: any;\n\n  let user: User;\n  let category: Category;\n  let article: Article;\n\n  before(async () => {\n    await cleanDatabase();\n    await Article.destroy({where: {}});\n    await Category.destroy({where: {}});\n    await User.destroy({where: {}});\n\n    user = await makeUser();\n\n    category = await makeCategory();\n    article = await makeArticle({categoryId: category.id});\n\n    const serverStuff = makeServer(true);\n    app = serverStuff.app;\n    app.use('/', (req: any, _: any, next: () => void) => {\n      req.user = user;\n      next();\n    });\n\n    app.use('/', await mountAPI(true));\n    server = serverStuff.start(3000);\n  });\n\n  after(async () => {\n    await server.close();\n    destroyUpdateNotificationService();\n  });\n\n  it('Test we get notifications when moderators assigned to categories', async () => {\n    async function assignCategoryModerator(data: Array<number>) {\n      const apiClient = chai.request(app);\n      const {status, body} = await apiClient.post(`/services/assignments/categories/${category.id}`).send({data});\n      expect(status).is.equal(200);\n      expect(body.status).is.equal(REPLY_SUCCESS_VALUE);\n    }\n\n    await listenForMessages(async () => {\n      await assignCategoryModerator([user.id]);\n      await sleep(100);\n      await assignCategoryModerator([]);\n    },\n    [\n      (m: any) => { assertSystemMessage(m); },\n      (m: any) => { assertUserMessage(m); },\n      (m: any) => { assertGlobalMessage(m); },\n      (m: any) => {\n        assertArticleUpdateMessage(m);\n        expect(m.data.categories.length).eq(1);\n        expect(m.data.categories[0].assignedModerators.length).eq(1);\n        expect(m.data.categories[0].assignedModerators[0]).eq(user.id.toString());\n        expect(m.data.articles.length).eq(0);\n      },\n      (m: any) => {\n        assertArticleUpdateMessage(m);\n        expect(m.data.categories.length).eq(1);\n        expect(m.data.categories[0].assignedModerators.length).eq(1);\n        expect(m.data.categories[0].assignedModerators[0]).eq(user.id.toString());\n        expect(m.data.articles.length).eq(1);\n        expect(m.data.articles[0].assignedModerators.length).eq(1);\n        expect(m.data.articles[0].assignedModerators[0]).eq(user.id.toString());\n      },\n      (m: any) => {\n        assertArticleUpdateMessage(m);\n        expect(m.data.categories.length).eq(1);\n        expect(m.data.categories[0].assignedModerators.length).eq(0);\n        expect(m.data.articles.length).eq(0);\n\n      },\n      (m: any) => {\n        assertArticleUpdateMessage(m);\n        expect(m.data.categories.length).eq(1);\n        expect(m.data.categories[0].assignedModerators.length).eq(0);\n        expect(m.data.articles.length).eq(1);\n        expect(m.data.articles[0].assignedModerators.length).eq(0);\n      },\n    ]);\n  });\n\n  it('Test we get notifications when moderators assigned to articles', async () => {\n    async function assignArticleModerator(data: Array<number>) {\n      const apiClient = chai.request(app);\n      const {status} = await apiClient.post(`/services/assignments/article/${article.id}`).send({data});\n      expect(status).is.equal(200);\n    }\n\n    await listenForMessages(async () => {\n      await assignArticleModerator([user.id]);\n      await sleep(100);\n      await assignArticleModerator([]);\n    },\n    [\n      (m: any) => { assertSystemMessage(m); },\n      (m: any) => { assertUserMessage(m); },\n      (m: any) => { assertGlobalMessage(m); },\n      (m: any) => {\n        assertArticleUpdateMessage(m);\n        expect(m.data.categories[0].assignedModerators.length).eq(0);\n        expect(m.data.articles[0].assignedModerators.length).eq(1);\n        expect(m.data.articles[0].assignedModerators[0]).eq(user.id.toString());\n      },\n      (m: any) => {\n        assertArticleUpdateMessage(m);\n        expect(m.data.categories[0].assignedModerators.length).eq(0);\n        expect(m.data.articles[0].assignedModerators.length).eq(0);\n      },\n    ]);\n  });\n});\n"
  },
  {
    "path": "packages/backend-api/src/test/integration/websocket/update_notifications.spec.ts",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as chai from 'chai';\n\nimport { Article, Category, User } from '../../../models';\n\nimport { REPLY_SUCCESS_VALUE } from '../../../api/constants';\nimport { destroyUpdateNotificationService } from '../../../api/services/updateNotifications';\nimport { makeServer } from '../../../api/util/server';\nimport { mountAPI} from '../../../index';\nimport {\n  assertArticleUpdateMessage,\n  assertGlobalMessage,\n  assertSystemMessage,\n  assertUserMessage,\n  expect,\n  listenForMessages,\n  makeArticle,\n  makeCategory,\n  makeUser,\n  sleep,\n} from '../../fixture';\n\nconst chaiHttp = require('chai-http');\nchai.use(chaiHttp);\n\ndescribe('websocket tests: update_notifications', () => {\n  let app: any;\n  let server: any;\n\n  let user: User;\n  let category: Category;\n  let article: Article;\n\n  before(async () => {\n    await Article.destroy({where: {}});\n    await Category.destroy({where: {}});\n    await User.destroy({where: {}});\n\n    user = await makeUser();\n\n    category = await makeCategory();\n    article = await makeArticle({categoryId: category.id});\n\n    const serverStuff = makeServer(true);\n    app = serverStuff.app;\n    app.use('/', (req: any, _: any, next: () => void) => {\n      req.user = user;\n      next();\n    });\n\n    app.use('/', await mountAPI(true));\n    server = serverStuff.start(3000);\n  });\n\n  after(async () => {\n    await server.close();\n    destroyUpdateNotificationService();\n  });\n\n  it('Test we get notifications when setting article attributes', async () => {\n    async function setArticleAttributes(isCommentingEnabled: boolean, isAutoModerated: boolean) {\n      const data = {isCommentingEnabled, isAutoModerated};\n      const apiClient = chai.request(app);\n      const {status, body} = await apiClient.post(`/services/simple/article/update/${article.id}`).send(data);\n      expect(status).is.equal(200);\n      expect(body.status).is.equal(REPLY_SUCCESS_VALUE);\n    }\n\n    await listenForMessages(async () => {\n        await setArticleAttributes(true, false);\n        await sleep(50);\n        await setArticleAttributes(false, true);\n        await sleep(50);\n        await setArticleAttributes(false, false);\n        await sleep(50);\n        await setArticleAttributes(true, true);\n      },\n      [\n        (m: any) => { assertSystemMessage(m); },\n        (m: any) => { assertUserMessage(m); },\n        (m: any) => { assertGlobalMessage(m); },\n        (m: any) => {\n          assertArticleUpdateMessage(m);\n          expect(m.data.articles[0].id).eq(article.id.toString());\n          expect(m.data.articles[0].isAutoModerated).eq(false);\n          expect(m.data.articles[0].isCommentingEnabled).eq(true);\n        },\n        (m: any) => {\n          assertArticleUpdateMessage(m);\n          expect(m.data.articles[0].isAutoModerated).eq(true);\n          expect(m.data.articles[0].isCommentingEnabled).eq(false);\n        },\n        (m: any) => {\n          assertArticleUpdateMessage(m);\n          expect(m.data.articles[0].isAutoModerated).eq(false);\n          expect(m.data.articles[0].isCommentingEnabled).eq(false);\n        },\n        (m: any) => {\n          assertArticleUpdateMessage(m);\n          expect(m.data.articles[0].isAutoModerated).eq(true);\n          expect(m.data.articles[0].isCommentingEnabled).eq(true);\n        },\n      ]);\n  });\n});\n"
  },
  {
    "path": "packages/backend-api/src/test/integration/websocket/websocket.spec.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as WebSocket from 'ws';\n\nimport { User } from '../../../models';\n\nimport { destroyUpdateNotificationService } from '../../../api/services/updateNotifications';\nimport { makeServer } from '../../../api/util/server';\nimport { mountAPI } from '../../../index';\nimport {\n  assertGlobalMessage,\n  assertSystemMessage,\n  assertUserMessage,\n  expect,\n  listenForMessages,\n  makeUser,\n  sleep,\n} from '../../fixture';\nimport {cleanDatabase} from '../../test_helper';\n\ndescribe('websocket tests', () => {\n  before(async () => {\n    await cleanDatabase();\n  });\n  beforeEach(async () => {\n    await User.destroy({where: {}});\n  });\n\n  it('Test what we get when connect without authentication', async () => {\n    const serverStuff = makeServer(true);\n    const app = serverStuff.app;\n    app.use('/', await mountAPI(true));\n    const server = serverStuff.start(3000);\n\n    try {\n      let gotClose = false;\n      let gotMessage = false;\n      const socket = new WebSocket('ws://localhost:3000/services/updates/summary');\n\n      socket.onclose = () => {\n        gotClose = true;\n      };\n\n      socket.onmessage = () => {\n        gotMessage = true;\n      };\n\n      await sleep(100);\n\n      expect(gotMessage).is.false;\n      expect(gotClose).is.true;\n    }\n    finally {\n      server.close();\n    }\n  });\n\n  it('Test what we get when connect with authentication', async () => {\n    const user = await makeUser();\n\n    const serverStuff = makeServer(true);\n    const app = serverStuff.app;\n    app.use('/', (req, _, next) => {\n      req.user = user;\n      next();\n    });\n    app.use('/', await mountAPI(true));\n    const server = serverStuff.start(3000);\n\n    try {\n      await listenForMessages(async () => {\n          await sleep(10);\n        },\n        [\n          (m: any) => { assertSystemMessage(m); },\n          (m: any) => { assertUserMessage(m); },\n          (m: any) => { assertGlobalMessage(m); },\n        ]);\n    }\n    finally {\n      server.close();\n      destroyUpdateNotificationService();\n    }\n  });\n});\n"
  },
  {
    "path": "packages/backend-api/src/test/pipeline/pipeline.spec.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { assert } from 'chai';\nimport { groupBy } from 'lodash';\nimport * as moment from 'moment';\n\nimport {\n  Article,\n  Category,\n  Comment,\n  CommentScore,\n  CommentScoreRequest,\n  CommentSummaryScore,\n  Decision,\n  MODERATION_ACTION_ACCEPT,\n  MODERATION_ACTION_REJECT,\n  Tag,\n} from '../../models';\nimport {\n  compileScoresData,\n  compileSummaryScoresData,\n  completeMachineScoring,\n  findOrCreateTagsByKey,\n  getCommentsToResendForScoring,\n  processMachineScore,\n  recordDecision,\n} from '../../pipeline';\nimport { IScores, ISummaryScores } from '../../pipeline/shim';\nimport { getIsDoneScoring } from '../../pipeline/state';\nimport {\n  createArticle,\n  createCategory,\n  createComment,\n  createCommentScoreRequest,\n  createCommentSummaryScore,\n  createModerationRule,\n  createModeratorUser,\n  createTag,\n  createUser,\n} from '../domain/comments/fixture';\n\ndescribe('Pipeline Tests', () => {\n  beforeEach(async () => {\n    await CommentSummaryScore.destroy({where: {}});\n    await CommentScore.destroy({where: {}});\n    await CommentScoreRequest.destroy({where: {}});\n    await Decision.destroy({where: {}});\n    await Comment.destroy({where: {}});\n    await Article.destroy({where: {}});\n    await Category.destroy({where: {}});\n    await Tag.destroy({where: {}});\n  });\n\n  describe('getCommentsToResendForScoring', () => {\n    it('should fetch comments that need to be resent for scoring', async () => {\n      const [\n        comment,\n        notQuiteStaleComment,\n        staleComment,\n        acceptedComment,\n        rejectedComment,\n        scoredComment,\n        scoredStaleComment,\n      ] = await Promise.all([\n\n        // Standard comment\n        createComment(),\n\n        // Not quite re-sendable\n        createComment({\n          isAccepted: null,\n          sentForScoring: moment().subtract(5, 'minutes').add(10, 'seconds'),\n        }),\n\n        // Freshly re-sendable\n        createComment({\n          isAccepted: null,\n          sentForScoring: moment().subtract(5, 'minutes').subtract(10, 'seconds'),\n        }),\n\n        // Accepted comments should be ignored\n        createComment({\n          isAccepted: true,\n        }),\n\n        // Rejected comments should be ignored\n        createComment({\n          isAccepted: false,\n        }),\n\n        // Scored comments should be ignored\n        createComment({\n          isScored: true,\n        }),\n\n        // Scored, stale comments should be ignored\n        createComment({\n          isScored: true,\n          sentForScoring: moment().subtract(5, 'minutes').subtract(10, 'seconds'),\n        }),\n      ]);\n\n      const comments = await getCommentsToResendForScoring();\n      const ids = comments.map((c) => c.id);\n\n      assert.notInclude(ids, comment.id);\n      assert.notInclude(ids, notQuiteStaleComment.id);\n      assert.include(ids, staleComment.id);\n      assert.notInclude(ids, acceptedComment.id);\n      assert.notInclude(ids, rejectedComment.id);\n      assert.notInclude(ids, scoredComment.id);\n      assert.notInclude(ids, scoredStaleComment.id);\n    });\n  });\n\n  describe('processMachineScore', () => {\n    it('should process the passed in score data, updating the request record and adding score records', async () => {\n      // Create test data\n\n      const fakeScoreData: any = {\n        scores: {\n          ATTACK_ON_COMMENTER: [\n            {\n              score: 0.2,\n              begin: 0,\n              end: 62,\n            },\n          ],\n          INFLAMMATORY: [\n            {\n              score: 0.4,\n              begin: 0,\n              end: 62,\n            },\n            {\n              score: 0.7,\n              begin: 63,\n              end: 66,\n            },\n          ],\n        },\n\n        summaryScores: {\n          ATTACK_ON_COMMENTER: 0.2,\n          INFLAMMATORY: 0.55,\n        },\n\n        error: '',\n      };\n\n      // Put a series of fixture data into the database\n\n      const [comment, serviceUser] = await Promise.all([\n        createComment(),\n        createModeratorUser(),\n      ]);\n\n      const commentScoreRequest = await createCommentScoreRequest({\n        commentId: comment.id,\n        userId: serviceUser.id,\n      });\n\n      // Call processMachineScore and start making assertions\n      await processMachineScore(comment.id, serviceUser.id, fakeScoreData);\n\n      // This is the only score in the queue, so it should be complete (true).\n      assert.isTrue(await getIsDoneScoring(comment.id));\n      await completeMachineScoring(comment.id);\n\n      // Get scores and score requests from the database\n      const scores = await CommentScore.findAll({\n          where: { commentId: comment.id },\n          include: [Tag],\n        });\n      const request = await CommentScoreRequest.findOne({\n          where: { id: commentScoreRequest.id },\n          include: [Comment],\n        });\n      const summaryScores = await CommentSummaryScore.findAll({\n          where: { commentId: comment.id },\n          include: [Tag],\n        });\n\n      // Scores assertions\n      assert.lengthOf(scores, 3);\n\n      // Summary scores assertions\n      assert.lengthOf(summaryScores, 2);\n\n      // Assertions against test data\n\n      for (const score of scores) {\n        assert.equal(score.sourceType, 'Machine');\n        assert.equal(score.userId, serviceUser.id);\n\n        if (score.score === 0.2) {\n          assert.equal(score.annotationStart, 0);\n          assert.equal(score.annotationEnd, 62);\n          assert.equal((await score.getTag())!.key, 'ATTACK_ON_COMMENTER');\n        }\n\n        if (score.score === 0.4) {\n          assert.equal(score.annotationStart, 0);\n          assert.equal(score.annotationEnd, 62);\n          assert.equal((await score.getTag())!.key, 'INFLAMMATORY');\n        }\n\n        if (score.score === 0.7) {\n          assert.equal(score.annotationStart, 63);\n          assert.equal(score.annotationEnd, 66);\n          assert.equal((await score.getTag())!.key, 'INFLAMMATORY');\n        }\n      }\n\n      for (const score of summaryScores) {\n        if (score.score === 0.2) {\n          assert.equal((await score.getTag())!.key, 'ATTACK_ON_COMMENTER');\n        }\n\n        if (score.score === 0.55) {\n          assert.equal((await score.getTag())!.key, 'INFLAMMATORY');\n        }\n      }\n\n      // Request assertions\n      assert.isNotNull(request);\n      assert.isOk(request!.doneAt);\n      assert.equal(request!.commentId, comment.id);\n      assert.isTrue((await request!.getComment())!.isScored);\n    });\n\n    it('should short-circuit if error key is present and not falsy in the scoreData', async () => {\n      const fakeScoreData: any = {\n        scores: {\n          SPAM: [\n            {\n              score: 0.2,\n              begin: 0,\n              end: 15,\n            },\n          ],\n        },\n\n        summaryScores: {\n          SPAM: 0.2,\n        },\n\n        error: 'Some error message',\n      };\n\n      try {\n        await processMachineScore(1, 1, fakeScoreData);\n        throw new Error('`processMachineScore` successfully resolved when it should have been rejected');\n      } catch (err) {\n        assert.instanceOf(err, Error);\n      }\n    });\n\n    it('should fail for any missed queries', async () => {\n      // Create test data\n\n      const fakeScoreData: any = {\n        scores: {\n          ATTACK_ON_COMMENTER: [\n            {\n              score: 0.2,\n              begin: 0,\n              end: 62,\n            },\n          ],\n          INFLAMMATORY: [\n            {\n              score: 0.4,\n              begin: 0,\n              end: 62,\n            },\n            {\n              score: 0.7,\n              begin: 63,\n              end: 66,\n            },\n          ],\n        },\n\n        summaryScores: {\n          ATTACK_ON_COMMENTER: 0.2,\n          INFLAMMATORY: 0.55,\n        },\n\n        error: '',\n      };\n\n      // Create similar fixture data as to previous test case, but leave out the score request creation\n      const [comment, serviceUser] = await Promise.all([\n        createComment(),\n        createModeratorUser(),\n      ]);\n\n      try {\n        await processMachineScore(comment.id, serviceUser.id, fakeScoreData);\n        throw new Error('`processMachineScore` unexpectedly resolved successfully');\n      } catch (err) {\n        assert.instanceOf(err, Error);\n      }\n    });\n\n    it('should not mark the comment as `isScored` when not all score requests have come back', async () => {\n      // Test data\n\n      const fakeScoreData: any = {\n        scores: {\n          ATTACK_ON_COMMENTER: [\n            {\n              score: 0.2,\n              begin: 0,\n              end: 62,\n            },\n          ],\n          INFLAMMATORY: [\n            {\n              score: 0.4,\n              begin: 0,\n              end: 62,\n            },\n            {\n              score: 0.7,\n              begin: 63,\n              end: 66,\n            },\n          ],\n        },\n\n        summaryScores: {\n          ATTACK_ON_COMMENTER: 0.2,\n          INFLAMMATORY: 0.55,\n        },\n\n        error: '',\n      };\n\n      // Create similar fixture data as to previous test case, but leave out the score request\n\n      const [comment, serviceUser1, serviceUser2] = await Promise.all([\n        createComment(),\n        createModeratorUser(),\n        createModeratorUser(),\n      ]);\n\n      // Make one request for each scorer\n\n      const [commentScoreRequest1] = await Promise.all([\n        createCommentScoreRequest({\n          commentId: comment.id,\n          userId: serviceUser1.id,\n        }),\n        createCommentScoreRequest({\n          commentId: comment.id,\n          userId: serviceUser2.id,\n        }),\n      ]);\n\n      // Receive a score for the first scorer\n\n      await processMachineScore(comment.id, serviceUser1.id, fakeScoreData);\n\n      const commentScoreRequests = await CommentScoreRequest.findAll({\n        where: { commentId: comment.id },\n        include: [Comment],\n        order: [['id', 'ASC']],\n      });\n\n      assert.lengthOf(commentScoreRequests, 2);\n\n      commentScoreRequests.forEach((request: CommentScoreRequest) => {\n        if (request.id === commentScoreRequest1.id) {\n          assert.isOk(request.doneAt);\n        } else {\n          assert.isNull(request.doneAt);\n        }\n      });\n\n      assert.isFalse((await commentScoreRequests[0].getComment())!.isScored);\n    });\n  });\n\n  describe('completeMachineScoring', () => {\n    it('should denormalize', async () => {\n      const category = await createCategory();\n      const article = await createArticle({ categoryId: category.id });\n      const comment = await createComment({ isScored: true, articleId: article.id });\n      const tag = await createTag();\n\n      await createCommentSummaryScore({\n        commentId: comment.id,\n        tagId: tag.id,\n        score: 0.5,\n      });\n\n      await createModerationRule({\n        action: MODERATION_ACTION_REJECT,\n        tagId: tag.id,\n        lowerThreshold: 0.0,\n        upperThreshold: 1.0,\n      });\n\n      await completeMachineScoring(comment.id);\n\n      const updatedCategory = (await Category.findByPk(category.id))!;\n      const updatedArticle = (await Article.findByPk(article.id))!;\n      const updatedComment = (await Comment.findByPk(comment.id))!;\n\n      assert.isTrue(updatedComment.isAutoResolved, 'comment isAutoResolved');\n      assert.equal(updatedComment.unresolvedFlagsCount, 0, 'comment unresolvedFlagsCount');\n\n      assert.equal(updatedCategory.moderatedCount, 1, 'category moderatedCount');\n      assert.equal(updatedCategory.rejectedCount, 1, 'category rejectedCount');\n\n      assert.equal(updatedArticle.moderatedCount, 1, 'article moderatedCount');\n      assert.equal(updatedArticle.rejectedCount, 1, 'article rejectedCount');\n      assert.isNull(updatedArticle.lastModeratedAt); // last moderated doesn't get updated by machine ops\n    });\n\n    it('should record the Reject decision from a rule', async () => {\n      const category = await createCategory();\n      const article = await createArticle({ categoryId: category.id });\n      const comment = await createComment({ articleId: article.id });\n      const tag = await createTag();\n\n      await createCommentSummaryScore({\n        commentId: comment.id,\n        tagId: tag.id,\n        score: 0.5,\n      });\n\n      const rule = await createModerationRule({\n        action: MODERATION_ACTION_REJECT,\n        tagId: tag.id,\n        lowerThreshold: 0.0,\n        upperThreshold: 1.0,\n      });\n\n      await completeMachineScoring(comment.id);\n\n      const decision = (await Decision.findOne({\n        where: { commentId: comment.id },\n      }))!;\n\n      assert.equal(decision.status, MODERATION_ACTION_REJECT);\n      assert.equal(decision.source, 'Rule');\n      assert.equal(decision.moderationRuleId, rule.id);\n    });\n  });\n\n  describe('compileScoresData', () => {\n    it('should compile raw score and model data into an array for CommentScore bulk creation', async () => {\n      const scoreData: IScores = {\n        ATTACK_ON_COMMENTER: [\n          {\n            score: 0.2,\n            begin: 0,\n            end: 62,\n          },\n        ],\n        INFLAMMATORY: [\n          {\n            score: 0.4,\n            begin: 0,\n            end: 62,\n          },\n          {\n            score: 0.7,\n            begin: 63,\n            end: 66,\n          },\n        ],\n      };\n\n      const tags = await findOrCreateTagsByKey(Object.keys(scoreData));\n\n      const tagsByKey = groupBy(tags, (tag: Tag) => {\n        return tag.key;\n      });\n\n      const [comment, serviceUser] = await Promise.all([\n        createComment(),\n        createModeratorUser(),\n      ]);\n\n      const commentScoreRequest = await createCommentScoreRequest({\n        commentId: comment.id,\n        userId: serviceUser.id,\n      });\n\n      const sourceType = 'Machine';\n\n      const expected = [\n        {\n          sourceType,\n          userId: serviceUser.id,\n          commentId: comment.id,\n          commentScoreRequestId: commentScoreRequest.id,\n          tagId: tagsByKey.ATTACK_ON_COMMENTER[0].id,\n          score: 0.2,\n          annotationStart: 0,\n          annotationEnd: 62,\n        },\n        {\n          sourceType,\n          userId: serviceUser.id,\n          commentId: comment.id,\n          commentScoreRequestId: commentScoreRequest.id,\n          tagId: tagsByKey.INFLAMMATORY[0].id,\n          score: 0.4,\n          annotationStart: 0,\n          annotationEnd: 62,\n        },\n        {\n          sourceType,\n          userId: serviceUser.id,\n          commentId: comment.id,\n          commentScoreRequestId: commentScoreRequest.id,\n          tagId: tagsByKey.INFLAMMATORY[0].id,\n          score: 0.7,\n          annotationStart: 63,\n          annotationEnd: 66,\n        },\n      ];\n\n      const compiled = compileScoresData(sourceType, serviceUser.id, scoreData, {\n        comment,\n        commentScoreRequest,\n        tags,\n      });\n\n      assert.deepEqual(compiled, expected);\n    });\n  });\n\n  describe('compileSummaryScoresData', () => {\n    it('should compile raw score and model data into an array for CommentSummaryScore bulk creation', async () => {\n      const summarScoreData: ISummaryScores = {\n        ATTACK_ON_COMMENTER: 0.2,\n        INFLAMMATORY: 0.55,\n      };\n\n      const tags = await findOrCreateTagsByKey(Object.keys(summarScoreData));\n\n      const tagsByKey = groupBy(tags, (tag: Tag) => {\n        return tag.key;\n      });\n\n      const comment = await createComment();\n\n      const expected = [\n        {\n          commentId: comment.id,\n          tagId: tagsByKey.ATTACK_ON_COMMENTER[0].id,\n          score: 0.2,\n        },\n        {\n          commentId: comment.id,\n          tagId: tagsByKey.INFLAMMATORY[0].id,\n          score: 0.55,\n        },\n      ];\n\n      const compiled = compileSummaryScoresData(summarScoreData, comment, tags);\n\n      assert.deepEqual(compiled, expected);\n    });\n  });\n\n  describe('findOrCreateTagsByKey', () => {\n    it('should create tags not present in the database and resolve their data', async () => {\n      const keys = ['ATTACK_ON_AUTHOR'];\n\n      const results = await findOrCreateTagsByKey(keys);\n\n      assert.lengthOf(results, 1);\n\n      const tag = results[0];\n      assert.equal(tag.key, keys[0]);\n      assert.equal(tag.label, 'Attack On Author');\n\n      const instance = (await Tag.findOne({\n        where: { key: keys[0] },\n      }))!;\n\n      assert.equal(tag.id, instance.id);\n      assert.equal(tag.key, instance.key);\n      assert.equal(tag.label, instance.label);\n    });\n\n    it('should find existing tags and resolve their data', async () => {\n      const key = 'SPAM';\n\n      const dbTag = await Tag.create({\n        key,\n        label: 'Spam',\n      });\n\n      const results = await findOrCreateTagsByKey([key]);\n\n      assert.lengthOf(results, 1);\n\n      const tag = results[0];\n      assert.equal(tag.id, dbTag.id);\n      assert.equal(tag.key, key);\n      assert.equal(tag.label, 'Spam');\n    });\n\n    it('should resolve a mix of existing and new tags', async () => {\n      const keys = ['INCOHERENT', 'OFF_TOPIC'];\n\n      const dbTag = await Tag.create({\n        key: 'INCOHERENT',\n        label: 'Incoherent',\n      });\n\n      const results = await findOrCreateTagsByKey(keys);\n\n      assert.lengthOf(results, keys.length);\n\n      results.forEach((tag) => {\n        if (tag.key === 'INCOHERENT') {\n          assert.equal(tag.id, dbTag.id);\n        } else {\n          assert.isNumber(tag.id);\n          assert.equal(tag.key, 'OFF_TOPIC');\n          assert.equal(tag.label, 'Off Topic');\n        }\n      });\n    });\n  });\n\n  describe('recordDecision', () => {\n    it('should record the descision to accept', async () => {\n      const comment = await createComment();\n      const user = await createUser();\n      await recordDecision(comment, MODERATION_ACTION_ACCEPT, user);\n\n      const foundDecisions = await Decision.findAll({\n        where: { commentId: comment.id },\n      });\n\n      assert.lengthOf(foundDecisions, 1);\n\n      const firstDecision = foundDecisions[0];\n\n      assert.equal(firstDecision.commentId, comment.id);\n      assert.equal(firstDecision.source, 'User');\n      assert.equal(firstDecision.userId, user.id);\n      assert.equal(firstDecision.status, MODERATION_ACTION_ACCEPT);\n      assert.isTrue(firstDecision.isCurrentDecision);\n    });\n\n    it('should clear old decisions', async () => {\n      const user = await createUser();\n      const tag = await createTag();\n      const rule = await createModerationRule({\n        action: MODERATION_ACTION_REJECT,\n        tagId: tag.id,\n        lowerThreshold: 0.0,\n        upperThreshold: 1.0,\n      });\n\n      const comment = await createComment();\n\n      await recordDecision(comment, MODERATION_ACTION_ACCEPT, user);\n      await recordDecision(comment, MODERATION_ACTION_REJECT, rule);\n\n      const foundDecisions = await Decision.findAll({\n        where: { commentId: comment.id },\n      });\n\n      assert.lengthOf(foundDecisions, 2);\n\n      const currentDecisions = await Decision.findAll({\n        where: {\n          commentId: comment.id,\n          isCurrentDecision: true,\n        },\n      });\n\n      assert.lengthOf(currentDecisions, 1);\n\n      const firstDecision = currentDecisions[0];\n\n      assert.equal(firstDecision.commentId, comment.id);\n      assert.equal(firstDecision.source, 'Rule');\n      assert.equal(firstDecision.moderationRuleId, rule.id);\n      assert.equal(firstDecision.status, MODERATION_ACTION_REJECT);\n      assert.isTrue(firstDecision.isCurrentDecision);\n    });\n  });\n});\n"
  },
  {
    "path": "packages/backend-api/src/test/pipeline/rules.spec.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { assert } from 'chai';\n\nimport {\n  Article,\n  Category,\n  Comment,\n  CommentSummaryScore,\n  MODERATION_ACTION_ACCEPT,\n  MODERATION_ACTION_DEFER,\n  MODERATION_ACTION_HIGHLIGHT,\n  MODERATION_ACTION_REJECT,\n  ModerationRule,\n  Tag,\n} from '../../models';\nimport {\n  compileScores,\n  processRulesForComment,\n  resolveComment,\n} from '../../pipeline/rules';\nimport {\n  createArticle,\n  createCategory,\n  createComment,\n  createCommentSummaryScore,\n  createModerationRule,\n  createTag,\n  getCommentSummaryScoreData,\n  getTagData,\n} from '../domain/comments/fixture';\n\ndescribe('Pipeline Rules Tests', () => {\n  beforeEach(async () => {\n    await CommentSummaryScore.destroy({where: {}});\n    await Comment.destroy({where: {}});\n    await ModerationRule.destroy({where: {}});\n    await Tag.destroy({where: {}});\n    await Article.destroy({where: {}});\n    await Category.destroy({where: {}});\n  });\n\n  describe('compileScores', () => {\n    it('should return an object of scores keyed by tag id', () => {\n      const tag1 = Tag.build(getTagData());\n      tag1.id = 1;\n\n      const tag2 = Tag.build(getTagData());\n      tag2.id = 2;\n\n      const score1 = CommentSummaryScore.build(getCommentSummaryScoreData({tagId: 1, score: 0.57}));\n      const score2 = CommentSummaryScore.build(getCommentSummaryScoreData({tagId: 2, score: 0.75}));\n      const scores = [score1, score2];\n\n      const expected = {\n        1: 0.57,\n        2: 0.75,\n      };\n\n      assert.deepEqual(compileScores(scores), expected);\n    });\n\n    it('should get the max scores with the same tag', () => {\n      const tag1 = Tag.build(getTagData());\n      tag1.id = 1;\n\n      const tag2 = Tag.build(getTagData());\n      tag2.id = 2 ;\n\n      const score1 = CommentSummaryScore.build(getCommentSummaryScoreData({tagId: 1, score: 0.5}));\n      const score2 = CommentSummaryScore.build(getCommentSummaryScoreData({tagId: 2, score: 0.6}));\n      const score3 = CommentSummaryScore.build(getCommentSummaryScoreData({tagId: 2, score: 0.8}));\n      const scores = [score1, score2, score3];\n\n      const expected = {\n        1: 0.5,\n        2: 0.8,\n      };\n\n      assert.deepEqual(compileScores(scores), expected);\n    });\n  });\n\n  describe('resolveComment', () => {\n    let comment: any;\n\n    beforeEach(async () => {\n      const category = await createCategory();\n      const article = await createArticle({ categoryId: category.id });\n      comment = await createComment({\n        articleId: article.id,\n        maxSummaryScore:  0.8,\n      });\n    });\n\n    it('should accept a comment when a single \"accept\" action is ruled', async () => {\n      const scores = [\n        CommentSummaryScore.build({\n          commentId: comment.id,\n          tagId: 1,\n          score: 0.5,\n        }),\n      ];\n\n      const rules = [\n        ModerationRule.build({\n          tagId: 1,\n          lowerThreshold: 0.4,\n          upperThreshold: 0.6,\n          action: MODERATION_ACTION_ACCEPT,\n        }),\n      ];\n\n      await resolveComment(comment, scores, rules);\n      const updated = await Comment.findByPk(comment.id);\n      assert.isNotNull(updated);\n\n      if (updated) {\n        assert.isTrue(updated.isAccepted);\n        assert.isTrue(updated.isAutoResolved);\n        assert.isFalse(updated.isHighlighted);\n        assert.isFalse(updated.isDeferred);\n        assert.isTrue(updated.isModerated);\n        assert.isFalse(updated.isScored);\n\n        // Rules shouldn't updated lastModeratedAt\n        const updatedArticle = await updated.getArticle();\n        assert.isNull(updatedArticle!.lastModeratedAt);\n      }\n    });\n\n    it('should accept a comment when a single \"accept\" rule for Summary Score', async () => {\n      const summaryTag = await Tag.build({\n        label: 'Summary Score',\n        key: 'SUMMARY_SCORE',\n      });\n\n      const scores = [\n        CommentSummaryScore.build({\n          commentId: comment.id,\n          tagId: summaryTag.id,\n          score: comment.maxSummaryScore,\n        }),\n      ];\n\n      const rules = [\n        ModerationRule.build({\n          tagId: summaryTag.id,\n          lowerThreshold: 0,\n          upperThreshold: 1,\n          action: MODERATION_ACTION_ACCEPT,\n        }),\n      ];\n\n      await resolveComment(comment, scores, rules);\n      const updated = await Comment.findByPk(comment.id);\n      assert.isNotNull(updated);\n\n      if (updated) {\n        assert.isTrue(updated.isAccepted);\n        assert.isTrue(updated.isAutoResolved);\n        assert.isFalse(updated.isHighlighted);\n        assert.isFalse(updated.isDeferred);\n        assert.isTrue(updated.isModerated);\n        assert.isFalse(updated.isScored);\n\n        const updatedArticle = await updated.getArticle();\n        assert.isNull(updatedArticle!.lastModeratedAt);\n      }\n    });\n\n    it('should accept a comment when unanimous \"accept\" actions are ruled', async () => {\n      const scores = [\n        CommentSummaryScore.build({\n          commentId: comment.id,\n          tagId: 3,\n          score: 0.8,\n        }),\n      ];\n\n      const rules = [\n        ModerationRule.build({\n          tagId: 3,\n          lowerThreshold: 0.7,\n          upperThreshold: 0.9,\n          action: MODERATION_ACTION_ACCEPT,\n        }),\n\n        ModerationRule.build({\n          tagId: 3,\n          lowerThreshold: 0.7,\n          upperThreshold: 0.8,\n          action: MODERATION_ACTION_ACCEPT,\n        }),\n\n        // This should be ignored\n\n        ModerationRule.build({\n          tagId: 3,\n          lowerThreshold: 0.5,\n          upperThreshold: 0.7,\n          action: MODERATION_ACTION_REJECT,\n        }),\n      ];\n\n      await resolveComment(comment, scores, rules);\n      const updated = await Comment.findByPk(comment.id);\n      assert.isNotNull(updated);\n\n      if (updated) {\n        assert.isTrue(updated.isAccepted);\n        assert.isTrue(updated.isAutoResolved);\n        assert.isFalse(updated.isHighlighted);\n        assert.isFalse(updated.isDeferred);\n        assert.isTrue(updated.isModerated);\n        assert.isFalse(updated.isScored);\n\n        const updatedArticle = await updated.getArticle();\n        assert.isNull(updatedArticle!.lastModeratedAt);\n      }\n    });\n\n    it('should accept and highlight a comment when both \"accept\" and \"highlight\" actions are ruled', async () => {\n      const scores = [\n        CommentSummaryScore.build({\n          commentId: comment.id,\n          tagId: 1,\n          score: 0.95,\n        }),\n\n        CommentSummaryScore.build({\n          commentId: comment.id,\n          tagId: 3,\n          score: 0.8,\n        }),\n      ];\n\n      const rules = [\n        ModerationRule.build({\n          tagId: 1,\n          lowerThreshold: 0.7,\n          upperThreshold: 1,\n          action: MODERATION_ACTION_HIGHLIGHT,\n        }),\n\n        ModerationRule.build({\n          tagId: 3,\n          lowerThreshold: 0.7,\n          upperThreshold: 0.8,\n          action: MODERATION_ACTION_ACCEPT,\n        }),\n      ];\n\n      await resolveComment(comment, scores, rules);\n      const updated = await Comment.findByPk(comment.id);\n      assert.isNotNull(updated);\n\n      if (updated) {\n        assert.isTrue(updated.isAccepted);\n        assert.isTrue(updated.isAutoResolved);\n        assert.isTrue(updated.isHighlighted);\n        assert.isFalse(updated.isDeferred);\n        assert.isTrue(updated.isModerated);\n        assert.isFalse(updated.isScored);\n\n        const updatedArticle = await updated.getArticle();\n        assert.isNull(updatedArticle!.lastModeratedAt);\n      }\n    });\n\n    it('should defer a comment when both \"accept\" and \"reject\" actions are ruled', async () => {\n      const scores = [\n        CommentSummaryScore.build({\n          commentId: comment.id,\n          tagId: 1,\n          score: 0.9,\n        }),\n\n        CommentSummaryScore.build({\n          commentId: comment.id,\n          tagId: 2,\n          score: 0.8,\n        }),\n      ];\n\n      const rules = [\n        ModerationRule.build({\n          tagId: 1,\n          lowerThreshold: 0.8,\n          upperThreshold: 0.9,\n          action: MODERATION_ACTION_REJECT,\n        }),\n\n        ModerationRule.build({\n          tagId: 2,\n          lowerThreshold: 0.7,\n          upperThreshold: 0.8,\n          action: MODERATION_ACTION_ACCEPT,\n        }),\n      ];\n\n      await resolveComment(comment, scores, rules);\n      const updated = await Comment.findByPk(comment.id);\n      assert.isNotNull(updated);\n\n      if (updated) {\n        assert.isNull(updated.isAccepted);\n        assert.isTrue(updated.isAutoResolved);\n        assert.isFalse(updated.isHighlighted);\n        assert.isTrue(updated.isDeferred);\n        assert.isTrue(updated.isModerated);\n        assert.isFalse(updated.isScored);\n\n        const updatedArticle = await updated.getArticle();\n        assert.isNull(updatedArticle!.lastModeratedAt);\n      }\n    });\n\n    it('should reject when a \"reject\" action is ruled', async () => {\n      const scores = [\n        CommentSummaryScore.build({\n          commentId: comment.id,\n          tagId: 29,\n          score: 0.64,\n        }),\n      ];\n\n      const rules = [\n        ModerationRule.build({\n          tagId: 29,\n          lowerThreshold: 0.5,\n          upperThreshold: 1,\n          action: MODERATION_ACTION_REJECT,\n        }),\n      ];\n\n      await resolveComment(comment, scores, rules);\n      const updated = await Comment.findByPk(comment.id);\n      assert.isNotNull(updated);\n\n      if (updated) {\n        assert.isFalse(updated.isAccepted);\n        assert.isTrue(updated.isAutoResolved);\n        assert.isFalse(updated.isHighlighted);\n        assert.isFalse(updated.isDeferred);\n        assert.isTrue(updated.isModerated);\n        assert.isFalse(updated.isScored);\n\n        const updatedArticle = await updated.getArticle();\n        assert.isNull(updatedArticle!.lastModeratedAt);\n      }\n    });\n\n    it('should reject when multiple \"reject\" actions are ruled', async () => {\n      const scores = [\n        CommentSummaryScore.build({\n          commentId: comment.id,\n          tagId: 46,\n          score: 0.98,\n        }),\n\n        CommentSummaryScore.build({\n          commentId: comment.id,\n          tagId: 83,\n          score: 0.87,\n        }),\n      ];\n\n      const rules = [\n        ModerationRule.build({\n          tagId: 46,\n          lowerThreshold: 0.9,\n          upperThreshold: 1,\n          action: MODERATION_ACTION_REJECT,\n        }),\n\n        ModerationRule.build({\n          tagId: 83,\n          lowerThreshold: 0.5,\n          upperThreshold: 1,\n          action: MODERATION_ACTION_REJECT,\n        }),\n      ];\n\n      await resolveComment(comment, scores, rules);\n      const updated = await Comment.findByPk(comment.id);\n      assert.isNotNull(updated);\n\n      if (updated) {\n        assert.isFalse(updated.isAccepted);\n        assert.isTrue(updated.isAutoResolved);\n        assert.isFalse(updated.isHighlighted);\n        assert.isFalse(updated.isDeferred);\n        assert.isTrue(updated.isModerated);\n        assert.isFalse(updated.isScored);\n\n        const updatedArticle = await updated.getArticle();\n        assert.isNull(updatedArticle!.lastModeratedAt);\n      }\n    });\n\n    it('should defer when a \"defer\" action is ruled', async () => {\n      const scores = [\n        CommentSummaryScore.build({\n          commentId: comment.id,\n          tagId: 15,\n          score: 0.64,\n        }),\n      ];\n\n      const rules = [\n        ModerationRule.build({\n          tagId: 15,\n          lowerThreshold: 0.5,\n          upperThreshold: 1,\n          action: MODERATION_ACTION_DEFER,\n        }),\n      ];\n\n      await resolveComment(comment, scores, rules);\n      const updated = (await Comment.findByPk(comment.id));\n      assert.isNotNull(updated);\n\n      if (updated) {\n        assert.isNull(updated.isAccepted);\n        assert.isTrue(updated.isAutoResolved);\n        assert.isFalse(updated.isHighlighted);\n        assert.isTrue(updated.isDeferred);\n        assert.isTrue(updated.isModerated);\n        assert.isFalse(updated.isScored);\n\n        const updatedArticle = await updated.getArticle();\n        assert.isNull(updatedArticle!.lastModeratedAt);\n      }\n    });\n\n    it('should defer when both \"accept\" and \"defer\" actions are ruled', async () => {\n      const scores = [\n        CommentSummaryScore.build({\n          commentId: comment.id,\n          tagId: 217,\n          score: 0.45,\n        }),\n\n        CommentSummaryScore.build({\n          commentId: comment.id,\n          tagId: 415,\n          score: 0.67,\n        }),\n      ];\n\n      const rules = [\n        ModerationRule.build({\n          tagId: 217,\n          lowerThreshold: 0.4,\n          upperThreshold: 0.9,\n          action: MODERATION_ACTION_ACCEPT,\n        }),\n\n        ModerationRule.build({\n          tagId: 415,\n          lowerThreshold: 0.5,\n          upperThreshold: 0.7,\n          action: MODERATION_ACTION_DEFER,\n        }),\n\n        // Should be ignored...\n\n        ModerationRule.build({\n          tagId: 415,\n          lowerThreshold: 0.7,\n          upperThreshold: 0.9,\n          action: MODERATION_ACTION_REJECT,\n        }),\n      ];\n\n      await resolveComment(comment, scores, rules);\n      const updated = await Comment.findByPk(comment.id);\n      assert.isNotNull(updated);\n\n      if (updated) {\n        assert.isNull(updated.isAccepted);\n        assert.isTrue(updated.isAutoResolved);\n        assert.isFalse(updated.isHighlighted);\n        assert.isTrue(updated.isDeferred);\n        assert.isTrue(updated.isModerated);\n        assert.isFalse(updated.isScored);\n\n        const updatedArticle = await updated.getArticle();\n        assert.isNull(updatedArticle!.lastModeratedAt);\n      }\n    });\n\n    it('should defer when \"accept\", \"reject\", and \"defer\" actions are ruled', async () => {\n      const scores = [\n        CommentSummaryScore.build({\n          commentId: comment.id,\n          tagId: 91,\n          score: 0.31,\n        }),\n\n        CommentSummaryScore.build({\n          commentId: comment.id,\n          tagId: 294,\n          score: 0.64,\n        }),\n\n        CommentSummaryScore.build({\n          commentId: comment.id,\n          tagId: 19,\n          score: 0.85,\n        }),\n      ];\n\n      const rules = [\n        ModerationRule.build({\n          tagId: 91,\n          lowerThreshold: 0.8,\n          upperThreshold: 0.9,\n          action: MODERATION_ACTION_ACCEPT,\n        }),\n\n        ModerationRule.build({\n          tagId: 294,\n          lowerThreshold: 0.7,\n          upperThreshold: 0.8,\n          action: MODERATION_ACTION_REJECT,\n        }),\n\n        ModerationRule.build({\n          tagId: 19,\n          lowerThreshold: 0.8,\n          upperThreshold: 1,\n          action: MODERATION_ACTION_DEFER,\n        }),\n      ];\n\n      await resolveComment(comment, scores, rules);\n      const updated = await Comment.findByPk(comment.id);\n      assert.isNotNull(updated);\n\n      if (updated) {\n        assert.isNull(updated.isAccepted);\n        assert.isTrue(updated.isAutoResolved);\n        assert.isFalse(updated.isHighlighted);\n        assert.isTrue(updated.isDeferred);\n        assert.isTrue(updated.isModerated);\n        assert.isFalse(updated.isScored);\n\n        const updatedArticle = await updated.getArticle();\n        assert.isNull(updatedArticle!.lastModeratedAt);\n      }\n    });\n\n    it('should highlight a comment if both \"accept\" and \"highlight\" actions are ruled', async () => {\n      const scores = [\n        CommentSummaryScore.build({\n          commentId: comment.id,\n          tagId: 81,\n          score: 0.31,\n        }),\n\n        CommentSummaryScore.build({\n          commentId: comment.id,\n          tagId: 901,\n          score: 0.64,\n        }),\n      ];\n\n      const rules = [\n        ModerationRule.build({\n          tagId: 901,\n          lowerThreshold: 0.1,\n          upperThreshold: 0.9,\n          action: MODERATION_ACTION_ACCEPT,\n        }),\n\n        ModerationRule.build({\n          tagId: 81,\n          lowerThreshold: 0.3,\n          upperThreshold: 0.4,\n          action: MODERATION_ACTION_HIGHLIGHT,\n        }),\n      ];\n\n      await resolveComment(comment, scores, rules);\n      const updated = await Comment.findByPk(comment.id);\n      assert.isNotNull(updated);\n\n      if (updated) {\n        assert.isTrue(updated.isAccepted);\n        assert.isTrue(updated.isAutoResolved);\n        assert.isTrue(updated.isHighlighted);\n        assert.isFalse(updated.isDeferred);\n        assert.isTrue(updated.isModerated);\n        assert.isFalse(updated.isScored);\n\n        const updatedArticle = await updated.getArticle();\n        assert.isNull(updatedArticle!.lastModeratedAt);\n      }\n    });\n\n    it('should defer and not highlight a comment if both \"reject\" and \"highlight\" actions are ruled', async () => {\n      const scores = [\n        CommentSummaryScore.build({\n          commentId: comment.id,\n          tagId: 2,\n          score: 0.87,\n        }),\n\n        CommentSummaryScore.build({\n          commentId: comment.id,\n          tagId: 4,\n          score: 0.43,\n        }),\n\n        CommentSummaryScore.build({\n          commentId: comment.id,\n          tagId: 6,\n          score: 0.91,\n        }),\n      ];\n\n      const rules = [\n        ModerationRule.build({\n          tagId: 2,\n          lowerThreshold: 0.8,\n          upperThreshold: 0.9,\n          action: MODERATION_ACTION_REJECT,\n        }),\n\n        ModerationRule.build({\n          tagId: 4,\n          lowerThreshold: 0.3,\n          upperThreshold: 0.5,\n          action: MODERATION_ACTION_HIGHLIGHT,\n        }),\n      ];\n\n      await resolveComment(comment, scores, rules);\n      const updated = await Comment.findByPk(comment.id);\n      assert.isNotNull(updated);\n\n      if (updated) {\n        assert.isNull(updated.isAccepted, 'isAccepted');\n        assert.isTrue(updated.isAutoResolved, 'isAutoResolved');\n        assert.isFalse(updated.isHighlighted, 'isHighlighted');\n        assert.isTrue(updated.isDeferred, 'isDeferred');\n        assert.isTrue(updated.isModerated, 'isModerated');\n        assert.isFalse(updated.isScored, 'isScored');\n\n        const updatedArticle = await updated.getArticle();\n        assert.isNull(updatedArticle!.lastModeratedAt);\n      }\n    });\n\n    it('should defer and not highlight a comment if both \"defer\" and \"highlight\" actions are ruled', async () => {\n      const scores = [\n        CommentSummaryScore.build({\n          commentId: comment.id,\n          tagId: 5,\n          score: 0.16,\n        }),\n\n        CommentSummaryScore.build({\n          commentId: comment.id,\n          tagId: 10,\n          score: 0.92,\n        }),\n\n        CommentSummaryScore.build({\n          commentId: comment.id,\n          tagId: 15,\n          score: 0.27,\n        }),\n      ];\n\n      const rules = [\n        ModerationRule.build({\n          tagId: 15,\n          lowerThreshold: 0.2,\n          upperThreshold: 0.3,\n          action: MODERATION_ACTION_DEFER,\n        }),\n\n        ModerationRule.build({\n          tagId: 10,\n          lowerThreshold: 0.9,\n          upperThreshold: 1,\n          action: MODERATION_ACTION_HIGHLIGHT,\n        }),\n\n        // Should be ignored\n\n        ModerationRule.build({\n          tagId: 5,\n          lowerThreshold: 0.3,\n          upperThreshold: 0.5,\n          action: MODERATION_ACTION_ACCEPT,\n        }),\n      ];\n\n      await resolveComment(comment, scores, rules);\n      const updated = await Comment.findByPk(comment.id);\n      assert.isNotNull(updated);\n\n      if (updated) {\n        assert.isNull(updated.isAccepted, 'isAccepted');\n        assert.isTrue(updated.isAutoResolved, 'isAutoResolved');\n        assert.isFalse(updated.isHighlighted, 'isHighlighted');\n        assert.isTrue(updated.isDeferred, 'isDeferred');\n        assert.isTrue(updated.isModerated, 'isModerated');\n        assert.isFalse(updated.isScored, 'isScored');\n\n        const updatedArticle = await updated.getArticle();\n        assert.isNull(updatedArticle!.lastModeratedAt);\n      }\n    });\n\n    it('should do nothing to the comment if no rules match', async () => {\n      const scores = [\n        CommentSummaryScore.build({\n          commentId: comment.id,\n          tagId: 4,\n          score: 0.16,\n        }),\n\n        CommentSummaryScore.build({\n          commentId: comment.id,\n          tagId: 12,\n          score: 0.92,\n        }),\n      ];\n\n      const rules = [\n        ModerationRule.build({\n          tagId: 12,\n          lowerThreshold: 0.2,\n          upperThreshold: 0.3,\n          action: MODERATION_ACTION_REJECT,\n        }),\n\n        ModerationRule.build({\n          tagId: 4,\n          lowerThreshold: 0.9,\n          upperThreshold: 1,\n          action: MODERATION_ACTION_HIGHLIGHT,\n        }),\n      ];\n\n      await resolveComment(comment, scores, rules);\n      const updated = await Comment.findByPk(comment.id);\n      assert.isNotNull(updated);\n\n      if (updated) {\n        assert.isNull(updated.isAccepted, 'isAccepted');\n        assert.isFalse(updated.isAutoResolved, 'isAutoResolved');\n        assert.isFalse(updated.isHighlighted, 'isHighlighted');\n        assert.isFalse(updated.isDeferred, 'isDeferred');\n        assert.isFalse(updated.isModerated, 'isModerated');\n        assert.isFalse(updated.isScored, 'isScored');\n\n        const updatedArticle = await updated.getArticle();\n        assert.isNull(updatedArticle!.lastModeratedAt);\n      }\n    });\n  });\n\n  describe('processRulesForComment', () => {\n    it('should do nothing if no matching rules are found', async () => {\n      const category = await createCategory();\n      const article = await createArticle({ categoryId: category.id });\n      const comment = await createComment({ articleId: article.id });\n\n      await processRulesForComment(comment);\n      const updated = await Comment.findByPk(comment.id);\n      assert.isNotNull(updated);\n\n      if (updated) {\n        assert.isNull(updated.isAccepted);\n        assert.isFalse(updated.isAutoResolved);\n        assert.isFalse(updated.isHighlighted);\n        assert.isFalse(updated.isDeferred);\n        assert.isFalse(updated.isModerated);\n        assert.isFalse(updated.isScored);\n\n        const updatedArticle = await updated.getArticle();\n        assert.isNull(updatedArticle!.lastModeratedAt);\n      }\n    });\n\n    it('should do nothing if no comment scores are found', async () => {\n      const category = await createCategory();\n      const article = await createArticle({ categoryId: category.id });\n      const comment = await createComment({ articleId: article.id });\n\n      const [tag1, tag2] = await Promise.all([\n        createTag(),\n        createTag(),\n      ]);\n\n      await Promise.all([\n        createModerationRule({ tagId: tag1.id }),\n        createModerationRule({ tagId: tag2.id }),\n      ]);\n\n      await processRulesForComment(comment);\n      const updated = await Comment.findByPk(comment.id);\n      assert.isNotNull(updated);\n\n      if (updated) {\n        assert.isNull(updated.isAccepted);\n        assert.isFalse(updated.isAutoResolved);\n        assert.isFalse(updated.isHighlighted);\n        assert.isFalse(updated.isDeferred);\n        assert.isFalse(updated.isModerated);\n        assert.isFalse(updated.isScored);\n      }\n    });\n\n    it('should mark a comment accepted for matching rules', async () => {\n      const category = await createCategory();\n      const article = await createArticle({ categoryId: category.id });\n      const comment = await createComment({ articleId: article.id });\n\n      const [tag1, tag2] = await Promise.all([\n        createTag(),\n        createTag(),\n      ]);\n\n      await Promise.all([\n        createModerationRule({\n          tagId: tag1.id,\n          lowerThreshold: 0.5,\n          upperThreshold: 1,\n          action: MODERATION_ACTION_ACCEPT,\n        }),\n\n        createModerationRule({\n          tagId: tag2.id,\n          lowerThreshold: 0.25,\n          upperThreshold: 0.75,\n          action: MODERATION_ACTION_ACCEPT,\n        }),\n      ]);\n\n      await Promise.all([\n        createCommentSummaryScore({\n          commentId: comment.id,\n          tagId: tag1.id,\n          score: 0.75,\n        }),\n\n        createCommentSummaryScore({\n          commentId: comment.id,\n          tagId: tag2.id,\n          score: 0.5,\n        }),\n      ]);\n\n      await processRulesForComment(comment);\n      const updated = await Comment.findByPk(comment.id);\n      assert.isNotNull(updated);\n\n      if (updated) {\n        assert.isTrue(updated.isAccepted);\n        assert.isTrue(updated.isAutoResolved);\n        assert.isFalse(updated.isHighlighted);\n        assert.isFalse(updated.isDeferred);\n        assert.isTrue(updated.isModerated);\n        assert.isFalse(updated.isScored);\n\n        const updatedArticle = await updated.getArticle();\n        assert.isNull(updatedArticle!.lastModeratedAt);\n      }\n    });\n\n    it('should do nothing if article has disabled rule processing', async () => {\n      const category = await createCategory();\n      const article = await createArticle({ categoryId: category.id, isAutoModerated: false });\n      const comment = await createComment({ articleId: article.id });\n\n      const [tag1, tag2] = await Promise.all([\n        createTag(),\n        createTag(),\n      ]);\n\n      await Promise.all([\n        createModerationRule({\n          tagId: tag1.id,\n          lowerThreshold: 0.5,\n          upperThreshold: 1,\n          action: MODERATION_ACTION_ACCEPT,\n        }),\n        createModerationRule({\n          tagId: tag2.id,\n          lowerThreshold: 0.25,\n          upperThreshold: 0.75,\n          action: MODERATION_ACTION_ACCEPT,\n        }),\n      ]);\n\n      await Promise.all([\n        createCommentSummaryScore({\n          commentId: comment.id,\n          tagId: tag1.id,\n          score: 0.75,\n        }),\n\n        createCommentSummaryScore({\n          commentId: comment.id,\n          tagId: tag2.id,\n          score: 0.5,\n        }),\n      ]);\n\n      await processRulesForComment(comment);\n\n      const updated = await Comment.findByPk(comment.id);\n      assert.isNotNull(updated);\n      if (updated) {\n        assert.isNull(updated.isAccepted);\n      }\n    });\n  });\n});\n"
  },
  {
    "path": "packages/backend-api/src/test/pipeline/state.spec.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as chai from 'chai';\nimport * as moment from 'moment';\n\nimport {\n  Comment,\n  CommentScoreRequest,\n  Decision,\n  IResolution,\n  MODERATION_ACTION_ACCEPT,\n  MODERATION_ACTION_DEFER,\n  MODERATION_ACTION_REJECT,\n} from '../../models';\nimport {\n  approve,\n  defer,\n  getIsDoneScoring,\n  highlight,\n  reject,\n  scoresComplete,\n  setCommentState,\n} from '../../pipeline/state';\nimport {\n  createArticle,\n  createComment,\n  createCommentScoreRequest,\n  createModeratorUser,\n  createUser,\n} from '../domain/comments/fixture';\n\n// tslint:disable no-import-side-effect\nimport '../test_helper';\n// tslint:enable no-import-side-effect\n\nconst assert = chai.assert;\n\nasync function shouldRecordDecision(\n  comment: Comment,\n  status: IResolution,\n  source: 'User' | 'Rule',\n  userId: number) {\n  const foundDecisions = await Decision.findAll({\n    where: { commentId: comment.id },\n  });\n\n  assert.lengthOf(foundDecisions, 1);\n\n  const firstDecision = foundDecisions[0];\n\n  assert.equal(firstDecision.commentId, comment.id);\n  assert.equal(firstDecision.userId, userId);\n  assert.equal(firstDecision.source, source);\n  assert.equal(firstDecision.status, status);\n  assert.isTrue(firstDecision.isCurrentDecision);\n  const article = await comment.getArticle();\n  assert.isNotNull(article!.lastModeratedAt);\n\n}\n\ndescribe('Pipeline States Tests', () => {\n  let comment: any;\n  let article;\n\n  beforeEach(async () => {\n    article = await createArticle();\n    comment = await createComment({ articleId: article.id });\n  });\n\n  describe('scoresComplete', () => {\n    it('should return false for no score requests', () => {\n      assert.isFalse(scoresComplete([]));\n    });\n\n    it('should return false with score requests that dont have a `doneAt` value set', () => {\n      const scoreRequests = [\n        CommentScoreRequest.build({\n          commentId: 1,\n          userId: 1,\n          sentAt: moment().subtract(2, 'weeks').toDate(),\n          doneAt: null,\n        }),\n      ];\n\n      assert.isFalse(scoresComplete(scoreRequests));\n    });\n\n    it('should return false with a mix of `doneAt` settings', () => {\n      const scoreRequests = [\n        CommentScoreRequest.build({\n          commentId: 1,\n          userId: 1,\n          sentAt: moment().subtract(2, 'weeks').toDate(),\n          doneAt: moment().toDate(),\n        }),\n\n        CommentScoreRequest.build({\n          commentId: 1,\n          userId: 2,\n          sentAt: moment().subtract(2, 'weeks').toDate(),\n          doneAt: null,\n        }),\n      ];\n\n      assert.isFalse(scoresComplete(scoreRequests));\n    });\n\n    it('should return true when all score requests have `doneAt` value set', () => {\n      const scoreRequests = [\n        CommentScoreRequest.build({\n          commentId: 1,\n          userId: 1,\n          sentAt: moment().subtract(2, 'weeks').toDate(),\n          doneAt: moment().toDate(),\n        }),\n\n        CommentScoreRequest.build({\n          commentId: 1,\n          userId: 2,\n          sentAt: moment().subtract(2, 'weeks').toDate(),\n          doneAt: moment().toDate(),\n        }),\n      ];\n\n      assert.isTrue(scoresComplete(scoreRequests));\n    });\n\n    it('should return true when a scorer has repeat request and the latter has a `doneAt` set', () => {\n      const scoreRequests = [\n        CommentScoreRequest.build({\n          commentId: 1,\n          userId: 1,\n          sentAt: moment().subtract(2, 'weeks').toDate(),\n          doneAt: null,\n        }),\n\n        CommentScoreRequest.build({\n          commentId: 1,\n          userId: 1,\n          sentAt: moment().subtract(2, 'weeks').toDate(),\n          doneAt: moment().toDate(),\n        }),\n\n        CommentScoreRequest.build({\n          commentId: 1,\n          userId: 2,\n          sentAt: moment().subtract(2, 'weeks').toDate(),\n          doneAt: moment().toDate(),\n        }),\n      ];\n\n      assert.isTrue(scoresComplete(scoreRequests));\n    });\n  });\n\n  describe('getIsDoneScoring', () => {\n    it('should resolve to true if all score requests have a `doneAt` timestamp set', async () => {\n      const [scorer1, scorer2] = await Promise.all([\n        createModeratorUser(),\n        createModeratorUser(),\n      ]);\n\n      await Promise.all([\n        createCommentScoreRequest({\n          commentId: comment.id,\n          userId: scorer1.id,\n          doneAt: moment().toDate(),\n        }),\n        createCommentScoreRequest({\n          commentId: comment.id,\n          userId: scorer2.id,\n          doneAt: moment().toDate(),\n        }),\n      ]);\n\n      const isDoneScoring = await getIsDoneScoring(comment.id);\n      assert.isTrue(isDoneScoring);\n    });\n\n    it('should resolve to false if unique scorer requests dont have a `doneAt` timestamp set', async () => {\n\n      const [scorer1, scorer2] = await Promise.all([\n        createModeratorUser(),\n        createModeratorUser(),\n      ]);\n\n      await Promise.all([\n        createCommentScoreRequest({\n          commentId: comment.id,\n          userId: scorer1.id,\n          doneAt: moment().toDate(),\n        }),\n        createCommentScoreRequest({\n          commentId: comment.id,\n          userId: scorer2.id,\n        }),\n      ]);\n\n      const isDoneScoring = await getIsDoneScoring(comment.id);\n      assert.isFalse(isDoneScoring);\n    });\n\n    it(\n      'should resolve to true if all requests have `doneAt` and at least one of multiple requests to a scorer is set',\n      async () => {\n\n        const [scorer1, scorer2] = await Promise.all([\n          createModeratorUser(),\n          createModeratorUser(),\n        ]);\n\n        await Promise.all([\n          createCommentScoreRequest({\n            commentId: comment.id,\n            userId: scorer1.id,\n          }),\n          createCommentScoreRequest({\n            commentId: comment.id,\n            userId: scorer1.id,\n            doneAt: moment().toDate(),\n          }),\n          createCommentScoreRequest({\n            commentId: comment.id,\n            userId: scorer2.id,\n            doneAt: moment().toDate(),\n          }),\n        ]);\n\n        const isDoneScoring = await getIsDoneScoring(comment.id);\n        assert.isTrue(isDoneScoring);\n      },\n    );\n  });\n\n  describe('setCommentState', () => {\n    it('should set the passed in state on the comment', async () => {\n      const updated = await setCommentState(comment, null, { isAccepted: true });\n\n      assert.equal(comment.id, updated.id);\n      assert.isTrue(updated.isAccepted);\n    });\n\n    it('should include optional data and exclude conflicting keys with state', async () => {\n      const updated = await setCommentState(\n        comment,\n        null,\n        { isHighlighted: true },\n        { isHighlighted: false, isBatchResolved: true },\n      );\n\n      assert.equal(comment.id, updated.id);\n      assert.isTrue(updated.isHighlighted);\n      assert.isTrue(updated.isBatchResolved);\n      const updatedArticle = await updated.getArticle();\n      assert.isNull(updatedArticle!.lastModeratedAt);\n    });\n  });\n\n  describe('approve', () => {\n    it('should set the passed in comment to a \"approved\" state and save it', async () => {\n      const user = await createUser();\n      const updated = await approve(comment, user);\n\n      assert.equal(comment.id, updated.id);\n      assert.isTrue(updated.isAccepted);\n      assert.isFalse(updated.isDeferred);\n\n      await shouldRecordDecision(updated, MODERATION_ACTION_ACCEPT, 'User', user.id);\n    });\n\n    it('should optionally accept additional data', async () => {\n      const user = await createUser();\n      const updated = await approve(comment, user);\n\n      assert.equal(comment.id, updated.id);\n      assert.isTrue(updated.isAccepted);\n      assert.isFalse(updated.isDeferred);\n\n      await shouldRecordDecision(updated, MODERATION_ACTION_ACCEPT, 'User', user.id);\n    });\n  });\n\n  describe('reject', () => {\n    it('should set the passed in comment to a \"rejected\" state and save it', async () => {\n      const user = await createUser();\n      const updated = await reject(comment, user);\n\n      assert.equal(comment.id, updated.id);\n      assert.isFalse(updated.isAccepted);\n      assert.isFalse(updated.isDeferred);\n\n      await shouldRecordDecision(updated, MODERATION_ACTION_REJECT, 'User', user.id);\n    });\n  });\n\n  describe('defer', () => {\n    it('should set the passed in comment to a \"deferred\" state and save it', async () => {\n      const user = await createUser();\n      const updated = await defer(comment, user);\n\n      assert.equal(comment.id, updated.id);\n      assert.isNull(updated.isAccepted);\n      assert.isTrue(updated.isDeferred);\n\n      await shouldRecordDecision(updated, MODERATION_ACTION_DEFER, 'User', user.id);\n    });\n  });\n\n  describe('highlight', () => {\n    it('should set the passed in comment to a \"highlighted\" state and save it', async () => {\n      const user = await createUser();\n      const updated = await highlight(comment, user);\n\n      assert.equal(comment.id, updated.id);\n      assert.isTrue(updated.isHighlighted);\n    });\n  });\n});\n"
  },
  {
    "path": "packages/backend-api/src/test/test_helper.ts",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as winston from 'winston';\n\nimport { logger } from '../logger';\nimport { setTestMode } from '../notification_router';\nimport { quit } from '../redis';\nimport { sequelize } from '../sequelize';\n\nconst TEST_ENVS = ['test', 'circle_ci'];\n\nfunction isTestEnv() {\n  return TEST_ENVS.indexOf(process.env.NODE_ENV || '') > -1;\n}\n\nlogger.configure({\n  level: 'error',\n  transports: [\n    new winston.transports.Console({\n      format: winston.format.simple(),\n    }),\n  ],\n});\n\nlet ignoreOthers = false;\n\nexport async function cleanDatabase(isRoot?: boolean) {\n  if (!isTestEnv()) {\n    throw new Error('Refusing to destroy database if NODE_ENV is not `test`.');\n  }\n\n  if (ignoreOthers && !isRoot) {\n    return;\n  }\n\n  await sequelize.sync({ force: true });\n  ignoreOthers = true;\n  setTestMode();\n}\n\nexport async function dropDatabase() {\n  if (!isTestEnv()) {\n    throw new Error('Refusing to destroy database if NODE_ENV is not `test`.');\n  }\n  await sequelize.drop();\n  await sequelize.close();\n  await quit();\n}\n\nbefore('Clean database before', () => cleanDatabase(true));\nafter(dropDatabase);\n"
  },
  {
    "path": "packages/backend-api/src/test/unit/services/authorCounts.spec.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport {\n  Comment,\n  CommentScoreRequest,\n} from '../../../models';\n\nimport {\n  expect,\n  makeComment,\n} from '../../fixture';\n\nimport {\n  getAuthorCounts,\n} from '../../../api/services/authorCounts';\n\ndescribe('authorCounts Functions', () => {\n  beforeEach(async () => {\n    await CommentScoreRequest.destroy({where: {}});\n    await Comment.destroy({where: {}});\n  });\n\n  describe('getAuthorCounts', () => {\n    it('should return 0 for unknown authors', async () => {\n      const results = await getAuthorCounts('fake');\n\n      expect(results).to.be.deep.equal({\n        approvedCount: 0,\n        rejectedCount: 0,\n      });\n    });\n\n    it('should count accepted and rejected', async () => {\n      await makeComment({ authorSourceId: 'something else', isAccepted: true });\n\n      const authorSourceId = 'test123';\n      const approvedCount = 2;\n      const rejectedCount = 7;\n      const otherCount = 2;\n\n      for (let i = 0; i < approvedCount; i++) {\n        await makeComment({ authorSourceId, isAccepted: true });\n      }\n\n      for (let i = 0; i < rejectedCount; i++) {\n        await makeComment({ authorSourceId, isAccepted: false });\n      }\n\n      for (let i = 0; i < otherCount; i++) {\n        await makeComment({ authorSourceId, isAccepted: null });\n      }\n\n      const results = await getAuthorCounts(authorSourceId);\n\n      expect(results).to.be.deep.equal({\n        approvedCount,\n        rejectedCount,\n      });\n    });\n  });\n\n});\n"
  },
  {
    "path": "packages/backend-api/src/test/unit/services/histogramScores.spec.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport {\n  Article,\n  Category,\n  Comment,\n  CommentSummaryScore,\n  Tag,\n} from '../../../models';\n\nimport {\n  expect,\n  makeArticle,\n  makeCategory,\n  makeComment,\n  makeCommentSummaryScore,\n  makeTag,\n} from '../../fixture';\n\nimport {\n  getHistogramScoresForAllCategories,\n  getHistogramScoresForArticle,\n  getHistogramScoresForCategory,\n  NotFoundError,\n} from '../../../api/services/histogramScores/util';\n\ndescribe('histogramScores Functions', () => {\n  beforeEach(async () => {\n    await CommentSummaryScore.destroy({where: {}});\n    await Comment.destroy({where: {}});\n    await Article.destroy({where: {}});\n    await Category.destroy({where: {}});\n    await Tag.destroy({where: {}});\n  });\n\n  describe('getHistogramScoresForAllCategories', () => {\n    it('returns scores across all categories for tag', async () => {\n      const tag = await makeTag({ key: 'SPAM', label: 'spam' });\n\n      async function createScore(categoryLabel: string, scoreValue: number) {\n        const category = await makeCategory({ label: categoryLabel });\n        const article = await makeArticle({ categoryId: category.id });\n        const comment = await makeComment({ articleId: article.id});\n        const score = await makeCommentSummaryScore({ commentId: comment.id, tagId: tag.id, score: scoreValue });\n\n        return { category, article, comment, score };\n      }\n\n      const result1 = await createScore('One', 1.0);\n      const result2 = await createScore('Two', 0.5);\n\n      const results = await getHistogramScoresForAllCategories(tag.id);\n\n      expect(results).to.be.lengthOf(2);\n      expect(results).to.deep.include({\n        commentId: result1.comment.id,\n        score: 1.0,\n      });\n\n      expect(results).to.deep.include({\n        commentId: result2.comment.id,\n        score: 0.5,\n      });\n    });\n\n    it('throw if missing tag', async () => {\n      let wasThrown = false;\n\n      try {\n        await getHistogramScoresForAllCategories(1);\n      } catch (e) {\n        wasThrown = true;\n        expect(e).to.be.an.instanceOf(NotFoundError);\n      } finally {\n        expect(wasThrown).to.be.true;\n      }\n    });\n  });\n\n  describe('getHistogramScoresForCategory', () => {\n    it('returns scores in a category for tag', async () => {\n      const tag = await makeTag({ key: 'SPAM', label: 'spam' });\n      const category1 = await makeCategory({ label: 'Category 1' });\n      const category2 = await makeCategory({ label: 'Category 2' });\n\n      async function createScore(scoreValue: number, category: Category) {\n        const article = await makeArticle({ categoryId: category.id });\n        const comment = await makeComment({ articleId: article.id});\n        const score = await makeCommentSummaryScore({ commentId: comment.id, tagId: tag.id, score: scoreValue });\n\n        return { article, comment, score };\n      }\n\n      const result1 = await createScore(1.0, category1);\n      const result2 = await createScore(0.5, category1);\n\n      // Should not appear\n      await createScore(0.25, category2);\n\n      const results = await getHistogramScoresForCategory(category1.id, tag.id);\n\n      expect(results).to.be.lengthOf(2);\n      expect(results).to.deep.include({\n        commentId: result1.comment.id,\n        score: 1.0,\n      });\n\n      expect(results).to.deep.include({\n        commentId: result2.comment.id,\n        score: 0.5,\n      });\n    });\n\n    it('throw if missing category', async () => {\n      const tag = await makeTag({ key: 'SPAM', label: 'spam' });\n      let wasThrown = false;\n\n      try {\n        await getHistogramScoresForCategory(0, tag.id);\n      } catch (e) {\n        wasThrown = true;\n        expect(e).to.be.an.instanceOf(NotFoundError);\n      } finally {\n        expect(wasThrown).to.be.true;\n      }\n    });\n\n    it('throw if missing tag', async () => {\n      const category = await makeCategory({ label: 'Category 1' });\n      let wasThrown = false;\n\n      try {\n        await getHistogramScoresForCategory(category.id, 0);\n      } catch (e) {\n        wasThrown = true;\n        expect(e).to.be.an.instanceOf(NotFoundError);\n      } finally {\n        expect(wasThrown).to.be.true;\n      }\n    });\n  });\n\n  describe('getHistogramScoresForArticle', () => {\n    it('returns scores in an article for tag', async () => {\n      const tag = await makeTag({ key: 'SPAM', label: 'spam' });\n      const article1 = await makeArticle();\n      const article2 = await makeArticle();\n\n      async function createScore(scoreValue: number, article: Article) {\n        const comment = await makeComment({ articleId: article.id});\n        const score = await makeCommentSummaryScore({ commentId: comment.id, tagId: tag.id, score: scoreValue });\n\n        return { article, comment, score };\n      }\n\n      const result1 = await createScore(1.0, article1);\n      const result2 = await createScore(0.5, article1);\n\n      // Should not appear\n      await createScore(0.25, article2);\n\n      const results = await getHistogramScoresForArticle(article1.id, tag.id);\n\n      expect(results).to.be.lengthOf(2);\n      expect(results).to.deep.include({\n        commentId: result1.comment.id,\n        score: 1.0,\n      });\n\n      expect(results).to.deep.include({\n        commentId: result2.comment.id,\n        score: 0.5,\n      });\n    });\n\n    it('throw if missing category', async () => {\n      const tag = await makeTag({ key: 'SPAM', label: 'spam' });\n      let wasThrown = false;\n\n      try {\n        await getHistogramScoresForArticle(0, tag.id);\n      } catch (e) {\n        wasThrown = true;\n        expect(e).to.be.an.instanceOf(NotFoundError);\n      } finally {\n        expect(wasThrown).to.be.true;\n      }\n    });\n\n    it('throw if missing tag', async () => {\n      const article = await makeArticle();\n      let wasThrown = false;\n\n      try {\n        await getHistogramScoresForArticle(article.id, 0);\n      } catch (e) {\n        wasThrown = true;\n        expect(e).to.be.an.instanceOf(NotFoundError);\n      } finally {\n        expect(wasThrown).to.be.true;\n      }\n    });\n  });\n});\n"
  },
  {
    "path": "packages/backend-api/src/test/unit/util/notifications.spec.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as chai from 'chai';\n\nimport {updateArticleAssignments, updateCategoryAssignments} from '../../../actions/assignment_updaters';\nimport {\n  createRangeObject,\n  createTagObject,\n  deleteRangeObject,\n  modifyRangeObject,\n  modifyTagObject,\n} from '../../../actions/object_updaters';\nimport { denormalizeCommentCountsForArticle } from '../../../domain';\nimport {\n  Article,\n  Category,\n  ModerationRule,\n  MODERATION_ACTION_ACCEPT,\n  ModeratorAssignment,\n  Preselect,\n  Tag,\n  TaggingSensitivity,\n  User,\n  UserCategoryAssignment,\n} from '../../../models';\nimport {\n  clearInterested,\n  createSendNotificationHook,\n  registerInterest,\n  sendNotification,\n} from '../../../notification_router';\nimport {\n  makeArticle,\n  makeCategory,\n  makeTag,\n  makeUser,\n} from '../../fixture';\n\nconst assert = chai.assert;\n\nasync function awaitNotification(action: () => Promise<void>): Promise<Array<boolean>> {\n  let notifyHappened = false;\n  let notifyPartialHappened = false;\n  let id: NodeJS.Timer;\n\n  const timeout = new Promise((_, reject) => {\n    id = setTimeout(() => {\n      reject('Timed out while waiting for notification');\n    }, 1000);\n  });\n\n  const notification = new Promise<void>((resolve, _) => {\n    registerInterest({\n      processNotification: async (data) => {\n        if (data.objectType === 'article') {\n          notifyPartialHappened = true;\n        } else {\n          notifyHappened = true;\n        }\n        resolve();\n      },\n    });\n  });\n\n  await action();\n\n  await Promise.race([\n    timeout,\n    notification,\n  ]);\n  clearTimeout(id!);\n  clearInterested();\n  return [notifyHappened, notifyPartialHappened];\n}\n\ndescribe('Notification tests', () => {\n  beforeEach(async () => {\n    await Article.destroy({where: {}});\n    await Category.destroy({where: {}});\n    await User.destroy({where: {}});\n    await Tag.destroy({where: {}});\n    await TaggingSensitivity.destroy({where: {}});\n    await ModerationRule.destroy({where: {}});\n    await Preselect.destroy({where: {}});\n    await UserCategoryAssignment.destroy({where: {}});\n    await ModeratorAssignment.destroy({where: {}});\n  });\n\n  afterEach(clearInterested);\n\n  it('Test notifier directly', async () => {\n    const res = await awaitNotification(async () => {\n      sendNotification('global');\n    });\n    assert.isTrue(res[0]);\n    assert.isFalse(res[1]);\n  });\n\n  it('Test partial notifier directly', async () => {\n    const res = await awaitNotification(async () => {\n      sendNotification('article', 'modify', 1);\n    });\n    assert.isFalse(res[0]);\n    assert.isTrue(res[1]);\n  });\n\n  it('Test notifier hook', async () => {\n    const res = await awaitNotification(async () => {\n      const notifier = createSendNotificationHook<string>('article', 'modify', (_) => 1);\n      notifier( '1');\n    });\n    assert.isFalse(res[0]);\n    assert.isTrue(res[1]);\n\n    const res2 = await awaitNotification(async () => {\n      await makeArticle();\n    });\n    assert.isFalse(res2[0]);\n    assert.isTrue(res2[1]);\n  });\n\n  it('Test notifier when denormalisation happens', async () => {\n    const article = await makeArticle();\n    article.allCount = 5;\n    await article.save();\n\n    const res = await awaitNotification(async () => {\n      await denormalizeCommentCountsForArticle(article, false);\n    });\n\n    assert.isFalse(res[0]);\n    assert.isTrue(res[1]);\n  });\n\n  it('Test notifier when denormalisation happens (this time with a category)', async () => {\n    const category = await makeCategory();\n    category.allCount = 5;\n    await category.save();\n    const article = await makeArticle({categoryId: category.id} );\n    article.allCount = 5;\n    await article.save();\n\n    const res = await awaitNotification(async () => {\n      await denormalizeCommentCountsForArticle(article, false);\n    });\n\n    assert.isTrue(res[0]);\n    assert.isTrue(res[1]);\n  });\n\n  it('Test category assignment', async () => {\n    const category = await makeCategory();\n    await makeArticle({categoryId: category.id} );\n    const user = await makeUser();\n\n    const res = await awaitNotification(async () => {\n      await updateCategoryAssignments(category.id, [user.id]);\n    });\n\n    assert.isTrue(res[0]);\n    assert.isTrue(res[1]);\n  });\n\n  it('Test article assignment', async () => {\n    const category = await makeCategory();\n    const article = await makeArticle({categoryId: category.id} );\n    const user = await makeUser();\n\n    const res = await awaitNotification(async () => {\n      await updateArticleAssignments(article.id, new Set([user.id]));\n    });\n\n    assert.isFalse(res[0]);\n    assert.isTrue(res[1]);\n  });\n\n  it('Test notifies when user updated', async () => {\n    let user: User;\n\n    const res = await awaitNotification(async () => {\n      user = await makeUser();\n    });\n    assert.isTrue(res[0]);\n    assert.isFalse(res[1]);\n\n    const res2 = await awaitNotification(async () => {\n      user.update({\n        name: 'newname',\n      });\n    });\n    assert.isTrue(res2[0]);\n    assert.isFalse(res2[1]);\n  });\n\n  it('Test notifies when tag updated', async () => {\n\n    const res = await awaitNotification(async () => {\n      await createTagObject({\n        key: 'test',\n        label: 'Test',\n        color: '#FFFFFF',\n      });\n    });\n    assert.isTrue(res[0]);\n    assert.isFalse(res[1]);\n\n    const tag = await Tag.findOne();\n\n    const res2 = await awaitNotification(async () => {\n      await modifyTagObject(tag!.id, {\n        label: 'newname',\n      });\n    });\n    assert.isTrue(res2[0]);\n    assert.isFalse(res2[1]);\n\n    const res3 = await awaitNotification(async () => {\n      await deleteRangeObject('tag', tag!.id);\n    });\n    assert.isTrue(res3[0]);\n    assert.isFalse(res3[1]);\n  });\n\n  it('Test notifies when taggingSensitivity updated', async () => {\n    const res = await awaitNotification(async () => {\n      await createRangeObject('tagging_sensitivity', {\n        lowerThreshold: 0,\n        upperThreshold: 1,\n      });\n    });\n    assert.isTrue(res[0]);\n    assert.isFalse(res[1]);\n\n    const ts = await TaggingSensitivity.findOne();\n\n    const res2 = await awaitNotification(async () => {\n      await modifyRangeObject('tagging_sensitivity', ts!.id, {\n        lowerThreshold: 0.5,\n      });\n    });\n    assert.isTrue(res2[0]);\n    assert.isFalse(res2[1]);\n\n    const res3 = await awaitNotification(async () => {\n      await deleteRangeObject('tagging_sensitivity', ts!.id);\n    });\n    assert.isTrue(res3[0]);\n    assert.isFalse(res3[1]);\n  });\n\n  it('Test notifies when rule updated', async () => {\n    const t = await makeTag();\n\n    const res = await awaitNotification(async () => {\n      await createRangeObject('moderation_rule', {\n        tagId: t.id,\n        action: MODERATION_ACTION_ACCEPT,\n        lowerThreshold: 0,\n        upperThreshold: 1,\n      });\n    });\n    assert.isTrue(res[0]);\n    assert.isFalse(res[1]);\n\n    const mr = await ModerationRule.findOne();\n\n    const res2 = await awaitNotification(async () => {\n      await modifyRangeObject('moderation_rule', mr!.id, {\n        lowerThreshold: 0.5,\n      });\n    });\n    assert.isTrue(res2[0]);\n    assert.isFalse(res2[1]);\n\n    const res3 = await awaitNotification(async () => {\n      await deleteRangeObject('moderation_rule', mr!.id);\n    });\n    assert.isTrue(res3[0]);\n    assert.isFalse(res3[1]);\n  });\n\n  it('Test notifies when preselect updated', async () => {\n    const res = await awaitNotification(async () => {\n      await createRangeObject('preselect', {\n        lowerThreshold: 0,\n        upperThreshold: 1,\n      });\n    });\n    assert.isTrue(res[0]);\n    assert.isFalse(res[1]);\n\n    const ps = await Preselect.findOne();\n\n    const res2 = await awaitNotification(async () => {\n      await modifyRangeObject('preselect', ps!.id, {\n        lowerThreshold: 0.5,\n      });\n    });\n    assert.isTrue(res2[0]);\n    assert.isFalse(res2[1]);\n\n    const res3 = await awaitNotification(async () => {\n      await deleteRangeObject('preselect', ps!.id);\n    });\n    assert.isTrue(res3[0]);\n    assert.isFalse(res3[1]);\n  });\n});\n"
  },
  {
    "path": "packages/backend-api/src/worker.ts",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { syncYoutubeTask } from './integrations';\nimport { USER_GROUP_YOUTUBE } from './models';\nimport { heartbeatTask, registerWorkItem, startWorker } from './processing';\n\nregisterWorkItem(USER_GROUP_YOUTUBE, syncYoutubeTask);\nregisterWorkItem('heartbeat', heartbeatTask);\nstartWorker();\n"
  },
  {
    "path": "packages/backend-api/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"es6\",\n    \"module\": \"commonjs\",\n    \"noImplicitAny\": true,\n    \"noUnusedParameters\": true,\n    \"noUnusedLocals\": true,\n    \"strictNullChecks\": true,\n    \"baseUrl\": \".\",\n    \"paths\": {\n      \"passport\": [\"./node_modules/@types/passport/index\"]\n    }\n  },\n  \"exclude\": [\n    \"node_modules\",\n    \"dist\"\n  ]\n}\n"
  },
  {
    "path": "packages/frontend-web/.babelrc",
    "content": "{\n  \"presets\": [\n    \"@babel/preset-react\",\n    \"@babel/preset-env\",\n    \"@babel/preset-typescript\"\n  ],\n  \"plugins\": [\n    \"lodash\",\n    \"babel-plugin-react-require\",\n    \"@babel/transform-react-constant-elements\",\n    \"@babel/transform-react-inline-elements\",\n    \"react-hot-loader/babel\",\n    \"@babel/proposal-object-rest-spread\",\n    [\"@babel/proposal-decorators\", { \"legacy\": true }],\n    [\"@babel/plugin-proposal-class-properties\", { \"loose\": true }]\n  ],\n  \"env\": {\n    \"test\": {\n      \"plugins\": [\n        \"require-context-hook\"\n      ]\n    }\n  }\n}\n"
  },
  {
    "path": "packages/frontend-web/LICENSE",
    "content": "                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   Copyright {2016} {Jigsaw}\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License.\n"
  },
  {
    "path": "packages/frontend-web/README.md",
    "content": "## Getting Started\n\nRequirement:\n\n- NodeJS LTS 6.11.x\n\nInstall dependencies:\n\n```\n./bin/install\n```\n\n## Running Storybook\n\n```\n./bin/storybook\n```\n\nVisit [http://localhost:9001/](http://localhost:9001/).\n\n## Interactive Storybook Testing\n\n```\ncd packages/frontend-web\nnpm run storybook:test -- -u\n```\n\n## Running front-end development server\n\nRun webpack dev server:\n\n```\ncd packages/frontend-web\nnpm run start\n```\n\nVisit [http://localhost:8000/](http://localhost:8000/).\n\n### Linting\n\nTo run linters, use:\n\n```\n./bin/lint\n```\n\n### Testing\n\nTo run tests, use:\n\n```\n./bin/test\n```\n"
  },
  {
    "path": "packages/frontend-web/package.json",
    "content": "{\n  \"name\": \"@conversationai/moderator-frontend-web\",\n  \"version\": \"1.1.0\",\n  \"main\": \"dist/index.js\",\n  \"types\": \"./dist/index.d.ts\",\n  \"description\": \"OSMod project React.js frontend\",\n  \"scripts\": {\n    \"build\": \"npm run compile\",\n    \"lint\": \"find src -name *.ts -o -name *.tsx | xargs ../../node_modules/.bin/tslint -c ../../tslint.json\",\n    \"lint:fix\": \"find src -name *.ts -o -name *.tsx | xargs ../../node_modules/.bin/tslint -c ../../tslint.json --fix\",\n    \"test\": \"npm run test:spec && npm run test:storybook\",\n    \"test:spec\": \"jest src/**\",\n    \"storybook\": \"start-storybook -p 9001 -c tooling/storybook -s public\",\n    \"storybook:build\": \"build-storybook -c tooling/storybook -s public -o build/storybook\",\n    \"test:storybook\": \"jest -c tooling/storybook/jest.config.json tooling/storybook/Storyshots.test.js\",\n    \"compile\": \"npm run compile:lib && npm run compile:web\",\n    \"compile:test\": \"../../node_modules/.bin/tsc --noEmit\",\n    \"compile:lib\": \"rm -rf dist && ../../node_modules/.bin/tsc --sourceMap --outDir dist --declaration && npm run compile:lib:cleanup\",\n    \"compile:lib:cleanup\": \"find dist -type f -exec sed -i -e 's/_1\\\\.default(/_1(/g' {} \\\\; && find dist -type f -exec sed -i -e 's/_1\\\\.default\\\\./_1\\\\./g' {} \\\\;\",\n    \"compile:lib:watch\": \"../../node_modules/.bin/tsc --sourceMap --outDir dist --declaration --watch\",\n    \"compile:web\": \"webpack --bail --progress --profile --config tooling/webpack.config.production.js\",\n    \"watch\": \"webpack-cli serve --config tooling/webpack.config.js --progress\"\n  },\n  \"license\": \"Apache-2.0\",\n  \"browserslist\": [\n    \"> 1%\",\n    \"maintained node versions\",\n    \"not dead\"\n  ],\n  \"jest\": {\n    \"verbose\": true,\n    \"preset\": \"ts-jest\"\n  },\n  \"devDependencies\": {\n    \"@babel/core\": \"^7.12.10\",\n    \"@babel/plugin-transform-react-constant-elements\": \"^7.12.1\",\n    \"@babel/plugin-transform-react-inline-elements\": \"^7.12.1\",\n    \"@babel/polyfill\": \"^7.12.1\",\n    \"@babel/preset-env\": \"^7.12.11\",\n    \"@babel/preset-react\": \"^7.12.10\",\n    \"@babel/register\": \"^7.12.10\",\n    \"@storybook/addon-actions\": \"^6.1.11\",\n    \"@storybook/addon-storyshots\": \"^6.1.11\",\n    \"@storybook/cli\": \"^6.1.11\",\n    \"@storybook/react\": \"^6.1.11\",\n    \"@types/chai\": \"^4.2.14\",\n    \"@types/expect\": \"^24.3.0\",\n    \"@types/express\": \"^4.17.9\",\n    \"@types/jest\": \"24.0.23\",\n    \"@types/sinon\": \"^9.0.10\",\n    \"babel-jest\": \"^26.6.3\",\n    \"babel-loader\": \"^8.2.2\",\n    \"babel-plugin-lodash\": \"^3.3.4\",\n    \"babel-plugin-react-require\": \"^3.1.3\",\n    \"babel-plugin-require-context-hook\": \"^1.0.0\",\n    \"chai\": \"^4.2.0\",\n    \"circular-dependency-plugin\": \"^5.2.2\",\n    \"express\": \"^4.17.1\",\n    \"identity-obj-proxy\": \"^3.0.0\",\n    \"jest\": \"^26.6.3\",\n    \"jest-environment-jsdom-sixteen\": \"^1.0.3\",\n    \"process\": \"^0.11.10\",\n    \"react-hot-loader\": \"^4.13.0\",\n    \"react-test-renderer\": \"^17.0.1\",\n    \"sinon\": \"^9.2.2\",\n    \"source-map-loader\": \"^2.0.0\",\n    \"ts-jest\": \"^26.4.4\",\n    \"webpack\": \"^5.11.1\",\n    \"webpack-cli\": \"^4.3.0\",\n    \"webpack-dev-server\": \"^3.11.2\",\n    \"webpack-hot-middleware\": \"^2.25.0\"\n  },\n  \"dependencies\": {\n    \"@material-ui/core\": \"^4.11.2\",\n    \"@material-ui/icons\": \"^4.11.2\",\n    \"@types/aphrodite\": \"^2.0.0\",\n    \"@types/check-types\": \"^7.3.1\",\n    \"@types/enzyme\": \"^3.10.8\",\n    \"@types/faker\": \"^5.1.5\",\n    \"@types/jwt-decode\": \"^3.1.0\",\n    \"@types/keyboardjs\": \"^2.5.0\",\n    \"@types/lodash\": \"^4.14.166\",\n    \"@types/prop-types\": \"^15.7.3\",\n    \"@types/qs\": \"^6.9.5\",\n    \"@types/react\": \"^17.0.0\",\n    \"@types/react-custom-scrollbars\": \"^4.0.7\",\n    \"@types/react-dom\": \"^17.0.0\",\n    \"@types/react-linkify\": \"^1.0.0\",\n    \"@types/react-redux\": \"^7.1.14\",\n    \"@types/react-router-dom\": \"^5.1.6\",\n    \"@types/react-virtualized-auto-sizer\": \"^1.0.0\",\n    \"@types/react-window\": \"^1.8.2\",\n    \"@types/redux-actions\": \"^2.6.1\",\n    \"aphrodite\": \"^2.4.0\",\n    \"axios\": \"^1.6.0\",\n    \"check-types\": \"^11.1.2\",\n    \"copy-to-clipboard\": \"^3.3.1\",\n    \"core-decorators\": \"^0.20.0\",\n    \"crypto-browserify\": \"^3.12.0\",\n    \"crypto-random-string\": \"^3.3.0\",\n    \"date-fns\": \"^2.16.1\",\n    \"enzyme\": \"^3.11.0\",\n    \"faker\": \"^5.1.0\",\n    \"focus-trap-react\": \"^8.3.2\",\n    \"i\": \"^0.3.6\",\n    \"immutable\": \"3.8.2\",\n    \"jwt-decode\": \"^3.1.2\",\n    \"keyboardjs\": \"^2.6.4\",\n    \"lodash\": \"^4.17.20\",\n    \"normalize.css\": \"^8.0.1\",\n    \"npm\": \"^8.11.0\",\n    \"prop-types\": \"^15.7.2\",\n    \"qs\": \"^6.9.4\",\n    \"query-string\": \"^6.13.8\",\n    \"react\": \"^17.0.1\",\n    \"react-a11y\": \"^1.1.0\",\n    \"react-custom-scrollbars\": \"^4.2.1\",\n    \"react-dom\": \"^17.0.1\",\n    \"react-draggable\": \"^4.4.3\",\n    \"react-linkify\": \"1.0.0-alpha\",\n    \"react-perfect-scrollbar\": \"^1.5.8\",\n    \"react-redux\": \"^7.2.2\",\n    \"react-router-dom\": \"^5.2.0\",\n    \"react-virtualized-auto-sizer\": \"^1.0.3\",\n    \"react-window\": \"^1.8.6\",\n    \"redux\": \"^4.0.5\",\n    \"redux-actions\": \"^2.6.5\",\n    \"redux-devtools\": \"^3.7.0\",\n    \"redux-thunk\": \"^2.3.0\",\n    \"reselect\": \"^4.0.0\",\n    \"slugify\": \"^1.4.6\",\n    \"stream-browserify\": \"^3.0.0\",\n    \"typescript\": \"^4.1.3\"\n  }\n}\n"
  },
  {
    "path": "packages/frontend-web/public/css/fonts/fonts.css",
    "content": "@font-face {\n  font-family: LibreFranklin-Medium;\n  src: url(LibreFranklin-Medium.ttf);\n}\n"
  },
  {
    "path": "packages/frontend-web/public/css/moderator.css",
    "content": "html {\n  height: 100%;\n  overflow: hidden;\n}\n\nbody {\n  font-family: \"Libre Franklin\";\n  height: 100%;\n  overflow: hidden; /* prevent the scroll bounce */\n\n  /* AKA \"NICE_MIDDLE_BLUE\" from app/styles/colors */\n  background-color: #185bac;\n  line-height: 1.43;\n}\n\n#app {\n  height: 100%;\n}\n\na {\n  text-decoration: none;\n}\n\na:hover,\na:focus {\n  text-decoration: none;\n}\n\n.landing_headerTag {\n  position: absolute;\n  top: 2vh;\n  left: 2vh;\n  height: 3vh;\n  font-size: 3vh;\n  font-weight: 800;\n  color: white;\n}\n\n.landing_centerOnPage {\n  width: 100vw;\n  height: 100vh;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n}\n\n.landing_bubbleSet {\n  display: grid;\n  grid-template-columns: repeat(5, 1.8vh);\n  grid-template-rows: repeat(5, 1.8vh);\n  grid-gap: 1.8vh;\n}\n\n.landing_bubble {\n  width: 100%;\n  height: 100%;\n  border-radius: 50%;\n  background-color: white;\n}\n\n.landing_footerTag {\n  position: absolute;\n  bottom: 2vh;\n  left: 2vh;\n  font-size: 2vh;\n  color: white;\n}\n\n.landing_link {\n  color: white;\n}\n\n.landing_link:hover {\n  text-decoration: underline;\n}\n\n.landing_extratext {\n  opacity: 0.6;\n}\n"
  },
  {
    "path": "packages/frontend-web/public/css/normalize.css",
    "content": "/*! normalize.css v5.0.0 | MIT License | github.com/necolas/normalize.css */\n\n/**\n * 1. Change the default font family in all browsers (opinionated).\n * 2. Correct the line height in all browsers.\n * 3. Prevent adjustments of font size after orientation changes in\n *    IE on Windows Phone and in iOS.\n */\n\n/* Document\n   ========================================================================== */\n\nhtml {\n  font-family: sans-serif; /* 1 */\n  line-height: 1.15; /* 2 */\n  -ms-text-size-adjust: 100%; /* 3 */\n  -webkit-text-size-adjust: 100%; /* 3 */\n}\n\n/* Sections\n   ========================================================================== */\n\n/**\n * Remove the margin in all browsers (opinionated).\n */\n\nbody {\n  margin: 0;\n}\n\n/**\n * Add the correct display in IE 9-.\n */\n\narticle,\naside,\nfooter,\nheader,\nnav,\nsection {\n  display: block;\n}\n\n/**\n * Correct the font size and margin on `h1` elements within `section` and\n * `article` contexts in Chrome, Firefox, and Safari.\n */\n\nh1 {\n  font-size: 2em;\n  margin: 0.67em 0;\n}\n\n/* Grouping content\n   ========================================================================== */\n\n/**\n * Add the correct display in IE 9-.\n * 1. Add the correct display in IE.\n */\n\nfigcaption,\nfigure,\nmain { /* 1 */\n  display: block;\n}\n\n/**\n * Add the correct margin in IE 8.\n */\n\nfigure {\n  margin: 1em 40px;\n}\n\n/**\n * 1. Add the correct box sizing in Firefox.\n * 2. Show the overflow in Edge and IE.\n */\n\nhr {\n  box-sizing: content-box; /* 1 */\n  height: 0; /* 1 */\n  overflow: visible; /* 2 */\n}\n\n/**\n * 1. Correct the inheritance and scaling of font size in all browsers.\n * 2. Correct the odd `em` font sizing in all browsers.\n */\n\npre {\n  font-family: monospace, monospace; /* 1 */\n  font-size: 1em; /* 2 */\n}\n\n/* Text-level semantics\n   ========================================================================== */\n\n/**\n * 1. Remove the gray background on active links in IE 10.\n * 2. Remove gaps in links underline in iOS 8+ and Safari 8+.\n */\n\na {\n  background-color: transparent; /* 1 */\n  -webkit-text-decoration-skip: objects; /* 2 */\n}\n\n/**\n * Remove the outline on focused links when they are also active or hovered\n * in all browsers (opinionated).\n */\n\na:active,\na:hover {\n  outline-width: 0;\n}\n\n/**\n * 1. Remove the bottom border in Firefox 39-.\n * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.\n */\n\nabbr[title] {\n  border-bottom: none; /* 1 */\n  text-decoration: underline; /* 2 */\n  text-decoration: underline dotted; /* 2 */\n}\n\n/**\n * Prevent the duplicate application of `bolder` by the next rule in Safari 6.\n */\n\nb,\nstrong {\n  font-weight: inherit;\n}\n\n/**\n * Add the correct font weight in Chrome, Edge, and Safari.\n */\n\nb,\nstrong {\n  font-weight: bolder;\n}\n\n/**\n * 1. Correct the inheritance and scaling of font size in all browsers.\n * 2. Correct the odd `em` font sizing in all browsers.\n */\n\ncode,\nkbd,\nsamp {\n  font-family: monospace, monospace; /* 1 */\n  font-size: 1em; /* 2 */\n}\n\n/**\n * Add the correct font style in Android 4.3-.\n */\n\ndfn {\n  font-style: italic;\n}\n\n/**\n * Add the correct background and color in IE 9-.\n */\n\nmark {\n  background-color: #ff0;\n  color: #000;\n}\n\n/**\n * Add the correct font size in all browsers.\n */\n\nsmall {\n  font-size: 80%;\n}\n\n/**\n * Prevent `sub` and `sup` elements from affecting the line height in\n * all browsers.\n */\n\nsub,\nsup {\n  font-size: 75%;\n  line-height: 0;\n  position: relative;\n  vertical-align: baseline;\n}\n\nsub {\n  bottom: -0.25em;\n}\n\nsup {\n  top: -0.5em;\n}\n\n/* Embedded content\n   ========================================================================== */\n\n/**\n * Add the correct display in IE 9-.\n */\n\naudio,\nvideo {\n  display: inline-block;\n}\n\n/**\n * Add the correct display in iOS 4-7.\n */\n\naudio:not([controls]) {\n  display: none;\n  height: 0;\n}\n\n/**\n * Remove the border on images inside links in IE 10-.\n */\n\nimg {\n  border-style: none;\n}\n\n/**\n * Hide the overflow in IE.\n */\n\nsvg:not(:root) {\n  overflow: hidden;\n}\n\n/* Forms\n   ========================================================================== */\n\n/**\n * 1. Change the font styles in all browsers (opinionated).\n * 2. Remove the margin in Firefox and Safari.\n */\n\nbutton,\ninput,\noptgroup,\nselect,\ntextarea {\n  font-family: sans-serif; /* 1 */\n  font-size: 16px; /* Stops input zooming on iOS */\n  line-height: 1.15; /* 1 */\n  margin: 0; /* 2 */\n}\n\n/**\n * Show the overflow in IE.\n * 1. Show the overflow in Edge.\n */\n\nbutton,\ninput { /* 1 */\n  overflow: visible;\n}\n\n/**\n * Remove the inheritance of text transform in Edge, Firefox, and IE.\n * 1. Remove the inheritance of text transform in Firefox.\n */\n\nbutton,\nselect { /* 1 */\n  text-transform: none;\n}\n\n/**\n * 1. Prevent a WebKit bug where (2) destroys native `audio` and `video`\n *    controls in Android 4.\n * 2. Correct the inability to style clickable types in iOS and Safari.\n */\n\nbutton,\nhtml [type=\"button\"], /* 1 */\n[type=\"reset\"],\n[type=\"submit\"] {\n  -webkit-appearance: button; /* 2 */\n}\n\n/**\n * Remove the inner border and padding in Firefox.\n */\n\nbutton::-moz-focus-inner,\n[type=\"button\"]::-moz-focus-inner,\n[type=\"reset\"]::-moz-focus-inner,\n[type=\"submit\"]::-moz-focus-inner {\n  border-style: none;\n  padding: 0;\n}\n\n/**\n * Restore the focus styles unset by the previous rule.\n */\n\nbutton:-moz-focusring,\n[type=\"button\"]:-moz-focusring,\n[type=\"reset\"]:-moz-focusring,\n[type=\"submit\"]:-moz-focusring {\n  outline: 1px dotted ButtonText;\n}\n\n/**\n * Change the border, margin, and padding in all browsers (opinionated).\n */\n\nfieldset {\n  border: 1px solid #c0c0c0;\n  margin: 0 2px;\n  padding: 0.35em 0.625em 0.75em;\n}\n\n/**\n * 1. Correct the text wrapping in Edge and IE.\n * 2. Correct the color inheritance from `fieldset` elements in IE.\n * 3. Remove the padding so developers are not caught out when they zero out\n *    `fieldset` elements in all browsers.\n */\n\nlegend {\n  box-sizing: border-box; /* 1 */\n  color: inherit; /* 2 */\n  display: table; /* 1 */\n  max-width: 100%; /* 1 */\n  padding: 0; /* 3 */\n  white-space: normal; /* 1 */\n}\n\n/**\n * 1. Add the correct display in IE 9-.\n * 2. Add the correct vertical alignment in Chrome, Firefox, and Opera.\n */\n\nprogress {\n  display: inline-block; /* 1 */\n  vertical-align: baseline; /* 2 */\n}\n\n/**\n * Remove the default vertical scrollbar in IE.\n */\n\ntextarea {\n  overflow: auto;\n}\n\n/**\n * 1. Add the correct box sizing in IE 10-.\n * 2. Remove the padding in IE 10-.\n */\n\n[type=\"checkbox\"],\n[type=\"radio\"] {\n  box-sizing: border-box; /* 1 */\n  padding: 0; /* 2 */\n}\n\n/**\n * Correct the cursor style of increment and decrement buttons in Chrome.\n */\n\n[type=\"number\"]::-webkit-inner-spin-button,\n[type=\"number\"]::-webkit-outer-spin-button {\n  height: auto;\n}\n\n/**\n * 1. Correct the odd appearance in Chrome and Safari.\n * 2. Correct the outline style in Safari.\n */\n\n[type=\"search\"] {\n  -webkit-appearance: textfield; /* 1 */\n  outline-offset: -2px; /* 2 */\n}\n\n/**\n * Remove the inner padding and cancel buttons in Chrome and Safari on macOS.\n */\n\n[type=\"search\"]::-webkit-search-cancel-button,\n[type=\"search\"]::-webkit-search-decoration {\n  -webkit-appearance: none;\n}\n\n/**\n * 1. Correct the inability to style clickable types in iOS and Safari.\n * 2. Change font properties to `inherit` in Safari.\n */\n\n::-webkit-file-upload-button {\n  -webkit-appearance: button; /* 1 */\n  font: inherit; /* 2 */\n}\n\n/* Interactive\n   ========================================================================== */\n\n/*\n * Add the correct display in IE 9-.\n * 1. Add the correct display in Edge, IE, and Firefox.\n */\n\ndetails, /* 1 */\nmenu {\n  display: block;\n}\n\n/*\n * Add the correct display in all browsers.\n */\n\nsummary {\n  display: list-item;\n}\n\n/* Scripting\n   ========================================================================== */\n\n/**\n * Add the correct display in IE 9-.\n */\n\ncanvas {\n  display: inline-block;\n}\n\n/**\n * Add the correct display in IE.\n */\n\ntemplate {\n  display: none;\n}\n\n/* Hidden\n   ========================================================================== */\n\n/**\n * Add the correct display in IE 10-.\n */\n\n[hidden] {\n  display: none;\n}\n"
  },
  {
    "path": "packages/frontend-web/public/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n  <title></title>\n  <meta charset=\"UTF-8\">\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no\" />\n  <link rel=\"icon\" type=\"image/png\" href=\"/icons/favicon-32x32.png\" sizes=\"32x32\">\n  <link rel=\"icon\" type=\"image/png\" href=\"/icons/favicon-16x16.png\" sizes=\"16x16\">\n  <link rel=\"stylesheet\" href=\"/css/normalize.css\">\n  <link rel=\"stylesheet\" href=\"/css/fonts/fonts.css\">\n  <link rel=\"stylesheet\" href=\"/css/moderator.css\">\n</head>\n<body>\n  <div id=\"app\">\n    <div class=\"landing_headerTag\">Moderator</div>\n    <div class=\"landing_centerOnPage\">\n      <div class=\"landing_bubbleSet\">\n        <div><div class=\"landing_bubble\"></div></div>\n        <div></div>\n        <div></div>\n        <div></div>\n        <div><div class=\"landing_bubble\"></div></div>\n\n        <div><div class=\"landing_bubble\"></div></div>\n        <div><div class=\"landing_bubble\"></div></div>\n        <div></div>\n        <div><div class=\"landing_bubble\"></div></div>\n        <div><div class=\"landing_bubble\"></div></div>\n\n        <div><div class=\"landing_bubble\"></div></div>\n        <div><div class=\"landing_bubble\"></div></div>\n        <div><div class=\"landing_bubble\"></div></div>\n        <div><div class=\"landing_bubble\"></div></div>\n        <div><div class=\"landing_bubble\"></div></div>\n\n        <div><div class=\"landing_bubble\"></div></div>\n        <div><div class=\"landing_bubble\"></div></div>\n        <div><div class=\"landing_bubble\"></div></div>\n        <div><div class=\"landing_bubble\"></div></div>\n        <div><div class=\"landing_bubble\"></div></div>\n\n        <div><div class=\"landing_bubble\"></div></div>\n        <div><div class=\"landing_bubble\"></div></div>\n        <div><div class=\"landing_bubble\"></div></div>\n        <div><div class=\"landing_bubble\"></div></div>\n        <div><div class=\"landing_bubble\"></div></div>\n      </div>\n    </div>\n    <div class=\"landing_footerTag\">\n      <a href=\"https://conversationai.github.io/\" target=\"_blank\" class=\"landing_link\">Learn more</a> <span class=\"landing_extratext\">about Modereator.</span>\n    </div>\n  </div>\n  <script>\n    window.osmod_config = window.osmod_config || {};\n    window.osmod_config.API_URL = '{{API_URL}}';\n    window.osmod_config.APP_NAME = '{{APP_NAME}}';\n    window.osmod_config.REQUIRE_REASON_TO_REJECT = '{{REQUIRE_REASON_TO_REJECT}}';\n    window.osmod_config.RESTRICT_TO_SESSION = '{{RESTRICT_TO_SESSION}}';\n    window.osmod_config.MODERATOR_GUIDELINES_URL = '{{MODERATOR_GUIDELINES_URL}}';\n    window.osmod_config.SUBMIT_FEEDBACK_URL = '{{SUBMIT_FEEDBACK_URL}}';\n    window.osmod_config.COMMENTS_EDITABLE_FLAG = '{{COMMENTS_EDITABLE_FLAG}}';\n  </script>\n  <script src=\"/js/moderator.js\"></script>\n</body>\n</html>\n"
  },
  {
    "path": "packages/frontend-web/src/app/appstate.ts",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\nimport { Action } from 'redux';\nimport { ThunkAction, ThunkDispatch } from 'redux-thunk';\n\nimport { IScenesState } from './scenes/appstate';\nimport { IGlobalState } from './stores/appstate';\n\nexport type IAppState = Readonly<{\n  global: IGlobalState;\n  scenes: IScenesState;\n}>;\n\nexport type IThunkAction<R> = ThunkAction<R, IAppState, undefined, Action>;\nexport type IAppDispatch = ThunkDispatch<IAppState, undefined, Action>;\n"
  },
  {
    "path": "packages/frontend-web/src/app/auth.ts",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport axios from 'axios';\nimport JwtDecode from 'jwt-decode';\nimport { isEmpty } from 'lodash';\nimport qs from 'query-string';\n\nimport { AuthenticationStates, SystemStates, WebsocketStates } from '../types';\nimport { IAppDispatch } from './appstate';\nimport { checkAuthorization, checkServerStatus } from './platform/dataService';\nimport { getToken, saveToken } from './platform/localStore';\nimport { connectNotifier, disconnectNotifier, STATUS_RESET, STATUS_UP }  from './platform/websocketService';\nimport { articlesLoaded, articlesUpdated } from './stores/articles';\nimport { categoriesLoaded, categoriesUpdated } from './stores/categories';\nimport { assignmentCountUpdated } from './stores/counts';\nimport { preselectsUpdated } from './stores/preselects';\nimport { rulesUpdated } from './stores/rules';\nimport { taggingSensitivitiesUpdated } from './stores/taggingSensitivities';\nimport { tagsUpdated } from './stores/tags';\nimport { setMyUserId, usersUpdated } from './stores/users';\nimport { clearCSRF, clearReturnURL, getCSRF, getReturnURL } from './util';\n\nexport function setAxiosToken(token: string): void {\n  // Use query string for auth.\n  axios.interceptors.request.use((config) => {\n    config.params = {\n      token,\n      ...(config.params || {}),\n    };\n\n    return config;\n  });\n\n  // Use header for auth.\n  // axios.defaults.headers.common['Authorization'] = 'JWT ' + token;\n}\n\nexport function decodeToken(token: string): any {\n  return JwtDecode(token);\n}\n\nasync function connectWebsocket(\n  dispatch: IAppDispatch,\n  setState: (state: WebsocketStates) => void,\n) {\n  setState('ws_connecting');\n  connectNotifier(\n    (status: string) => {\n      if (status === STATUS_UP) {\n        setState('ws_gtg');\n      }\n      else {\n        setState('ws_connecting');\n        if (status === STATUS_RESET) {\n          logout();\n        }\n      }\n    },\n    (data) => {\n      dispatch(usersUpdated(data.users));\n      dispatch(tagsUpdated(data.tags));\n      dispatch(taggingSensitivitiesUpdated(data.taggingSensitivities));\n      dispatch(rulesUpdated(data.rules));\n      dispatch(preselectsUpdated(data.preselects));\n    },\n    (data) => {\n      dispatch(categoriesLoaded(data.categories));\n      dispatch(articlesLoaded(data.articles));\n    },\n    (data) => {\n      if (data.categories) {\n        dispatch(categoriesUpdated(data.categories));\n      }\n      if (data.articles) {\n        dispatch(articlesUpdated(data.articles));\n      }\n    },\n    (data) => {\n      dispatch(assignmentCountUpdated(data.assignments));\n    },\n  );\n}\n\nasync function completeAuthentication(\n  dispatch: IAppDispatch,\n  setState: (state: SystemStates) => void,\n): Promise<void> {\n  const token = getToken();\n  setAxiosToken(token);\n  await checkAuthorization();\n\n  const data = decodeToken(token);\n  setMyUserId((data['user'] as number).toString());\n  await connectWebsocket(dispatch, setState);\n  setState('gtg');\n}\n\nfunction verifyCSRF(csrf?: string): void {\n  if (!csrf) {\n    throw new Error(`CSRF not returned from backend`);\n  }\n\n  const storedCSRF = getCSRF();\n\n  if (storedCSRF !== csrf) {\n    throw new Error(`CSRF returned from backend did not match stored local version`);\n  }\n\n  clearCSRF();\n}\n\n// Returns the final destination to go to after the redirect.\nasync function handleLoginRedirect(\n  dispatch: IAppDispatch,\n  setState: (state: AuthenticationStates) => void,\n  queryString: qs.ParsedQuery,\n) {\n  verifyCSRF(queryString['csrf'] as string);\n  saveToken(queryString['token'] as string);\n  await completeAuthentication(dispatch, setState);\n  const returnURL = getReturnURL();\n  clearReturnURL();\n  if (returnURL && !isEmpty(returnURL)) {\n    // We've saved off a pathname and search string, so use that.\n    return `${returnURL.pathname}${returnURL.search}`;\n  }\n  else {\n    // We haven't got anything saved.  So use the current URL (derived from the http\n    // referrer of the original request) but strip off the token and csrf stuff first\n    return window.location.pathname;\n  }\n}\n\nlet setAuthenticationState: (state: AuthenticationStates) => void = null;\n\nexport async function start(\n  dispatch: IAppDispatch,\n  setState: (state: SystemStates) => void,\n  setRoute: (route: string) => void,\n  setError: (error: string) => void,\n) {\n  setAuthenticationState = setState;\n  try {\n    const status = await checkServerStatus();\n    setState(status);\n    if (status !== 's_gtg') {\n      return;\n    }\n  }\n  catch (e) {\n    if (e.response) {\n      // Server is there but ignoring/rejecting the healthchek.  Assume gtg\n      setState('s_gtg');\n    }\n    else if (e.request) {\n      // Server is not there\n      setState('s_unavailable');\n      return;\n    }\n    else {\n      // Something else went wrong\n      console.log(e);\n      setError(`Something went wrong: ${e.message}`);\n      return;\n    }\n  }\n\n  try {\n    const queryString = qs.parse(window.location.search);\n    if (queryString && queryString['token']) {\n      setState('check_token');\n      setRoute(await handleLoginRedirect(dispatch, setState, queryString));\n    }\n    else if (getToken()) {\n      setState('check_token');\n      await completeAuthentication(dispatch, setState);\n    }\n    else {\n      setState('unauthenticated');\n    }\n  }\n  catch (e) {\n    if (e.response) {\n      // network error\n      if (e.response.status === 401) {\n        console.log('Token didn\\'t work, so resetting');\n        saveToken(null);\n        setState('unauthenticated');\n      }\n      else {\n        setError(`API connection error: ${e.response.status}: ${e.response.statusText}`);\n      }\n    }\n    else if (e.message) {\n      setError(e.message);\n    }\n    else {\n      console.error(e);\n      setError(`Internal error.  See console.`);\n    }\n  }\n}\n\nexport function logout() {\n  setMyUserId(null);\n  saveToken(null);\n  disconnectNotifier();\n  setAuthenticationState('unauthenticated');\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/Arrow/Arrow.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport React from 'react';\nimport { css, stylesheet } from '../../utilx';\n\nimport { OPACITY_TRANSITION } from '../../styles';\n\nconst STYLES = stylesheet({\n  container: {\n    ...OPACITY_TRANSITION,\n  },\n\n  button: {\n    alignItems: 'center',\n    backgroundColor: 'transparent',\n    border: 'none',\n    borderRadius: 0,\n    display: 'flex',\n    justifyContent: 'center',\n    cursor: 'pointer',\n    position: 'relative',\n    ':disabled': {\n      opacity: 0.3,\n      pointerEvents: 'none',\n    },\n  },\n\n  disabled: {\n    opacity: 0.5,\n  },\n\n  arrow: {\n    borderTop: '2px solid transparent',\n    borderRight: '2px solid transparent',\n    borderBottom: '2px solid transparent',\n    borderLeft: '2px solid transparent',\n  },\n\n  upArrow: {\n    transform: 'rotate(90deg)',\n  },\n\n  downArrow: {\n    transform: 'rotate(-90deg)',\n  },\n\n  rightArrow: {\n    transform: 'rotate(180deg)',\n  },\n\n  leftArrow: {\n    transform: 'rotate(0deg)',\n  },\n});\n\nexport interface IArrowProps {\n  direction?: 'up' | 'down' | 'left'| 'right';\n  icon: JSX.Element;\n  label: string;\n  isDisabled?: boolean;\n  size?: number;\n  isActive?: boolean;\n  color?: any;\n}\n\nexport class Arrow extends React.PureComponent<IArrowProps> {\n\n  render() {\n    const {\n      direction,\n      icon,\n      isDisabled,\n      size,\n      isActive,\n      color,\n    } = this.props;\n\n    const computedIconStyle = {\n      ...(isActive && direction === 'right' && ({ borderTopColor: color })),\n      ...(isActive && direction === 'up' && ({ borderRightColor: color })),\n      ...(isActive && direction === 'left' && ({ borderBottomColor: color })),\n      ...(isActive && direction === 'down' && ({ borderLeftColor: color })),\n    };\n\n    return (\n      <div {...css(STYLES.container, isDisabled && STYLES.disabled)}>\n        <span\n          key=\"arrow button\"\n          {...css(\n            STYLES.button,\n            {\n              width: size || 64,\n              height: size || 64,\n            },\n          )}\n        >\n          {direction === 'up' && <div {...css(STYLES.arrow, STYLES.upArrow, computedIconStyle)}>{icon}</div>}\n          {direction === 'down' && <div {...css(STYLES.arrow, STYLES.downArrow, computedIconStyle)}>{icon}</div>}\n          {direction === 'left' && <div {...css(STYLES.arrow, STYLES.leftArrow, computedIconStyle)}>{icon}</div>}\n          {direction === 'right' && <div {...css(STYLES.arrow, STYLES.rightArrow, computedIconStyle)}>{icon}</div>}\n        </span>\n      </div>\n    );\n  }\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/Arrow/ArrowStory.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { storiesOf } from '@storybook/react';\nimport { DARK_COLOR } from '../../styles';\nimport { css } from '../../utilx';\nimport { ArrowIcon } from '../Icons';\nimport { Arrow } from './Arrow';\n\nstoriesOf('Arrow', module)\n  .add('base', () => (\n    <div>\n      <Arrow\n        direction={'up'}\n        label={'Up arrow'}\n        color={DARK_COLOR}\n        icon={<ArrowIcon {...css({ fill: DARK_COLOR })} size={24}/>}\n      />\n      <Arrow\n        direction={'down'}\n        label={'Down arrow'}\n        color={DARK_COLOR}\n        icon={<ArrowIcon {...css({ fill: DARK_COLOR })} size={24}/>}\n      />\n      <Arrow\n        direction={'right'}\n        label={'Right arrow'}\n        color={DARK_COLOR}\n        icon={<ArrowIcon {...css({ fill: DARK_COLOR })} size={24}/>}\n      />\n      <Arrow\n        direction={'left'}\n        label={'Left arrow'}\n        color={DARK_COLOR}\n        icon={<ArrowIcon {...css({ fill: DARK_COLOR })} size={24}/>}\n      />\n    </div>\n  ))\n  .add('active', () => (\n    <div>\n      <Arrow\n        isActive\n        direction={'up'}\n        label={'Up arrow'}\n        color={DARK_COLOR}\n        icon={<ArrowIcon {...css({ fill: DARK_COLOR })} size={24}/>}\n      />\n      <Arrow\n        isActive\n        direction={'down'}\n        label={'Down arrow'}\n        color={DARK_COLOR}\n        icon={<ArrowIcon {...css({ fill: DARK_COLOR })} size={24}/>}\n      />\n      <Arrow\n        isActive\n        direction={'right'}\n        label={'Right arrow'}\n        color={DARK_COLOR}\n        icon={<ArrowIcon {...css({ fill: DARK_COLOR })} size={24}/>}\n      />\n      <Arrow\n        isActive\n        direction={'left'}\n        label={'Left arrow'}\n        color={DARK_COLOR}\n        icon={<ArrowIcon {...css({ fill: DARK_COLOR })} size={24}/>}\n      />\n    </div>\n  ))\n  .add('disabled', () => (\n    <div>\n      <Arrow\n        isDisabled\n        direction={'up'}\n        label={'Up arrow'}\n        color={DARK_COLOR}\n        icon={<ArrowIcon {...css({ fill: DARK_COLOR })} size={24}/>}\n      />\n      <Arrow\n        isDisabled\n        direction={'down'}\n        label={'Down arrow'}\n        color={DARK_COLOR}\n        icon={<ArrowIcon {...css({ fill: DARK_COLOR })} size={24}/>}\n      />\n      <Arrow\n        isDisabled\n        direction={'right'}\n        label={'Right arrow'}\n        color={DARK_COLOR}\n        icon={<ArrowIcon {...css({ fill: DARK_COLOR })} size={24}/>}\n      />\n      <Arrow\n        isDisabled\n        direction={'left'}\n        label={'Left arrow'}\n        color={DARK_COLOR}\n        icon={<ArrowIcon {...css({ fill: DARK_COLOR })} size={24}/>}\n      />\n    </div>\n  ));\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/Arrow/__spec__/.gitkeep",
    "content": ""
  },
  {
    "path": "packages/frontend-web/src/app/components/Arrow/index.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nexport { Arrow } from './Arrow';\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/AspectRatio/AspectRatio.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { autobind } from 'core-decorators';\nimport React from 'react';\nimport ReactDOM from 'react-dom';\nimport { css } from '../../utilx';\n\nexport interface IAspectRatioProps extends React.HTMLProps<any> {\n  ratio: number;\n  contents(width: number, height: number): React.ReactNode;\n}\n\nexport interface IAspectRatioState {\n  width: number;\n  height: number;\n}\n\nexport class AspectRatio extends React.PureComponent<\n  IAspectRatioProps, IAspectRatioState\n> {\n  state: IAspectRatioState = {\n    width: null,\n    height: null,\n  };\n\n  render() {\n    const style = {\n      width: `100%`,\n      height: `${this.state.height}px`,\n      position: 'relative',\n    };\n\n    return (\n      <div {...css(style)}>\n        {this.props.contents(this.state.width, this.state.height)}\n      </div>\n    );\n  }\n\n  componentDidMount() {\n    window.addEventListener('resize', this.onResize);\n    // Wait for Aphrodite styles to start to parse\n    setTimeout(() => this.onResize(), 60);\n  }\n\n  componentWillUnmount() {\n    window.removeEventListener('resize', this.onResize);\n  }\n\n  @autobind\n  private onResize() {\n    const { width } = (ReactDOM.findDOMNode(this) as Element).getBoundingClientRect();\n\n    this.setState({\n      width,\n      height: Math.ceil(width * (1 / this.props.ratio)),\n    });\n  }\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/AspectRatio/index.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nexport { AspectRatio } from './AspectRatio';\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/AssignModerators/AssignModerators.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { Set } from 'immutable';\nimport React, {useMemo} from 'react';\nimport PerfectScrollbar from 'react-perfect-scrollbar';\nimport {useSelector} from 'react-redux';\n\nimport { IUserModel, ModelId } from '../../../models';\nimport { css, stylesheet } from '../../utilx';\n\nimport {getMyUserId, getUsers} from '../../stores/users';\nimport {\n  GUTTER_DEFAULT_SPACING,\n} from '../../styles';\nimport { Avatar } from '../Avatar';\nimport { CheckboxRow, GOOD_IMAGE_SIZE } from '../CheckboxRow/CheckboxRow';\nimport { ContainerFooter, ContainerHeader, OverflowContainer } from '../OverflowContainer';\n\nconst STYLES = stylesheet({\n  list: {\n    listStyle: 'none',\n    margin: 0,\n    height: 280,\n    paddingTop: 0,\n    paddingLeft: 0,\n    paddingRight: `${GUTTER_DEFAULT_SPACING / 2}px`,\n    paddingBottom: 0,\n  },\n\n  listItem: {\n    textDecoration: 'none',\n    ':focus': {\n      outline: 'none',\n      textDecoration: 'underline',\n    },\n  },\n});\n\nfunction ModeratorListItem(props: {\n  user: IUserModel,\n  moderatorIds: Set<ModelId>;\n  categoryModeratorIds?: Set<ModelId>;\n  onModeratorStatusChange?(userId: string, checked: boolean): void;\n}) {\n  const {\n    user,\n    moderatorIds,\n    categoryModeratorIds,\n    onModeratorStatusChange,\n  } = props;\n\n  const isDisabled = categoryModeratorIds && categoryModeratorIds.includes(user.id);\n  const isSelected = moderatorIds && moderatorIds.includes(user.id) || isDisabled;\n  function onChange(userId: ModelId) {\n    onModeratorStatusChange(userId, isSelected);\n  }\n\n  return (\n    <li {...css(STYLES.listItem)} key={user.id}>\n      <CheckboxRow\n        label={user.name}\n        value={user.id}\n        image={<Avatar size={GOOD_IMAGE_SIZE} target={user}/>}\n        isSelected={isSelected}\n        isDisabled={isDisabled}\n        onChange={onChange}\n      />\n    </li>\n  );\n}\n\nfunction ModeratorList(props: {\n  users: Array<IUserModel>;\n  moderatorIds: Set<ModelId>;\n  categoryModeratorIds?: Set<ModelId>;\n  onModeratorStatusChange?(userId: string, checked: boolean): void;\n}) {\n  const {\n    users,\n    moderatorIds,\n    categoryModeratorIds,\n    onModeratorStatusChange,\n  } = props;\n\n  return (\n    <PerfectScrollbar>\n      <ul {...css(STYLES.list)}>\n        {users.map((user) => (\n          <ModeratorListItem\n            key={user.id}\n            user={user}\n            moderatorIds={moderatorIds}\n            categoryModeratorIds={categoryModeratorIds}\n            onModeratorStatusChange={onModeratorStatusChange}\n          />\n        ))}\n      </ul>\n    </PerfectScrollbar>\n  );\n}\n\nexport interface IAssignModeratorsProps {\n  moderatorIds?: Set<ModelId>;\n  superModeratorIds?: Set<ModelId>;\n  label: string;\n  onClickDone?(): void;\n  onClickClose?(): void;\n  onAddModerator?(userId: string): any;\n  onRemoveModerator?(userId: string): any;\n}\n\nexport function AssignModerators(props: IAssignModeratorsProps) {\n  const allUsers = useSelector(getUsers);\n  const users = useMemo(() => {\n    const userId = getMyUserId();\n    const currentUser = [];\n    const assignedUsers = [];\n    const unassignedUsers = [];\n\n    for (const u of allUsers.valueSeq().toArray()) {\n      if (u.id === userId) {\n        currentUser.push(u);\n      }\n      else if (props.moderatorIds.has(u.id)) {\n        assignedUsers.push(u);\n      }\n      else if (props.superModeratorIds.has(u.id)) {\n        assignedUsers.push(u);\n      }\n      else {\n        unassignedUsers.push(u);\n      }\n    }\n\n    const assignedUsersSorted = assignedUsers.sort((a, b) => a.name.localeCompare(b.name));\n    const unassignedUsersSorted = unassignedUsers.sort((a, b) => a.name.localeCompare(b.name));\n\n    return [...currentUser, ...assignedUsersSorted, ...unassignedUsersSorted];\n  }, [allUsers]);\n\n  const {\n    label,\n    moderatorIds,\n    superModeratorIds,\n    onClickClose,\n    onClickDone,\n  } = props;\n\n  function onModeratorStatusChange(userid: string, checked: boolean) {\n    if (checked && props.onAddModerator) {\n      props.onRemoveModerator(userid);\n    }\n    else if (!checked && props.onRemoveModerator) {\n      props.onAddModerator(userid);\n    }\n  }\n\n  return (\n    <OverflowContainer\n      header={<ContainerHeader onClickClose={onClickClose}>{label}</ContainerHeader>}\n      body={(\n        <div {...css({ marginTop: `${GUTTER_DEFAULT_SPACING}px`, marginBottom: `${GUTTER_DEFAULT_SPACING}px`, })}>\n          <ModeratorList\n            users={users}\n            moderatorIds={moderatorIds}\n            categoryModeratorIds={superModeratorIds}\n            onModeratorStatusChange={onModeratorStatusChange}\n          />\n        </div>\n      )}\n      footer={<ContainerFooter onClick={onClickDone} />}\n    />\n  );\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/AssignModerators/AssignModeratorsStory.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { storiesOf } from '@storybook/react';\nimport faker from 'faker';\nimport {Map as IMap, Set} from 'immutable';\nimport React from 'react';\nimport {Provider} from 'react-redux';\nimport {createStore} from 'redux';\n\nimport { ModelId } from '../../../models';\nimport { fakeUserModel } from '../../../models/fake';\nimport { AssignModerators } from './AssignModerators';\n\nconst users = [\n  fakeUserModel({\n    name: 'Person One',\n    avatarURL: faker.internet.avatar(),\n  }),\n  fakeUserModel({\n    name: 'Person Two',\n    avatarURL: faker.internet.avatar(),\n  }),\n  fakeUserModel({\n    name: 'Person Three',\n    avatarURL: faker.internet.avatar(),\n  }),\n];\n\nconst moderatorIds = Set<ModelId>([users[0].id]);\n\nexport const store = createStore(\n  (s, _a) => s,\n  {global: {users: {humans: IMap(users.map((u) => [u.id, u]))}}},\n);\n\nstoriesOf('AssignModerators', module)\n    .add('DontTest:Default', () => (\n      <Provider store={store}>\n        <AssignModerators\n          moderatorIds={moderatorIds}\n          label=\"Add a moderator\"\n        />\n      </Provider>\n    ));\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/AssignTagsForm.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport {Set} from 'immutable';\nimport React, {useEffect, useState} from 'react';\nimport {useSelector} from 'react-redux';\n\nimport {\n  ClickAwayListener,\n  DialogTitle,\n} from '@material-ui/core';\n\nimport {\n  ICommentModel,\n  ITagModel,\n  ModelId,\n} from '../../models';\nimport { useCachedArticle } from '../injectors/articleInjector';\nimport { getSensitivitiesForCategory, getSummaryScoresAboveThreshold } from '../scenes/Comments/scoreFilters';\nimport { getTaggingSensitivities } from '../stores/taggingSensitivities';\nimport { getTaggableTags } from '../stores/tags';\nimport { css, stylesheet } from '../utilx';\nimport { CheckboxRow } from './CheckboxRow';\n\nimport {\n  GUTTER_DEFAULT_SPACING,\n  LIGHT_PRIMARY_TEXT_COLOR,\n  NICE_CONTROL_BLUE,\n  NICE_MIDDLE_BLUE,\n  SCRIM_STYLE,\n} from '../styles';\n\nconst STYLES = stylesheet({\n  tagsList: {\n    listStyle: 'none',\n    margin: 0,\n    padding: `0 0 ${GUTTER_DEFAULT_SPACING}px 0`,\n  },\n  listItem: {\n    textDecoration: 'none',\n    ':focus': {\n      outline: 'none',\n      textDecoration: 'underline',\n    },\n  },\n  tagsButton: {\n    backgroundColor: 'transparent',\n    border: 'none',\n    borderRadius: 0,\n    color: NICE_MIDDLE_BLUE,\n    cursor: 'pointer',\n    padding: '8px 20px',\n    textAlign: 'left',\n    width: '100%',\n\n    ':hover': {\n      backgroundColor: NICE_MIDDLE_BLUE,\n      color: LIGHT_PRIMARY_TEXT_COLOR,\n    },\n\n    ':focus': {\n      backgroundColor: NICE_MIDDLE_BLUE,\n      color: LIGHT_PRIMARY_TEXT_COLOR,\n    },\n  },\n  button: {\n    width: '100%',\n  },\n});\n\nexport interface IAssignTagsFormProps {\n  articleId: ModelId;\n  comment: ICommentModel;\n  clearPopups(): void;\n  submit(commentId: ModelId, selectedTagIds: Set<ModelId>, rejectedTagIds: Set<ModelId>): Promise<void>;\n}\n\nexport function AssignTagsForm(props: IAssignTagsFormProps) {\n  const tags = useSelector(getTaggableTags);\n  const sensitivities = useSelector(getTaggingSensitivities);\n  const {articleId, comment} = props;\n  const {article} = useCachedArticle(articleId);\n  const summaryScores = comment.summaryScores;\n\n  function getPreselected() {\n    if (!summaryScores) {\n      return Set<ModelId>();\n    }\n    const categoryId = article ? article.categoryId : 'na';\n    const sensitivitiesForCategory = getSensitivitiesForCategory(categoryId, sensitivities);\n    const scoresAboveThreshold = getSummaryScoresAboveThreshold(sensitivitiesForCategory, summaryScores);\n    return Set(scoresAboveThreshold.map((score) => score.tagId));\n  }\n\n  const [selected, setSelected] = useState(Set<ModelId>());\n  useEffect(() => {\n    setSelected(selected.merge(getPreselected()));\n  }, [summaryScores]);\n\n  function onTagButtonClick(tagId: ModelId) {\n    if (selected.includes(tagId)) {\n      setSelected(selected.delete(tagId));\n    } else {\n      setSelected(selected.add(tagId));\n    }\n  }\n\n  function submit() {\n    if (selected.size === 0) {\n      return;\n    }\n    const preselected = getPreselected();\n    const rejected = preselected.subtract(selected);\n    props.submit(comment.id, selected, rejected);\n  }\n\n  return (\n    <ClickAwayListener onClickAway={props.clearPopups}>\n      <div {...css(SCRIM_STYLE.popupMenu, {padding: '20px 60px'})}>\n        <DialogTitle id=\"article-controls\">Reason for rejection</DialogTitle>\n        <ul {...css(STYLES.tagsList)}>\n          {tags && tags.map((t: ITagModel) => (\n            <li key={`tag${t.id}`} {...css(STYLES.listItem)}>\n              <CheckboxRow\n                label={t.label}\n                value={t.id}\n                isSelected={selected.includes(t.id)}\n                onChange={onTagButtonClick}\n              />\n            </li>\n          ))}\n        </ul>\n        <div key=\"footer\" {...css({textAlign: 'right', marginBottom: '30px'})}>\n          <span onClick={props.clearPopups} {...css({marginRight: '30px', opacity: '0.5'})}>Cancel</span>\n          <span onClick={submit} {...css({color: NICE_CONTROL_BLUE, opacity: selected.size > 0 ? 1 : 0.35})}>Reject Comment</span>\n        </div>\n      </div>\n    </ClickAwayListener>\n  );\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/Avatar/Avatar.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport React, { ReactNode } from 'react';\n\nimport { Avatar as MAvatar, Tooltip, withStyles } from '@material-ui/core';\n\nimport {\n  IAuthorModel,\n  IUserModel,\n} from '../../../models';\nimport { randomDarkColor } from '../../util/color';\n\nconst AVATAR_TEXT_SCALE_FACTOR = 2;\n\nconst MyTooltip = withStyles((theme) => ({\n  tooltip: {\n    fontSize: 14,\n    backgroundColor: theme.palette.common.white,\n    color: 'rgba(0, 0, 0, 0.87)',\n    boxShadow: theme.shadows[1],\n  },\n}))(Tooltip);\n\nexport function Avatar(props: {\n  target: IAuthorModel | IUserModel;\n  size: number;\n}) {\n  const { target, size } = props;\n\n  let avatarURL = (target as IUserModel).avatarURL || (target as IAuthorModel).avatar;\n  if (avatarURL && !avatarURL.startsWith('https://')) {\n    avatarURL = null;\n  }\n\n  const name = target.name;\n  const initials = name.match(/\\b\\w/g) || [];\n  const ins = ((initials.shift() || '') + (initials.pop() || '')).toUpperCase();\n  const color = randomDarkColor(name);\n  return (\n    <div style={{display: 'inline-block', margin: '1px'}}>\n      <MyTooltip title={name} placement=\"top\">\n        <MAvatar\n          alt={name}\n          src={avatarURL}\n          style={{\n            width: `${size}px`,\n            height: `${size}px`,\n            backgroundColor: color,\n            color: 'white',\n            fontSize: `${size / AVATAR_TEXT_SCALE_FACTOR}px`,\n          }}\n        >\n          {ins}\n        </MAvatar>\n      </MyTooltip>\n    </div>\n  );\n}\n\nexport function PseudoAvatar(props: {\n  children: ReactNode;\n  size: number;\n}) {\n  const {children, size} = props;\n\n  return (\n    <div style={{display: 'inline-block', margin: '1px'}}>\n      <MAvatar\n        style={{\n          width: `${size}px`,\n          height: `${size}px`,\n          fontSize: `${size / 2}px`,\n        }}\n      >\n        {children}\n      </MAvatar>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/Avatar/AvatarStory.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { storiesOf } from '@storybook/react';\nimport faker from 'faker';\nimport React from 'react';\n\nimport { IAuthorModel } from '../../../models';\nimport { fakeUserModel } from '../../../models/fake';\nimport { Avatar } from './Avatar';\n\nconst authorWithAvatar = {\n  email: 'name@email.com',\n  location: 'NYC',\n  name: 'Bridie Skiles V',\n  avatar: faker.internet.avatar(),\n} as IAuthorModel;\n\nconst authorWithoutAvatar = {\n  email: 'name@email.com',\n  location: 'NYC',\n  name: 'Bridie Skiles V',\n} as IAuthorModel;\n\nconst user = fakeUserModel({\n  name: 'Test Person',\n  avatarURL: faker.internet.avatar(),\n});\n\nstoriesOf('Avatar', module)\n  .addDecorator((story) => (\n    <div style={{margin: '50px'}}>{story()}</div>\n  ))\n  .add('DontTest:commenter', () => {\n    return (\n      <Avatar size={54} target={authorWithAvatar} />\n    );\n  })\n  .add('DontTest:moderator', () => {\n    return (\n      <Avatar size={54} target={user} />\n    );\n  })\n  .add('no avatar', () => {\n    return (\n      <Avatar size={54} target={authorWithoutAvatar} />\n    );\n  })\n  .add('DontTest:36x36', () => {\n    return (\n      <Avatar size={36} target={user} />\n    );\n  })\n  .add('30x30 no avatar', () => {\n    return (\n      <Avatar size={30} target={authorWithoutAvatar}/>\n    );\n  });\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/Avatar/__spec__/.gitkeep",
    "content": ""
  },
  {
    "path": "packages/frontend-web/src/app/components/Avatar/index.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nexport { Avatar, PseudoAvatar } from './Avatar';\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/Button/Button.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport React from 'react';\n\nimport {\n  BUTTON_LINK_TYPE,\n  GREY_COLOR,\n  LIGHT_COLOR,\n  LIGHT_PRIMARY_TEXT_COLOR,\n  NICE_MIDDLE_BLUE,\n  WHITE_COLOR,\n} from '../../styles';\nimport { css, IStyle, stylesheet } from '../../utilx';\n\nconst STYLES = stylesheet({\n  button: {\n    ...BUTTON_LINK_TYPE,\n    background: NICE_MIDDLE_BLUE,\n    border: 0,\n    borderRadius: '3px',\n    color: LIGHT_PRIMARY_TEXT_COLOR,\n    cursor: 'pointer',\n    padding: '16px 34px 14px 34px',\n    ':focus': {\n      outline: 'none',\n      background: LIGHT_COLOR,\n    },\n  },\n\n  disabled: {\n    color: WHITE_COLOR,\n    background: GREY_COLOR,\n  },\n});\n\nexport interface IButtonProps {\n  label: string;\n  onClick?(e: React.MouseEvent<any>): any;\n  disabled?: boolean;\n  buttonStyles?: IStyle;\n}\n\nexport class Button extends React.PureComponent<IButtonProps> {\n  render() {\n    const {\n      label,\n      onClick,\n      disabled,\n      buttonStyles,\n    } = this.props;\n\n    return (\n      <button key={label} {...css(STYLES.button, disabled && STYLES.disabled, buttonStyles)} onClick={onClick} disabled={disabled}>{label}</button>\n    );\n  }\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/Button/ButtonStory.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { action } from '@storybook/addon-actions';\nimport { storiesOf } from '@storybook/react';\nimport { Button } from './Button';\n\nstoriesOf('Button', module)\n    .add('Done', () => (\n        <Button label=\"Done\" onClick={action('Done')} />\n    ));\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/Button/index.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nexport { Button } from './Button';\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/CanvasTruncate/CanvasTruncate.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { autobind } from 'core-decorators';\nimport { fromJS, Map } from 'immutable';\nimport React from 'react';\nimport { ITypeStyle } from '../../styles';\nimport { getTextHeight, measureLine, wordWrap } from '../../util/measureText';\nimport { css } from '../../utilx';\n\nconst canvas = document.createElement('canvas');\n\nfunction clampLines(text: string, width: number, fontStyles: ITypeStyle, lines: number): Array<string> {\n  if (!width) { return []; }\n\n  const wrappedLines = wordWrap(canvas, text, width, fontStyles);\n\n  if (wrappedLines.length <= lines) { return wrappedLines; }\n\n  const clampedLines = wrappedLines.slice(0, lines);\n  let lastLine = clampedLines[clampedLines.length - 1];\n\n  let lastLineLength = measureLine(canvas, lastLine + '...', fontStyles);\n  let attempts = 3;\n\n  while (lastLineLength > width && attempts--) {\n    lastLine = lastLine.substring(0, lastLine.length - 1);\n    lastLineLength = measureLine(canvas, lastLine + '...', fontStyles);\n  }\n\n  clampedLines[clampedLines.length - 1] = lastLine + '...';\n\n  return clampedLines;\n}\n\nlet cache = Map<any, Array<string>>();\nfunction memoizedClampLines(text: string, width: number, fontStyles: ITypeStyle, lines: number): Array<string> {\n  const params = fromJS({ text, width, fontStyles, lines });\n\n  if (!cache.get(params)) {\n    cache = cache.set(params, clampLines(text, width, fontStyles, lines));\n  }\n\n  return cache.get(params);\n}\n\nexport interface ICanvasTruncateProps {\n  text: string;\n  lines: number;\n  fontStyles: ITypeStyle;\n  id?: string;\n}\n\nexport interface ICanvasTruncateState {\n  width: number;\n}\n\nexport class CanvasTruncate extends React.PureComponent<ICanvasTruncateProps, ICanvasTruncateState> {\n  elem: HTMLDivElement;\n\n  state: ICanvasTruncateState = {\n    width: 0,\n  };\n\n  componentDidMount() {\n    window.addEventListener('resize', this.onResize);\n\n    // Need to wait for Aphrodite styles to load\n    setTimeout(() => this.onResize(), 60);\n  }\n\n  componentWillUnmount() {\n    window.removeEventListener('resize', this.onResize);\n  }\n\n  @autobind\n  onResize() {\n    this.setState({\n      width: this.elem ? this.elem.getBoundingClientRect().width : 0,\n    });\n  }\n\n  @autobind\n  saveElementRef(elem: HTMLDivElement) {\n    this.elem = elem;\n  }\n\n  render() {\n    const {\n      text,\n      lines,\n      fontStyles,\n      id,\n    } = this.props;\n\n    const { width } = this.state;\n\n    let height;\n    let output;\n\n    if (width) {\n\n      const clamped = memoizedClampLines(text, width, fontStyles, lines);\n      height = getTextHeight(clamped, width, fontStyles);\n      output = clamped.reduce((sum, line, i, fullArray) => {\n        sum.push(<span aria-hidden=\"true\">{line}</span>);\n\n        if (i < fullArray.length - 1) {\n          sum.push(<br />);\n        }\n\n        return sum;\n      }, []);\n    } else {\n      height = 0;\n      output = null;\n    }\n\n    return (\n      <span\n        id={id}\n        aria-label={text}\n        ref={this.saveElementRef}\n        title={text}\n        {...css({\n          display: 'block',\n          width: '100%',\n          height: `${height}px`,\n          overflow: 'hidden',\n        })}\n      >\n        {output}\n      </span>\n    );\n  }\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/CanvasTruncate/index.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nexport { CanvasTruncate } from './CanvasTruncate';\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/CheckboxRow/CheckboxRow.tsx",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport React from 'react';\n\nimport {\n  Radio,\n} from '@material-ui/core';\n\nimport {\n  ARTICLE_CATEGORY_TYPE,\n  DARK_PRIMARY_TEXT_COLOR,\n} from '../../styles';\nimport { css, stylesheet } from '../../utilx';\n\nexport const GOOD_IMAGE_SIZE = 46;\n\nconst STYLES = stylesheet({\n  base: {\n    position: 'relative',\n  },\n\n  row: {\n    ...ARTICLE_CATEGORY_TYPE,\n    alignItems: 'center',\n    justifyContent: 'space-between',\n    display: 'flex',\n    marginBottom: '14px',\n    userSelect: 'none',\n    width: '100%',\n    color: DARK_PRIMARY_TEXT_COLOR,\n  },\n\n  rowDisabled: {\n    opacity: '0.75',\n    backgroundColor: '#eee',\n  },\n\n  avatar: {\n    marginRight: '28px',\n  },\n\n  name: {\n    flex: 1,\n  },\n\n  center: {\n    alignItems: 'center',\n    cursor: 'pointer',\n    display: 'flex',\n    width: '100%',\n  },\n});\n\nexport interface ICheckboxRowProps<T> {\n  label: string;\n  value: T;\n  image?: React.ReactNode;\n  isSelected?: boolean;\n  isDisabled?: boolean;\n  onChange?(value: T): void;\n}\n\nexport function CheckboxRow<T>(props: ICheckboxRowProps<T>) {\n  const {label, value, image, isSelected, isDisabled} = props;\n  const id = label.replace(/\\s/g, '');\n  function handleClick(e: any) {\n    e.preventDefault();\n    props.onChange(value);\n  }\n\n  return (\n    <div {...css(STYLES.base)}>\n      <div {...css(STYLES.row, isDisabled ? STYLES.rowDisabled : {})}>\n        <label\n          htmlFor={id}\n          onClick={handleClick}\n          {...css(STYLES.center)}\n        >\n          {image && (\n            <span {...css(STYLES.avatar)} aria-hidden=\"true\">\n              {image}\n            </span>\n          )}\n          <span {...css(STYLES.name)}>{label}</span>\n          <Radio color=\"primary\" checked={isSelected}/>\n        </label>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/CheckboxRow/CheckboxRowStory.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { action } from '@storybook/addon-actions';\nimport { storiesOf } from '@storybook/react';\nimport faker from 'faker';\n\nimport { fakeUserModel } from '../../../models/fake';\nimport { Avatar } from '../Avatar';\nimport { CheckboxRow, GOOD_IMAGE_SIZE } from './CheckboxRow';\n\nconst user = fakeUserModel({\n  name: 'Person One',\n  avatarURL: faker.internet.avatar(),\n});\n\nstoriesOf('CheckboxRow', module)\n    .add('DontTest:Default', () => (\n      <CheckboxRow\n        label={user.name}\n        value={user.id}\n        image={<Avatar size={GOOD_IMAGE_SIZE} target={user}/>}\n        onChange={action('Change Lucas Dixon')}\n      />\n    ))\n    .add('DontTest:Selected', () => (\n      <CheckboxRow\n        label={user.name}\n        value={user.id}\n        image={<Avatar size={GOOD_IMAGE_SIZE} target={user}/>}\n        isSelected\n        onChange={action('Change Lucas Dixon')}\n      />\n    ));\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/CheckboxRow/index.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nexport { CheckboxRow } from './CheckboxRow';\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/CommentActionButton/CommentActionButton.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { autobind } from 'core-decorators';\nimport React from 'react';\nimport { css, IStyle, stylesheet } from '../../utilx';\n\nimport {\n  BUTTON_LINK_TYPE,\n  GUTTER_DEFAULT_SPACING,\n  LIGHT_PRIMARY_TEXT_COLOR,\n  MEDIUM_OPACITY,\n  NICE_MIDDLE_BLUE,\n  SHORT_SCREEN_QUERY,\n  WHITE_COLOR,\n} from '../../styles';\n\nexport const ICON_SIZE = 24;\n\nconst STYLES = stylesheet({\n  base: {\n    position: 'relative',\n    background: 'none',\n    border: 0,\n    cursor: 'pointer',\n    padding: GUTTER_DEFAULT_SPACING,\n    display: 'flex',\n    justifyContent: 'center',\n    ':hover': {},\n    ':focus': {\n      outline: 0,\n    },\n    [SHORT_SCREEN_QUERY] : {\n      padding: '16px',\n    },\n  },\n\n  disabledButton: {\n    opacity: MEDIUM_OPACITY,\n    cursor: 'default',\n  },\n\n  content: {\n    display: 'flex',\n    justifyContent: 'center',\n    alignItems: 'center',\n    margin: '0 auto',\n    borderBottom: '2px solid transparent',\n  },\n\n  baseLabel: {\n    ...BUTTON_LINK_TYPE,\n    color: LIGHT_PRIMARY_TEXT_COLOR,\n    marginLeft: 16,\n    borderBottom: '2px solid transparent',\n  },\n\n  labelFocused: {\n    borderBottom: `2px solid ${WHITE_COLOR}`,\n  },\n\n  iconFocused: {\n    borderBottom: `2px solid ${NICE_MIDDLE_BLUE}`,\n  },\n\n  noLabel: {\n    display: 'none',\n  },\n});\n\nexport interface ICommentActionProps {\n  style?: IStyle;\n  label: string;\n  disabled?: boolean;\n  icon: JSX.Element;\n  iconHovered?: JSX.Element;\n  hideLabel?: boolean;\n  onClick?(e: React.MouseEvent<any>): any;\n  buttonRef?: React.Ref<HTMLButtonElement>;\n  isActive?: boolean;\n}\n\nexport interface ICommentActionState {\n  isFocused?: boolean;\n  isHovered?: boolean;\n}\n\nexport class CommentActionButton\n    extends React.PureComponent<ICommentActionProps, ICommentActionState> {\n\n  state = {\n    isFocused: false,\n    isHovered: false,\n  };\n\n  render() {\n    const {\n      style,\n      disabled,\n      label,\n      icon,\n      iconHovered,\n      hideLabel,\n      buttonRef,\n      isActive,\n      onClick,\n    } = this.props;\n\n    const {\n      isHovered,\n      isFocused,\n    } = this.state;\n\n    const showActive = iconHovered && (isActive || (!disabled && isHovered));\n\n    return (\n      <button\n        type=\"button\"\n        ref={buttonRef}\n        disabled={disabled}\n        {...css(\n          STYLES.base,\n          disabled && STYLES.disabledButton,\n          style,\n        )}\n        aria-label={label}\n        onClick={onClick}\n        onMouseEnter={this.onMouseEnter}\n        onMouseLeave={this.onMouseLeave}\n        onFocus={this.onFocus}\n        onBlur={this.onBlur}\n      >\n        <div aria-hidden {...css(STYLES.content, isFocused && STYLES.iconFocused)}>\n          <div>\n            {showActive ? iconHovered : icon}\n          </div>\n          <div {...css(STYLES.baseLabel, hideLabel && STYLES.noLabel, isFocused && STYLES.labelFocused)}>\n            {label}\n          </div>\n        </div>\n      </button>\n    );\n  }\n\n  @autobind\n  onMouseEnter() {\n    this.setState({ isHovered: true });\n  }\n\n  @autobind\n  onMouseLeave() {\n    this.setState({ isHovered: false });\n  }\n\n  @autobind\n  onFocus() {\n    this.setState({ isFocused: true });\n  }\n\n  @autobind\n  onBlur() {\n    this.setState({ isFocused: false });\n  }\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/CommentActionButton/CommentActionButtonStory.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { storiesOf } from '@storybook/react';\nimport {\n  LIGHT_PRIMARY_TEXT_COLOR,\n  MEDIUM_COLOR,\n} from '../../styles';\nimport { css } from '../../utilx';\nimport { CommentActionButton } from '../CommentActionButton';\nimport {\n  ApproveIcon,\n  DeferIcon,\n} from '../Icons';\n\nstoriesOf('CommentActionButton', module)\n  .add('Full Width', () => (\n    <div {...css({ background: MEDIUM_COLOR, display: 'inline-block' })}>\n      <CommentActionButton\n        label=\"Approve\"\n        icon={<ApproveIcon {...css({ fill: LIGHT_PRIMARY_TEXT_COLOR })} />}\n        iconHovered={<DeferIcon {...css({ fill: LIGHT_PRIMARY_TEXT_COLOR })} />}\n      />\n    </div>\n  ))\n  .add('Hide Label', () => (\n    <div {...css({ background: MEDIUM_COLOR, display: 'inline-block' })}>\n      <CommentActionButton\n        label=\"Approve\"\n        hideLabel\n        icon={<ApproveIcon {...css({ fill: LIGHT_PRIMARY_TEXT_COLOR })} />}\n        iconHovered={<DeferIcon {...css({ fill: LIGHT_PRIMARY_TEXT_COLOR })} />}\n      />\n    </div>\n  ))\n  .add('Disabled', () => (\n    <div {...css({ background: MEDIUM_COLOR, display: 'inline-block' })}>\n      <CommentActionButton\n        label=\"Approve\"\n        disabled\n        icon={<ApproveIcon {...css({ fill: LIGHT_PRIMARY_TEXT_COLOR })} />}\n        iconHovered={<DeferIcon {...css({ fill: LIGHT_PRIMARY_TEXT_COLOR })} />}\n      />\n    </div>\n  ));\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/CommentActionButton/index.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nexport { CommentActionButton, ICON_SIZE } from './CommentActionButton';\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/CommentList/CommentList.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { List, Set } from 'immutable';\nimport React from 'react';\nimport AutoSizer from 'react-virtualized-auto-sizer';\nimport {VariableSizeList} from 'react-window';\n\nimport { ITagModel, ModelId } from '../../../models';\nimport { IConfirmationAction } from '../../../types';\nimport { css, stylesheet } from '../../utilx';\nimport { ILinkTargetGetter } from '../LazyLoadComment';\nimport { LinkedBasicBody } from '../LazyLoadComment';\nimport { CheckboxColumn } from './components/CheckboxColumn';\nimport { SortColumn } from './components/SortColumn';\n\nimport {\n  ARTICLE_CATEGORY_TYPE,\n  BASE_Z_INDEX,\n  BOX_DEFAULT_SPACING,\n  GUTTER_DEFAULT_SPACING,\n  HEADER_HEIGHT,\n  NICE_MIDDLE_BLUE,\n  OFFSCREEN,\n  SELECT_ELEMENT,\n  SELECT_Z_INDEX,\n} from '../../styles';\nimport {COMMENT_HEADER_BACKGROUND_COLOR} from '../styles';\nimport {CustomScrollbarsVirtualList} from '../VirtualListScrollbar';\n\nconst ARROW_SIZE = 6;\n\nconst STYLES = stylesheet({\n  dropdown: {\n    position: 'relative',\n  },\n\n  select: {\n    ...SELECT_ELEMENT,\n    paddingRight: `${GUTTER_DEFAULT_SPACING * 2}px`,\n    position: 'relative',\n    zIndex: SELECT_Z_INDEX,\n    textAlignLast: 'left',\n    borderBottom: `2px solid transparent`,\n    ':focus': {\n      outline: 0,\n      borderBottom: `2px solid ${NICE_MIDDLE_BLUE}`,\n      borderRadius: 0,\n    },\n  },\n\n  arrow: {\n    position: 'absolute',\n    zIndex: BASE_Z_INDEX,\n    right: `${GUTTER_DEFAULT_SPACING}px`,\n    top: '8px',\n    borderLeft: `${ARROW_SIZE}px solid transparent`,\n    borderRight: `${ARROW_SIZE}px solid transparent`,\n    borderTop: `${ARROW_SIZE}px solid ${NICE_MIDDLE_BLUE}`,\n    display: 'block',\n    height: 0,\n    width: 0,\n    marginLeft: `${BOX_DEFAULT_SPACING}px`,\n    marginRight: `${BOX_DEFAULT_SPACING}px`,\n  },\n\n  approval: {\n    ...ARTICLE_CATEGORY_TYPE,\n    color: NICE_MIDDLE_BLUE,\n    padding: `0 0 0 ${GUTTER_DEFAULT_SPACING * 1.5}px`,\n    textAlign: 'left',\n  },\n});\n\nconst ROW_FLEX_STYLE = {\n  display: 'flex',\n  flexDirection: 'row' as any, // Fix bug in typescript/react typings\n  justifyContent: 'center',\n};\n\nconst DEFAULT_ROW_HEIGHT = 180;\nconst ROW_PADDING_WITH_TITLE = 200;\nconst ROW_PADDING_WITHOUT_TITLE = 130;\n\nconst SELECT_ALL_ID = 'select-all-checkbox';\n\nexport interface ICommentListProps {\n  commentIds: Array<ModelId>;\n  textSizes: Map<ModelId, number>;\n  heightOffset: number;\n  totalItems: number;\n\n  areAllSelected?: boolean;\n  onSelectAllChange?(): void;\n  onSelectionChange?(commentId: string): void;\n  isItemChecked(id: string): boolean;\n\n  currentSort?: string;\n  sortOptions?: List<ITagModel>;\n  onSortChange?(e: React.ChangeEvent<any>): any;\n\n  getLinkTarget: ILinkTargetGetter;\n  onCommentClick?(commentIndex: string): any;\n  rowHeight?: number;\n  hideCommentAction?: boolean;\n  dispatchConfirmedAction?(action: IConfirmationAction, ids: Array<string>): void;\n  scrollToRow?: number;\n  ownerHeight?: number;\n  searchTerm?: string;\n  handleAssignTagsSubmit(commentId: ModelId, selectedTagIds: Set<ModelId>, rejectedTagIds: Set<ModelId>): Promise<void>;\n  displayArticleTitle?: boolean;\n  selectedTag?: ITagModel;\n  onTableScroll?(scrollPos: number): boolean;\n}\n\nexport function CommentList(props: ICommentListProps) {\n\n  const {\n    commentIds,\n    textSizes,\n    isItemChecked,\n    dispatchConfirmedAction,\n    selectedTag,\n    getLinkTarget,\n    onCommentClick,\n    hideCommentAction,\n    searchTerm,\n    displayArticleTitle,\n    handleAssignTagsSubmit,\n    currentSort,\n  } = props;\n\n  const padding = displayArticleTitle ? ROW_PADDING_WITH_TITLE : ROW_PADDING_WITHOUT_TITLE;\n  function rowHeight(idx: number): number {\n    const commentId = commentIds[idx];\n\n    return commentId && textSizes && textSizes.has(commentId)\n      ? textSizes.get(commentId) + padding\n      : DEFAULT_ROW_HEIGHT;\n  }\n\n  function SortOption() {\n    return (\n      <>\n        <label htmlFor=\"sorted-type\" {...css(OFFSCREEN)}>\n          Sort comments by\n        </label>\n        <div\n          {...css(\n            STYLES.dropdown,\n            {marginLeft: `${smallerViewport ? 0 : GUTTER_DEFAULT_SPACING * 1.5}px`},\n          )}\n        >\n          <select\n            id=\"sorted-type\"\n            onChange={onSortChange}\n            {...css(STYLES.select)}\n            value={currentSort}\n          >\n            {sortOptions.map((option) => (\n              <option key={option.key} value={option.key}>\n                {option.label}\n              </option>\n            ))}\n          </select>\n          <span aria-hidden=\"true\" {...css(STYLES.arrow)} />\n        </div>\n      </>\n    );\n  }\n\n  const {\n    onSortChange,\n    sortOptions,\n    areAllSelected,\n    onSelectAllChange,\n  } = props;\n\n  const heightOffset = props.heightOffset || HEADER_HEIGHT;\n  const tableWidth = window.innerWidth;\n  const tableHeight = window.innerHeight - heightOffset;\n  const smallerViewport = tableWidth < 1200;\n\n  const checkboxColumnWidth = smallerViewport ? 80 : 250;\n  const rightmostColumnWidth = smallerViewport ? 240 : 290;\n\n  function renderRow(index: number, style: any) {\n    const commentId = commentIds[index];\n    return (\n      <div\n        key={commentId}\n        style={{\n          ...style,\n          ...ROW_FLEX_STYLE,\n          paddingTop: '10px',\n          backgroundColor: index % 2 ? '#F7F7F7' : 'white',\n        }}\n      >\n        <div style={{width: `${checkboxColumnWidth}px`}}>\n          <CheckboxColumn\n            commentId={commentId}\n            inputId={`select_${index}`}\n            isItemChecked={isItemChecked}\n            onCheck={props.onSelectionChange}\n          />\n        </div>\n        <div style={{width: '700px'}}>\n          <LinkedBasicBody\n            searchTerm={searchTerm}\n            getLinkTarget={getLinkTarget}\n            onCommentClick={onCommentClick}\n            hideCommentAction={hideCommentAction}\n            commentId={commentId}\n            selectedTag={selectedTag}\n            handleAssignTagsSubmit={handleAssignTagsSubmit}\n            displayArticleTitle={displayArticleTitle}\n            dispatchConfirmedAction={dispatchConfirmedAction}\n            showActions\n          />\n        </div>\n        <div style={{width: `${rightmostColumnWidth}px`}}>\n          {sortOptions && (\n            <SortColumn\n              selectedSort={currentSort}\n              selectedTag={selectedTag}\n              style={STYLES.approval}\n              commentId={commentId}\n            />\n          )}\n        </div>\n      </div>\n    );\n  }\n\n  return (\n    <div style={{width: tableWidth, height: tableHeight }}>\n      <div\n        key=\"header\"\n        style={{\n          ...ROW_FLEX_STYLE,\n          alignItems: 'center',\n          height: `${HEADER_HEIGHT}px`,\n          backgroundColor: COMMENT_HEADER_BACKGROUND_COLOR,\n        }}\n      >\n        <div style={{width: `${checkboxColumnWidth}px`}}>\n          <CheckboxColumn\n            isSelected={areAllSelected}\n            onCheck={onSelectAllChange}\n            inputId={SELECT_ALL_ID}\n          />\n        </div>\n        <div style={{width: `700px`}}>\n          <label htmlFor={SELECT_ALL_ID} onClick={onSelectAllChange}>\n            {areAllSelected ? 'Deselect All' : 'Select All'}\n          </label>\n        </div>\n        <div style={{width: `${rightmostColumnWidth}px`}}>\n          {sortOptions && (\n            <SortOption />\n          )}\n        </div>\n      </div>\n      <AutoSizer>\n        {({width, height}) => (\n          <VariableSizeList\n            outerElementType={CustomScrollbarsVirtualList}\n            itemSize={rowHeight}\n            itemCount={commentIds.length}\n            height={height}\n            width={width}\n            overscanCount={10}\n          >\n            {({index, style}) => (\n              renderRow(index, style)\n            )}\n          </VariableSizeList>\n        )}\n      </AutoSizer>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/CommentList/components/CheckboxColumn/CheckboxColumn.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport React from 'react';\n\nimport {\n  Radio,\n} from '@material-ui/core';\n\nimport { ModelId } from '../../../../../models';\nimport { maybeCallback, partial } from '../../../../util';\nimport { css, stylesheet } from '../../../../utilx';\n\nimport {\n  GUTTER_DEFAULT_SPACING,\n  VISUALLY_HIDDEN,\n} from '../../../../styles';\n\nconst STYLES = stylesheet({\n  base: {\n    padding: '0 25px',\n    height: '100%',\n  },\n\n  label: {\n    cursor: 'pointer',\n    display: 'flex',\n    userSelect: 'none',\n    justifyContent: 'flex-end',\n    marginRight: `${GUTTER_DEFAULT_SPACING * 2}px`,\n  },\n\n  labelSlim: {\n    marginRight: `${GUTTER_DEFAULT_SPACING}px`,\n  },\n});\n\nexport interface ICheckboxColumnProps {\n  commentId?: ModelId;\n  isSelected?: boolean;\n  inputId: string;\n  isItemChecked?(commentId: ModelId): boolean;\n  onCheck?(commentId: ModelId): void;\n}\n\nexport function CheckboxColumn(props: ICheckboxColumnProps) {\n  const {\n    isSelected,\n    onCheck,\n    commentId,\n    isItemChecked,\n    inputId,\n  } = props;\n  const smallerScreen = window.innerWidth < 1025;\n\n  return (\n    <div {...css(STYLES.base)}>\n      <label\n        {...css(STYLES.label, smallerScreen && STYLES.labelSlim)}\n        htmlFor={inputId}\n        onClick={partial(maybeCallback(onCheck), commentId)}\n      >\n        <Radio color=\"primary\" checked={isItemChecked ? isItemChecked(commentId) : isSelected}/>\n        <span {...css(VISUALLY_HIDDEN)}>Select item</span>\n      </label>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/CommentList/components/CheckboxColumn/index.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nexport { CheckboxColumn } from './CheckboxColumn';\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/CommentList/components/SortColumn/SortColumn.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport {format, parseISO} from 'date-fns';\nimport React from 'react';\nimport {useSelector} from 'react-redux';\n\nimport { getSummaryForTag, ITagModel, ModelId } from '../../../../../models';\nimport { DATE_FORMAT_HM, DATE_FORMAT_MDY } from '../../../../config';\nimport { useCachedComment } from '../../../../injectors/commentInjector';\nimport { getTags } from '../../../../stores/tags';\nimport {\n  DARK_SECONDARY_TEXT_COLOR,\n} from '../../../../styles';\nimport { css, IStyle } from '../../../../utilx';\n\nexport interface ISortColumnProps extends React.HTMLProps<any> {\n  style?: IStyle;\n  commentId?: ModelId;\n  selectedSort?: string;\n  selectedTag?: ITagModel;\n}\n\nexport function SortColumn(props: ISortColumnProps) {\n  const {\n    style,\n    commentId,\n    selectedSort,\n    selectedTag,\n  } = props;\n\n  const {comment} = useCachedComment(commentId);\n  const tags = useSelector(getTags);\n\n  if (['newest', 'oldest', 'updated'].includes(selectedSort)) {\n    const dateStr = selectedSort === 'updated' ? comment.updatedAt : comment.sourceCreatedAt;\n    const date = dateStr ? parseISO(dateStr) : null;\n    const mdy = date ? format(date, DATE_FORMAT_MDY) : '--';\n    const hm = date ? format(date, DATE_FORMAT_HM) : '--';\n    return (\n      <div {...css(style)}>\n        <p key=\"date\" {...css({margin: '0px'})}>{mdy}</p>\n        <p key=\"time\" {...css({margin: '0px'})}>{hm}</p>\n      </div>\n    );\n  }\n\n  if (selectedSort === 'flagged') {\n    return (\n      <div {...css(style)}>\n        <p key=\"flags\" {...css({margin: '0px'})}>{comment.unresolvedFlagsCount}</p>\n      </div>\n    );\n  }\n\n  if (selectedTag && selectedTag.key !== 'SUMMARY_SCORE') {\n    const summary = getSummaryForTag(comment, selectedTag.id);\n    if (summary) {\n      return (\n        <div {...css(style)}>\n          <p key=\"summary\" {...css({margin: '0px'})}>{(summary.score * 100.0).toFixed()}%</p>\n        </div>\n      );\n    }\n  }\n\n  if (!comment.maxSummaryScore) {\n    return (\n      <div {...css(style)}>\n        <p key=\"summary\" {...css({margin: '0px'})}>Unscored</p>\n      </div>\n    );\n  }\n\n  const maxSummaryScoreTag = tags.find((tag) => tag.id === comment.maxSummaryScoreTagId);\n  const tagLabel = maxSummaryScoreTag && maxSummaryScoreTag.label;\n\n  return (\n    <div {...css(style)}>\n      <p key=\"summary\" {...css({margin: '0px'})}>{(comment.maxSummaryScore * 100.0).toFixed()}%</p>\n      <p key=\"label\" {...css({margin: '0px', color: DARK_SECONDARY_TEXT_COLOR})}>{tagLabel}</p>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/CommentList/components/SortColumn/index.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nexport { SortColumn } from './SortColumn';\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/CommentList/index.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nexport * from './CommentList';\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/CommentText.tsx",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport React from 'react';\nimport Linkify from 'react-linkify';\n\nimport { makeStyles } from '@material-ui/styles';\nimport { ITopScore } from '../../models';\n\nconst useStyles = makeStyles({\n  bold: {\n    fontWeight: 600,\n  },\n});\n\nfunction linkifyLink(decoratedHref: string, decoratedText: string, key: number) {\n  return (\n    <a href={decoratedHref} key={key} target=\"_blank\">\n      {decoratedText}\n    </a>\n  );\n}\n\nexport function CommentText(props: {\n  text: string,\n  highlight?: ITopScore,\n}) {\n  const { text, highlight } = props;\n  const classes = useStyles(props);\n\n  const output = [];\n  if (!highlight || highlight.start >= highlight.end) {\n    output.push(<span key=\"text\">{text}</span>);\n  }\n  else {\n    if (highlight.start > 0) {\n      output.push(<span key=\"text-before\">{text.slice(0, highlight.start)}</span>);\n    }\n    output.push(<span key=\"text-highlighted\" className={classes.bold}>{text.slice(highlight.start, highlight.end)}</span>);\n    if (highlight.end < text.length - 1) {\n      output.push(<span key=\"text-after\">{text.slice(highlight.end, text.length)}</span>);\n    }\n  }\n  return (\n    <Linkify componentDecorator={linkifyLink}>\n      {output}\n    </Linkify>\n  );\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/ConfirmationCircle/ConfirmationCircle.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport React from 'react';\nimport { LIGHT_PRIMARY_TEXT_COLOR } from '../../styles';\nimport { css, stylesheet } from '../../utilx';\nimport {\n  ApproveIcon,\n  DeferIcon,\n  HighlightIcon,\n  RejectIcon,\n  UndoIcon,\n} from '../Icons';\n\nconst STYLES = stylesheet({\n  circle: {\n    height: '100%',\n    width: '100%',\n    borderRadius: '50%',\n    display: 'flex',\n    alignItems: 'center',\n    justifyContent: 'center',\n  },\n  small: {\n    height: 40,\n    width: 40,\n  },\n  large: {\n    height: 120,\n    width: 120,\n  },\n});\n\nexport interface IConfirmationCircleProps {\n  backgroundColor: string;\n  action: string;\n  size: number;\n  iconSize?: number;\n}\n\nexport class ConfirmationCircle extends React.PureComponent<IConfirmationCircleProps> {\n  render() {\n    const {\n      backgroundColor,\n      action,\n      size,\n      iconSize,\n    } = this.props;\n\n    return (\n      <div\n        id={action}\n        {...css(STYLES.circle, {\n          backgroundColor,\n          ...(size ? { width: size, height: size } : {}),\n        })}\n      >\n        { action === 'approve' && (\n          <ApproveIcon\n            role=\"alert\"\n            label=\"Approve\"\n            {...css({ fill: LIGHT_PRIMARY_TEXT_COLOR })}\n            size={iconSize || 40}\n          />\n        )}\n        { action === 'defer' && (\n          <DeferIcon\n            role=\"alert\"\n            label=\"Defer\"\n            {...css({ fill: LIGHT_PRIMARY_TEXT_COLOR })}\n            size={iconSize || 40}\n          />\n        )}\n        { action === 'highlight' && (\n          <HighlightIcon\n            role=\"alert\"\n            label=\"Highlight\"\n            {...css({ fill: LIGHT_PRIMARY_TEXT_COLOR })}\n            size={iconSize || 40}\n          />\n        )}\n        { action === 'reject' && (\n          <RejectIcon\n            role=\"alert\"\n            label=\"Reject\"\n            {...css({ fill: LIGHT_PRIMARY_TEXT_COLOR })}\n            size={iconSize || 40}\n          />\n        )}\n        { action === 'reset' && (\n          <UndoIcon\n            role=\"alert\"\n            label=\"Reset\"\n            {...css({ fill: LIGHT_PRIMARY_TEXT_COLOR })}\n            size={iconSize || 40}\n          />\n        )}\n      </div>\n    );\n  }\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/ConfirmationCircle/ConfirmationCircleStory.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { storiesOf } from '@storybook/react';\nimport { ConfirmationCircle } from './ConfirmationCircle';\n\nconst APPROVE_COLOR = '#27d073';\nconst DEFER_COLOR = '#999999';\nconst HIGHLIGHT_COLOR = '#f9b453';\nconst REJECT_COLOR = '#fc4a79';\n\nexport interface IConfirmationCircleProps {\n  backgroundColor: string;\n  action: 'approve' | 'defer' | 'highlight' | 'reject';\n  size: number;\n}\n\nstoriesOf('ConfirmationCircle', module)\n.add('approve small', () => {\n  return (\n    <ConfirmationCircle\n      size={40}\n      backgroundColor={APPROVE_COLOR}\n      action={'approve'}\n    />\n  );\n})\n.add('defer small', () => {\n  return (\n    <ConfirmationCircle\n      size={40}\n      backgroundColor={DEFER_COLOR}\n      action={'defer'}\n    />\n  );\n})\n.add('highlight small', () => {\n  return (\n    <ConfirmationCircle\n      size={40}\n      backgroundColor={HIGHLIGHT_COLOR}\n      action={'highlight'}\n    />\n  );\n})\n.add('reject small', () => {\n  return (\n    <ConfirmationCircle\n      size={40}\n      backgroundColor={REJECT_COLOR}\n      action={'reject'}\n    />\n  );\n})\n.add('approve large', () => {\n  return (\n    <ConfirmationCircle\n      size={120}\n      backgroundColor={APPROVE_COLOR}\n      action={'approve'}\n    />\n  );\n})\n.add('defer large', () => {\n  return (\n    <ConfirmationCircle\n      size={120}\n      backgroundColor={DEFER_COLOR}\n      action={'defer'}\n    />\n  );\n})\n.add('highlight large', () => {\n  return (\n    <ConfirmationCircle\n      size={120}\n      backgroundColor={HIGHLIGHT_COLOR}\n      action={'highlight'}\n    />\n  );\n})\n.add('reject large', () => {\n  return (\n    <ConfirmationCircle\n      size={120}\n      backgroundColor={REJECT_COLOR}\n      action={'reject'}\n    />\n  );\n});\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/ConfirmationCircle/__spec__/.gitkeep",
    "content": ""
  },
  {
    "path": "packages/frontend-web/src/app/components/ConfirmationCircle/index.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nexport { ConfirmationCircle } from './ConfirmationCircle';\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/DotChart/DotChart.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { autobind } from 'core-decorators';\nimport React from 'react';\nimport {\n  BASE_Z_INDEX,\n  LIGHT_COLOR,\n  NICE_MIDDLE_BLUE,\n} from '../../styles';\nimport { DotChartRenderer, ICommentsByColumn, IRange } from '../../util';\nimport { css, stylesheet } from '../../utilx';\n\nexport const ICON_SIZE = 24;\n\nconst STYLES = stylesheet({\n  base: {\n    display: 'inline-block',\n    position: 'relative',\n  },\n\n  ruleBlock: {\n    background: LIGHT_COLOR,\n    borderLeft: `1px solid ${NICE_MIDDLE_BLUE}`,\n    borderRight: `1px solid ${NICE_MIDDLE_BLUE}`,\n    height: '100%',\n    position: 'absolute',\n    top: 0,\n\n    ':hover': {\n      zIndex: BASE_Z_INDEX,\n    },\n  },\n\n  icon: {\n    background: LIGHT_COLOR,\n    borderWidth: 2,\n    borderStyle: 'solid',\n    borderColor: NICE_MIDDLE_BLUE,\n    borderRadius: '50%',\n    display: 'block',\n    height: `${ICON_SIZE}px`,\n    left: '50%',\n    margin: '0 auto',\n    overflow: 'hidden',\n    padding: '2px',\n    position: 'absolute',\n    top: 0,\n    transform: 'translate(-50%, -50%)',\n    width: `${ICON_SIZE}px`,\n  },\n});\n\nexport interface IAppliedRule {\n  rule: string;\n  start: number;\n  end: number;\n  icon: JSX.Element;\n}\n\nexport interface IDotChartProps {\n  appliedRules?: Array<IAppliedRule>;\n  columnCount?: number;\n  commentsByColumn: ICommentsByColumn;\n  selectedRange?: IRange;\n  width: number;\n  height: number;\n}\n\nexport interface IDotChartState {\n  showAll?: boolean;\n}\n\nexport class DotChart extends React.PureComponent<IDotChartProps, IDotChartState> {\n  canvasRender: DotChartRenderer;\n\n  state = {\n    showAll: false,\n  };\n\n  constructor(props: IDotChartProps) {\n    super(props);\n\n    this.canvasRender = new DotChartRenderer(\n      (width, height) => {\n        const canvas = document.createElement('canvas');\n        canvas.width = width;\n        canvas.height = height;\n\n        return canvas;\n      },\n    );\n  }\n\n  @autobind\n  toggleShowAll() {\n    this.setState({ showAll: !this.state.showAll });\n  }\n\n  @autobind\n  saveCanvasRef(canvas: HTMLCanvasElement) {\n    if (canvas) {\n      this.canvasRender.setProps({ canvas });\n    }\n  }\n\n  render() {\n    const {\n      selectedRange,\n      appliedRules,\n      width,\n    } = this.props;\n\n    this.canvasRender.setProps({\n      selectedRangeStart: selectedRange ? selectedRange.start : undefined,\n      selectedRangeEnd: selectedRange ? selectedRange.end : undefined,\n      ...this.props,\n      ...this.state,\n    });\n\n    return (\n      <div\n        {...css(STYLES.base)}\n        onDoubleClick={this.toggleShowAll}\n      >\n        <canvas ref={this.saveCanvasRef} />\n\n        {appliedRules && appliedRules.map((rule, i) => (\n          <div\n            key={i}\n            {...css(STYLES.ruleBlock, {\n              left: width * rule.start,\n              right: width - (width * rule.end),\n            })}\n          >\n            <span {...css(STYLES.icon)}>\n              {rule.icon}\n            </span>\n          </div>\n        ))}\n      </div>\n    );\n  }\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/DotChart/DotChartStory.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { storiesOf } from '@storybook/react';\nimport * as faker from 'faker';\n\nimport { ICommentDate, ICommentScore } from '../../../models';\nimport {\n  LIGHT_PRIMARY_TEXT_COLOR,\n  MEDIUM_COLOR,\n} from '../../styles';\nimport { groupByDateColumns, groupByScoreColumns } from '../../util';\nimport { css } from '../../utilx';\nimport { DotChart } from '../DotChart';\nimport {\n  ApproveIcon,\n  DeferIcon,\n  HighlightIcon,\n  RejectIcon,\n} from '../Icons';\n\nconst COLCOUNT = 100;\n\nfunction randomTaggedComments(count: number) {\n  const taggedComments = [];\n\n  for (let i = 0; i < count; i++) {\n    taggedComments.push({\n      commentId: i.toString(),\n      score: Math.random(),\n    });\n  }\n\n  return taggedComments;\n}\n\nfunction randomDatedComments(count: number, dateAge: number): Array<ICommentDate> {\n  const datedComments: Array<ICommentDate> = [];\n\n  for (let i = 0; i < count; i++) {\n    datedComments.push({\n      commentId: i.toString(),\n      date: faker.date.recent(dateAge),\n    });\n  }\n\n  return datedComments;\n}\n\nfunction generateRules() {\n  return [\n    {\n      rule: 'approved',\n      start: 0,\n      end: .04,\n      icon: <ApproveIcon {...css({ fill: LIGHT_PRIMARY_TEXT_COLOR })} />,\n    },\n    {\n      rule: 'highlight',\n      start: .04,\n      end: .05,\n      icon: <HighlightIcon {...css({ fill: LIGHT_PRIMARY_TEXT_COLOR })} />,\n    },\n    {\n      rule: 'deferred',\n      start: .25,\n      end: .32,\n      icon: <DeferIcon {...css({ fill: LIGHT_PRIMARY_TEXT_COLOR })} />,\n    },\n    {\n      rule: 'rejected',\n      start: .9,\n      end: 1,\n      icon: <RejectIcon {...css({ fill: LIGHT_PRIMARY_TEXT_COLOR })} />,\n    },\n  ];\n}\n\nstoriesOf('DotChart', module)\n  .add('Default', () => {\n    return (\n      <div {...css({ backgroundColor: MEDIUM_COLOR, padding: '50px' })}>\n        <DotChart\n          commentsByColumn={groupByScoreColumns<ICommentScore>(randomTaggedComments(1000), COLCOUNT)}\n          selectedRange={{start: 0, end: 0.25}}\n          width={902}\n          height={282}\n        />\n      </div>\n    );\n  })\n  .add('By Date (30 days)', () => {\n    const grouped = groupByDateColumns<ICommentDate>(randomDatedComments(1000, 30), COLCOUNT);\n    const columnsByIndex = Object.keys(grouped).sort();\n\n    return (\n      <div {...css({ backgroundColor: MEDIUM_COLOR, padding: '50px' })}>\n        <DotChart\n          commentsByColumn={grouped}\n          selectedRange={{start: parseFloat(columnsByIndex[0]), end: parseFloat(columnsByIndex[10])}}\n          width={902}\n          height={282}\n        />\n      </div>\n    );\n  })\n  .add('By Date (24 hours)', () => {\n    const comments = randomDatedComments(1000, 1);\n    const grouped = groupByDateColumns<ICommentDate>(comments, COLCOUNT);\n    const columnsByIndex = Object.keys(grouped).sort();\n\n    return (\n      <div {...css({ backgroundColor: MEDIUM_COLOR, padding: '50px' })}>\n        <DotChart\n          commentsByColumn={grouped}\n          selectedRange={{start: parseFloat(columnsByIndex[0]), end: parseFloat(columnsByIndex[10])}}\n          width={902}\n          height={282}\n        />\n      </div>\n    );\n  })\n  .add('Applied rules', () => {\n    return (\n      <div {...css({ backgroundColor: MEDIUM_COLOR, padding: '50px' })}>\n        <DotChart\n          appliedRules={generateRules()}\n          commentsByColumn={groupByScoreColumns<ICommentScore>(randomTaggedComments(1000), COLCOUNT)}\n          selectedRange={{start: 0, end: 0.25}}\n          width={902}\n          height={282}\n        />\n      </div>\n    );\n  })\n  .add('Narrow', () => {\n    return (\n      <div {...css({ backgroundColor: MEDIUM_COLOR, padding: '50px' })}>\n        <DotChart\n          commentsByColumn={groupByScoreColumns<ICommentScore>(randomTaggedComments(1000), COLCOUNT)}\n          selectedRange={{start: 0.50, end: 0.51}}\n          width={768}\n          height={282}\n        />\n      </div>\n    );\n  })\n  .add('Mobile', () => {\n    return (\n      <div {...css({ backgroundColor: MEDIUM_COLOR, padding: '50px' })}>\n        <DotChart\n          commentsByColumn={groupByScoreColumns(randomTaggedComments(1000), 20)}\n          width={480}\n          height={282}\n        />\n      </div>\n    );\n  });\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/DotChart/__spec__/.gitkeep",
    "content": ""
  },
  {
    "path": "packages/frontend-web/src/app/components/DotChart/comments-data.json",
    "content": "[{ \"id\": 0, \"amount\": 0.0 }, { \"id\": 9953451, \"amount\": 0.02 }, { \"id\": 9953455, \"amount\": 0.04 }, { \"id\": 9953470, \"amount\": 0.09 }, { \"id\": 9953474, \"amount\": 0.38 }, { \"id\": 9953476, \"amount\": 0.03 }, { \"id\": 9953480, \"amount\": 0.03 }, { \"id\": 9953483, \"amount\": 0.2 }, { \"id\": 9953487, \"amount\": 0.22 }, { \"id\": 9953493, \"amount\": 0.1 }, { \"id\": 9953494, \"amount\": 0.69 }, { \"id\": 9953495, \"amount\": 0.03 }, { \"id\": 9953498, \"amount\": 0.05 }, { \"id\": 9953501, \"amount\": 0.0 }, { \"id\": 9953508, \"amount\": 0.01 }, { \"id\": 9953510, \"amount\": 0.06 }, { \"id\": 9953516, \"amount\": 0.26 }, { \"id\": 9953520, \"amount\": 0.03 }, { \"id\": 9953521, \"amount\": 0.05 }, { \"id\": 9953523, \"amount\": 0.06 }, { \"id\": 9953526, \"amount\": 0.17 }, { \"id\": 9953527, \"amount\": 0.03 }, { \"id\": 9953529, \"amount\": 0.48 }, { \"id\": 9953530, \"amount\": 0.38 }, { \"id\": 9953534, \"amount\": 0.03 }, { \"id\": 9953539, \"amount\": 0.11 }, { \"id\": 9953541, \"amount\": 0.16 }, { \"id\": 9953542, \"amount\": 0.2 }, { \"id\": 9953553, \"amount\": 0.07 }, { \"id\": 9953555, \"amount\": 0.37 }, { \"id\": 9953556, \"amount\": 0.07 }, { \"id\": 9953557, \"amount\": 0.13 }, { \"id\": 9953568, \"amount\": 0.1 }, { \"id\": 9953572, \"amount\": 0.04 }, { \"id\": 9953574, \"amount\": 0.19 }, { \"id\": 9953578, \"amount\": 0.14 }, { \"id\": 9953579, \"amount\": 0.05 }, { \"id\": 9953580, \"amount\": 0.01 }, { \"id\": 9953581, \"amount\": 0.15 }, { \"id\": 9953584, \"amount\": 0.11 }, { \"id\": 9953586, \"amount\": 0.34 }, { \"id\": 9953592, \"amount\": 0.12 }, { \"id\": 9953593, \"amount\": 0.14 }, { \"id\": 9953596, \"amount\": 0.28 }, { \"id\": 9953598, \"amount\": 0.63 }, { \"id\": 9953599, \"amount\": 0.09 }, { \"id\": 9953601, \"amount\": 0.02 }, { \"id\": 9953604, \"amount\": 0.18 }, { \"id\": 9953605, \"amount\": 0.11 }, { \"id\": 9953609, \"amount\": 0.45 }, { \"id\": 9953610, \"amount\": 0.41 }, { \"id\": 9953615, \"amount\": 0.11 }, { \"id\": 9953619, \"amount\": 0.21 }, { \"id\": 9953620, \"amount\": 0.05 }, { \"id\": 9953621, \"amount\": 0.09 }, { \"id\": 9953623, \"amount\": 0.48 }, { \"id\": 9953624, \"amount\": 0.14 }, { \"id\": 9953632, \"amount\": 0.06 }, { \"id\": 9953633, \"amount\": 0.07 }, { \"id\": 9953634, \"amount\": 0.09 }, { \"id\": 9953637, \"amount\": 0.19 }, { \"id\": 9953639, \"amount\": 0.38 }, { \"id\": 9953641, \"amount\": 0.11 }, { \"id\": 9953644, \"amount\": 0.05 }, { \"id\": 9953645, \"amount\": 0.19 }, { \"id\": 9953649, \"amount\": 0.14 }, { \"id\": 9953652, \"amount\": 0.27 }, { \"id\": 9953655, \"amount\": 0.21 }, { \"id\": 9953658, \"amount\": 0.01 }, { \"id\": 9953659, \"amount\": 0.48 }, { \"id\": 9953662, \"amount\": 0.19 }, { \"id\": 9953664, \"amount\": 0.32 }, { \"id\": 9953668, \"amount\": 0.05 }, { \"id\": 9953669, \"amount\": 0.46 }, { \"id\": 9953671, \"amount\": 0.02 }, { \"id\": 9953672, \"amount\": 0.21 }, { \"id\": 9953674, \"amount\": 0.03 }, { \"id\": 9953675, \"amount\": 0.02 }, { \"id\": 9953676, \"amount\": 0.09 }, { \"id\": 9953677, \"amount\": 0.04 }, { \"id\": 9953679, \"amount\": 0.09 }, { \"id\": 9953681, \"amount\": 0.57 }, { \"id\": 9953684, \"amount\": 0.11 }, { \"id\": 9953685, \"amount\": 0.05 }, { \"id\": 9953686, \"amount\": 0.12 }, { \"id\": 9953689, \"amount\": 0.48 }, { \"id\": 9953696, \"amount\": 0.19 }, { \"id\": 9953697, \"amount\": 0.02 }, { \"id\": 9953698, \"amount\": 0.21 }, { \"id\": 9953699, \"amount\": 0.16 }, { \"id\": 9953700, \"amount\": 0.05 }, { \"id\": 9953701, \"amount\": 0.46 }, { \"id\": 9953703, \"amount\": 0.22 }, { \"id\": 9953704, \"amount\": 0.24 }, { \"id\": 9953705, \"amount\": 0.12 }, { \"id\": 9953711, \"amount\": 0.03 }, { \"id\": 9953715, \"amount\": 0.22 }, { \"id\": 9953716, \"amount\": 0.14 }, { \"id\": 9953719, \"amount\": 0.07 }, { \"id\": 9953720, \"amount\": 0.33 }, { \"id\": 9953722, \"amount\": 0.03 }, { \"id\": 9953725, \"amount\": 0.3 }, { \"id\": 9953727, \"amount\": 0.16 }, { \"id\": 9953729, \"amount\": 0.19 }, { \"id\": 9953730, \"amount\": 0.03 }, { \"id\": 9953731, \"amount\": 0.31 }, { \"id\": 9953734, \"amount\": 0.0 }, { \"id\": 9953735, \"amount\": 0.26 }, { \"id\": 9953736, \"amount\": 0.25 }, { \"id\": 9953739, \"amount\": 0.23 }, { \"id\": 9953740, \"amount\": 0.0 }, { \"id\": 9953744, \"amount\": 0.04 }, { \"id\": 9953745, \"amount\": 0.17 }, { \"id\": 9953746, \"amount\": 0.22 }, { \"id\": 9953750, \"amount\": 0.09 }, { \"id\": 9953751, \"amount\": 0.53 }, { \"id\": 9953753, \"amount\": 0.09 }, { \"id\": 9953754, \"amount\": 0.17 }, { \"id\": 9953761, \"amount\": 0.48 }, { \"id\": 9953762, \"amount\": 0.04 }, { \"id\": 9953763, \"amount\": 0.01 }, { \"id\": 9953764, \"amount\": 0.13 }, { \"id\": 9953767, \"amount\": 0.5 }, { \"id\": 9953768, \"amount\": 0.46 }, { \"id\": 9953771, \"amount\": 0.12 }, { \"id\": 9953774, \"amount\": 0.05 }, { \"id\": 9953775, \"amount\": 0.38 }, { \"id\": 9953782, \"amount\": 0.1 }, { \"id\": 9953783, \"amount\": 0.54 }, { \"id\": 9953784, \"amount\": 0.13 }, { \"id\": 9953786, \"amount\": 0.2 }, { \"id\": 9953788, \"amount\": 0.15 }, { \"id\": 9953789, \"amount\": 0.37 }, { \"id\": 9953791, \"amount\": 0.02 }, { \"id\": 9953792, \"amount\": 0.66 }, { \"id\": 9953795, \"amount\": 0.18 }, { \"id\": 9953796, \"amount\": 0.02 }, { \"id\": 9953798, \"amount\": 0.56 }, { \"id\": 9953799, \"amount\": 0.1 }, { \"id\": 9953800, \"amount\": 0.06 }, { \"id\": 9953802, \"amount\": 0.16 }, { \"id\": 9953803, \"amount\": 0.5 }, { \"id\": 9953804, \"amount\": 0.05 }, { \"id\": 9953806, \"amount\": 0.08 }, { \"id\": 9953807, \"amount\": 0.04 }, { \"id\": 9953809, \"amount\": 0.25 }, { \"id\": 9953810, \"amount\": 0.12 }, { \"id\": 9953814, \"amount\": 0.04 }, { \"id\": 9953815, \"amount\": 0.29 }, { \"id\": 9953816, \"amount\": 0.0 }, { \"id\": 9953817, \"amount\": 0.29 }, { \"id\": 9953818, \"amount\": 0.02 }, { \"id\": 9953819, \"amount\": 0.39 }, { \"id\": 9953822, \"amount\": 0.19 }, { \"id\": 9953823, \"amount\": 0.24 }, { \"id\": 9953825, \"amount\": 0.09 }, { \"id\": 9953829, \"amount\": 0.02 }, { \"id\": 9953833, \"amount\": 0.02 }, { \"id\": 9953834, \"amount\": 0.13 }, { \"id\": 9953836, \"amount\": 0.13 }, { \"id\": 9953837, \"amount\": 0.15 }, { \"id\": 9953838, \"amount\": 0.09 }, { \"id\": 9953839, \"amount\": 0.43 }, { \"id\": 9953840, \"amount\": 0.2 }, { \"id\": 9953841, \"amount\": 0.13 }, { \"id\": 9953843, \"amount\": 0.06 }, { \"id\": 9953844, \"amount\": 0.13 }, { \"id\": 9953845, \"amount\": 0.2 }, { \"id\": 9953847, \"amount\": 0.24 }, { \"id\": 9953848, \"amount\": 0.08 }, { \"id\": 9953849, \"amount\": 0.27 }, { \"id\": 9953850, \"amount\": 0.11 }, { \"id\": 9953852, \"amount\": 0.11 }, { \"id\": 9953857, \"amount\": 0.01 }, { \"id\": 9953859, \"amount\": 0.43 }, { \"id\": 9953860, \"amount\": 0.05 }, { \"id\": 9953861, \"amount\": 0.4 }, { \"id\": 9953862, \"amount\": 0.22 }, { \"id\": 9953868, \"amount\": 0.08 }, { \"id\": 9953870, \"amount\": 0.31 }, { \"id\": 9953871, \"amount\": 0.66 }, { \"id\": 9953873, \"amount\": 0.06 }, { \"id\": 9953876, \"amount\": 0.37 }, { \"id\": 9953878, \"amount\": 0.14 }, { \"id\": 9953881, \"amount\": 0.28 }, { \"id\": 9953886, \"amount\": 0.11 }, { \"id\": 9953891, \"amount\": 0.12 }, { \"id\": 9953893, \"amount\": 0.09 }, { \"id\": 9953894, \"amount\": 0.38 }, { \"id\": 9953898, \"amount\": 0.13 }, { \"id\": 9953899, \"amount\": 0.13 }, { \"id\": 9953901, \"amount\": 0.32 }, { \"id\": 9953902, \"amount\": 0.11 }, { \"id\": 9953905, \"amount\": 0.24 }, { \"id\": 9953906, \"amount\": 0.44 }, { \"id\": 9953910, \"amount\": 0.35 }, { \"id\": 9953912, \"amount\": 0.04 }, { \"id\": 9953913, \"amount\": 0.21 }, { \"id\": 9953915, \"amount\": 0.0 }, { \"id\": 9953919, \"amount\": 0.07 }, { \"id\": 9953920, \"amount\": 0.36 }, { \"id\": 9953922, \"amount\": 0.01 }, { \"id\": 9953924, \"amount\": 0.55 }, { \"id\": 9953927, \"amount\": 0.06 }, { \"id\": 9953928, \"amount\": 0.08 }, { \"id\": 9953929, \"amount\": 0.11 }, { \"id\": 9953931, \"amount\": 0.14 }, { \"id\": 9953933, \"amount\": 0.24 }, { \"id\": 9953934, \"amount\": 0.2 }, { \"id\": 9953937, \"amount\": 0.07 }, { \"id\": 9953938, \"amount\": 0.22 }, { \"id\": 9953940, \"amount\": 0.27 }, { \"id\": 9953943, \"amount\": 0.17 }, { \"id\": 9953944, \"amount\": 0.17 }, { \"id\": 9953945, \"amount\": 0.23 }, { \"id\": 9953946, \"amount\": 0.01 }, { \"id\": 9953949, \"amount\": 0.06 }, { \"id\": 9953950, \"amount\": 0.09 }, { \"id\": 9953954, \"amount\": 0.43 }, { \"id\": 9953955, \"amount\": 0.03 }, { \"id\": 9953956, \"amount\": 0.19 }, { \"id\": 9953957, \"amount\": 0.12 }, { \"id\": 9953958, \"amount\": 0.62 }, { \"id\": 9953959, \"amount\": 0.0 }, { \"id\": 9953960, \"amount\": 0.45 }, { \"id\": 9953961, \"amount\": 0.08 }, { \"id\": 9953963, \"amount\": 0.17 }, { \"id\": 9953964, \"amount\": 0.36 }, { \"id\": 9953965, \"amount\": 0.06 }, { \"id\": 9953966, \"amount\": 0.36 }, { \"id\": 9953969, \"amount\": 0.08 }, { \"id\": 9953973, \"amount\": 0.33 }, { \"id\": 9953975, \"amount\": 0.1 }, { \"id\": 9953976, \"amount\": 0.07 }, { \"id\": 9953979, \"amount\": 0.17 }, { \"id\": 9953980, \"amount\": 0.05 }, { \"id\": 9953982, \"amount\": 0.15 }, { \"id\": 9953983, \"amount\": 0.03 }, { \"id\": 9953985, \"amount\": 0.21 }, { \"id\": 9953986, \"amount\": 0.2 }, { \"id\": 9953987, \"amount\": 0.32 }, { \"id\": 9953988, \"amount\": 0.1 }, { \"id\": 9953993, \"amount\": 0.48 }, { \"id\": 9953995, \"amount\": 0.11 }, { \"id\": 9953996, \"amount\": 0.16 }, { \"id\": 9953997, \"amount\": 0.05 }, { \"id\": 9953999, \"amount\": 0.04 }, { \"id\": 9954001, \"amount\": 0.38 }, { \"id\": 9954002, \"amount\": 0.1 }, { \"id\": 9954004, \"amount\": 0.27 }, { \"id\": 9954008, \"amount\": 0.22 }, { \"id\": 9954010, \"amount\": 0.03 }, { \"id\": 9954011, \"amount\": 0.06 }, { \"id\": 9954015, \"amount\": 0.22 }, { \"id\": 9954016, \"amount\": 0.34 }, { \"id\": 9954018, \"amount\": 0.03 }, { \"id\": 9954019, \"amount\": 0.27 }, { \"id\": 9954020, \"amount\": 0.1 }, { \"id\": 9954022, \"amount\": 0.05 }, { \"id\": 9954023, \"amount\": 0.11 }, { \"id\": 9954024, \"amount\": 0.07 }, { \"id\": 9954026, \"amount\": 0.16 }, { \"id\": 9954032, \"amount\": 0.04 }, { \"id\": 9954035, \"amount\": 0.05 }, { \"id\": 9954037, \"amount\": 0.46 }, { \"id\": 9954038, \"amount\": 0.24 }, { \"id\": 9954039, \"amount\": 0.03 }, { \"id\": 9954040, \"amount\": 0.06 }, { \"id\": 9954044, \"amount\": 0.2 }, { \"id\": 9954046, \"amount\": 0.61 }, { \"id\": 9954047, \"amount\": 0.03 }, { \"id\": 9954048, \"amount\": 0.05 }, { \"id\": 9954049, \"amount\": 0.58 }, { \"id\": 9954051, \"amount\": 0.22 }, { \"id\": 9954052, \"amount\": 0.06 }, { \"id\": 9954053, \"amount\": 0.2 }, { \"id\": 9954054, \"amount\": 0.06 }, { \"id\": 9954055, \"amount\": 0.07 }, { \"id\": 9954057, \"amount\": 0.18 }, { \"id\": 9954060, \"amount\": 0.05 }, { \"id\": 9954062, \"amount\": 0.25 }, { \"id\": 9954064, \"amount\": 0.11 }, { \"id\": 9954065, \"amount\": 0.1 }, { \"id\": 9954066, \"amount\": 0.02 }, { \"id\": 9954068, \"amount\": 0.12 }, { \"id\": 9954070, \"amount\": 0.16 }, { \"id\": 9954072, \"amount\": 0.59 }, { \"id\": 9954073, \"amount\": 0.1 }, { \"id\": 9954076, \"amount\": 0.07 }, { \"id\": 9954077, \"amount\": 0.19 }, { \"id\": 9954079, \"amount\": 0.08 }, { \"id\": 9954080, \"amount\": 0.69 }, { \"id\": 9954081, \"amount\": 0.02 }, { \"id\": 9954082, \"amount\": 0.63 }, { \"id\": 9954083, \"amount\": 0.11 }, { \"id\": 9954087, \"amount\": 0.08 }, { \"id\": 9954089, \"amount\": 0.23 }, { \"id\": 9954091, \"amount\": 0.46 }, { \"id\": 9954093, \"amount\": 0.31 }, { \"id\": 9954094, \"amount\": 0.65 }, { \"id\": 9954095, \"amount\": 0.01 }, { \"id\": 9954097, \"amount\": 0.24 }, { \"id\": 9954099, \"amount\": 0.03 }, { \"id\": 9954100, \"amount\": 0.51 }, { \"id\": 9954103, \"amount\": 0.12 }, { \"id\": 9954104, \"amount\": 0.14 }, { \"id\": 9954107, \"amount\": 0.38 }, { \"id\": 9954112, \"amount\": 0.03 }, { \"id\": 9954115, \"amount\": 0.22 }, { \"id\": 9954116, \"amount\": 0.14 }, { \"id\": 9954118, \"amount\": 0.07 }, { \"id\": 9954119, \"amount\": 0.11 }, { \"id\": 9954120, \"amount\": 0.15 }, { \"id\": 9954124, \"amount\": 0.28 }, { \"id\": 9954125, \"amount\": 0.62 }, { \"id\": 9954126, \"amount\": 0.28 }, { \"id\": 9954128, \"amount\": 0.12 }, { \"id\": 9954132, \"amount\": 0.01 }, { \"id\": 9954136, \"amount\": 0.18 }, { \"id\": 9954140, \"amount\": 0.16 }, { \"id\": 9954142, \"amount\": 0.45 }, { \"id\": 9954143, \"amount\": 0.08 }, { \"id\": 9954146, \"amount\": 0.02 }, { \"id\": 9954147, \"amount\": 0.05 }, { \"id\": 9954148, \"amount\": 0.18 }, { \"id\": 9954152, \"amount\": 0.14 }, { \"id\": 9954153, \"amount\": 0.17 }, { \"id\": 9954154, \"amount\": 0.46 }, { \"id\": 9954155, \"amount\": 0.07 }, { \"id\": 9954161, \"amount\": 0.1 }, { \"id\": 9954162, \"amount\": 0.24 }, { \"id\": 9954163, \"amount\": 0.29 }, { \"id\": 9954165, \"amount\": 0.21 }, { \"id\": 9954166, \"amount\": 0.03 }, { \"id\": 9954167, \"amount\": 0.37 }, { \"id\": 9954168, \"amount\": 0.06 }, { \"id\": 9954171, \"amount\": 0.05 }, { \"id\": 9954177, \"amount\": 0.18 }, { \"id\": 9954178, \"amount\": 0.44 }, { \"id\": 9954180, \"amount\": 0.01 }, { \"id\": 9954181, \"amount\": 0.71 }, { \"id\": 9954188, \"amount\": 0.24 }, { \"id\": 9954189, \"amount\": 0.28 }, { \"id\": 9954195, \"amount\": 0.2 }, { \"id\": 9954196, \"amount\": 0.17 }, { \"id\": 9954197, \"amount\": 0.05 }, { \"id\": 9954198, \"amount\": 0.5 }, { \"id\": 9954201, \"amount\": 0.16 }, { \"id\": 9954202, \"amount\": 0.13 }, { \"id\": 9954204, \"amount\": 0.14 }, { \"id\": 9954206, \"amount\": 0.01 }, { \"id\": 9954207, \"amount\": 0.32 }, { \"id\": 9954212, \"amount\": 0.32 }, { \"id\": 9954214, \"amount\": 0.28 }, { \"id\": 9954218, \"amount\": 0.3 }, { \"id\": 9954223, \"amount\": 0.18 }, { \"id\": 9954226, \"amount\": 0.12 }, { \"id\": 9954230, \"amount\": 0.06 }, { \"id\": 9954231, \"amount\": 0.02 }, { \"id\": 9954232, \"amount\": 0.65 }, { \"id\": 9954233, \"amount\": 0.14 }, { \"id\": 9954234, \"amount\": 0.08 }, { \"id\": 9954235, \"amount\": 0.13 }, { \"id\": 9954237, \"amount\": 0.44 }, { \"id\": 9954238, \"amount\": 0.02 }, { \"id\": 9954239, \"amount\": 0.24 }, { \"id\": 9954240, \"amount\": 0.29 }, { \"id\": 9954241, \"amount\": 0.51 }, { \"id\": 9954242, \"amount\": 0.03 }, { \"id\": 9954243, \"amount\": 0.2 }, { \"id\": 9954244, \"amount\": 0.39 }, { \"id\": 9954245, \"amount\": 0.14 }, { \"id\": 9954246, \"amount\": 0.51 }, { \"id\": 9954248, \"amount\": 0.13 }, { \"id\": 9954249, \"amount\": 0.22 }, { \"id\": 9954250, \"amount\": 0.09 }, { \"id\": 9954252, \"amount\": 0.38 }, { \"id\": 9954254, \"amount\": 0.01 }, { \"id\": 9954256, \"amount\": 0.21 }, { \"id\": 9954257, \"amount\": 0.45 }, { \"id\": 9954259, \"amount\": 0.2 }, { \"id\": 9954260, \"amount\": 0.11 }, { \"id\": 9954261, \"amount\": 0.35 }, { \"id\": 9954263, \"amount\": 0.57 }, { \"id\": 9954265, \"amount\": 0.12 }, { \"id\": 9954266, \"amount\": 0.13 }, { \"id\": 9954267, \"amount\": 0.19 }, { \"id\": 9954269, \"amount\": 0.34 }, { \"id\": 9954271, \"amount\": 0.65 }, { \"id\": 9954275, \"amount\": 0.36 }, { \"id\": 9954276, \"amount\": 0.22 }, { \"id\": 9954277, \"amount\": 0.11 }, { \"id\": 9954280, \"amount\": 0.49 }, { \"id\": 9954283, \"amount\": 0.19 }, { \"id\": 9954284, \"amount\": 0.04 }, { \"id\": 9954285, \"amount\": 0.14 }, { \"id\": 9954287, \"amount\": 0.02 }, { \"id\": 9954293, \"amount\": 0.14 }, { \"id\": 9954295, \"amount\": 0.05 }, { \"id\": 9954299, \"amount\": 0.31 }, { \"id\": 9954301, \"amount\": 0.16 }, { \"id\": 9954303, \"amount\": 0.18 }, { \"id\": 9954307, \"amount\": 0.11 }, { \"id\": 9954308, \"amount\": 0.08 }, { \"id\": 9954309, \"amount\": 0.27 }, { \"id\": 9954313, \"amount\": 0.16 }, { \"id\": 9954317, \"amount\": 0.17 }, { \"id\": 9954319, \"amount\": 0.09 }, { \"id\": 9954321, \"amount\": 0.22 }, { \"id\": 9954322, \"amount\": 0.09 }, { \"id\": 9954323, \"amount\": 0.08 }, { \"id\": 9954324, \"amount\": 0.28 }, { \"id\": 9954325, \"amount\": 0.57 }, { \"id\": 9954326, \"amount\": 0.08 }, { \"id\": 9954327, \"amount\": 0.07 }, { \"id\": 9954328, \"amount\": 0.24 }, { \"id\": 9954329, \"amount\": 0.05 }, { \"id\": 9954330, \"amount\": 0.06 }, { \"id\": 9954332, \"amount\": 0.22 }, { \"id\": 9954333, \"amount\": 0.49 }, { \"id\": 9954335, \"amount\": 0.12 }, { \"id\": 9954336, \"amount\": 0.24 }, { \"id\": 9954337, \"amount\": 0.07 }, { \"id\": 9954338, \"amount\": 0.04 }, { \"id\": 9954339, \"amount\": 0.23 }, { \"id\": 9954344, \"amount\": 0.01 }, { \"id\": 9954346, \"amount\": 0.45 }, { \"id\": 9954347, \"amount\": 0.56 }, { \"id\": 9954349, \"amount\": 0.1 }, { \"id\": 9954351, \"amount\": 0.24 }, { \"id\": 9954354, \"amount\": 0.09 }, { \"id\": 9954355, \"amount\": 0.02 }, { \"id\": 9954356, \"amount\": 0.03 }, { \"id\": 9954357, \"amount\": 0.0 }, { \"id\": 9954358, \"amount\": 0.1 }, { \"id\": 9954361, \"amount\": 0.23 }, { \"id\": 9954363, \"amount\": 0.18 }, { \"id\": 9954365, \"amount\": 0.64 }, { \"id\": 9954366, \"amount\": 0.29 }, { \"id\": 9954368, \"amount\": 0.2 }, { \"id\": 9954373, \"amount\": 0.12 }, { \"id\": 9954374, \"amount\": 0.06 }, { \"id\": 9954375, \"amount\": 0.09 }, { \"id\": 9954376, \"amount\": 0.13 }, { \"id\": 9954377, \"amount\": 0.04 }, { \"id\": 9954378, \"amount\": 0.11 }, { \"id\": 9954381, \"amount\": 0.19 }, { \"id\": 9954383, \"amount\": 0.15 }, { \"id\": 9954384, \"amount\": 0.1 }, { \"id\": 9954389, \"amount\": 0.5 }, { \"id\": 9954391, \"amount\": 0.67 }, { \"id\": 9954395, \"amount\": 0.41 }, { \"id\": 9954396, \"amount\": 0.33 }, { \"id\": 9954398, \"amount\": 0.04 }, { \"id\": 9954399, \"amount\": 0.33 }, { \"id\": 9954400, \"amount\": 0.19 }, { \"id\": 9954401, \"amount\": 0.17 }, { \"id\": 9954402, \"amount\": 0.16 }, { \"id\": 9954403, \"amount\": 0.27 }, { \"id\": 9954404, \"amount\": 0.16 }, { \"id\": 9954407, \"amount\": 0.1 }, { \"id\": 9954408, \"amount\": 0.14 }, { \"id\": 9954409, \"amount\": 0.01 }, { \"id\": 9954410, \"amount\": 0.23 }, { \"id\": 9954411, \"amount\": 0.18 }, { \"id\": 9954412, \"amount\": 0.04 }, { \"id\": 9954414, \"amount\": 0.21 }, { \"id\": 9954416, \"amount\": 0.21 }, { \"id\": 9954417, \"amount\": 0.09 }, { \"id\": 9954421, \"amount\": 0.03 }, { \"id\": 9954425, \"amount\": 0.61 }, { \"id\": 9954426, \"amount\": 0.09 }, { \"id\": 9954427, \"amount\": 0.0 }, { \"id\": 9954430, \"amount\": 0.12 }, { \"id\": 9954431, \"amount\": 0.02 }, { \"id\": 9954432, \"amount\": 0.07 }, { \"id\": 9954435, \"amount\": 0.4 }, { \"id\": 9954436, \"amount\": 0.08 }, { \"id\": 9954437, \"amount\": 0.04 }, { \"id\": 9954439, \"amount\": 0.08 }, { \"id\": 9954440, \"amount\": 0.09 }, { \"id\": 9954443, \"amount\": 0.06 }, { \"id\": 9954444, \"amount\": 0.61 }, { \"id\": 9954445, \"amount\": 0.09 }, { \"id\": 9954447, \"amount\": 0.01 }, { \"id\": 9954448, \"amount\": 0.01 }, { \"id\": 9954449, \"amount\": 0.05 }, { \"id\": 9954450, \"amount\": 0.24 }, { \"id\": 9954451, \"amount\": 0.13 }, { \"id\": 9954453, \"amount\": 0.09 }, { \"id\": 9954456, \"amount\": 0.05 }, { \"id\": 9954457, \"amount\": 0.05 }, { \"id\": 9954460, \"amount\": 0.36 }, { \"id\": 9954461, \"amount\": 0.44 }, { \"id\": 9954462, \"amount\": 0.22 }, { \"id\": 9954463, \"amount\": 0.46 }, { \"id\": 9954464, \"amount\": 0.03 }, { \"id\": 9954465, \"amount\": 0.19 }, { \"id\": 9954467, \"amount\": 0.41 }, { \"id\": 9954468, \"amount\": 0.1 }, { \"id\": 9954469, \"amount\": 0.1 }, { \"id\": 9954473, \"amount\": 0.05 }, { \"id\": 9954474, \"amount\": 0.13 }, { \"id\": 9954475, \"amount\": 0.1 }, { \"id\": 9954476, \"amount\": 0.22 }, { \"id\": 9954479, \"amount\": 0.01 }, { \"id\": 9954481, \"amount\": 0.46 }, { \"id\": 9954483, \"amount\": 0.18 }, { \"id\": 9954484, \"amount\": 0.33 }, { \"id\": 9954485, \"amount\": 0.29 }, { \"id\": 9954486, \"amount\": 0.19 }, { \"id\": 9954487, \"amount\": 0.16 }, { \"id\": 9954489, \"amount\": 0.02 }, { \"id\": 9954490, \"amount\": 0.32 }, { \"id\": 9954493, \"amount\": 0.08 }, { \"id\": 9954495, \"amount\": 0.09 }, { \"id\": 9954498, \"amount\": 0.1 }, { \"id\": 9954499, \"amount\": 0.09 }, { \"id\": 9954504, \"amount\": 0.19 }, { \"id\": 9954505, \"amount\": 0.13 }, { \"id\": 9954506, \"amount\": 0.01 }, { \"id\": 9954507, \"amount\": 0.04 }, { \"id\": 9954512, \"amount\": 0.19 }, { \"id\": 9954513, \"amount\": 0.04 }, { \"id\": 9954514, \"amount\": 0.05 }, { \"id\": 9954515, \"amount\": 0.02 }, { \"id\": 9954518, \"amount\": 0.01 }, { \"id\": 9954519, \"amount\": 0.06 }, { \"id\": 9954521, \"amount\": 0.26 }, { \"id\": 9954525, \"amount\": 0.23 }, { \"id\": 9954527, \"amount\": 0.09 }, { \"id\": 9954528, \"amount\": 0.53 }, { \"id\": 9954529, \"amount\": 0.21 }, { \"id\": 9954532, \"amount\": 0.14 }, { \"id\": 9954534, \"amount\": 0.01 }, { \"id\": 9954539, \"amount\": 0.41 }, { \"id\": 9954540, \"amount\": 0.56 }, { \"id\": 9954541, \"amount\": 0.6 }, { \"id\": 9954544, \"amount\": 0.07 }, { \"id\": 9954545, \"amount\": 0.04 }, { \"id\": 9954546, \"amount\": 0.21 }, { \"id\": 9954548, \"amount\": 0.21 }, { \"id\": 9954549, \"amount\": 0.06 }, { \"id\": 9954552, \"amount\": 0.32 }, { \"id\": 9954556, \"amount\": 0.34 }, { \"id\": 9954557, \"amount\": 0.38 }, { \"id\": 9954560, \"amount\": 0.03 }, { \"id\": 9954561, \"amount\": 0.15 }, { \"id\": 9954562, \"amount\": 0.03 }, { \"id\": 9954566, \"amount\": 0.12 }, { \"id\": 9954568, \"amount\": 0.16 }, { \"id\": 9954570, \"amount\": 0.13 }, { \"id\": 9954572, \"amount\": 0.08 }, { \"id\": 9954573, \"amount\": 0.03 }, { \"id\": 9954574, \"amount\": 0.1 }, { \"id\": 9954575, \"amount\": 0.01 }, { \"id\": 9954579, \"amount\": 0.03 }, { \"id\": 9954581, \"amount\": 0.05 }, { \"id\": 9954582, \"amount\": 0.09 }, { \"id\": 9954583, \"amount\": 0.36 }, { \"id\": 9954584, \"amount\": 0.18 }, { \"id\": 9954585, \"amount\": 0.05 }, { \"id\": 9954586, \"amount\": 0.11 }, { \"id\": 9954587, \"amount\": 0.09 }, { \"id\": 9954591, \"amount\": 0.06 }, { \"id\": 9954592, \"amount\": 0.37 }, { \"id\": 9954593, \"amount\": 0.64 }, { \"id\": 9954595, \"amount\": 0.62 }, { \"id\": 9954597, \"amount\": 0.29 }, { \"id\": 9954603, \"amount\": 0.04 }, { \"id\": 9954607, \"amount\": 0.28 }, { \"id\": 9954608, \"amount\": 0.09 }, { \"id\": 9954609, \"amount\": 0.39 }, { \"id\": 9954610, \"amount\": 0.39 }, { \"id\": 9954612, \"amount\": 0.29 }, { \"id\": 9954613, \"amount\": 0.33 }, { \"id\": 9954616, \"amount\": 0.08 }, { \"id\": 9954618, \"amount\": 0.23 }, { \"id\": 9954619, \"amount\": 0.15 }, { \"id\": 9954620, \"amount\": 0.26 }, { \"id\": 9954621, \"amount\": 0.74 }, { \"id\": 9954622, \"amount\": 0.05 }, { \"id\": 9954625, \"amount\": 0.19 }, { \"id\": 9954626, \"amount\": 0.17 }, { \"id\": 9954627, \"amount\": 0.26 }, { \"id\": 9954628, \"amount\": 0.6 }, { \"id\": 9954629, \"amount\": 0.24 }, { \"id\": 9954631, \"amount\": 0.27 }, { \"id\": 9954634, \"amount\": 0.32 }, { \"id\": 9954635, \"amount\": 0.03 }, { \"id\": 9954639, \"amount\": 0.66 }, { \"id\": 9954640, \"amount\": 0.07 }, { \"id\": 9954643, \"amount\": 0.09 }, { \"id\": 9954647, \"amount\": 0.48 }, { \"id\": 9954648, \"amount\": 0.05 }, { \"id\": 9954650, \"amount\": 0.46 }, { \"id\": 9954651, \"amount\": 0.15 }, { \"id\": 9954655, \"amount\": 0.7 }, { \"id\": 9954658, \"amount\": 0.17 }, { \"id\": 9954662, \"amount\": 0.16 }, { \"id\": 9954664, \"amount\": 0.03 }, { \"id\": 9954666, \"amount\": 0.05 }, { \"id\": 9954667, \"amount\": 0.25 }, { \"id\": 9954668, \"amount\": 0.03 }, { \"id\": 9954669, \"amount\": 0.01 }, { \"id\": 9954670, \"amount\": 0.3 }, { \"id\": 9954671, \"amount\": 0.63 }, { \"id\": 9954672, \"amount\": 0.06 }, { \"id\": 9954673, \"amount\": 0.13 }, { \"id\": 9954676, \"amount\": 0.06 }, { \"id\": 9954677, \"amount\": 0.43 }, { \"id\": 9954678, \"amount\": 0.08 }, { \"id\": 9954679, \"amount\": 0.08 }, { \"id\": 9954680, \"amount\": 0.3 }, { \"id\": 9954681, \"amount\": 0.02 }, { \"id\": 9954682, \"amount\": 0.07 }, { \"id\": 9954683, \"amount\": 0.28 }, { \"id\": 9954684, \"amount\": 0.19 }, { \"id\": 9954689, \"amount\": 0.29 }, { \"id\": 9954690, \"amount\": 0.15 }, { \"id\": 9954691, \"amount\": 0.33 }, { \"id\": 9954692, \"amount\": 0.12 }, { \"id\": 9954694, \"amount\": 0.35 }, { \"id\": 9954695, \"amount\": 0.02 }, { \"id\": 9954698, \"amount\": 0.1 }, { \"id\": 9954699, \"amount\": 0.67 }, { \"id\": 9954700, \"amount\": 0.09 }, { \"id\": 9954702, \"amount\": 0.26 }, { \"id\": 9954703, \"amount\": 0.41 }, { \"id\": 9954704, \"amount\": 0.2 }, { \"id\": 9954705, \"amount\": 0.09 }, { \"id\": 9954707, \"amount\": 0.27 }, { \"id\": 9954708, \"amount\": 0.12 }, { \"id\": 9954709, \"amount\": 0.64 }, { \"id\": 9954711, \"amount\": 0.27 }, { \"id\": 9954712, \"amount\": 0.0 }, { \"id\": 9954713, \"amount\": 0.04 }, { \"id\": 9954716, \"amount\": 0.75 }, { \"id\": 9954724, \"amount\": 0.04 }, { \"id\": 9954726, \"amount\": 0.02 }, { \"id\": 9954728, \"amount\": 0.52 }, { \"id\": 9954729, \"amount\": 0.23 }, { \"id\": 9954734, \"amount\": 0.59 }, { \"id\": 9954735, \"amount\": 0.16 }, { \"id\": 9954737, \"amount\": 0.25 }, { \"id\": 9954738, \"amount\": 0.41 }, { \"id\": 9954740, \"amount\": 0.18 }, { \"id\": 9954741, \"amount\": 0.13 }, { \"id\": 9954742, \"amount\": 0.31 }, { \"id\": 9954744, \"amount\": 0.08 }, { \"id\": 9954746, \"amount\": 0.13 }, { \"id\": 9954748, \"amount\": 0.01 }, { \"id\": 9954751, \"amount\": 0.2 }, { \"id\": 9954754, \"amount\": 0.12 }, { \"id\": 9954756, \"amount\": 0.22 }, { \"id\": 9954759, \"amount\": 0.22 }, { \"id\": 9954763, \"amount\": 0.04 }, { \"id\": 9954767, \"amount\": 0.44 }, { \"id\": 9954769, \"amount\": 0.2 }, { \"id\": 9954772, \"amount\": 0.27 }, { \"id\": 9954773, \"amount\": 0.12 }, { \"id\": 9954774, \"amount\": 0.08 }, { \"id\": 9954775, \"amount\": 0.12 }, { \"id\": 9954777, \"amount\": 0.86 }, { \"id\": 9954784, \"amount\": 0.03 }, { \"id\": 9954786, \"amount\": 0.3 }, { \"id\": 9954787, \"amount\": 0.13 }, { \"id\": 9954792, \"amount\": 0.27 }, { \"id\": 9954793, \"amount\": 0.12 }, { \"id\": 9954796, \"amount\": 0.06 }, { \"id\": 9954797, \"amount\": 0.0 }, { \"id\": 9954798, \"amount\": 0.03 }, { \"id\": 9954799, \"amount\": 0.08 }, { \"id\": 9954804, \"amount\": 0.13 }, { \"id\": 9954805, \"amount\": 0.08 }, { \"id\": 9954807, \"amount\": 0.07 }, { \"id\": 9954810, \"amount\": 0.46 }, { \"id\": 9954811, \"amount\": 0.18 }, { \"id\": 9954813, \"amount\": 0.08 }, { \"id\": 9954814, \"amount\": 0.14 }, { \"id\": 9954816, \"amount\": 0.02 }, { \"id\": 9954817, \"amount\": 0.34 }, { \"id\": 9954818, \"amount\": 0.03 }, { \"id\": 9954820, \"amount\": 0.04 }, { \"id\": 9954822, \"amount\": 0.04 }, { \"id\": 9954825, \"amount\": 0.51 }, { \"id\": 9954826, \"amount\": 0.61 }, { \"id\": 9954828, \"amount\": 0.04 }, { \"id\": 9954830, \"amount\": 0.09 }, { \"id\": 9954831, \"amount\": 0.23 }, { \"id\": 9954832, \"amount\": 0.03 }, { \"id\": 9954833, \"amount\": 0.36 }, { \"id\": 9954834, \"amount\": 0.54 }, { \"id\": 9954835, \"amount\": 0.06 }, { \"id\": 9954836, \"amount\": 0.14 }, { \"id\": 9954838, \"amount\": 0.39 }, { \"id\": 9954839, \"amount\": 0.23 }, { \"id\": 9954840, \"amount\": 0.05 }, { \"id\": 9954841, \"amount\": 0.29 }, { \"id\": 9954842, \"amount\": 0.11 }, { \"id\": 9954844, \"amount\": 0.08 }, { \"id\": 9954846, \"amount\": 0.2 }, { \"id\": 9954847, \"amount\": 0.38 }, { \"id\": 9954853, \"amount\": 0.13 }, { \"id\": 9954855, \"amount\": 0.03 }, { \"id\": 9954857, \"amount\": 0.1 }, { \"id\": 9954858, \"amount\": 0.51 }, { \"id\": 9954859, \"amount\": 0.49 }, { \"id\": 9954861, \"amount\": 0.15 }, { \"id\": 9954862, \"amount\": 0.04 }, { \"id\": 9954864, \"amount\": 0.09 }, { \"id\": 9954867, \"amount\": 0.17 }, { \"id\": 9954868, \"amount\": 0.07 }, { \"id\": 9954870, \"amount\": 0.07 }, { \"id\": 9954871, \"amount\": 0.19 }, { \"id\": 9954872, \"amount\": 0.01 }, { \"id\": 9954873, \"amount\": 0.62 }, { \"id\": 9954875, \"amount\": 0.13 }, { \"id\": 9954877, \"amount\": 0.01 }, { \"id\": 9954879, \"amount\": 0.29 }, { \"id\": 9954880, \"amount\": 0.6 }, { \"id\": 9954881, \"amount\": 0.24 }, { \"id\": 9954882, \"amount\": 0.14 }, { \"id\": 9954884, \"amount\": 0.05 }, { \"id\": 9954885, \"amount\": 0.13 }, { \"id\": 9954886, \"amount\": 0.22 }, { \"id\": 9954888, \"amount\": 0.75 }, { \"id\": 9954891, \"amount\": 0.55 }, { \"id\": 9954893, \"amount\": 0.11 }, { \"id\": 9954897, \"amount\": 0.33 }, { \"id\": 9954898, \"amount\": 0.36 }, { \"id\": 9954901, \"amount\": 0.11 }, { \"id\": 9954903, \"amount\": 0.09 }, { \"id\": 9954905, \"amount\": 0.25 }, { \"id\": 9954906, \"amount\": 0.03 }, { \"id\": 9954907, \"amount\": 0.12 }, { \"id\": 9954908, \"amount\": 0.38 }, { \"id\": 9954909, \"amount\": 0.5 }, { \"id\": 9954911, \"amount\": 0.03 }, { \"id\": 9954912, \"amount\": 0.07 }, { \"id\": 9954913, \"amount\": 0.08 }, { \"id\": 9954915, \"amount\": 0.07 }, { \"id\": 9954916, \"amount\": 0.17 }, { \"id\": 9954917, \"amount\": 0.15 }, { \"id\": 9954918, \"amount\": 0.45 }, { \"id\": 9954922, \"amount\": 0.01 }, { \"id\": 9954923, \"amount\": 0.06 }, { \"id\": 9954924, \"amount\": 0.09 }, { \"id\": 9954925, \"amount\": 0.15 }, { \"id\": 9954928, \"amount\": 0.43 }, { \"id\": 9954933, \"amount\": 0.04 }, { \"id\": 9954937, \"amount\": 0.02 }, { \"id\": 9954947, \"amount\": 0.02 }, { \"id\": 9954948, \"amount\": 0.14 }, { \"id\": 9954949, \"amount\": 0.02 }, { \"id\": 9954950, \"amount\": 0.61 }, { \"id\": 9954954, \"amount\": 0.45 }, { \"id\": 9954955, \"amount\": 0.17 }, { \"id\": 9954957, \"amount\": 0.09 }, { \"id\": 9954960, \"amount\": 0.44 }, { \"id\": 9954962, \"amount\": 0.29 }, { \"id\": 9954963, \"amount\": 0.39 }, { \"id\": 9954964, \"amount\": 0.18 }, { \"id\": 9954967, \"amount\": 0.41 }, { \"id\": 9954968, \"amount\": 0.55 }, { \"id\": 9954969, \"amount\": 0.08 }, { \"id\": 9954973, \"amount\": 0.05 }, { \"id\": 9954974, \"amount\": 0.69 }, { \"id\": 9954975, \"amount\": 0.13 }, { \"id\": 9954978, \"amount\": 0.11 }, { \"id\": 9954982, \"amount\": 0.08 }, { \"id\": 9954983, \"amount\": 0.69 }, { \"id\": 9954984, \"amount\": 0.12 }, { \"id\": 9954986, \"amount\": 0.13 }, { \"id\": 9954987, \"amount\": 0.03 }, { \"id\": 9954988, \"amount\": 0.42 }, { \"id\": 9954992, \"amount\": 0.15 }, { \"id\": 9954993, \"amount\": 0.13 }, { \"id\": 9954995, \"amount\": 0.46 }, { \"id\": 9954997, \"amount\": 0.16 }, { \"id\": 9954998, \"amount\": 0.17 }, { \"id\": 9954999, \"amount\": 0.49 }, { \"id\": 9955004, \"amount\": 0.25 }, { \"id\": 9955006, \"amount\": 0.14 }, { \"id\": 9955007, \"amount\": 0.33 }, { \"id\": 9955008, \"amount\": 0.46 }, { \"id\": 9955012, \"amount\": 0.14 }, { \"id\": 9955014, \"amount\": 0.3 }, { \"id\": 9955015, \"amount\": 0.52 }, { \"id\": 9955019, \"amount\": 0.04 }, { \"id\": 9955020, \"amount\": 0.13 }, { \"id\": 9955023, \"amount\": 0.0 }, { \"id\": 9955024, \"amount\": 0.12 }, { \"id\": 9955027, \"amount\": 0.2 }, { \"id\": 9955028, \"amount\": 0.18 }, { \"id\": 9955031, \"amount\": 0.12 }, { \"id\": 9955033, \"amount\": 0.33 }, { \"id\": 9955034, \"amount\": 0.08 }, { \"id\": 9955036, \"amount\": 0.23 }, { \"id\": 9955041, \"amount\": 0.62 }, { \"id\": 9955042, \"amount\": 0.28 }, { \"id\": 9955045, \"amount\": 0.18 }, { \"id\": 9955046, \"amount\": 0.09 }, { \"id\": 9955052, \"amount\": 0.16 }, { \"id\": 9955054, \"amount\": 0.07 }, { \"id\": 9955055, \"amount\": 0.43 }, { \"id\": 9955056, \"amount\": 0.2 }, { \"id\": 9955057, \"amount\": 0.28 }, { \"id\": 9955058, \"amount\": 0.0 }, { \"id\": 9955059, \"amount\": 0.44 }, { \"id\": 9955060, \"amount\": 0.05 }, { \"id\": 9955066, \"amount\": 0.0 }, { \"id\": 9955069, \"amount\": 0.26 }, { \"id\": 9955070, \"amount\": 0.14 }, { \"id\": 9955072, \"amount\": 0.0 }, { \"id\": 9955075, \"amount\": 0.12 }, { \"id\": 9955076, \"amount\": 0.25 }, { \"id\": 9955083, \"amount\": 0.56 }, { \"id\": 9955084, \"amount\": 0.28 }, { \"id\": 9955085, \"amount\": 0.09 }, { \"id\": 9955089, \"amount\": 0.05 }, { \"id\": 9955090, \"amount\": 0.11 }, { \"id\": 9955092, \"amount\": 0.14 }, { \"id\": 9955094, \"amount\": 0.15 }, { \"id\": 9955096, \"amount\": 0.45 }, { \"id\": 9955098, \"amount\": 0.02 }, { \"id\": 9955100, \"amount\": 0.44 }, { \"id\": 9955101, \"amount\": 0.36 }, { \"id\": 9955102, \"amount\": 0.19 }, { \"id\": 9955103, \"amount\": 0.19 }, { \"id\": 9955105, \"amount\": 0.2 }, { \"id\": 9955107, \"amount\": 0.45 }, { \"id\": 9955108, \"amount\": 0.33 }, { \"id\": 9955109, \"amount\": 0.03 }, { \"id\": 9955110, \"amount\": 0.01 }, { \"id\": 9955112, \"amount\": 0.36 }, { \"id\": 9955115, \"amount\": 0.18 }, { \"id\": 9955116, \"amount\": 0.09 }, { \"id\": 9955117, \"amount\": 0.35 }, { \"id\": 9955121, \"amount\": 0.46 }, { \"id\": 9955122, \"amount\": 0.51 }, { \"id\": 9955124, \"amount\": 0.03 }, { \"id\": 9955125, \"amount\": 0.14 }, { \"id\": 9955126, \"amount\": 0.03 }, { \"id\": 9955128, \"amount\": 0.41 }, { \"id\": 9955130, \"amount\": 0.58 }, { \"id\": 9955132, \"amount\": 0.18 }, { \"id\": 9955133, \"amount\": 0.33 }, { \"id\": 9955134, \"amount\": 0.21 }, { \"id\": 9955136, \"amount\": 0.15 }, { \"id\": 9955137, \"amount\": 0.04 }, { \"id\": 9955138, \"amount\": 0.02 }, { \"id\": 9955141, \"amount\": 0.47 }, { \"id\": 9955142, \"amount\": 0.11 }, { \"id\": 9955150, \"amount\": 0.53 }, { \"id\": 9955151, \"amount\": 0.1 }, { \"id\": 9955152, \"amount\": 0.22 }, { \"id\": 9955153, \"amount\": 0.58 }, { \"id\": 9955154, \"amount\": 0.05 }, { \"id\": 9955155, \"amount\": 0.16 }, { \"id\": 9955157, \"amount\": 0.4 }, { \"id\": 9955158, \"amount\": 0.1 }, { \"id\": 9955160, \"amount\": 0.13 }, { \"id\": 9955161, \"amount\": 0.0 }, { \"id\": 9955162, \"amount\": 0.63 }, { \"id\": 9955166, \"amount\": 0.06 }, { \"id\": 9955167, \"amount\": 0.24 }, { \"id\": 9955168, \"amount\": 0.14 }, { \"id\": 9955172, \"amount\": 0.05 }, { \"id\": 9955173, \"amount\": 0.65 }, { \"id\": 9955174, \"amount\": 0.04 }, { \"id\": 9955175, \"amount\": 0.64 }, { \"id\": 9955176, \"amount\": 0.15 }, { \"id\": 9955177, \"amount\": 0.05 }, { \"id\": 9955179, \"amount\": 0.52 }, { \"id\": 9955180, \"amount\": 0.03 }, { \"id\": 9955182, \"amount\": 0.32 }, { \"id\": 9955183, \"amount\": 0.05 }, { \"id\": 9955184, \"amount\": 0.28 }, { \"id\": 9955185, \"amount\": 0.35 }, { \"id\": 9955187, \"amount\": 0.16 }, { \"id\": 9955189, \"amount\": 0.37 }, { \"id\": 9955192, \"amount\": 0.28 }, { \"id\": 9955193, \"amount\": 0.02 }, { \"id\": 9955194, \"amount\": 0.58 }, { \"id\": 9955196, \"amount\": 0.12 }, { \"id\": 9955198, \"amount\": 0.03 }, { \"id\": 9955201, \"amount\": 0.42 }, { \"id\": 9955202, \"amount\": 0.29 }, { \"id\": 9955203, \"amount\": 0.15 }, { \"id\": 9955204, \"amount\": 0.06 }, { \"id\": 9955205, \"amount\": 0.13 }, { \"id\": 9955206, \"amount\": 0.07 }, { \"id\": 9955208, \"amount\": 0.46 }, { \"id\": 9955209, \"amount\": 0.09 }, { \"id\": 9955210, \"amount\": 0.29 }, { \"id\": 9955211, \"amount\": 0.11 }, { \"id\": 9955212, \"amount\": 0.15 }, { \"id\": 9955213, \"amount\": 0.27 }, { \"id\": 9955216, \"amount\": 0.14 }, { \"id\": 9955217, \"amount\": 0.21 }, { \"id\": 9955218, \"amount\": 0.13 }, { \"id\": 9955219, \"amount\": 0.48 }, { \"id\": 9955221, \"amount\": 0.05 }, { \"id\": 9955222, \"amount\": 0.08 }, { \"id\": 9955223, \"amount\": 0.11 }, { \"id\": 9955224, \"amount\": 0.54 }, { \"id\": 9955226, \"amount\": 0.07 }, { \"id\": 9955227, \"amount\": 0.06 }, { \"id\": 9955228, \"amount\": 0.34 }, { \"id\": 9955231, \"amount\": 0.22 }, { \"id\": 9955233, \"amount\": 0.19 }, { \"id\": 9955234, \"amount\": 0.07 }, { \"id\": 9955235, \"amount\": 0.42 }, { \"id\": 9955236, \"amount\": 0.1 }, { \"id\": 9955237, \"amount\": 0.01 }, { \"id\": 9955239, \"amount\": 0.17 }, { \"id\": 9955240, \"amount\": 0.43 }, { \"id\": 9955241, \"amount\": 0.03 }, { \"id\": 9955242, \"amount\": 0.02 }, { \"id\": 9955243, \"amount\": 0.19 }, { \"id\": 9955244, \"amount\": 0.43 }, { \"id\": 9955246, \"amount\": 0.07 }, { \"id\": 9955247, \"amount\": 0.01 }, { \"id\": 9955249, \"amount\": 0.08 }, { \"id\": 9955254, \"amount\": 0.1 }, { \"id\": 9955257, \"amount\": 0.03 }, { \"id\": 9955258, \"amount\": 0.31 }, { \"id\": 9955261, \"amount\": 0.16 }, { \"id\": 9955263, \"amount\": 0.13 }, { \"id\": 9955265, \"amount\": 0.34 }, { \"id\": 9955266, \"amount\": 0.02 }, { \"id\": 9955267, \"amount\": 0.03 }, { \"id\": 9955268, \"amount\": 0.03 }, { \"id\": 9955272, \"amount\": 0.29 }, { \"id\": 9955273, \"amount\": 0.04 }, { \"id\": 9955279, \"amount\": 0.09 }, { \"id\": 9955280, \"amount\": 0.16 }, { \"id\": 9955281, \"amount\": 0.16 }, { \"id\": 9955283, \"amount\": 0.05 }, { \"id\": 9955285, \"amount\": 0.31 }, { \"id\": 9955286, \"amount\": 0.21 }, { \"id\": 9955289, \"amount\": 0.0 }, { \"id\": 9955290, \"amount\": 0.15 }, { \"id\": 9955291, \"amount\": 0.06 }, { \"id\": 9955292, \"amount\": 0.25 }, { \"id\": 9955295, \"amount\": 0.31 }, { \"id\": 9955296, \"amount\": 0.44 }, { \"id\": 9955297, \"amount\": 0.1 }, { \"id\": 9955298, \"amount\": 0.21 }, { \"id\": 9955301, \"amount\": 0.36 }, { \"id\": 9955304, \"amount\": 0.24 }, { \"id\": 9955305, \"amount\": 0.11 }, { \"id\": 9955306, \"amount\": 0.1 }, { \"id\": 9955309, \"amount\": 0.15 }, { \"id\": 9955311, \"amount\": 0.08 }, { \"id\": 9955312, \"amount\": 0.12 }, { \"id\": 9955314, \"amount\": 0.1 }, { \"id\": 9955316, \"amount\": 0.34 }, { \"id\": 9955317, \"amount\": 0.19 }, { \"id\": 9955321, \"amount\": 0.1 }, { \"id\": 9955322, \"amount\": 0.45 }, { \"id\": 9955324, \"amount\": 0.21 }, { \"id\": 9955331, \"amount\": 0.04 }, { \"id\": 9955332, \"amount\": 0.4 }, { \"id\": 9955335, \"amount\": 0.29 }, { \"id\": 9955338, \"amount\": 0.29 }, { \"id\": 9955341, \"amount\": 0.04 }, { \"id\": 9955342, \"amount\": 0.15 }, { \"id\": 9955343, \"amount\": 0.05 }, { \"id\": 9955344, \"amount\": 0.11 }, { \"id\": 9955345, \"amount\": 0.54 }, { \"id\": 9955346, \"amount\": 0.21 }, { \"id\": 9955347, \"amount\": 0.49 }, { \"id\": 9955349, \"amount\": 0.45 }, { \"id\": 9955350, \"amount\": 0.09 }, { \"id\": 9955351, \"amount\": 0.03 }, { \"id\": 9955352, \"amount\": 0.25 }, { \"id\": 9955354, \"amount\": 0.55 }, { \"id\": 9955355, \"amount\": 0.02 }, { \"id\": 9955357, \"amount\": 0.38 }, { \"id\": 9955358, \"amount\": 0.61 }, { \"id\": 9955362, \"amount\": 0.05 }, { \"id\": 9955364, \"amount\": 0.23 }, { \"id\": 9955365, \"amount\": 0.08 }, { \"id\": 9955366, \"amount\": 0.13 }, { \"id\": 9955367, \"amount\": 0.03 }, { \"id\": 9955368, \"amount\": 0.21 }, { \"id\": 9955371, \"amount\": 0.22 }, { \"id\": 9955372, \"amount\": 0.71 }, { \"id\": 9955373, \"amount\": 0.01 }, { \"id\": 9955374, \"amount\": 0.05 }, { \"id\": 9955375, \"amount\": 0.07 }, { \"id\": 9955376, \"amount\": 0.12 }, { \"id\": 9955379, \"amount\": 0.16 }, { \"id\": 9955380, \"amount\": 0.32 }, { \"id\": 9955383, \"amount\": 0.38 }, { \"id\": 9955384, \"amount\": 0.07 }, { \"id\": 9955385, \"amount\": 0.56 }, { \"id\": 9955386, \"amount\": 0.05 }, { \"id\": 9955387, \"amount\": 0.29 }, { \"id\": 9955388, \"amount\": 0.05 }, { \"id\": 9955393, \"amount\": 0.12 }, { \"id\": 9955394, \"amount\": 0.41 }, { \"id\": 9955396, \"amount\": 0.74 }, { \"id\": 9955397, \"amount\": 0.39 }, { \"id\": 9955398, \"amount\": 0.07 }, { \"id\": 9955400, \"amount\": 0.07 }, { \"id\": 9955402, \"amount\": 0.02 }, { \"id\": 9955403, \"amount\": 0.17 }, { \"id\": 9955404, \"amount\": 0.06 }, { \"id\": 9955405, \"amount\": 0.15 }, { \"id\": 9955406, \"amount\": 0.02 }, { \"id\": 9955408, \"amount\": 0.01 }, { \"id\": 9955409, \"amount\": 0.22 }, { \"id\": 9955415, \"amount\": 0.04 }, { \"id\": 9955421, \"amount\": 0.63 }, { \"id\": 9955423, \"amount\": 0.09 }, { \"id\": 9955427, \"amount\": 0.03 }, { \"id\": 9955430, \"amount\": 0.07 }, { \"id\": 9955431, \"amount\": 0.02 }, { \"id\": 9955432, \"amount\": 0.26 }, { \"id\": 9955433, \"amount\": 0.48 }, { \"id\": 9955434, \"amount\": 0.04 }, { \"id\": 9955435, \"amount\": 0.3 }, { \"id\": 9955436, \"amount\": 0.05 }, { \"id\": 9955437, \"amount\": 0.14 }, { \"id\": 9955440, \"amount\": 0.03 }, { \"id\": 9955443, \"amount\": 0.23 }, { \"id\": 9955445, \"amount\": 0.15 }, { \"id\": 9955446, \"amount\": 0.55 }, { \"id\": 9955447, \"amount\": 0.04 }, { \"id\": 9955451, \"amount\": 0.18 }, { \"id\": 9955453, \"amount\": 0.06 }, { \"id\": 9955457, \"amount\": 0.51 }, { \"id\": 9955461, \"amount\": 0.12 }, { \"id\": 9955464, \"amount\": 0.19 }, { \"id\": 9955465, \"amount\": 0.09 }, { \"id\": 9955466, \"amount\": 0.02 }, { \"id\": 9955467, \"amount\": 0.64 }, { \"id\": 9955468, \"amount\": 0.16 }, { \"id\": 9955471, \"amount\": 0.03 }, { \"id\": 9955477, \"amount\": 0.03 }, { \"id\": 9955478, \"amount\": 0.04 }, { \"id\": 9955482, \"amount\": 0.42 }, { \"id\": 9955483, \"amount\": 0.33 }, { \"id\": 9955486, \"amount\": 0.2 }, { \"id\": 9955487, \"amount\": 0.07 }, { \"id\": 9955488, \"amount\": 0.51 }, { \"id\": 9955489, \"amount\": 0.22 }, { \"id\": 9955492, \"amount\": 0.11 }, { \"id\": 9955493, \"amount\": 0.37 }, { \"id\": 9955495, \"amount\": 0.1 }, { \"id\": 9955496, \"amount\": 0.47 }, { \"id\": 9955497, \"amount\": 0.11 }, { \"id\": 9955498, \"amount\": 0.16 }, { \"id\": 9955504, \"amount\": 0.36 }, { \"id\": 9955505, \"amount\": 0.45 }, { \"id\": 9955506, \"amount\": 0.38 }, { \"id\": 9955507, \"amount\": 0.5 }, { \"id\": 9955510, \"amount\": 0.16 }, { \"id\": 9955511, \"amount\": 0.0 }, { \"id\": 9955513, \"amount\": 0.41 }, { \"id\": 9955516, \"amount\": 0.19 }, { \"id\": 9955517, \"amount\": 0.17 }, { \"id\": 9955518, \"amount\": 0.59 }, { \"id\": 9955521, \"amount\": 0.01 }, { \"id\": 9955522, \"amount\": 0.2 }, { \"id\": 9955523, \"amount\": 0.12 }, { \"id\": 9955525, \"amount\": 0.42 }, { \"id\": 9955526, \"amount\": 0.63 }, { \"id\": 9955538, \"amount\": 0.01 }, { \"id\": 9955539, \"amount\": 0.03 }, { \"id\": 9955540, \"amount\": 0.16 }, { \"id\": 9955541, \"amount\": 0.22 }, { \"id\": 9955542, \"amount\": 0.31 }, { \"id\": 9955547, \"amount\": 0.38 }, { \"id\": 9955549, \"amount\": 0.04 }, { \"id\": 9955552, \"amount\": 0.1 }, { \"id\": 9955553, \"amount\": 0.52 }, { \"id\": 9955557, \"amount\": 0.43 }, { \"id\": 9955558, \"amount\": 0.68 }, { \"id\": 9955559, \"amount\": 0.56 }, { \"id\": 9955561, \"amount\": 0.07 }, { \"id\": 9955562, \"amount\": 0.7 }, { \"id\": 9955565, \"amount\": 0.6 }, { \"id\": 9955566, \"amount\": 0.17 }, { \"id\": 9955567, \"amount\": 0.39 }, { \"id\": 9955572, \"amount\": 0.22 }, { \"id\": 9955573, \"amount\": 0.16 }, { \"id\": 9955574, \"amount\": 0.21 }, { \"id\": 9955578, \"amount\": 0.06 }, { \"id\": 9955583, \"amount\": 0.16 }, { \"id\": 9955584, \"amount\": 0.08 }, { \"id\": 9955585, \"amount\": 0.14 }, { \"id\": 9955586, \"amount\": 0.55 }, { \"id\": 9955587, \"amount\": 0.1 }, { \"id\": 9955590, \"amount\": 0.41 }, { \"id\": 9955591, \"amount\": 0.34 }, { \"id\": 9955592, \"amount\": 0.03 }, { \"id\": 9955593, \"amount\": 0.25 }, { \"id\": 9955597, \"amount\": 0.05 }, { \"id\": 9955599, \"amount\": 0.47 }, { \"id\": 9955600, \"amount\": 0.1 }, { \"id\": 9955601, \"amount\": 0.16 }, { \"id\": 9955604, \"amount\": 0.04 }, { \"id\": 9955608, \"amount\": 0.59 }, { \"id\": 9955609, \"amount\": 0.66 }, { \"id\": 9955611, \"amount\": 0.21 }, { \"id\": 9955612, \"amount\": 0.02 }, { \"id\": 9955613, \"amount\": 0.09 }, { \"id\": 9955615, \"amount\": 0.37 }, { \"id\": 9955616, \"amount\": 0.04 }, { \"id\": 9955617, \"amount\": 0.0 }, { \"id\": 9955618, \"amount\": 0.07 }, { \"id\": 9955622, \"amount\": 0.3 }, { \"id\": 9955625, \"amount\": 0.02 }, { \"id\": 9955626, \"amount\": 0.2 }, { \"id\": 9955628, \"amount\": 0.23 }, { \"id\": 9955634, \"amount\": 0.1 }, { \"id\": 9955635, \"amount\": 0.04 }, { \"id\": 9955636, \"amount\": 0.22 }, { \"id\": 9955637, \"amount\": 0.09 }, { \"id\": 9955638, \"amount\": 0.19 }, { \"id\": 9955639, \"amount\": 0.43 }, { \"id\": 9955648, \"amount\": 0.06 }, { \"id\": 9955649, \"amount\": 0.3 }, { \"id\": 9955650, \"amount\": 0.02 }, { \"id\": 9955651, \"amount\": 0.31 }, { \"id\": 9955653, \"amount\": 0.08 }, { \"id\": 9955654, \"amount\": 0.41 }, { \"id\": 9955655, \"amount\": 0.02 }, { \"id\": 9955656, \"amount\": 0.12 }, { \"id\": 9955658, \"amount\": 0.04 }, { \"id\": 9955659, \"amount\": 0.31 }, { \"id\": 9955662, \"amount\": 0.18 }, { \"id\": 9955664, \"amount\": 0.03 }, { \"id\": 9955665, \"amount\": 0.27 }, { \"id\": 9955666, \"amount\": 0.39 }, { \"id\": 9955667, \"amount\": 0.0 }, { \"id\": 9955668, \"amount\": 0.19 }, { \"id\": 9955678, \"amount\": 0.31 }, { \"id\": 9955679, \"amount\": 0.1 }, { \"id\": 9955680, \"amount\": 0.02 }, { \"id\": 9955681, \"amount\": 0.49 }, { \"id\": 9955682, \"amount\": 0.62 }, { \"id\": 9955683, \"amount\": 0.33 }, { \"id\": 9955685, \"amount\": 0.39 }, { \"id\": 9955686, \"amount\": 0.03 }, { \"id\": 9955687, \"amount\": 0.04 }, { \"id\": 9955688, \"amount\": 0.24 }, { \"id\": 9955689, \"amount\": 0.17 }, { \"id\": 9955690, \"amount\": 0.08 }, { \"id\": 9955691, \"amount\": 0.24 }, { \"id\": 9955695, \"amount\": 0.07 }, { \"id\": 9955696, \"amount\": 0.17 }, { \"id\": 9955699, \"amount\": 0.24 }, { \"id\": 9955702, \"amount\": 0.03 }, { \"id\": 9955703, \"amount\": 0.11 }, { \"id\": 9955705, \"amount\": 0.05 }, { \"id\": 9955706, \"amount\": 0.16 }, { \"id\": 9955707, \"amount\": 0.18 }, { \"id\": 9955711, \"amount\": 0.06 }, { \"id\": 9955714, \"amount\": 0.06 }, { \"id\": 9955717, \"amount\": 0.01 }, { \"id\": 9955720, \"amount\": 0.4 }, { \"id\": 9955721, \"amount\": 0.07 }, { \"id\": 9955722, \"amount\": 0.09 }, { \"id\": 9955725, \"amount\": 0.21 }, { \"id\": 9955726, \"amount\": 0.21 }, { \"id\": 9955728, \"amount\": 0.05 }, { \"id\": 9955730, \"amount\": 0.08 }, { \"id\": 9955732, \"amount\": 0.36 }, { \"id\": 9955736, \"amount\": 0.09 }, { \"id\": 9955737, \"amount\": 0.07 }, { \"id\": 9955739, \"amount\": 0.57 }, { \"id\": 9955745, \"amount\": 0.18 }, { \"id\": 9955747, \"amount\": 0.21 }, { \"id\": 9955749, \"amount\": 0.04 }, { \"id\": 9955750, \"amount\": 0.49 }, { \"id\": 9955752, \"amount\": 0.68 }, { \"id\": 9955755, \"amount\": 0.18 }, { \"id\": 9955757, \"amount\": 0.15 }, { \"id\": 9955759, \"amount\": 0.1 }, { \"id\": 9955763, \"amount\": 0.17 }, { \"id\": 9955765, \"amount\": 0.04 }, { \"id\": 9955769, \"amount\": 0.56 }, { \"id\": 9955770, \"amount\": 0.06 }, { \"id\": 9955774, \"amount\": 0.53 }, { \"id\": 9955779, \"amount\": 0.56 }, { \"id\": 9955783, \"amount\": 0.1 }, { \"id\": 9955784, \"amount\": 0.3 }, { \"id\": 9955785, \"amount\": 0.09 }, { \"id\": 9955786, \"amount\": 0.15 }, { \"id\": 9955788, \"amount\": 0.09 }, { \"id\": 9955791, \"amount\": 0.16 }, { \"id\": 9955795, \"amount\": 0.03 }, { \"id\": 9955810, \"amount\": 0.2 }, { \"id\": 9955811, \"amount\": 0.61 }, { \"id\": 9955814, \"amount\": 0.52 }, { \"id\": 9955821, \"amount\": 0.29 }, { \"id\": 9955825, \"amount\": 0.63 }, { \"id\": 9955827, \"amount\": 0.1 }, { \"id\": 9955828, \"amount\": 0.14 }, { \"id\": 9955829, \"amount\": 0.08 }, { \"id\": 9955831, \"amount\": 0.57 }, { \"id\": 9955841, \"amount\": 0.1 }, { \"id\": 9955842, \"amount\": 0.28 }, { \"id\": 9955843, \"amount\": 0.27 }, { \"id\": 9955849, \"amount\": 0.0 }, { \"id\": 9955850, \"amount\": 0.05 }, { \"id\": 9955853, \"amount\": 0.24 }, { \"id\": 9955855, \"amount\": 0.11 }, { \"id\": 9955856, \"amount\": 0.26 }, { \"id\": 9955859, \"amount\": 0.16 }, { \"id\": 9955860, \"amount\": 0.44 }, { \"id\": 9955861, \"amount\": 0.14 }, { \"id\": 9955863, \"amount\": 0.21 }, { \"id\": 9955867, \"amount\": 0.06 }, { \"id\": 9955870, \"amount\": 0.06 }, { \"id\": 9955871, \"amount\": 0.02 }, { \"id\": 9955877, \"amount\": 0.15 }, { \"id\": 9955878, \"amount\": 0.06 }, { \"id\": 9955879, \"amount\": 0.25 }, { \"id\": 9955880, \"amount\": 0.07 }, { \"id\": 9955882, \"amount\": 0.17 }, { \"id\": 9955884, \"amount\": 0.71 }, { \"id\": 9955885, \"amount\": 0.03 }, { \"id\": 9955886, \"amount\": 0.25 }, { \"id\": 9955887, \"amount\": 0.6 }, { \"id\": 9955891, \"amount\": 0.28 }, { \"id\": 9955892, \"amount\": 0.27 }, { \"id\": 9955895, \"amount\": 0.1 }, { \"id\": 9955896, \"amount\": 0.6 }, { \"id\": 9955901, \"amount\": 0.76 }, { \"id\": 9955906, \"amount\": 0.29 }, { \"id\": 9955907, \"amount\": 0.39 }, { \"id\": 9955909, \"amount\": 0.03 }, { \"id\": 9955910, \"amount\": 0.01 }, { \"id\": 9955911, \"amount\": 0.39 }, { \"id\": 9955913, \"amount\": 0.18 }, { \"id\": 9955914, \"amount\": 0.49 }, { \"id\": 9955915, \"amount\": 0.2 }, { \"id\": 9955916, \"amount\": 0.25 }, { \"id\": 9955917, \"amount\": 0.07 }, { \"id\": 9955919, \"amount\": 0.7 }, { \"id\": 9955920, \"amount\": 0.25 }, { \"id\": 9955921, \"amount\": 0.72 }, { \"id\": 9955922, \"amount\": 0.47 }, { \"id\": 9955923, \"amount\": 0.3 }, { \"id\": 9955928, \"amount\": 0.01 }, { \"id\": 9955929, \"amount\": 0.02 }, { \"id\": 9955930, \"amount\": 0.06 }, { \"id\": 9955933, \"amount\": 0.61 }, { \"id\": 9955934, \"amount\": 0.09 }, { \"id\": 9955945, \"amount\": 0.69 }, { \"id\": 9955948, \"amount\": 0.07 }, { \"id\": 9955951, \"amount\": 0.07 }, { \"id\": 9955952, \"amount\": 0.01 }, { \"id\": 9955954, \"amount\": 0.36 }, { \"id\": 9955955, \"amount\": 0.45 }, { \"id\": 9955958, \"amount\": 0.05 }, { \"id\": 9955959, \"amount\": 0.04 }, { \"id\": 9955961, \"amount\": 0.09 }, { \"id\": 9955963, \"amount\": 0.18 }, { \"id\": 9955965, \"amount\": 0.13 }, { \"id\": 9955967, \"amount\": 0.55 }, { \"id\": 9955968, \"amount\": 0.13 }, { \"id\": 9955970, \"amount\": 0.25 }, { \"id\": 9955971, \"amount\": 0.15 }, { \"id\": 9955976, \"amount\": 0.23 }, { \"id\": 9955978, \"amount\": 0.13 }, { \"id\": 9955982, \"amount\": 0.53 }, { \"id\": 9955984, \"amount\": 0.19 }, { \"id\": 9955987, \"amount\": 0.07 }, { \"id\": 9955989, \"amount\": 0.03 }, { \"id\": 9955990, \"amount\": 0.04 }, { \"id\": 9955994, \"amount\": 0.05 }, { \"id\": 9955997, \"amount\": 0.37 }, { \"id\": 9955999, \"amount\": 0.11 }, { \"id\": 9956000, \"amount\": 0.67 }, { \"id\": 9956003, \"amount\": 0.11 }, { \"id\": 9956004, \"amount\": 0.32 }, { \"id\": 9956011, \"amount\": 0.05 }, { \"id\": 9956015, \"amount\": 0.18 }, { \"id\": 9956016, \"amount\": 0.28 }, { \"id\": 9956021, \"amount\": 0.06 }, { \"id\": 9956022, \"amount\": 0.08 }, { \"id\": 9956024, \"amount\": 0.12 }, { \"id\": 9956030, \"amount\": 0.57 }, { \"id\": 9956032, \"amount\": 0.29 }, { \"id\": 9956033, \"amount\": 0.03 }, { \"id\": 9956035, \"amount\": 0.27 }, { \"id\": 9956037, \"amount\": 0.19 }, { \"id\": 9956038, \"amount\": 0.03 }, { \"id\": 9956041, \"amount\": 0.07 }, { \"id\": 9956046, \"amount\": 0.19 }, { \"id\": 9956047, \"amount\": 0.26 }, { \"id\": 9956051, \"amount\": 0.04 }, { \"id\": 9956052, \"amount\": 0.05 }, { \"id\": 9956054, \"amount\": 0.72 }, { \"id\": 9956056, \"amount\": 0.65 }, { \"id\": 9956061, \"amount\": 0.32 }, { \"id\": 9956062, \"amount\": 0.04 }, { \"id\": 9956063, \"amount\": 0.05 }, { \"id\": 9956065, \"amount\": 0.53 }, { \"id\": 9956066, \"amount\": 0.16 }, { \"id\": 9956068, \"amount\": 0.01 }, { \"id\": 9956069, \"amount\": 0.61 }, { \"id\": 9956070, \"amount\": 0.33 }, { \"id\": 9956072, \"amount\": 0.05 }, { \"id\": 9956073, \"amount\": 0.15 }, { \"id\": 9956075, \"amount\": 0.18 }, { \"id\": 9956076, \"amount\": 0.07 }, { \"id\": 9956079, \"amount\": 0.14 }, { \"id\": 9956082, \"amount\": 0.23 }, { \"id\": 9956084, \"amount\": 0.03 }, { \"id\": 9956085, \"amount\": 0.14 }, { \"id\": 9956092, \"amount\": 0.41 }, { \"id\": 9956093, \"amount\": 0.07 }, { \"id\": 9956094, \"amount\": 0.01 }, { \"id\": 9956096, \"amount\": 0.02 }, { \"id\": 9956099, \"amount\": 0.09 }, { \"id\": 9956100, \"amount\": 0.1 }, { \"id\": 9956101, \"amount\": 0.62 }, { \"id\": 9956102, \"amount\": 0.02 }, { \"id\": 9956103, \"amount\": 0.17 }, { \"id\": 9956104, \"amount\": 0.13 }, { \"id\": 9956105, \"amount\": 0.38 }, { \"id\": 9956106, \"amount\": 0.05 }, { \"id\": 9956107, \"amount\": 0.34 }, { \"id\": 9956111, \"amount\": 0.28 }, { \"id\": 9956113, \"amount\": 0.19 }, { \"id\": 9956116, \"amount\": 0.16 }, { \"id\": 9956121, \"amount\": 0.58 }, { \"id\": 9956122, \"amount\": 0.05 }, { \"id\": 9956124, \"amount\": 0.43 }, { \"id\": 9956125, \"amount\": 0.41 }, { \"id\": 9956127, \"amount\": 0.09 }, { \"id\": 9956130, \"amount\": 0.17 }, { \"id\": 9956133, \"amount\": 0.5 }, { \"id\": 9956135, \"amount\": 0.04 }, { \"id\": 9956137, \"amount\": 0.29 }, { \"id\": 9956138, \"amount\": 0.16 }, { \"id\": 9956139, \"amount\": 0.16 }, { \"id\": 9956141, \"amount\": 0.14 }, { \"id\": 9956143, \"amount\": 0.17 }, { \"id\": 9956145, \"amount\": 0.04 }, { \"id\": 9956147, \"amount\": 0.54 }, { \"id\": 9956149, \"amount\": 0.49 }, { \"id\": 9956151, \"amount\": 0.09 }, { \"id\": 9956153, \"amount\": 0.07 }, { \"id\": 9956156, \"amount\": 0.14 }, { \"id\": 9956157, \"amount\": 0.12 }, { \"id\": 9956159, \"amount\": 0.15 }, { \"id\": 9956160, \"amount\": 0.1 }, { \"id\": 9956162, \"amount\": 0.1 }, { \"id\": 9956167, \"amount\": 0.26 }, { \"id\": 9956168, \"amount\": 0.15 }, { \"id\": 9956169, \"amount\": 0.11 }, { \"id\": 9956170, \"amount\": 0.13 }, { \"id\": 9956173, \"amount\": 0.77 }, { \"id\": 9956179, \"amount\": 0.01 }, { \"id\": 9956181, \"amount\": 0.22 }, { \"id\": 9956184, \"amount\": 0.05 }, { \"id\": 9956187, \"amount\": 0.07 }, { \"id\": 9956188, \"amount\": 0.02 }, { \"id\": 9956189, \"amount\": 0.3 }, { \"id\": 9956191, \"amount\": 0.23 }, { \"id\": 9956195, \"amount\": 0.23 }, { \"id\": 9956197, \"amount\": 0.44 }, { \"id\": 9956198, \"amount\": 0.37 }, { \"id\": 9956200, \"amount\": 0.13 }, { \"id\": 9956202, \"amount\": 0.32 }, { \"id\": 9956212, \"amount\": 0.63 }, { \"id\": 9956214, \"amount\": 0.16 }, { \"id\": 9956215, \"amount\": 0.56 }, { \"id\": 9956216, \"amount\": 0.19 }, { \"id\": 9956217, \"amount\": 0.32 }, { \"id\": 9956226, \"amount\": 0.26 }, { \"id\": 9956230, \"amount\": 0.11 }, { \"id\": 9956232, \"amount\": 0.23 }, { \"id\": 9956233, \"amount\": 0.41 }, { \"id\": 9956234, \"amount\": 0.02 }, { \"id\": 9956239, \"amount\": 0.0 }, { \"id\": 9956240, \"amount\": 0.21 }, { \"id\": 9956243, \"amount\": 0.02 }, { \"id\": 9956244, \"amount\": 0.23 }, { \"id\": 9956246, \"amount\": 0.0 }, { \"id\": 9956248, \"amount\": 0.16 }, { \"id\": 9956249, \"amount\": 0.11 }, { \"id\": 9956250, \"amount\": 0.11 }, { \"id\": 9956251, \"amount\": 0.06 }, { \"id\": 9956255, \"amount\": 0.18 }, { \"id\": 9956258, \"amount\": 0.44 }, { \"id\": 9956260, \"amount\": 0.1 }, { \"id\": 9956263, \"amount\": 0.49 }, { \"id\": 9956264, \"amount\": 0.12 }, { \"id\": 9956265, \"amount\": 0.48 }, { \"id\": 9956266, \"amount\": 0.13 }, { \"id\": 9956268, \"amount\": 0.32 }, { \"id\": 9956269, \"amount\": 0.69 }, { \"id\": 9956270, \"amount\": 0.15 }, { \"id\": 9956278, \"amount\": 0.05 }, { \"id\": 9956280, \"amount\": 0.05 }, { \"id\": 9956283, \"amount\": 0.2 }, { \"id\": 9956286, \"amount\": 0.21 }, { \"id\": 9956287, \"amount\": 0.34 }, { \"id\": 9956288, \"amount\": 0.28 }, { \"id\": 9956289, \"amount\": 0.07 }, { \"id\": 9956290, \"amount\": 0.34 }, { \"id\": 9956291, \"amount\": 0.06 }, { \"id\": 9956295, \"amount\": 0.07 }, { \"id\": 9956296, \"amount\": 0.17 }, { \"id\": 9956304, \"amount\": 0.67 }, { \"id\": 9956308, \"amount\": 0.36 }, { \"id\": 9956311, \"amount\": 0.37 }, { \"id\": 9956312, \"amount\": 0.0 }, { \"id\": 9956314, \"amount\": 0.03 }, { \"id\": 9956318, \"amount\": 0.23 }, { \"id\": 9956322, \"amount\": 0.06 }, { \"id\": 9956324, \"amount\": 0.65 }, { \"id\": 9956327, \"amount\": 0.13 }, { \"id\": 9956328, \"amount\": 0.5 }, { \"id\": 9956331, \"amount\": 0.1 }, { \"id\": 9956333, \"amount\": 0.37 }, { \"id\": 9956338, \"amount\": 0.09 }, { \"id\": 9956340, \"amount\": 0.11 }, { \"id\": 9956341, \"amount\": 0.66 }, { \"id\": 9956342, \"amount\": 0.35 }, { \"id\": 9956346, \"amount\": 0.46 }, { \"id\": 9956347, \"amount\": 0.72 }, { \"id\": 9956351, \"amount\": 0.42 }, { \"id\": 9956352, \"amount\": 0.25 }, { \"id\": 9956356, \"amount\": 0.53 }, { \"id\": 9956360, \"amount\": 0.43 }, { \"id\": 9956362, \"amount\": 0.26 }, { \"id\": 9956363, \"amount\": 0.15 }, { \"id\": 9956364, \"amount\": 0.47 }, { \"id\": 9956366, \"amount\": 0.54 }, { \"id\": 9956367, \"amount\": 0.04 }, { \"id\": 9956369, \"amount\": 0.02 }, { \"id\": 9956371, \"amount\": 0.27 }, { \"id\": 9956373, \"amount\": 0.3 }, { \"id\": 9956374, \"amount\": 0.12 }, { \"id\": 9956375, \"amount\": 0.06 }, { \"id\": 9956379, \"amount\": 0.56 }, { \"id\": 9956380, \"amount\": 0.14 }, { \"id\": 9956393, \"amount\": 0.13 }, { \"id\": 9956395, \"amount\": 0.13 }, { \"id\": 9956397, \"amount\": 0.07 }, { \"id\": 9956399, \"amount\": 0.33 }, { \"id\": 9956400, \"amount\": 0.1 }, { \"id\": 9956401, \"amount\": 0.04 }, { \"id\": 9956403, \"amount\": 0.42 }, { \"id\": 9956416, \"amount\": 0.13 }, { \"id\": 9956417, \"amount\": 0.12 }, { \"id\": 9956419, \"amount\": 0.18 }, { \"id\": 9956423, \"amount\": 0.41 }, { \"id\": 9956426, \"amount\": 0.05 }, { \"id\": 9956427, \"amount\": 0.4 }, { \"id\": 9956428, \"amount\": 0.11 }, { \"id\": 9956429, \"amount\": 0.04 }, { \"id\": 9956432, \"amount\": 0.05 }, { \"id\": 9956433, \"amount\": 0.3 }, { \"id\": 9956434, \"amount\": 0.61 }, { \"id\": 9956436, \"amount\": 0.63 }, { \"id\": 9956438, \"amount\": 0.4 }, { \"id\": 9956439, \"amount\": 0.22 }, { \"id\": 9956440, \"amount\": 0.36 }, { \"id\": 9956442, \"amount\": 0.73 }, { \"id\": 9956445, \"amount\": 0.21 }, { \"id\": 9956446, \"amount\": 0.35 }, { \"id\": 9956448, \"amount\": 0.26 }, { \"id\": 9956449, \"amount\": 0.15 }, { \"id\": 9956451, \"amount\": 0.33 }, { \"id\": 9956452, \"amount\": 0.01 }, { \"id\": 9956455, \"amount\": 0.07 }, { \"id\": 9956456, \"amount\": 0.13 }, { \"id\": 9956457, \"amount\": 0.23 }, { \"id\": 9956458, \"amount\": 0.06 }, { \"id\": 9956460, \"amount\": 0.71 }, { \"id\": 9956462, \"amount\": 0.08 }, { \"id\": 9956464, \"amount\": 0.05 }, { \"id\": 9956465, \"amount\": 0.62 }, { \"id\": 9956468, \"amount\": 0.52 }, { \"id\": 9956469, \"amount\": 0.14 }, { \"id\": 9956473, \"amount\": 0.04 }, { \"id\": 9956475, \"amount\": 0.16 }, { \"id\": 9956476, \"amount\": 0.59 }, { \"id\": 9956481, \"amount\": 0.33 }, { \"id\": 9956482, \"amount\": 0.03 }, { \"id\": 9956484, \"amount\": 0.12 }, { \"id\": 9956487, \"amount\": 0.09 }, { \"id\": 9956489, \"amount\": 0.6 }, { \"id\": 9956490, \"amount\": 0.61 }, { \"id\": 9956491, \"amount\": 0.0 }, { \"id\": 9956492, \"amount\": 0.27 }, { \"id\": 9956498, \"amount\": 0.55 }, { \"id\": 9956499, \"amount\": 0.06 }, { \"id\": 9956500, \"amount\": 0.51 }, { \"id\": 9956502, \"amount\": 0.03 }, { \"id\": 9956503, \"amount\": 0.02 }, { \"id\": 9956504, \"amount\": 0.28 }, { \"id\": 9956507, \"amount\": 0.46 }, { \"id\": 9956511, \"amount\": 0.05 }, { \"id\": 9956512, \"amount\": 0.24 }, { \"id\": 9956513, \"amount\": 0.69 }, { \"id\": 9956517, \"amount\": 0.05 }, { \"id\": 9956520, \"amount\": 0.29 }, { \"id\": 9956522, \"amount\": 0.22 }, { \"id\": 9956523, \"amount\": 0.18 }, { \"id\": 9956524, \"amount\": 0.11 }, { \"id\": 9956525, \"amount\": 0.38 }, { \"id\": 9956526, \"amount\": 0.21 }, { \"id\": 9956527, \"amount\": 0.55 }, { \"id\": 9956532, \"amount\": 0.47 }, { \"id\": 9956535, \"amount\": 0.1 }, { \"id\": 9956537, \"amount\": 0.24 }, { \"id\": 9956541, \"amount\": 0.42 }, { \"id\": 9956542, \"amount\": 0.56 }, { \"id\": 9956545, \"amount\": 0.18 }, { \"id\": 9956546, \"amount\": 0.39 }, { \"id\": 9956549, \"amount\": 0.07 }, { \"id\": 9956550, \"amount\": 0.64 }, { \"id\": 9956552, \"amount\": 0.15 }, { \"id\": 9956558, \"amount\": 0.1 }, { \"id\": 9956562, \"amount\": 0.26 }, { \"id\": 9956563, \"amount\": 0.57 }, { \"id\": 9956565, \"amount\": 0.02 }, { \"id\": 9956567, \"amount\": 0.3 }, { \"id\": 9956570, \"amount\": 0.28 }, { \"id\": 9956574, \"amount\": 0.01 }, { \"id\": 9956583, \"amount\": 0.54 }, { \"id\": 9956584, \"amount\": 0.36 }, { \"id\": 9956585, \"amount\": 0.17 }, { \"id\": 9956586, \"amount\": 0.14 }, { \"id\": 9956587, \"amount\": 0.41 }, { \"id\": 9956590, \"amount\": 0.55 }, { \"id\": 9956594, \"amount\": 0.06 }, { \"id\": 9956596, \"amount\": 0.59 }, { \"id\": 9956598, \"amount\": 0.28 }, { \"id\": 9956599, \"amount\": 0.1 }, { \"id\": 9956600, \"amount\": 0.01 }, { \"id\": 9956608, \"amount\": 0.31 }, { \"id\": 9956610, \"amount\": 0.34 }, { \"id\": 9956615, \"amount\": 0.31 }, { \"id\": 9956616, \"amount\": 0.42 }, { \"id\": 9956617, \"amount\": 0.45 }, { \"id\": 9956618, \"amount\": 0.1 }, { \"id\": 9956620, \"amount\": 0.24 }, { \"id\": 9956621, \"amount\": 0.45 }, { \"id\": 9956622, \"amount\": 0.71 }, { \"id\": 9956623, \"amount\": 0.08 }, { \"id\": 9956626, \"amount\": 0.09 }, { \"id\": 9956630, \"amount\": 0.51 }, { \"id\": 9956634, \"amount\": 0.37 }, { \"id\": 9956635, \"amount\": 0.23 }, { \"id\": 9956636, \"amount\": 0.34 }, { \"id\": 9956637, \"amount\": 0.07 }, { \"id\": 9956638, \"amount\": 0.01 }, { \"id\": 9956639, \"amount\": 0.3 }, { \"id\": 9956640, \"amount\": 0.04 }, { \"id\": 9956644, \"amount\": 0.25 }, { \"id\": 9956647, \"amount\": 0.04 }, { \"id\": 9956650, \"amount\": 0.32 }, { \"id\": 9956651, \"amount\": 0.33 }, { \"id\": 9956654, \"amount\": 0.56 }, { \"id\": 9956656, \"amount\": 0.03 }, { \"id\": 9956660, \"amount\": 0.61 }, { \"id\": 9956662, \"amount\": 0.06 }, { \"id\": 9956663, \"amount\": 0.55 }, { \"id\": 9956668, \"amount\": 0.22 }, { \"id\": 9956670, \"amount\": 0.19 }, { \"id\": 9956672, \"amount\": 0.14 }, { \"id\": 9956673, \"amount\": 0.31 }, { \"id\": 9956675, \"amount\": 0.37 }, { \"id\": 9956676, \"amount\": 0.11 }, { \"id\": 9956683, \"amount\": 0.05 }, { \"id\": 9956685, \"amount\": 0.54 }, { \"id\": 9956687, \"amount\": 0.32 }, { \"id\": 9956688, \"amount\": 0.27 }, { \"id\": 9956690, \"amount\": 0.01 }, { \"id\": 9956691, \"amount\": 0.05 }, { \"id\": 9956692, \"amount\": 0.57 }, { \"id\": 9956694, \"amount\": 0.15 }, { \"id\": 9956695, \"amount\": 0.14 }, { \"id\": 9956697, \"amount\": 0.03 }, { \"id\": 9956699, \"amount\": 0.24 }, { \"id\": 9956700, \"amount\": 0.59 }, { \"id\": 9956701, \"amount\": 0.69 }, { \"id\": 9956703, \"amount\": 0.39 }, { \"id\": 9956704, \"amount\": 0.52 }, { \"id\": 9956708, \"amount\": 0.45 }, { \"id\": 9956711, \"amount\": 0.27 }, { \"id\": 9956712, \"amount\": 0.31 }, { \"id\": 9956714, \"amount\": 0.49 }, { \"id\": 9956719, \"amount\": 0.1 }, { \"id\": 9956720, \"amount\": 0.25 }, { \"id\": 9956721, \"amount\": 0.21 }, { \"id\": 9956722, \"amount\": 0.2 }, { \"id\": 9956723, \"amount\": 0.18 }, { \"id\": 9956725, \"amount\": 0.6 }, { \"id\": 9956726, \"amount\": 0.05 }, { \"id\": 9956729, \"amount\": 0.31 }, { \"id\": 9956730, \"amount\": 0.19 }, { \"id\": 9956731, \"amount\": 0.57 }, { \"id\": 9956732, \"amount\": 0.0 }, { \"id\": 9956733, \"amount\": 0.4 }, { \"id\": 9956734, \"amount\": 0.05 }, { \"id\": 9956735, \"amount\": 0.19 }, { \"id\": 9956740, \"amount\": 0.17 }, { \"id\": 9956741, \"amount\": 0.02 }, { \"id\": 9956742, \"amount\": 0.39 }, { \"id\": 9956744, \"amount\": 0.56 }, { \"id\": 9956747, \"amount\": 0.02 }, { \"id\": 9956749, \"amount\": 0.16 }, { \"id\": 9956751, \"amount\": 0.18 }, { \"id\": 9956754, \"amount\": 0.6 }, { \"id\": 9956759, \"amount\": 0.07 }, { \"id\": 9956762, \"amount\": 0.23 }, { \"id\": 9956763, \"amount\": 0.3 }, { \"id\": 9956765, \"amount\": 0.55 }, { \"id\": 9956768, \"amount\": 0.04 }, { \"id\": 9956769, \"amount\": 0.4 }, { \"id\": 9956770, \"amount\": 0.43 }, { \"id\": 9956772, \"amount\": 0.27 }, { \"id\": 9956774, \"amount\": 0.14 }, { \"id\": 9956775, \"amount\": 0.25 }, { \"id\": 9956776, \"amount\": 0.13 }, { \"id\": 9956781, \"amount\": 0.03 }, { \"id\": 9956788, \"amount\": 0.26 }, { \"id\": 9956790, \"amount\": 0.07 }, { \"id\": 9956795, \"amount\": 0.6 }, { \"id\": 9956796, \"amount\": 0.62 }, { \"id\": 9956801, \"amount\": 0.08 }, { \"id\": 9956803, \"amount\": 0.61 }, { \"id\": 9956806, \"amount\": 0.26 }, { \"id\": 9956807, \"amount\": 0.31 }, { \"id\": 9956808, \"amount\": 0.28 }, { \"id\": 9956811, \"amount\": 0.14 }, { \"id\": 9956812, \"amount\": 0.41 }, { \"id\": 9956816, \"amount\": 0.12 }, { \"id\": 9956818, \"amount\": 0.64 }, { \"id\": 9956819, \"amount\": 0.66 }, { \"id\": 9956820, \"amount\": 0.05 }, { \"id\": 9956822, \"amount\": 0.06 }, { \"id\": 9956828, \"amount\": 0.24 }, { \"id\": 9956829, \"amount\": 0.14 }, { \"id\": 9956830, \"amount\": 0.34 }, { \"id\": 9956835, \"amount\": 0.15 }, { \"id\": 9956840, \"amount\": 0.3 }, { \"id\": 9956841, \"amount\": 0.16 }, { \"id\": 9956844, \"amount\": 0.68 }, { \"id\": 9956848, \"amount\": 0.06 }, { \"id\": 9956850, \"amount\": 0.18 }, { \"id\": 9956851, \"amount\": 0.19 }, { \"id\": 9956853, \"amount\": 0.37 }, { \"id\": 9956855, \"amount\": 0.14 }, { \"id\": 9956862, \"amount\": 0.18 }, { \"id\": 9956863, \"amount\": 0.21 }, { \"id\": 9956867, \"amount\": 0.46 }, { \"id\": 9956868, \"amount\": 0.42 }, { \"id\": 9956869, \"amount\": 0.02 }, { \"id\": 9956872, \"amount\": 0.25 }, { \"id\": 9956874, \"amount\": 0.08 }, { \"id\": 9956876, \"amount\": 0.17 }, { \"id\": 9956878, \"amount\": 0.14 }, { \"id\": 9956880, \"amount\": 0.38 }, { \"id\": 9956882, \"amount\": 0.2 }, { \"id\": 9956883, \"amount\": 0.44 }, { \"id\": 9956884, \"amount\": 0.4 }, { \"id\": 9956885, \"amount\": 0.12 }, { \"id\": 9956895, \"amount\": 0.26 }, { \"id\": 9956896, \"amount\": 0.27 }, { \"id\": 9956898, \"amount\": 0.49 }, { \"id\": 9956899, \"amount\": 0.33 }, { \"id\": 9956900, \"amount\": 0.05 }, { \"id\": 9956901, \"amount\": 0.42 }, { \"id\": 9956902, \"amount\": 0.34 }, { \"id\": 9956905, \"amount\": 0.54 }, { \"id\": 9956907, \"amount\": 0.6 }, { \"id\": 9956909, \"amount\": 0.18 }, { \"id\": 9956912, \"amount\": 0.23 }, { \"id\": 9956913, \"amount\": 0.1 }, { \"id\": 9956916, \"amount\": 0.3 }, { \"id\": 9956917, \"amount\": 0.24 }, { \"id\": 9956920, \"amount\": 0.26 }, { \"id\": 9956921, \"amount\": 0.54 }, { \"id\": 9956922, \"amount\": 0.04 }, { \"id\": 9956925, \"amount\": 0.16 }, { \"id\": 9956926, \"amount\": 0.03 }, { \"id\": 9956927, \"amount\": 0.69 }, { \"id\": 9956928, \"amount\": 0.54 }, { \"id\": 9956931, \"amount\": 0.71 }, { \"id\": 9956933, \"amount\": 0.14 }, { \"id\": 9956935, \"amount\": 0.26 }, { \"id\": 9956937, \"amount\": 0.13 }, { \"id\": 9956938, \"amount\": 0.02 }, { \"id\": 9956940, \"amount\": 0.26 }, { \"id\": 9956941, \"amount\": 0.07 }, { \"id\": 9956942, \"amount\": 0.02 }, { \"id\": 9956947, \"amount\": 0.19 }, { \"id\": 9956948, \"amount\": 0.11 }, { \"id\": 9956949, \"amount\": 0.68 }, { \"id\": 9956951, \"amount\": 0.54 }, { \"id\": 9956953, \"amount\": 0.46 }, { \"id\": 9956954, \"amount\": 0.26 }, { \"id\": 9956956, \"amount\": 0.38 }, { \"id\": 9956957, \"amount\": 0.18 }, { \"id\": 9956962, \"amount\": 0.12 }, { \"id\": 9956964, \"amount\": 0.62 }, { \"id\": 9956971, \"amount\": 0.69 }, { \"id\": 9956973, \"amount\": 0.22 }, { \"id\": 9956974, \"amount\": 0.65 }, { \"id\": 9956978, \"amount\": 0.05 }, { \"id\": 9956979, \"amount\": 0.56 }, { \"id\": 9956980, \"amount\": 0.7 }, { \"id\": 9956983, \"amount\": 0.16 }, { \"id\": 9956986, \"amount\": 0.04 }, { \"id\": 9956987, \"amount\": 0.05 }, { \"id\": 9956990, \"amount\": 0.06 }, { \"id\": 9956991, \"amount\": 0.13 }, { \"id\": 9956993, \"amount\": 0.6 }, { \"id\": 9957002, \"amount\": 0.13 }, { \"id\": 9957004, \"amount\": 0.28 }, { \"id\": 9957006, \"amount\": 0.05 }, { \"id\": 9957007, \"amount\": 0.54 }, { \"id\": 9957009, \"amount\": 0.04 }, { \"id\": 9957011, \"amount\": 0.09 }, { \"id\": 9957014, \"amount\": 0.36 }, { \"id\": 9957017, \"amount\": 0.33 }, { \"id\": 9957018, \"amount\": 0.19 }, { \"id\": 9957020, \"amount\": 0.45 }, { \"id\": 9957021, \"amount\": 0.01 }, { \"id\": 9957022, \"amount\": 0.16 }, { \"id\": 9957025, \"amount\": 0.38 }, { \"id\": 9957026, \"amount\": 0.09 }, { \"id\": 9957027, \"amount\": 0.64 }, { \"id\": 9957028, \"amount\": 0.06 }, { \"id\": 9957035, \"amount\": 0.07 }, { \"id\": 9957041, \"amount\": 0.04 }, { \"id\": 9957042, \"amount\": 0.04 }, { \"id\": 9957052, \"amount\": 0.02 }, { \"id\": 9957062, \"amount\": 0.12 }, { \"id\": 9957065, \"amount\": 0.61 }, { \"id\": 9957066, \"amount\": 0.16 }, { \"id\": 9957069, \"amount\": 0.04 }, { \"id\": 9957071, \"amount\": 0.39 }, { \"id\": 9957075, \"amount\": 0.25 }, { \"id\": 9957077, \"amount\": 0.61 }, { \"id\": 9957079, \"amount\": 0.36 }, { \"id\": 9957081, \"amount\": 0.25 }, { \"id\": 9957082, \"amount\": 0.19 }, { \"id\": 9957083, \"amount\": 0.11 }, { \"id\": 9957088, \"amount\": 0.15 }, { \"id\": 9957094, \"amount\": 0.18 }, { \"id\": 9957095, \"amount\": 0.14 }, { \"id\": 9957099, \"amount\": 0.07 }, { \"id\": 9957100, \"amount\": 0.39 }, { \"id\": 9957106, \"amount\": 0.04 }, { \"id\": 9957107, \"amount\": 0.24 }, { \"id\": 9957110, \"amount\": 0.22 }, { \"id\": 9957111, \"amount\": 0.26 }, { \"id\": 9957114, \"amount\": 0.13 }, { \"id\": 9957115, \"amount\": 0.63 }, { \"id\": 9957116, \"amount\": 0.32 }, { \"id\": 9957118, \"amount\": 0.13 }, { \"id\": 9957120, \"amount\": 0.15 }, { \"id\": 9957122, \"amount\": 0.17 }, { \"id\": 9957123, \"amount\": 0.74 }, { \"id\": 9957127, \"amount\": 0.13 }, { \"id\": 9957128, \"amount\": 0.09 }, { \"id\": 9957129, \"amount\": 0.11 }, { \"id\": 9957131, \"amount\": 0.28 }, { \"id\": 9957137, \"amount\": 0.61 }, { \"id\": 9957144, \"amount\": 0.48 }, { \"id\": 9957146, \"amount\": 0.35 }, { \"id\": 9957151, \"amount\": 0.04 }, { \"id\": 9957152, \"amount\": 0.08 }, { \"id\": 9957154, \"amount\": 0.66 }, { \"id\": 9957155, \"amount\": 0.32 }, { \"id\": 9957156, \"amount\": 0.02 }, { \"id\": 9957159, \"amount\": 0.14 }, { \"id\": 9957161, \"amount\": 0.02 }, { \"id\": 9957162, \"amount\": 0.6 }, { \"id\": 9957163, \"amount\": 0.05 }, { \"id\": 9957165, \"amount\": 0.17 }, { \"id\": 9957166, \"amount\": 0.4 }, { \"id\": 9957167, \"amount\": 0.34 }, { \"id\": 9957172, \"amount\": 0.43 }, { \"id\": 9957178, \"amount\": 0.1 }, { \"id\": 9957180, \"amount\": 0.09 }, { \"id\": 9957183, \"amount\": 0.49 }, { \"id\": 9957185, \"amount\": 0.4 }, { \"id\": 9957187, \"amount\": 0.12 }, { \"id\": 9957192, \"amount\": 0.18 }, { \"id\": 9957193, \"amount\": 0.03 }, { \"id\": 9957195, \"amount\": 0.45 }, { \"id\": 9957200, \"amount\": 0.27 }, { \"id\": 9957203, \"amount\": 0.17 }, { \"id\": 9957206, \"amount\": 0.32 }, { \"id\": 9957208, \"amount\": 0.6 }, { \"id\": 9957209, \"amount\": 0.42 }, { \"id\": 9957210, \"amount\": 0.42 }, { \"id\": 9957212, \"amount\": 0.42 }, { \"id\": 9957214, \"amount\": 0.23 }, { \"id\": 9957217, \"amount\": 0.05 }, { \"id\": 9957218, \"amount\": 0.21 }, { \"id\": 9957220, \"amount\": 0.24 }, { \"id\": 9957224, \"amount\": 0.04 }, { \"id\": 9957228, \"amount\": 0.02 }, { \"id\": 9957231, \"amount\": 0.12 }, { \"id\": 9957232, \"amount\": 0.51 }, { \"id\": 9957233, \"amount\": 0.64 }, { \"id\": 9957240, \"amount\": 0.32 }, { \"id\": 9957241, \"amount\": 0.0 }, { \"id\": 9957243, \"amount\": 0.19 }, { \"id\": 9957244, \"amount\": 0.43 }, { \"id\": 9957245, \"amount\": 0.17 }, { \"id\": 9957249, \"amount\": 0.25 }, { \"id\": 9957254, \"amount\": 0.05 }, { \"id\": 9957256, \"amount\": 0.05 }, { \"id\": 9957258, \"amount\": 0.08 }, { \"id\": 9957259, \"amount\": 0.53 }, { \"id\": 9957268, \"amount\": 0.03 }, { \"id\": 9957269, \"amount\": 0.04 }, { \"id\": 9957274, \"amount\": 0.33 }, { \"id\": 9957280, \"amount\": 0.03 }, { \"id\": 9957283, \"amount\": 0.04 }, { \"id\": 9957284, \"amount\": 0.09 }, { \"id\": 9957286, \"amount\": 0.22 }, { \"id\": 9957287, \"amount\": 0.21 }, { \"id\": 9957289, \"amount\": 0.68 }, { \"id\": 9957290, \"amount\": 0.26 }, { \"id\": 9957298, \"amount\": 0.18 }, { \"id\": 9957300, \"amount\": 0.23 }, { \"id\": 9957302, \"amount\": 0.26 }, { \"id\": 9957305, \"amount\": 0.56 }, { \"id\": 9957307, \"amount\": 0.05 }, { \"id\": 9957309, \"amount\": 0.29 }, { \"id\": 9957312, \"amount\": 0.08 }, { \"id\": 9957313, \"amount\": 0.06 }, { \"id\": 9957318, \"amount\": 0.0 }, { \"id\": 9957320, \"amount\": 0.26 }, { \"id\": 9957322, \"amount\": 0.07 }, { \"id\": 9957324, \"amount\": 0.23 }, { \"id\": 9957333, \"amount\": 0.23 }, { \"id\": 9957334, \"amount\": 0.09 }, { \"id\": 9957335, \"amount\": 0.16 }, { \"id\": 9957337, \"amount\": 0.69 }, { \"id\": 9957349, \"amount\": 0.16 }, { \"id\": 9957355, \"amount\": 0.06 }, { \"id\": 9957356, \"amount\": 0.39 }, { \"id\": 9957357, \"amount\": 0.51 }, { \"id\": 9957358, \"amount\": 0.09 }, { \"id\": 9957361, \"amount\": 0.04 }, { \"id\": 9957363, \"amount\": 0.02 }, { \"id\": 9957364, \"amount\": 0.17 }, { \"id\": 9957366, \"amount\": 0.1 }, { \"id\": 9957368, \"amount\": 0.16 }, { \"id\": 9957369, \"amount\": 0.26 }, { \"id\": 9957371, \"amount\": 0.18 }, { \"id\": 9957377, \"amount\": 0.17 }, { \"id\": 9957379, \"amount\": 0.18 }, { \"id\": 9957382, \"amount\": 0.4 }, { \"id\": 9957385, \"amount\": 0.38 }, { \"id\": 9957387, \"amount\": 0.16 }, { \"id\": 9957390, \"amount\": 0.21 }, { \"id\": 9957392, \"amount\": 0.1 }, { \"id\": 9957402, \"amount\": 0.25 }, { \"id\": 9957403, \"amount\": 0.09 }, { \"id\": 9957405, \"amount\": 0.46 }, { \"id\": 9957408, \"amount\": 0.54 }, { \"id\": 9957414, \"amount\": 0.03 }, { \"id\": 9957416, \"amount\": 0.04 }, { \"id\": 9957425, \"amount\": 0.14 }, { \"id\": 9957426, \"amount\": 0.18 }, { \"id\": 9957427, \"amount\": 0.11 }, { \"id\": 9957434, \"amount\": 0.06 }, { \"id\": 9957436, \"amount\": 0.55 }, { \"id\": 9957437, \"amount\": 0.23 }, { \"id\": 9957445, \"amount\": 0.59 }, { \"id\": 9957446, \"amount\": 0.03 }, { \"id\": 9957450, \"amount\": 0.1 }, { \"id\": 9957451, \"amount\": 0.3 }, { \"id\": 9957453, \"amount\": 0.18 }, { \"id\": 9957454, \"amount\": 0.23 }, { \"id\": 9957455, \"amount\": 0.4 }, { \"id\": 9957456, \"amount\": 0.72 }, { \"id\": 9957460, \"amount\": 0.44 }, { \"id\": 9957462, \"amount\": 0.32 }, { \"id\": 9957464, \"amount\": 0.03 }, { \"id\": 9957465, \"amount\": 0.17 }, { \"id\": 9957467, \"amount\": 0.32 }, { \"id\": 9957469, \"amount\": 0.13 }, { \"id\": 9957477, \"amount\": 0.45 }, { \"id\": 9957480, \"amount\": 0.05 }, { \"id\": 9957481, \"amount\": 0.06 }, { \"id\": 9957486, \"amount\": 0.1 }, { \"id\": 9957487, \"amount\": 0.02 }, { \"id\": 9957488, \"amount\": 0.07 }, { \"id\": 9957491, \"amount\": 0.17 }, { \"id\": 9957493, \"amount\": 0.07 }, { \"id\": 9957494, \"amount\": 0.28 }, { \"id\": 9957498, \"amount\": 0.11 }, { \"id\": 9957500, \"amount\": 0.45 }, { \"id\": 9957501, \"amount\": 0.58 }, { \"id\": 9957505, \"amount\": 0.25 }, { \"id\": 9957506, \"amount\": 0.1 }, { \"id\": 9957510, \"amount\": 0.03 }, { \"id\": 9957511, \"amount\": 0.09 }, { \"id\": 9957512, \"amount\": 0.12 }, { \"id\": 9957516, \"amount\": 0.31 }, { \"id\": 9957518, \"amount\": 0.05 }, { \"id\": 9957521, \"amount\": 0.58 }, { \"id\": 9957523, \"amount\": 0.27 }, { \"id\": 9957524, \"amount\": 0.02 }, { \"id\": 9957525, \"amount\": 0.23 }, { \"id\": 9957526, \"amount\": 0.62 }, { \"id\": 9957527, \"amount\": 0.14 }, { \"id\": 9957530, \"amount\": 0.13 }, { \"id\": 9957537, \"amount\": 0.01 }, { \"id\": 9957538, \"amount\": 0.08 }, { \"id\": 9957539, \"amount\": 0.07 }, { \"id\": 9957540, \"amount\": 0.2 }, { \"id\": 9957544, \"amount\": 0.12 }, { \"id\": 9957549, \"amount\": 0.39 }, { \"id\": 9957550, \"amount\": 0.17 }, { \"id\": 9957552, \"amount\": 0.18 }, { \"id\": 9957553, \"amount\": 0.18 }, { \"id\": 9957555, \"amount\": 0.64 }, { \"id\": 9957556, \"amount\": 0.15 }, { \"id\": 9957559, \"amount\": 0.44 }, { \"id\": 9957563, \"amount\": 0.58 }, { \"id\": 9957571, \"amount\": 0.14 }, { \"id\": 9957575, \"amount\": 0.4 }, { \"id\": 9957579, \"amount\": 0.48 }, { \"id\": 9957587, \"amount\": 0.75 }, { \"id\": 9957589, \"amount\": 0.41 }, { \"id\": 9957592, \"amount\": 0.37 }, { \"id\": 9957593, \"amount\": 0.22 }, { \"id\": 9957595, \"amount\": 0.55 }, { \"id\": 9957597, \"amount\": 0.02 }, { \"id\": 9957600, \"amount\": 0.3 }, { \"id\": 9957601, \"amount\": 0.18 }, { \"id\": 9957602, \"amount\": 0.43 }, { \"id\": 9957606, \"amount\": 0.05 }, { \"id\": 9957607, \"amount\": 0.34 }, { \"id\": 9957609, \"amount\": 0.07 }, { \"id\": 9957610, \"amount\": 0.14 }, { \"id\": 9957612, \"amount\": 0.07 }, { \"id\": 9957623, \"amount\": 0.03 }, { \"id\": 9957624, \"amount\": 0.0 }, { \"id\": 9957626, \"amount\": 0.06 }, { \"id\": 9957629, \"amount\": 0.24 }, { \"id\": 9957631, \"amount\": 0.02 }, { \"id\": 9957644, \"amount\": 0.19 }, { \"id\": 9957646, \"amount\": 0.13 }, { \"id\": 9957648, \"amount\": 0.06 }, { \"id\": 9957649, \"amount\": 0.0 }, { \"id\": 9957651, \"amount\": 0.02 }, { \"id\": 9957655, \"amount\": 0.13 }, { \"id\": 9957657, \"amount\": 0.37 }, { \"id\": 9957658, \"amount\": 0.52 }, { \"id\": 9957663, \"amount\": 0.05 }, { \"id\": 9957665, \"amount\": 0.06 }, { \"id\": 9957666, \"amount\": 0.27 }, { \"id\": 9957669, \"amount\": 0.2 }, { \"id\": 9957675, \"amount\": 0.13 }, { \"id\": 9957677, \"amount\": 0.36 }, { \"id\": 9957682, \"amount\": 0.31 }, { \"id\": 9957683, \"amount\": 0.21 }, { \"id\": 9957688, \"amount\": 0.48 }, { \"id\": 9957690, \"amount\": 0.27 }, { \"id\": 9957692, \"amount\": 0.28 }, { \"id\": 9957701, \"amount\": 0.22 }, { \"id\": 9957704, \"amount\": 0.53 }, { \"id\": 9957705, \"amount\": 0.22 }, { \"id\": 9957710, \"amount\": 0.22 }, { \"id\": 9957711, \"amount\": 0.1 }, { \"id\": 9957714, \"amount\": 0.0 }, { \"id\": 9957715, \"amount\": 0.52 }, { \"id\": 9957716, \"amount\": 0.34 }, { \"id\": 9957717, \"amount\": 0.49 }, { \"id\": 9957721, \"amount\": 0.26 }, { \"id\": 9957722, \"amount\": 0.01 }, { \"id\": 9957724, \"amount\": 0.03 }, { \"id\": 9957725, \"amount\": 0.0 }, { \"id\": 9957729, \"amount\": 0.49 }, { \"id\": 9957733, \"amount\": 0.22 }, { \"id\": 9957739, \"amount\": 0.37 }, { \"id\": 9957741, \"amount\": 0.25 }, { \"id\": 9957744, \"amount\": 0.05 }, { \"id\": 9957753, \"amount\": 0.42 }, { \"id\": 9957754, \"amount\": 0.29 }, { \"id\": 9957757, \"amount\": 0.38 }, { \"id\": 9957765, \"amount\": 0.18 }, { \"id\": 9957767, \"amount\": 0.26 }, { \"id\": 9957768, \"amount\": 0.66 }, { \"id\": 9957772, \"amount\": 0.08 }, { \"id\": 9957779, \"amount\": 0.63 }, { \"id\": 9957785, \"amount\": 0.71 }, { \"id\": 9957787, \"amount\": 0.2 }, { \"id\": 9957791, \"amount\": 0.29 }, { \"id\": 9957793, \"amount\": 0.07 }, { \"id\": 9957794, \"amount\": 0.21 }, { \"id\": 9957799, \"amount\": 0.5 }, { \"id\": 9957801, \"amount\": 0.14 }, { \"id\": 9957802, \"amount\": 0.02 }, { \"id\": 9957803, \"amount\": 0.12 }, { \"id\": 9957812, \"amount\": 0.41 }, { \"id\": 9957813, \"amount\": 0.33 }, { \"id\": 9957814, \"amount\": 0.2 }, { \"id\": 9957816, \"amount\": 0.03 }, { \"id\": 9957818, \"amount\": 0.64 }, { \"id\": 9957820, \"amount\": 0.41 }, { \"id\": 9957825, \"amount\": 0.64 }, { \"id\": 9957826, \"amount\": 0.42 }, { \"id\": 9957830, \"amount\": 0.36 }, { \"id\": 9957834, \"amount\": 0.31 }, { \"id\": 9957835, \"amount\": 0.25 }, { \"id\": 9957839, \"amount\": 0.07 }, { \"id\": 9957850, \"amount\": 0.02 }, { \"id\": 9957852, \"amount\": 0.18 }, { \"id\": 9957854, \"amount\": 0.18 }, { \"id\": 9957861, \"amount\": 0.25 }, { \"id\": 9957862, \"amount\": 0.08 }, { \"id\": 9957866, \"amount\": 0.15 }, { \"id\": 9957871, \"amount\": 0.07 }, { \"id\": 9957873, \"amount\": 0.17 }, { \"id\": 9957874, \"amount\": 0.37 }, { \"id\": 9957885, \"amount\": 0.06 }, { \"id\": 9957886, \"amount\": 0.21 }, { \"id\": 9957888, \"amount\": 0.01 }, { \"id\": 9957895, \"amount\": 0.77 }, { \"id\": 9957896, \"amount\": 0.59 }, { \"id\": 9957897, \"amount\": 0.17 }, { \"id\": 9957899, \"amount\": 0.11 }, { \"id\": 9957900, \"amount\": 0.65 }, { \"id\": 9957901, \"amount\": 0.22 }, { \"id\": 9957905, \"amount\": 0.09 }, { \"id\": 9957907, \"amount\": 0.37 }, { \"id\": 9957910, \"amount\": 0.07 }, { \"id\": 9957912, \"amount\": 0.2 }, { \"id\": 9957914, \"amount\": 0.26 }, { \"id\": 9957917, \"amount\": 0.53 }, { \"id\": 9957919, \"amount\": 0.29 }, { \"id\": 9957920, \"amount\": 0.09 }, { \"id\": 9957925, \"amount\": 0.13 }, { \"id\": 9957929, \"amount\": 0.24 }, { \"id\": 9957932, \"amount\": 0.11 }, { \"id\": 9957937, \"amount\": 0.6 }, { \"id\": 9957943, \"amount\": 0.07 }, { \"id\": 9957947, \"amount\": 0.2 }, { \"id\": 9957950, \"amount\": 0.29 }, { \"id\": 9957957, \"amount\": 0.07 }, { \"id\": 9957959, \"amount\": 0.27 }, { \"id\": 9957960, \"amount\": 0.09 }, { \"id\": 9957965, \"amount\": 0.04 }, { \"id\": 9957969, \"amount\": 0.09 }, { \"id\": 9957970, \"amount\": 0.58 }, { \"id\": 9957971, \"amount\": 0.16 }, { \"id\": 9957977, \"amount\": 0.04 }, { \"id\": 9957980, \"amount\": 0.11 }, { \"id\": 9957983, \"amount\": 0.14 }, { \"id\": 9957985, \"amount\": 0.55 }, { \"id\": 9957986, \"amount\": 0.36 }, { \"id\": 9957988, \"amount\": 0.18 }, { \"id\": 9957994, \"amount\": 0.15 }, { \"id\": 9957995, \"amount\": 0.36 }, { \"id\": 9957996, \"amount\": 0.15 }, { \"id\": 9957997, \"amount\": 0.02 }, { \"id\": 9957998, \"amount\": 0.08 }, { \"id\": 9958002, \"amount\": 0.15 }, { \"id\": 9958006, \"amount\": 0.11 }, { \"id\": 9958012, \"amount\": 0.1 }, { \"id\": 9958015, \"amount\": 0.09 }, { \"id\": 9958016, \"amount\": 0.34 }, { \"id\": 9958019, \"amount\": 0.35 }, { \"id\": 9958020, \"amount\": 0.2 }, { \"id\": 9958024, \"amount\": 0.31 }, { \"id\": 9958031, \"amount\": 0.13 }, { \"id\": 9958032, \"amount\": 0.3 }, { \"id\": 9958035, \"amount\": 0.36 }, { \"id\": 9958037, \"amount\": 0.1 }, { \"id\": 9958038, \"amount\": 0.47 }, { \"id\": 9958046, \"amount\": 0.14 }, { \"id\": 9958047, \"amount\": 0.05 }, { \"id\": 9958051, \"amount\": 0.1 }, { \"id\": 9958056, \"amount\": 0.12 }, { \"id\": 9958060, \"amount\": 0.06 }, { \"id\": 9958061, \"amount\": 0.37 }, { \"id\": 9958064, \"amount\": 0.27 }, { \"id\": 9958066, \"amount\": 0.64 }, { \"id\": 9958067, \"amount\": 0.29 }, { \"id\": 9958076, \"amount\": 0.08 }, { \"id\": 9958081, \"amount\": 0.4 }, { \"id\": 9958087, \"amount\": 0.02 }, { \"id\": 9958092, \"amount\": 0.43 }, { \"id\": 9958097, \"amount\": 0.24 }, { \"id\": 9958099, \"amount\": 0.1 }, { \"id\": 9958102, \"amount\": 0.06 }, { \"id\": 9958105, \"amount\": 0.04 }, { \"id\": 9958106, \"amount\": 0.18 }, { \"id\": 9958107, \"amount\": 0.37 }, { \"id\": 9958109, \"amount\": 0.03 }, { \"id\": 9958110, \"amount\": 0.08 }, { \"id\": 9958111, \"amount\": 0.41 }, { \"id\": 9958115, \"amount\": 0.54 }, { \"id\": 9958116, \"amount\": 0.38 }, { \"id\": 9958130, \"amount\": 0.47 }, { \"id\": 9958136, \"amount\": 0.1 }, { \"id\": 9958139, \"amount\": 0.54 }, { \"id\": 9958142, \"amount\": 0.58 }, { \"id\": 9958149, \"amount\": 0.06 }, { \"id\": 9958150, \"amount\": 0.57 }, { \"id\": 9958154, \"amount\": 0.28 }, { \"id\": 9958156, \"amount\": 0.11 }, { \"id\": 9958157, \"amount\": 0.3 }, { \"id\": 9958158, \"amount\": 0.08 }, { \"id\": 9958159, \"amount\": 0.05 }, { \"id\": 9958160, \"amount\": 0.23 }, { \"id\": 9958166, \"amount\": 0.08 }, { \"id\": 9958169, \"amount\": 0.14 }, { \"id\": 9958176, \"amount\": 0.21 }, { \"id\": 9958180, \"amount\": 0.25 }, { \"id\": 9958182, \"amount\": 0.1 }, { \"id\": 9958184, \"amount\": 0.13 }, { \"id\": 9958192, \"amount\": 0.19 }, { \"id\": 9958193, \"amount\": 0.0 }, { \"id\": 9958194, \"amount\": 0.49 }, { \"id\": 9958198, \"amount\": 0.35 }, { \"id\": 9958201, \"amount\": 0.68 }, { \"id\": 9958202, \"amount\": 0.21 }, { \"id\": 9958214, \"amount\": 0.28 }, { \"id\": 9958218, \"amount\": 0.02 }, { \"id\": 9958220, \"amount\": 0.63 }, { \"id\": 9958221, \"amount\": 0.06 }, { \"id\": 9958222, \"amount\": 0.25 }, { \"id\": 9958223, \"amount\": 0.13 }, { \"id\": 9958224, \"amount\": 0.17 }, { \"id\": 9958228, \"amount\": 0.33 }, { \"id\": 9958235, \"amount\": 0.6 }, { \"id\": 9958236, \"amount\": 0.39 }, { \"id\": 9958237, \"amount\": 0.4 }, { \"id\": 9958239, \"amount\": 0.24 }, { \"id\": 9958241, \"amount\": 0.86 }, { \"id\": 9958243, \"amount\": 0.21 }, { \"id\": 9958244, \"amount\": 0.27 }, { \"id\": 9958246, \"amount\": 0.18 }, { \"id\": 9958251, \"amount\": 0.04 }, { \"id\": 9958255, \"amount\": 0.42 }, { \"id\": 9958256, \"amount\": 0.03 }, { \"id\": 9958257, \"amount\": 0.02 }, { \"id\": 9958260, \"amount\": 0.23 }, { \"id\": 9958261, \"amount\": 0.05 }, { \"id\": 9958268, \"amount\": 0.24 }, { \"id\": 9958270, \"amount\": 0.2 }, { \"id\": 9958274, \"amount\": 0.62 }, { \"id\": 9958275, \"amount\": 0.4 }, { \"id\": 9958282, \"amount\": 0.37 }, { \"id\": 9958283, \"amount\": 0.39 }, { \"id\": 9958285, \"amount\": 0.45 }, { \"id\": 9958286, \"amount\": 0.14 }, { \"id\": 9958297, \"amount\": 0.26 }, { \"id\": 9958299, \"amount\": 0.16 }, { \"id\": 9958301, \"amount\": 0.64 }, { \"id\": 9958306, \"amount\": 0.01 }, { \"id\": 9958309, \"amount\": 0.04 }, { \"id\": 9958310, \"amount\": 0.45 }, { \"id\": 9958311, \"amount\": 0.3 }, { \"id\": 9958312, \"amount\": 0.06 }, { \"id\": 9958315, \"amount\": 0.09 }, { \"id\": 9958317, \"amount\": 0.11 }, { \"id\": 9958318, \"amount\": 0.17 }, { \"id\": 9958322, \"amount\": 0.71 }, { \"id\": 9958324, \"amount\": 0.01 }, { \"id\": 9958327, \"amount\": 0.05 }, { \"id\": 9958329, \"amount\": 0.65 }, { \"id\": 9958330, \"amount\": 0.56 }, { \"id\": 9958332, \"amount\": 0.13 }, { \"id\": 9958333, \"amount\": 0.19 }, { \"id\": 9958334, \"amount\": 0.38 }, { \"id\": 9958335, \"amount\": 0.44 }, { \"id\": 9958338, \"amount\": 0.43 }, { \"id\": 9958339, \"amount\": 0.1 }, { \"id\": 9958346, \"amount\": 0.3 }, { \"id\": 9958347, \"amount\": 0.47 }, { \"id\": 9958350, \"amount\": 0.42 }, { \"id\": 9958353, \"amount\": 0.39 }, { \"id\": 9958358, \"amount\": 0.3 }, { \"id\": 9958359, \"amount\": 0.22 }, { \"id\": 9958364, \"amount\": 0.43 }, { \"id\": 9958365, \"amount\": 0.13 }, { \"id\": 9958367, \"amount\": 0.22 }, { \"id\": 9958371, \"amount\": 0.21 }, { \"id\": 9958376, \"amount\": 0.13 }, { \"id\": 9958377, \"amount\": 0.01 }, { \"id\": 9958379, \"amount\": 0.11 }, { \"id\": 9958385, \"amount\": 0.33 }, { \"id\": 9958386, \"amount\": 0.09 }, { \"id\": 9958387, \"amount\": 0.06 }, { \"id\": 9958388, \"amount\": 0.14 }, { \"id\": 9958393, \"amount\": 0.11 }, { \"id\": 9958394, \"amount\": 0.15 }, { \"id\": 9958395, \"amount\": 0.16 }, { \"id\": 9958396, \"amount\": 0.15 }, { \"id\": 9958397, \"amount\": 0.45 }, { \"id\": 9958400, \"amount\": 0.06 }, { \"id\": 9958403, \"amount\": 0.5 }, { \"id\": 9958404, \"amount\": 0.11 }, { \"id\": 9958411, \"amount\": 0.18 }, { \"id\": 9958412, \"amount\": 0.44 }, { \"id\": 9958413, \"amount\": 0.05 }, { \"id\": 9958414, \"amount\": 0.03 }, { \"id\": 9958422, \"amount\": 0.29 }, { \"id\": 9958426, \"amount\": 0.34 }, { \"id\": 9958427, \"amount\": 0.07 }, { \"id\": 9958437, \"amount\": 0.45 }, { \"id\": 9958438, \"amount\": 0.39 }, { \"id\": 9958445, \"amount\": 0.21 }, { \"id\": 9958447, \"amount\": 0.01 }, { \"id\": 9958449, \"amount\": 0.61 }, { \"id\": 9958450, \"amount\": 0.28 }, { \"id\": 9958451, \"amount\": 0.34 }, { \"id\": 9958459, \"amount\": 0.18 }, { \"id\": 9958460, \"amount\": 0.12 }, { \"id\": 9958463, \"amount\": 0.31 }, { \"id\": 9958469, \"amount\": 0.14 }, { \"id\": 9958475, \"amount\": 0.13 }, { \"id\": 9958478, \"amount\": 0.27 }, { \"id\": 9958479, \"amount\": 0.13 }, { \"id\": 9958481, \"amount\": 0.36 }, { \"id\": 9958484, \"amount\": 0.07 }, { \"id\": 9958488, \"amount\": 0.19 }, { \"id\": 9958489, \"amount\": 0.48 }, { \"id\": 9958490, \"amount\": 0.65 }, { \"id\": 9958492, \"amount\": 0.52 }, { \"id\": 9958499, \"amount\": 0.0 }, { \"id\": 9958503, \"amount\": 0.08 }, { \"id\": 9958506, \"amount\": 0.07 }, { \"id\": 9958507, \"amount\": 0.09 }, { \"id\": 9958515, \"amount\": 0.36 }, { \"id\": 9958516, \"amount\": 0.04 }, { \"id\": 9958532, \"amount\": 0.04 }, { \"id\": 9958533, \"amount\": 0.32 }, { \"id\": 9958535, \"amount\": 0.37 }, { \"id\": 9958537, \"amount\": 0.25 }, { \"id\": 9958538, \"amount\": 0.62 }, { \"id\": 9958540, \"amount\": 0.26 }, { \"id\": 9958549, \"amount\": 0.14 }, { \"id\": 9958550, \"amount\": 0.09 }, { \"id\": 9958556, \"amount\": 0.06 }, { \"id\": 9958557, \"amount\": 0.37 }, { \"id\": 9958560, \"amount\": 0.19 }, { \"id\": 9958563, \"amount\": 0.1 }, { \"id\": 9958567, \"amount\": 0.37 }, { \"id\": 9958571, \"amount\": 0.38 }, { \"id\": 9958572, \"amount\": 0.56 }, { \"id\": 9958574, \"amount\": 0.1 }, { \"id\": 9958577, \"amount\": 0.15 }, { \"id\": 9958578, \"amount\": 0.57 }, { \"id\": 9958581, \"amount\": 0.16 }, { \"id\": 9958582, \"amount\": 0.06 }, { \"id\": 9958587, \"amount\": 0.07 }, { \"id\": 9958589, \"amount\": 0.28 }, { \"id\": 9958592, \"amount\": 0.34 }, { \"id\": 9958596, \"amount\": 0.08 }, { \"id\": 9958599, \"amount\": 0.09 }, { \"id\": 9958600, \"amount\": 0.02 }, { \"id\": 9958601, \"amount\": 0.14 }, { \"id\": 9958602, \"amount\": 0.47 }, { \"id\": 9958607, \"amount\": 0.24 }, { \"id\": 9958608, \"amount\": 0.23 }, { \"id\": 9958610, \"amount\": 0.46 }, { \"id\": 9958612, \"amount\": 0.39 }, { \"id\": 9958615, \"amount\": 0.04 }, { \"id\": 9958619, \"amount\": 0.08 }, { \"id\": 9958621, \"amount\": 0.04 }, { \"id\": 9958630, \"amount\": 0.64 }, { \"id\": 9958633, \"amount\": 0.0 }, { \"id\": 9958634, \"amount\": 0.38 }, { \"id\": 9958636, \"amount\": 0.55 }, { \"id\": 9958637, \"amount\": 0.05 }, { \"id\": 9958640, \"amount\": 0.5 }, { \"id\": 9958643, \"amount\": 0.07 }, { \"id\": 9958646, \"amount\": 0.04 }, { \"id\": 9958648, \"amount\": 0.35 }, { \"id\": 9958652, \"amount\": 0.21 }, { \"id\": 9958653, \"amount\": 0.62 }, { \"id\": 9958655, \"amount\": 0.06 }, { \"id\": 9958658, \"amount\": 0.22 }, { \"id\": 9958661, \"amount\": 0.02 }, { \"id\": 9958664, \"amount\": 0.05 }, { \"id\": 9958666, \"amount\": 0.25 }, { \"id\": 9958669, \"amount\": 0.28 }, { \"id\": 9958679, \"amount\": 0.68 }, { \"id\": 9958681, \"amount\": 0.4 }, { \"id\": 9958683, \"amount\": 0.04 }, { \"id\": 9958687, \"amount\": 0.42 }, { \"id\": 9958693, \"amount\": 0.72 }, { \"id\": 9958696, \"amount\": 0.85 }, { \"id\": 9958698, \"amount\": 0.12 }, { \"id\": 9958699, \"amount\": 0.55 }, { \"id\": 9958701, \"amount\": 0.05 }, { \"id\": 9958708, \"amount\": 0.52 }, { \"id\": 9958709, \"amount\": 0.24 }, { \"id\": 9958713, \"amount\": 0.08 }, { \"id\": 9958715, \"amount\": 0.1 }, { \"id\": 9958716, \"amount\": 0.24 }, { \"id\": 9958720, \"amount\": 0.39 }, { \"id\": 9958722, \"amount\": 0.09 }, { \"id\": 9958724, \"amount\": 0.09 }, { \"id\": 9958726, \"amount\": 0.14 }, { \"id\": 9958727, \"amount\": 0.04 }, { \"id\": 9958729, \"amount\": 0.2 }, { \"id\": 9958731, \"amount\": 0.09 }, { \"id\": 9958732, \"amount\": 0.01 }, { \"id\": 9958733, \"amount\": 0.07 }, { \"id\": 9958735, \"amount\": 0.15 }, { \"id\": 9958743, \"amount\": 0.03 }, { \"id\": 9958744, \"amount\": 0.44 }, { \"id\": 9958748, \"amount\": 0.13 }, { \"id\": 9958751, \"amount\": 0.31 }, { \"id\": 9958756, \"amount\": 0.63 }, { \"id\": 9958762, \"amount\": 0.85 }, { \"id\": 9958763, \"amount\": 0.01 }, { \"id\": 9958770, \"amount\": 0.2 }, { \"id\": 9958773, \"amount\": 0.28 }, { \"id\": 9958774, \"amount\": 0.36 }, { \"id\": 9958775, \"amount\": 0.59 }, { \"id\": 9958776, \"amount\": 0.31 }, { \"id\": 9958779, \"amount\": 0.65 }, { \"id\": 9958780, \"amount\": 0.49 }, { \"id\": 9958782, \"amount\": 0.01 }, { \"id\": 9958783, \"amount\": 0.38 }, { \"id\": 9958784, \"amount\": 0.13 }, { \"id\": 9958790, \"amount\": 0.23 }, { \"id\": 9958791, \"amount\": 0.28 }, { \"id\": 9958800, \"amount\": 0.32 }, { \"id\": 9958803, \"amount\": 0.13 }, { \"id\": 9958804, \"amount\": 0.07 }, { \"id\": 9958810, \"amount\": 0.05 }, { \"id\": 9958812, \"amount\": 0.33 }, { \"id\": 9958815, \"amount\": 0.46 }, { \"id\": 9958818, \"amount\": 0.26 }, { \"id\": 9958820, \"amount\": 0.49 }, { \"id\": 9958822, \"amount\": 0.19 }, { \"id\": 9958828, \"amount\": 0.39 }, { \"id\": 9958829, \"amount\": 0.1 }, { \"id\": 9958836, \"amount\": 0.24 }, { \"id\": 9958837, \"amount\": 0.02 }, { \"id\": 9958839, \"amount\": 0.25 }, { \"id\": 9958840, \"amount\": 0.36 }, { \"id\": 9958846, \"amount\": 0.2 }, { \"id\": 9958848, \"amount\": 0.28 }, { \"id\": 9958849, \"amount\": 0.0 }, { \"id\": 9958851, \"amount\": 0.43 }, { \"id\": 9958852, \"amount\": 0.25 }, { \"id\": 9958853, \"amount\": 0.04 }, { \"id\": 9958856, \"amount\": 0.25 }, { \"id\": 9958859, \"amount\": 0.04 }, { \"id\": 9958863, \"amount\": 0.52 }, { \"id\": 9958865, \"amount\": 0.14 }, { \"id\": 9958866, \"amount\": 0.05 }, { \"id\": 9958871, \"amount\": 0.37 }, { \"id\": 9958874, \"amount\": 0.52 }, { \"id\": 9958875, \"amount\": 0.44 }, { \"id\": 9958877, \"amount\": 0.65 }, { \"id\": 9958879, \"amount\": 0.02 }, { \"id\": 9958882, \"amount\": 0.71 }, { \"id\": 9958883, \"amount\": 0.11 }, { \"id\": 9958887, \"amount\": 0.01 }, { \"id\": 9958893, \"amount\": 0.33 }, { \"id\": 9958898, \"amount\": 0.14 }, { \"id\": 9958900, \"amount\": 0.08 }, { \"id\": 9958902, \"amount\": 0.2 }, { \"id\": 9958910, \"amount\": 0.52 }, { \"id\": 9958916, \"amount\": 0.03 }, { \"id\": 9958923, \"amount\": 0.32 }, { \"id\": 9958924, \"amount\": 0.66 }, { \"id\": 9958927, \"amount\": 0.3 }, { \"id\": 9958931, \"amount\": 0.46 }, { \"id\": 9958932, \"amount\": 0.01 }, { \"id\": 9958937, \"amount\": 0.41 }, { \"id\": 9958938, \"amount\": 0.07 }, { \"id\": 9958940, \"amount\": 0.23 }, { \"id\": 9958944, \"amount\": 0.15 }, { \"id\": 9958945, \"amount\": 0.39 }, { \"id\": 9958949, \"amount\": 0.09 }, { \"id\": 9958950, \"amount\": 0.06 }, { \"id\": 9958952, \"amount\": 0.08 }, { \"id\": 9958968, \"amount\": 0.36 }, { \"id\": 9958971, \"amount\": 0.23 }, { \"id\": 9958972, \"amount\": 0.16 }, { \"id\": 9958984, \"amount\": 0.16 }, { \"id\": 9958987, \"amount\": 0.05 }, { \"id\": 9958995, \"amount\": 0.04 }, { \"id\": 9958997, \"amount\": 0.01 }, { \"id\": 9959001, \"amount\": 0.15 }, { \"id\": 9959003, \"amount\": 0.41 }, { \"id\": 9959008, \"amount\": 0.21 }, { \"id\": 9959011, \"amount\": 0.01 }, { \"id\": 9959016, \"amount\": 0.49 }, { \"id\": 9959019, \"amount\": 0.49 }, { \"id\": 9959022, \"amount\": 0.09 }, { \"id\": 9959024, \"amount\": 0.25 }, { \"id\": 9959025, \"amount\": 0.14 }, { \"id\": 9959031, \"amount\": 0.33 }, { \"id\": 9959033, \"amount\": 0.35 }, { \"id\": 9959034, \"amount\": 0.06 }, { \"id\": 9959035, \"amount\": 0.05 }, { \"id\": 9959036, \"amount\": 0.06 }, { \"id\": 9959038, \"amount\": 0.49 }, { \"id\": 9959042, \"amount\": 0.37 }, { \"id\": 9959043, \"amount\": 0.07 }, { \"id\": 9959044, \"amount\": 0.1 }, { \"id\": 9959045, \"amount\": 0.62 }, { \"id\": 9959048, \"amount\": 0.65 }, { \"id\": 9959053, \"amount\": 0.55 }, { \"id\": 9959061, \"amount\": 0.14 }, { \"id\": 9959063, \"amount\": 0.96 }, { \"id\": 9959064, \"amount\": 0.07 }, { \"id\": 9959070, \"amount\": 0.19 }, { \"id\": 9959071, \"amount\": 0.23 }, { \"id\": 9959072, \"amount\": 0.36 }, { \"id\": 9959073, \"amount\": 0.32 }, { \"id\": 9959075, \"amount\": 0.56 }, { \"id\": 9959080, \"amount\": 0.32 }, { \"id\": 9959085, \"amount\": 0.19 }, { \"id\": 9959086, \"amount\": 0.05 }, { \"id\": 9959088, \"amount\": 0.11 }, { \"id\": 9959091, \"amount\": 0.13 }, { \"id\": 9959094, \"amount\": 0.41 }, { \"id\": 9959095, \"amount\": 0.53 }, { \"id\": 9959098, \"amount\": 0.56 }, { \"id\": 9959100, \"amount\": 0.26 }, { \"id\": 9959103, \"amount\": 0.3 }, { \"id\": 9959104, \"amount\": 0.04 }, { \"id\": 9959105, \"amount\": 0.3 }, { \"id\": 9959106, \"amount\": 0.08 }, { \"id\": 9959113, \"amount\": 0.15 }, { \"id\": 9959115, \"amount\": 0.37 }, { \"id\": 9959122, \"amount\": 0.05 }, { \"id\": 9959124, \"amount\": 0.44 }, { \"id\": 9959125, \"amount\": 0.05 }, { \"id\": 9959126, \"amount\": 0.25 }, { \"id\": 9959134, \"amount\": 0.16 }, { \"id\": 9959137, \"amount\": 0.05 }, { \"id\": 9959139, \"amount\": 0.14 }, { \"id\": 9959140, \"amount\": 0.21 }, { \"id\": 9959141, \"amount\": 0.07 }, { \"id\": 9959152, \"amount\": 0.11 }, { \"id\": 9959153, \"amount\": 0.21 }, { \"id\": 9959155, \"amount\": 0.07 }, { \"id\": 9959159, \"amount\": 0.01 }, { \"id\": 9959160, \"amount\": 0.06 }, { \"id\": 9959162, \"amount\": 0.08 }, { \"id\": 9959166, \"amount\": 0.07 }, { \"id\": 9959171, \"amount\": 0.31 }, { \"id\": 9959178, \"amount\": 0.02 }, { \"id\": 9959185, \"amount\": 0.18 }, { \"id\": 9959186, \"amount\": 0.02 }, { \"id\": 9959188, \"amount\": 0.53 }, { \"id\": 9959193, \"amount\": 0.27 }, { \"id\": 9959195, \"amount\": 0.05 }, { \"id\": 9959441, \"amount\": 0.14 }, { \"id\": 9959443, \"amount\": 0.26 }, { \"id\": 9959445, \"amount\": 0.56 }, { \"id\": 9959447, \"amount\": 0.24 }, { \"id\": 9959492, \"amount\": 0.8 }, { \"id\": 9959495, \"amount\": 0.24 }, { \"id\": 9959504, \"amount\": 0.61 }, { \"id\": 9959508, \"amount\": 0.71 }, { \"id\": 9959515, \"amount\": 0.07 }, { \"id\": 9959520, \"amount\": 0.29 }, { \"id\": 9959551, \"amount\": 0.13 }, { \"id\": 9959566, \"amount\": 0.15 }, { \"id\": 9959575, \"amount\": 0.04 }, { \"id\": 9959581, \"amount\": 0.39 }, { \"id\": 9959590, \"amount\": 0.14 }, { \"id\": 9959592, \"amount\": 0.01 }, { \"id\": 9959593, \"amount\": 0.54 }, { \"id\": 9959609, \"amount\": 0.02 }, { \"id\": 9959611, \"amount\": 0.08 }, { \"id\": 9959624, \"amount\": 0.32 }, { \"id\": 9959626, \"amount\": 0.67 }, { \"id\": 9959630, \"amount\": 0.14 }, { \"id\": 9959633, \"amount\": 0.15 }, { \"id\": 9959640, \"amount\": 0.19 }, { \"id\": 9959644, \"amount\": 0.02 }, { \"id\": 9959652, \"amount\": 0.1 }, { \"id\": 9959659, \"amount\": 0.2 }, { \"id\": 9959666, \"amount\": 0.18 }, { \"id\": 9959670, \"amount\": 0.48 }, { \"id\": 9959672, \"amount\": 0.14 }, { \"id\": 9959687, \"amount\": 0.58 }, { \"id\": 9959690, \"amount\": 0.05 }, { \"id\": 9959693, \"amount\": 0.32 }, { \"id\": 9959706, \"amount\": 0.2 }, { \"id\": 9959710, \"amount\": 0.23 }, { \"id\": 9959714, \"amount\": 0.03 }, { \"id\": 9959718, \"amount\": 0.24 }, { \"id\": 9959719, \"amount\": 0.32 }, { \"id\": 9959726, \"amount\": 0.05 }, { \"id\": 9959727, \"amount\": 0.26 }, { \"id\": 9959732, \"amount\": 0.11 }, { \"id\": 9959735, \"amount\": 0.15 }, { \"id\": 9959738, \"amount\": 0.03 }, { \"id\": 9959740, \"amount\": 0.36 }, { \"id\": 9959742, \"amount\": 0.23 }, { \"id\": 9959746, \"amount\": 0.24 }, { \"id\": 9959747, \"amount\": 0.04 }, { \"id\": 9959749, \"amount\": 0.24 }, { \"id\": 9959757, \"amount\": 0.32 }, { \"id\": 9959758, \"amount\": 0.42 }, { \"id\": 9959761, \"amount\": 0.61 }, { \"id\": 9959763, \"amount\": 0.07 }, { \"id\": 9959770, \"amount\": 0.04 }, { \"id\": 9959772, \"amount\": 0.09 }, { \"id\": 9959773, \"amount\": 0.3 }, { \"id\": 9959775, \"amount\": 0.05 }, { \"id\": 9959776, \"amount\": 0.69 }, { \"id\": 9959778, \"amount\": 0.13 }, { \"id\": 9959781, \"amount\": 0.05 }, { \"id\": 9959783, \"amount\": 0.06 }, { \"id\": 9959795, \"amount\": 0.13 }, { \"id\": 9959796, \"amount\": 0.27 }, { \"id\": 9959801, \"amount\": 0.17 }, { \"id\": 9959813, \"amount\": 0.1 }, { \"id\": 9959815, \"amount\": 0.08 }, { \"id\": 9959817, \"amount\": 0.6 }, { \"id\": 9959819, \"amount\": 0.27 }, { \"id\": 9959820, \"amount\": 0.18 }, { \"id\": 9959825, \"amount\": 0.49 }, { \"id\": 9959829, \"amount\": 0.25 }, { \"id\": 9959835, \"amount\": 0.1 }, { \"id\": 9959844, \"amount\": 0.3 }, { \"id\": 9959857, \"amount\": 0.72 }, { \"id\": 9959861, \"amount\": 0.56 }, { \"id\": 9959863, \"amount\": 0.15 }, { \"id\": 9959868, \"amount\": 0.07 }, { \"id\": 9959869, \"amount\": 0.06 }, { \"id\": 9959871, \"amount\": 0.13 }, { \"id\": 9959875, \"amount\": 0.37 }, { \"id\": 9959880, \"amount\": 0.09 }, { \"id\": 9959882, \"amount\": 0.14 }, { \"id\": 9959890, \"amount\": 0.03 }, { \"id\": 9959891, \"amount\": 0.23 }, { \"id\": 9959893, \"amount\": 0.07 }, { \"id\": 9959898, \"amount\": 0.18 }, { \"id\": 9959899, \"amount\": 0.06 }, { \"id\": 9959901, \"amount\": 0.09 }, { \"id\": 9959907, \"amount\": 0.37 }, { \"id\": 9959911, \"amount\": 0.11 }, { \"id\": 9959912, \"amount\": 0.26 }, { \"id\": 9959917, \"amount\": 0.36 }, { \"id\": 9959921, \"amount\": 0.34 }, { \"id\": 9959922, \"amount\": 0.09 }, { \"id\": 9959927, \"amount\": 0.13 }, { \"id\": 9959933, \"amount\": 0.36 }, { \"id\": 9959938, \"amount\": 0.27 }, { \"id\": 9959941, \"amount\": 0.28 }, { \"id\": 9959942, \"amount\": 0.47 }, { \"id\": 9959946, \"amount\": 0.28 }, { \"id\": 9959948, \"amount\": 0.03 }, { \"id\": 9959950, \"amount\": 0.05 }, { \"id\": 9959952, \"amount\": 0.22 }, { \"id\": 9959953, \"amount\": 0.17 }, { \"id\": 9959956, \"amount\": 0.05 }, { \"id\": 9959957, \"amount\": 0.06 }, { \"id\": 9959960, \"amount\": 0.07 }, { \"id\": 9959962, \"amount\": 0.13 }, { \"id\": 9959963, \"amount\": 0.34 }, { \"id\": 9959965, \"amount\": 0.25 }, { \"id\": 9959967, \"amount\": 0.3 }, { \"id\": 9959968, \"amount\": 0.05 }, { \"id\": 9959969, \"amount\": 0.06 }, { \"id\": 9959972, \"amount\": 0.18 }, { \"id\": 9959975, \"amount\": 0.06 }, { \"id\": 9959983, \"amount\": 0.36 }, { \"id\": 9959984, \"amount\": 0.03 }, { \"id\": 9959987, \"amount\": 0.25 }, { \"id\": 9959993, \"amount\": 0.51 }, { \"id\": 9959995, \"amount\": 0.65 }, { \"id\": 9959997, \"amount\": 0.11 }, { \"id\": 9959999, \"amount\": 0.24 }, { \"id\": 9960001, \"amount\": 0.55 }, { \"id\": 9960002, \"amount\": 0.04 }, { \"id\": 9960006, \"amount\": 0.08 }, { \"id\": 9960007, \"amount\": 0.4 }, { \"id\": 9960011, \"amount\": 0.04 }, { \"id\": 9960013, \"amount\": 0.06 }, { \"id\": 9960014, \"amount\": 0.16 }, { \"id\": 9960016, \"amount\": 0.01 }, { \"id\": 9960017, \"amount\": 0.15 }, { \"id\": 9960020, \"amount\": 0.18 }, { \"id\": 9960021, \"amount\": 0.08 }, { \"id\": 9960023, \"amount\": 0.41 }, { \"id\": 9960027, \"amount\": 0.09 }, { \"id\": 9960031, \"amount\": 0.63 }, { \"id\": 9960032, \"amount\": 0.14 }, { \"id\": 9960034, \"amount\": 0.11 }, { \"id\": 9960038, \"amount\": 0.04 }, { \"id\": 9960040, \"amount\": 0.03 }, { \"id\": 9960041, \"amount\": 0.21 }, { \"id\": 9960045, \"amount\": 0.28 }, { \"id\": 9960050, \"amount\": 0.08 }, { \"id\": 9960051, \"amount\": 0.02 }, { \"id\": 9960058, \"amount\": 0.1 }, { \"id\": 9960066, \"amount\": 0.04 }, { \"id\": 9960072, \"amount\": 0.65 }, { \"id\": 9960075, \"amount\": 0.38 }, { \"id\": 9960077, \"amount\": 0.54 }, { \"id\": 9960078, \"amount\": 0.11 }, { \"id\": 9960080, \"amount\": 0.04 }, { \"id\": 9960083, \"amount\": 0.37 }, { \"id\": 9960089, \"amount\": 0.14 }, { \"id\": 9960090, \"amount\": 0.1 }, { \"id\": 9960093, \"amount\": 0.12 }, { \"id\": 9960096, \"amount\": 0.31 }, { \"id\": 9960098, \"amount\": 0.36 }, { \"id\": 9960099, \"amount\": 0.31 }, { \"id\": 9960101, \"amount\": 0.03 }, { \"id\": 9960107, \"amount\": 0.09 }, { \"id\": 9960110, \"amount\": 0.06 }, { \"id\": 9960112, \"amount\": 0.09 }, { \"id\": 9960113, \"amount\": 0.45 }, { \"id\": 9960114, \"amount\": 0.4 }, { \"id\": 9960116, \"amount\": 0.08 }, { \"id\": 9960117, \"amount\": 0.22 }, { \"id\": 9960125, \"amount\": 0.19 }, { \"id\": 9960128, \"amount\": 0.14 }, { \"id\": 9960135, \"amount\": 0.21 }, { \"id\": 9960136, \"amount\": 0.33 }, { \"id\": 9960138, \"amount\": 0.32 }, { \"id\": 9960141, \"amount\": 0.01 }, { \"id\": 9960144, \"amount\": 0.16 }, { \"id\": 9960145, \"amount\": 0.01 }, { \"id\": 9960147, \"amount\": 0.25 }, { \"id\": 9960148, \"amount\": 0.34 }, { \"id\": 9960151, \"amount\": 0.21 }, { \"id\": 9960152, \"amount\": 0.31 }, { \"id\": 9960156, \"amount\": 0.36 }, { \"id\": 9960158, \"amount\": 0.09 }, { \"id\": 9960160, \"amount\": 0.26 }, { \"id\": 9960171, \"amount\": 0.18 }, { \"id\": 9960172, \"amount\": 0.12 }, { \"id\": 9960173, \"amount\": 0.25 }, { \"id\": 9960182, \"amount\": 0.38 }, { \"id\": 9960185, \"amount\": 0.05 }, { \"id\": 9960187, \"amount\": 0.29 }, { \"id\": 9960192, \"amount\": 0.18 }, { \"id\": 9960195, \"amount\": 0.61 }, { \"id\": 9960196, \"amount\": 0.64 }, { \"id\": 9960200, \"amount\": 0.14 }, { \"id\": 9960205, \"amount\": 0.34 }, { \"id\": 9960207, \"amount\": 0.1 }, { \"id\": 9960213, \"amount\": 0.0 }, { \"id\": 9960214, \"amount\": 0.07 }, { \"id\": 9960215, \"amount\": 0.06 }, { \"id\": 9960225, \"amount\": 0.39 }, { \"id\": 9960227, \"amount\": 0.23 }, { \"id\": 9960229, \"amount\": 0.03 }, { \"id\": 9960231, \"amount\": 0.36 }, { \"id\": 9960238, \"amount\": 0.52 }, { \"id\": 9960244, \"amount\": 0.43 }, { \"id\": 9960245, \"amount\": 0.2 }, { \"id\": 9960248, \"amount\": 0.24 }, { \"id\": 9960252, \"amount\": 0.04 }, { \"id\": 9960253, \"amount\": 0.04 }, { \"id\": 9960257, \"amount\": 0.09 }, { \"id\": 9960260, \"amount\": 0.03 }, { \"id\": 9960261, \"amount\": 0.14 }, { \"id\": 9960263, \"amount\": 0.42 }, { \"id\": 9960265, \"amount\": 0.36 }, { \"id\": 9960269, \"amount\": 0.15 }, { \"id\": 9960273, \"amount\": 0.32 }, { \"id\": 9960275, \"amount\": 0.18 }, { \"id\": 9960279, \"amount\": 0.19 }, { \"id\": 9960283, \"amount\": 0.07 }, { \"id\": 9960284, \"amount\": 0.15 }, { \"id\": 9960292, \"amount\": 0.24 }, { \"id\": 9960296, \"amount\": 0.43 }, { \"id\": 9960297, \"amount\": 0.39 }, { \"id\": 9960299, \"amount\": 0.46 }, { \"id\": 9960303, \"amount\": 0.6 }, { \"id\": 9960304, \"amount\": 0.17 }, { \"id\": 9960306, \"amount\": 0.06 }, { \"id\": 9960307, \"amount\": 0.08 }, { \"id\": 9960308, \"amount\": 0.03 }, { \"id\": 9960310, \"amount\": 0.13 }, { \"id\": 9960313, \"amount\": 0.14 }, { \"id\": 9960315, \"amount\": 0.18 }, { \"id\": 9960318, \"amount\": 0.51 }, { \"id\": 9960321, \"amount\": 0.51 }, { \"id\": 9960329, \"amount\": 0.06 }, { \"id\": 9960341, \"amount\": 0.05 }, { \"id\": 9960345, \"amount\": 0.24 }, { \"id\": 9960347, \"amount\": 0.07 }, { \"id\": 9960349, \"amount\": 0.05 }, { \"id\": 9960353, \"amount\": 0.05 }, { \"id\": 9960358, \"amount\": 0.45 }, { \"id\": 9960359, \"amount\": 0.04 }, { \"id\": 9960360, \"amount\": 0.09 }, { \"id\": 9960363, \"amount\": 0.21 }, { \"id\": 9960374, \"amount\": 0.61 }, { \"id\": 9960375, \"amount\": 0.03 }, { \"id\": 9960381, \"amount\": 0.61 }, { \"id\": 9960383, \"amount\": 0.08 }, { \"id\": 9960386, \"amount\": 0.22 }, { \"id\": 9960391, \"amount\": 0.3 }, { \"id\": 9960396, \"amount\": 0.02 }, { \"id\": 9960399, \"amount\": 0.35 }, { \"id\": 9960405, \"amount\": 0.86 }, { \"id\": 9960407, \"amount\": 0.11 }, { \"id\": 9960408, \"amount\": 0.32 }, { \"id\": 9960414, \"amount\": 0.06 }, { \"id\": 9960416, \"amount\": 0.42 }, { \"id\": 9960425, \"amount\": 0.24 }, { \"id\": 9960432, \"amount\": 0.12 }, { \"id\": 9960437, \"amount\": 0.23 }, { \"id\": 9960438, \"amount\": 0.36 }, { \"id\": 9960439, \"amount\": 0.11 }, { \"id\": 9960454, \"amount\": 0.54 }, { \"id\": 9960455, \"amount\": 0.03 }, { \"id\": 9960457, \"amount\": 0.68 }, { \"id\": 9960461, \"amount\": 0.41 }, { \"id\": 9960466, \"amount\": 0.46 }, { \"id\": 9960476, \"amount\": 0.67 }, { \"id\": 9960478, \"amount\": 0.33 }, { \"id\": 9960479, \"amount\": 0.32 }, { \"id\": 9960488, \"amount\": 0.04 }, { \"id\": 9960492, \"amount\": 0.42 }, { \"id\": 9960501, \"amount\": 0.22 }, { \"id\": 9960508, \"amount\": 0.14 }, { \"id\": 9960509, \"amount\": 0.12 }, { \"id\": 9960510, \"amount\": 0.34 }, { \"id\": 9960513, \"amount\": 0.24 }, { \"id\": 9960514, \"amount\": 0.17 }, { \"id\": 9960515, \"amount\": 0.12 }, { \"id\": 9960516, \"amount\": 0.07 }, { \"id\": 9960518, \"amount\": 0.18 }, { \"id\": 9960520, \"amount\": 0.35 }, { \"id\": 9960521, \"amount\": 0.04 }, { \"id\": 9960525, \"amount\": 0.05 }, { \"id\": 9960531, \"amount\": 0.57 }, { \"id\": 9960535, \"amount\": 0.21 }, { \"id\": 9960536, \"amount\": 0.47 }, { \"id\": 9960537, \"amount\": 0.1 }, { \"id\": 9960539, \"amount\": 0.26 }, { \"id\": 9960540, \"amount\": 0.23 }, { \"id\": 9960543, \"amount\": 0.09 }, { \"id\": 9960546, \"amount\": 0.11 }, { \"id\": 9960557, \"amount\": 0.45 }, { \"id\": 9960559, \"amount\": 0.14 }, { \"id\": 9960561, \"amount\": 0.05 }, { \"id\": 9960562, \"amount\": 0.32 }, { \"id\": 9960563, \"amount\": 0.24 }, { \"id\": 9960568, \"amount\": 0.49 }, { \"id\": 9960571, \"amount\": 0.28 }, { \"id\": 9960573, \"amount\": 0.09 }, { \"id\": 9960575, \"amount\": 0.36 }, { \"id\": 9960583, \"amount\": 0.59 }, { \"id\": 9960584, \"amount\": 0.33 }, { \"id\": 9960586, \"amount\": 0.25 }, { \"id\": 9960587, \"amount\": 0.56 }, { \"id\": 9960588, \"amount\": 0.19 }, { \"id\": 9960592, \"amount\": 0.22 }, { \"id\": 9960593, \"amount\": 0.52 }, { \"id\": 9960603, \"amount\": 0.51 }, { \"id\": 9960605, \"amount\": 0.21 }, { \"id\": 9960608, \"amount\": 0.63 }, { \"id\": 9960609, \"amount\": 0.06 }, { \"id\": 9960614, \"amount\": 0.51 }, { \"id\": 9960617, \"amount\": 0.12 }, { \"id\": 9960619, \"amount\": 0.09 }, { \"id\": 9960627, \"amount\": 0.56 }, { \"id\": 9960629, \"amount\": 0.15 }, { \"id\": 9960631, \"amount\": 0.05 }, { \"id\": 9960632, \"amount\": 0.13 }, { \"id\": 9960633, \"amount\": 0.01 }, { \"id\": 9960636, \"amount\": 0.14 }, { \"id\": 9960638, \"amount\": 0.33 }, { \"id\": 9960641, \"amount\": 0.18 }, { \"id\": 9960643, \"amount\": 0.06 }, { \"id\": 9960645, \"amount\": 0.05 }, { \"id\": 9960646, \"amount\": 0.09 }, { \"id\": 9960648, \"amount\": 0.09 }, { \"id\": 9960652, \"amount\": 0.06 }, { \"id\": 9960659, \"amount\": 0.26 }, { \"id\": 9960661, \"amount\": 0.17 }, { \"id\": 9960663, \"amount\": 0.89 }, { \"id\": 9960670, \"amount\": 0.5 }, { \"id\": 9960671, \"amount\": 0.07 }, { \"id\": 9960672, \"amount\": 0.12 }, { \"id\": 9960678, \"amount\": 0.09 }, { \"id\": 9960679, \"amount\": 0.64 }, { \"id\": 9960687, \"amount\": 0.3 }, { \"id\": 9960688, \"amount\": 0.71 }, { \"id\": 9960691, \"amount\": 0.6 }, { \"id\": 9960697, \"amount\": 0.56 }, { \"id\": 9960698, \"amount\": 0.54 }, { \"id\": 9960700, \"amount\": 0.41 }, { \"id\": 9960701, \"amount\": 0.24 }, { \"id\": 9960703, \"amount\": 0.5 }, { \"id\": 9960704, \"amount\": 0.13 }, { \"id\": 9960716, \"amount\": 0.1 }, { \"id\": 9960726, \"amount\": 0.05 }, { \"id\": 9960734, \"amount\": 0.28 }, { \"id\": 9960735, \"amount\": 0.2 }, { \"id\": 9960737, \"amount\": 0.08 }, { \"id\": 9960740, \"amount\": 0.09 }, { \"id\": 9960746, \"amount\": 0.09 }, { \"id\": 9960750, \"amount\": 0.09 }, { \"id\": 9960753, \"amount\": 0.62 }, { \"id\": 9960761, \"amount\": 0.08 }, { \"id\": 9960764, \"amount\": 0.29 }, { \"id\": 9960767, \"amount\": 0.25 }, { \"id\": 9960769, \"amount\": 0.26 }, { \"id\": 9960770, \"amount\": 0.32 }, { \"id\": 9960778, \"amount\": 0.18 }, { \"id\": 9960787, \"amount\": 0.18 }, { \"id\": 9960791, \"amount\": 0.29 }, { \"id\": 9960794, \"amount\": 0.36 }, { \"id\": 9960803, \"amount\": 0.04 }, { \"id\": 9960805, \"amount\": 0.03 }, { \"id\": 9960807, \"amount\": 0.27 }, { \"id\": 9960811, \"amount\": 0.03 }, { \"id\": 9960813, \"amount\": 0.01 }, { \"id\": 9960815, \"amount\": 0.58 }, { \"id\": 9960820, \"amount\": 0.2 }, { \"id\": 9960824, \"amount\": 0.61 }, { \"id\": 9960828, \"amount\": 0.08 }, { \"id\": 9960831, \"amount\": 0.09 }, { \"id\": 9960832, \"amount\": 0.0 }, { \"id\": 9960833, \"amount\": 0.26 }, { \"id\": 9960834, \"amount\": 0.15 }, { \"id\": 9960835, \"amount\": 0.21 }, { \"id\": 9960837, \"amount\": 0.05 }, { \"id\": 9960843, \"amount\": 0.14 }, { \"id\": 9960849, \"amount\": 0.08 }, { \"id\": 9960853, \"amount\": 0.66 }, { \"id\": 9960857, \"amount\": 0.08 }, { \"id\": 9960860, \"amount\": 0.55 }, { \"id\": 9960862, \"amount\": 0.39 }, { \"id\": 9960865, \"amount\": 0.06 }, { \"id\": 9960872, \"amount\": 0.08 }, { \"id\": 9960877, \"amount\": 0.23 }, { \"id\": 9960878, \"amount\": 0.23 }, { \"id\": 9960882, \"amount\": 0.3 }, { \"id\": 9960883, \"amount\": 0.07 }, { \"id\": 9960887, \"amount\": 0.29 }, { \"id\": 9960894, \"amount\": 0.28 }, { \"id\": 9960897, \"amount\": 0.2 }, { \"id\": 9960898, \"amount\": 0.1 }, { \"id\": 9960899, \"amount\": 0.58 }, { \"id\": 9960906, \"amount\": 0.11 }, { \"id\": 9960909, \"amount\": 0.13 }, { \"id\": 9960922, \"amount\": 0.25 }, { \"id\": 9960927, \"amount\": 0.32 }, { \"id\": 9960931, \"amount\": 0.3 }, { \"id\": 9960932, \"amount\": 0.06 }, { \"id\": 9960934, \"amount\": 0.58 }, { \"id\": 9960937, \"amount\": 0.69 }, { \"id\": 9960938, \"amount\": 0.58 }, { \"id\": 9960940, \"amount\": 0.12 }, { \"id\": 9960942, \"amount\": 0.05 }, { \"id\": 9960944, \"amount\": 0.28 }, { \"id\": 9960946, \"amount\": 0.55 }, { \"id\": 9960947, \"amount\": 0.12 }, { \"id\": 9960951, \"amount\": 0.64 }, { \"id\": 9960952, \"amount\": 0.22 }, { \"id\": 9960953, \"amount\": 0.16 }, { \"id\": 9960958, \"amount\": 0.18 }, { \"id\": 9960959, \"amount\": 0.34 }, { \"id\": 9960963, \"amount\": 0.08 }, { \"id\": 9960969, \"amount\": 0.27 }, { \"id\": 9960970, \"amount\": 0.55 }, { \"id\": 9960975, \"amount\": 0.38 }, { \"id\": 9960976, \"amount\": 0.02 }, { \"id\": 9960977, \"amount\": 0.04 }, { \"id\": 9960978, \"amount\": 0.66 }, { \"id\": 9960984, \"amount\": 0.13 }, { \"id\": 9960986, \"amount\": 0.33 }, { \"id\": 9960990, \"amount\": 0.06 }, { \"id\": 9960993, \"amount\": 0.33 }, { \"id\": 9960996, \"amount\": 0.08 }, { \"id\": 9961001, \"amount\": 0.07 }, { \"id\": 9961002, \"amount\": 0.1 }, { \"id\": 9961006, \"amount\": 0.08 }, { \"id\": 9961008, \"amount\": 0.66 }, { \"id\": 9961011, \"amount\": 0.39 }, { \"id\": 9961012, \"amount\": 0.37 }, { \"id\": 9961018, \"amount\": 0.22 }, { \"id\": 9961025, \"amount\": 0.45 }, { \"id\": 9961029, \"amount\": 0.62 }, { \"id\": 9961038, \"amount\": 0.17 }, { \"id\": 9961044, \"amount\": 0.65 }, { \"id\": 9961045, \"amount\": 0.17 }, { \"id\": 9961052, \"amount\": 0.18 }, { \"id\": 9961055, \"amount\": 0.13 }, { \"id\": 9961056, \"amount\": 0.22 }, { \"id\": 9961061, \"amount\": 0.09 }, { \"id\": 9961063, \"amount\": 0.08 }, { \"id\": 9961068, \"amount\": 0.25 }, { \"id\": 9961075, \"amount\": 0.15 }, { \"id\": 9961077, \"amount\": 0.09 }, { \"id\": 9961081, \"amount\": 0.09 }, { \"id\": 9961085, \"amount\": 0.62 }, { \"id\": 9961091, \"amount\": 0.53 }, { \"id\": 9961092, \"amount\": 0.44 }, { \"id\": 9961095, \"amount\": 0.65 }, { \"id\": 9961103, \"amount\": 0.28 }, { \"id\": 9961106, \"amount\": 0.2 }, { \"id\": 9961107, \"amount\": 0.23 }, { \"id\": 9961115, \"amount\": 0.15 }, { \"id\": 9961116, \"amount\": 0.11 }, { \"id\": 9961120, \"amount\": 0.16 }, { \"id\": 9961122, \"amount\": 0.32 }, { \"id\": 9961127, \"amount\": 0.64 }, { \"id\": 9961129, \"amount\": 0.35 }, { \"id\": 9961131, \"amount\": 0.11 }, { \"id\": 9961134, \"amount\": 0.0 }, { \"id\": 9961137, \"amount\": 0.38 }, { \"id\": 9961138, \"amount\": 0.6 }, { \"id\": 9961139, \"amount\": 0.11 }, { \"id\": 9961142, \"amount\": 0.08 }, { \"id\": 9961144, \"amount\": 0.27 }, { \"id\": 9961146, \"amount\": 0.56 }, { \"id\": 9961151, \"amount\": 0.34 }, { \"id\": 9961152, \"amount\": 0.01 }, { \"id\": 9961154, \"amount\": 0.53 }, { \"id\": 9961156, \"amount\": 0.07 }, { \"id\": 9961163, \"amount\": 0.33 }, { \"id\": 9961165, \"amount\": 0.47 }, { \"id\": 9961174, \"amount\": 0.15 }, { \"id\": 9961176, \"amount\": 0.12 }, { \"id\": 9961184, \"amount\": 0.64 }, { \"id\": 9961187, \"amount\": 0.21 }, { \"id\": 9961191, \"amount\": 0.19 }, { \"id\": 9961195, \"amount\": 0.55 }, { \"id\": 9961197, \"amount\": 0.24 }, { \"id\": 9961200, \"amount\": 0.6 }, { \"id\": 9961203, \"amount\": 0.54 }, { \"id\": 9961211, \"amount\": 0.13 }, { \"id\": 9961216, \"amount\": 0.36 }, { \"id\": 9961219, \"amount\": 0.76 }, { \"id\": 9961220, \"amount\": 0.32 }, { \"id\": 9961221, \"amount\": 0.27 }, { \"id\": 9961222, \"amount\": 0.14 }, { \"id\": 9961223, \"amount\": 0.35 }, { \"id\": 9961226, \"amount\": 0.23 }, { \"id\": 9961228, \"amount\": 0.09 }, { \"id\": 9961231, \"amount\": 0.12 }, { \"id\": 9961236, \"amount\": 0.31 }, { \"id\": 9961243, \"amount\": 0.04 }, { \"id\": 9961246, \"amount\": 0.06 }, { \"id\": 9961249, \"amount\": 0.35 }, { \"id\": 9961253, \"amount\": 0.3 }, { \"id\": 9961262, \"amount\": 0.6 }, { \"id\": 9961264, \"amount\": 0.13 }, { \"id\": 9961266, \"amount\": 0.22 }, { \"id\": 9961267, \"amount\": 0.26 }, { \"id\": 9961269, \"amount\": 0.2 }, { \"id\": 9961270, \"amount\": 0.37 }, { \"id\": 9961274, \"amount\": 0.03 }, { \"id\": 9961280, \"amount\": 0.52 }, { \"id\": 9961288, \"amount\": 0.59 }, { \"id\": 9961290, \"amount\": 0.36 }, { \"id\": 9961294, \"amount\": 0.28 }, { \"id\": 9961302, \"amount\": 0.16 }, { \"id\": 9961304, \"amount\": 0.39 }, { \"id\": 9961306, \"amount\": 0.05 }, { \"id\": 9961308, \"amount\": 0.01 }, { \"id\": 9961310, \"amount\": 0.32 }, { \"id\": 9961311, \"amount\": 0.42 }, { \"id\": 9961314, \"amount\": 0.32 }, { \"id\": 9961322, \"amount\": 0.1 }, { \"id\": 9961330, \"amount\": 0.38 }, { \"id\": 9961333, \"amount\": 0.06 }, { \"id\": 9961336, \"amount\": 0.07 }, { \"id\": 9961341, \"amount\": 0.14 }, { \"id\": 9961346, \"amount\": 0.15 }, { \"id\": 9961347, \"amount\": 0.49 }, { \"id\": 9961351, \"amount\": 0.29 }, { \"id\": 9961354, \"amount\": 0.12 }, { \"id\": 9961362, \"amount\": 0.41 }, { \"id\": 9961363, \"amount\": 0.51 }, { \"id\": 9961366, \"amount\": 0.25 }, { \"id\": 9961367, \"amount\": 0.32 }, { \"id\": 9961372, \"amount\": 0.33 }, { \"id\": 9961374, \"amount\": 0.42 }, { \"id\": 9961375, \"amount\": 0.29 }, { \"id\": 9961377, \"amount\": 0.22 }, { \"id\": 9961383, \"amount\": 0.2 }, { \"id\": 9961390, \"amount\": 0.14 }, { \"id\": 9961393, \"amount\": 0.63 }, { \"id\": 9961394, \"amount\": 0.06 }, { \"id\": 9961404, \"amount\": 0.24 }, { \"id\": 9961406, \"amount\": 0.04 }, { \"id\": 9961407, \"amount\": 0.34 }, { \"id\": 9961413, \"amount\": 0.43 }, { \"id\": 9961414, \"amount\": 0.08 }, { \"id\": 9961419, \"amount\": 0.19 }, { \"id\": 9961420, \"amount\": 0.42 }, { \"id\": 9961437, \"amount\": 0.7 }, { \"id\": 9961439, \"amount\": 0.12 }, { \"id\": 9961443, \"amount\": 0.15 }, { \"id\": 9961452, \"amount\": 0.05 }, { \"id\": 9961456, \"amount\": 0.13 }, { \"id\": 9961457, \"amount\": 0.2 }, { \"id\": 9961459, \"amount\": 0.47 }, { \"id\": 9961461, \"amount\": 0.13 }, { \"id\": 9961463, \"amount\": 0.13 }, { \"id\": 9961466, \"amount\": 0.27 }, { \"id\": 9961470, \"amount\": 0.02 }, { \"id\": 9961477, \"amount\": 0.76 }, { \"id\": 9961480, \"amount\": 0.26 }, { \"id\": 9961484, \"amount\": 0.07 }, { \"id\": 9961487, \"amount\": 0.22 }, { \"id\": 9961492, \"amount\": 0.05 }, { \"id\": 9961494, \"amount\": 0.41 }, { \"id\": 9961495, \"amount\": 0.21 }, { \"id\": 9961496, \"amount\": 0.11 }, { \"id\": 9961505, \"amount\": 0.05 }, { \"id\": 9961507, \"amount\": 0.14 }, { \"id\": 9961519, \"amount\": 0.15 }, { \"id\": 9961522, \"amount\": 0.06 }, { \"id\": 9961530, \"amount\": 0.26 }, { \"id\": 9961535, \"amount\": 0.02 }, { \"id\": 9961538, \"amount\": 0.48 }, { \"id\": 9961539, \"amount\": 0.15 }, { \"id\": 9961542, \"amount\": 0.65 }, { \"id\": 9961547, \"amount\": 0.08 }, { \"id\": 9961551, \"amount\": 0.11 }, { \"id\": 9961567, \"amount\": 0.37 }, { \"id\": 9961573, \"amount\": 0.43 }, { \"id\": 9961576, \"amount\": 0.02 }, { \"id\": 9961577, \"amount\": 0.24 }, { \"id\": 9961585, \"amount\": 0.02 }, { \"id\": 9961586, \"amount\": 0.28 }, { \"id\": 9961592, \"amount\": 0.61 }, { \"id\": 9961593, \"amount\": 0.58 }, { \"id\": 9961594, \"amount\": 0.21 }, { \"id\": 9961599, \"amount\": 0.28 }, { \"id\": 9961610, \"amount\": 0.26 }, { \"id\": 9961619, \"amount\": 0.11 }, { \"id\": 9961628, \"amount\": 0.14 }, { \"id\": 9961633, \"amount\": 0.2 }, { \"id\": 9961635, \"amount\": 0.32 }, { \"id\": 9961641, \"amount\": 0.11 }, { \"id\": 9961643, \"amount\": 0.33 }, { \"id\": 9961647, \"amount\": 0.48 }, { \"id\": 9961649, \"amount\": 0.05 }, { \"id\": 9961652, \"amount\": 0.55 }, { \"id\": 9961664, \"amount\": 0.04 }, { \"id\": 9961689, \"amount\": 0.32 }, { \"id\": 9961700, \"amount\": 0.07 }, { \"id\": 9961701, \"amount\": 0.01 }, { \"id\": 9961703, \"amount\": 0.04 }, { \"id\": 9961711, \"amount\": 0.04 }, { \"id\": 9961720, \"amount\": 0.26 }, { \"id\": 9961721, \"amount\": 0.39 }, { \"id\": 9961722, \"amount\": 0.45 }, { \"id\": 9961724, \"amount\": 0.06 }, { \"id\": 9961729, \"amount\": 0.07 }, { \"id\": 9961739, \"amount\": 0.32 }, { \"id\": 9961742, \"amount\": 0.06 }, { \"id\": 9961744, \"amount\": 0.77 }, { \"id\": 9961748, \"amount\": 0.52 }, { \"id\": 9961749, \"amount\": 0.23 }, { \"id\": 9961750, \"amount\": 0.33 }, { \"id\": 9961759, \"amount\": 0.12 }, { \"id\": 9961761, \"amount\": 0.37 }, { \"id\": 9961767, \"amount\": 0.11 }, { \"id\": 9961769, \"amount\": 0.64 }, { \"id\": 9961773, \"amount\": 0.18 }, { \"id\": 9961775, \"amount\": 0.44 }, { \"id\": 9961777, \"amount\": 0.46 }, { \"id\": 9961782, \"amount\": 0.13 }, { \"id\": 9961791, \"amount\": 0.11 }, { \"id\": 9961792, \"amount\": 0.07 }, { \"id\": 9961793, \"amount\": 0.11 }, { \"id\": 9961795, \"amount\": 0.42 }, { \"id\": 9961808, \"amount\": 0.17 }, { \"id\": 9961811, \"amount\": 0.15 }, { \"id\": 9961812, \"amount\": 0.01 }, { \"id\": 9961818, \"amount\": 0.21 }, { \"id\": 9961822, \"amount\": 0.01 }, { \"id\": 9961823, \"amount\": 0.07 }, { \"id\": 9961829, \"amount\": 0.11 }, { \"id\": 9961834, \"amount\": 0.14 }, { \"id\": 9961841, \"amount\": 0.3 }, { \"id\": 9961844, \"amount\": 0.27 }, { \"id\": 9961846, \"amount\": 0.32 }, { \"id\": 9961851, \"amount\": 0.25 }, { \"id\": 9961855, \"amount\": 0.53 }, { \"id\": 9961860, \"amount\": 0.3 }, { \"id\": 9961861, \"amount\": 0.6 }, { \"id\": 9961867, \"amount\": 0.59 }, { \"id\": 9961871, \"amount\": 0.64 }, { \"id\": 9961873, \"amount\": 0.42 }, { \"id\": 9961875, \"amount\": 0.19 }, { \"id\": 9961876, \"amount\": 0.38 }, { \"id\": 9961884, \"amount\": 0.6 }, { \"id\": 9961887, \"amount\": 0.22 }, { \"id\": 9961904, \"amount\": 0.32 }, { \"id\": 9961905, \"amount\": 0.44 }, { \"id\": 9961907, \"amount\": 0.28 }, { \"id\": 9961913, \"amount\": 0.1 }, { \"id\": 9961916, \"amount\": 0.05 }, { \"id\": 9961919, \"amount\": 0.47 }, { \"id\": 9961920, \"amount\": 0.3 }, { \"id\": 9961921, \"amount\": 0.07 }, { \"id\": 9961922, \"amount\": 0.34 }, { \"id\": 9961923, \"amount\": 0.05 }, { \"id\": 9961925, \"amount\": 0.08 }, { \"id\": 9961926, \"amount\": 0.18 }, { \"id\": 9961928, \"amount\": 0.57 }, { \"id\": 9961932, \"amount\": 0.09 }, { \"id\": 9961937, \"amount\": 0.44 }, { \"id\": 9961946, \"amount\": 0.4 }, { \"id\": 9961948, \"amount\": 0.0 }, { \"id\": 9961951, \"amount\": 0.24 }, { \"id\": 9961962, \"amount\": 0.56 }, { \"id\": 9961963, \"amount\": 0.04 }, { \"id\": 9961964, \"amount\": 0.49 }, { \"id\": 9961973, \"amount\": 0.07 }, { \"id\": 9961974, \"amount\": 0.33 }, { \"id\": 9961980, \"amount\": 0.2 }, { \"id\": 9961982, \"amount\": 0.48 }, { \"id\": 9961988, \"amount\": 0.24 }, { \"id\": 9961991, \"amount\": 0.06 }, { \"id\": 9961996, \"amount\": 0.23 }, { \"id\": 9961997, \"amount\": 0.03 }, { \"id\": 9961998, \"amount\": 0.13 }, { \"id\": 9962001, \"amount\": 0.06 }, { \"id\": 9962008, \"amount\": 0.12 }, { \"id\": 9962014, \"amount\": 0.5 }, { \"id\": 9962017, \"amount\": 0.08 }, { \"id\": 9962020, \"amount\": 0.66 }, { \"id\": 9962022, \"amount\": 0.19 }, { \"id\": 9962025, \"amount\": 0.62 }, { \"id\": 9962028, \"amount\": 0.17 }, { \"id\": 9962036, \"amount\": 0.05 }, { \"id\": 9962038, \"amount\": 0.1 }, { \"id\": 9962045, \"amount\": 0.13 }, { \"id\": 9962047, \"amount\": 0.16 }, { \"id\": 9962048, \"amount\": 0.08 }, { \"id\": 9962055, \"amount\": 0.02 }, { \"id\": 9962056, \"amount\": 0.38 }, { \"id\": 9962060, \"amount\": 0.03 }, { \"id\": 9962062, \"amount\": 0.07 }, { \"id\": 9962065, \"amount\": 0.16 }, { \"id\": 9962067, \"amount\": 0.06 }, { \"id\": 9962071, \"amount\": 0.09 }, { \"id\": 9962072, \"amount\": 0.04 }, { \"id\": 9962081, \"amount\": 0.47 }, { \"id\": 9962083, \"amount\": 0.51 }, { \"id\": 9962084, \"amount\": 0.09 }, { \"id\": 9962086, \"amount\": 0.08 }, { \"id\": 9962090, \"amount\": 0.08 }, { \"id\": 9962104, \"amount\": 0.02 }, { \"id\": 9962105, \"amount\": 0.55 }, { \"id\": 9962106, \"amount\": 0.02 }, { \"id\": 9962114, \"amount\": 0.07 }, { \"id\": 9962115, \"amount\": 0.21 }, { \"id\": 9962117, \"amount\": 0.09 }, { \"id\": 9962121, \"amount\": 0.09 }, { \"id\": 9962123, \"amount\": 0.35 }, { \"id\": 9962129, \"amount\": 0.08 }, { \"id\": 9962138, \"amount\": 0.08 }, { \"id\": 9962142, \"amount\": 0.28 }, { \"id\": 9962144, \"amount\": 0.28 }, { \"id\": 9962145, \"amount\": 0.34 }, { \"id\": 9962147, \"amount\": 0.03 }, { \"id\": 9962151, \"amount\": 0.2 }, { \"id\": 9962152, \"amount\": 0.09 }, { \"id\": 9962153, \"amount\": 0.03 }, { \"id\": 9962154, \"amount\": 0.04 }, { \"id\": 9962161, \"amount\": 0.0 }, { \"id\": 9962166, \"amount\": 0.32 }, { \"id\": 9962168, \"amount\": 0.57 }, { \"id\": 9962169, \"amount\": 0.03 }, { \"id\": 9962172, \"amount\": 0.49 }, { \"id\": 9962176, \"amount\": 0.24 }, { \"id\": 9962183, \"amount\": 0.36 }, { \"id\": 9962188, \"amount\": 0.55 }, { \"id\": 9962197, \"amount\": 0.23 }, { \"id\": 9962198, \"amount\": 0.57 }, { \"id\": 9962202, \"amount\": 0.36 }, { \"id\": 9962205, \"amount\": 0.37 }, { \"id\": 9962206, \"amount\": 0.07 }, { \"id\": 9962207, \"amount\": 0.24 }, { \"id\": 9962208, \"amount\": 0.14 }, { \"id\": 9962209, \"amount\": 0.21 }, { \"id\": 9962214, \"amount\": 0.01 }, { \"id\": 9962215, \"amount\": 0.02 }, { \"id\": 9962223, \"amount\": 0.09 }, { \"id\": 9962225, \"amount\": 0.04 }, { \"id\": 9962227, \"amount\": 0.3 }, { \"id\": 9962228, \"amount\": 0.31 }, { \"id\": 9962229, \"amount\": 0.07 }, { \"id\": 9962237, \"amount\": 0.23 }, { \"id\": 9962239, \"amount\": 0.28 }, { \"id\": 9962241, \"amount\": 0.01 }, { \"id\": 9962245, \"amount\": 0.15 }, { \"id\": 9962250, \"amount\": 0.07 }, { \"id\": 9962257, \"amount\": 0.06 }, { \"id\": 9962267, \"amount\": 0.36 }, { \"id\": 9962268, \"amount\": 0.34 }, { \"id\": 9962272, \"amount\": 0.08 }, { \"id\": 9962279, \"amount\": 0.18 }, { \"id\": 9962280, \"amount\": 0.54 }, { \"id\": 9962283, \"amount\": 0.14 }, { \"id\": 9962287, \"amount\": 0.3 }, { \"id\": 9962288, \"amount\": 0.42 }, { \"id\": 9962291, \"amount\": 0.42 }, { \"id\": 9962292, \"amount\": 0.24 }, { \"id\": 9962297, \"amount\": 0.42 }, { \"id\": 9962299, \"amount\": 0.33 }, { \"id\": 9962302, \"amount\": 0.18 }, { \"id\": 9962303, \"amount\": 0.22 }, { \"id\": 9962307, \"amount\": 0.36 }, { \"id\": 9962308, \"amount\": 0.15 }, { \"id\": 9962311, \"amount\": 0.33 }, { \"id\": 9962312, \"amount\": 0.22 }, { \"id\": 9962316, \"amount\": 0.25 }, { \"id\": 9962317, \"amount\": 0.38 }, { \"id\": 9962320, \"amount\": 0.2 }, { \"id\": 9962321, \"amount\": 0.11 }, { \"id\": 9962333, \"amount\": 0.32 }, { \"id\": 9962337, \"amount\": 0.5 }, { \"id\": 9962342, \"amount\": 0.04 }, { \"id\": 9962345, \"amount\": 0.34 }, { \"id\": 9962352, \"amount\": 0.09 }, { \"id\": 9962353, \"amount\": 0.37 }, { \"id\": 9962355, \"amount\": 0.41 }, { \"id\": 9962356, \"amount\": 0.43 }, { \"id\": 9962358, \"amount\": 0.34 }, { \"id\": 9962359, \"amount\": 0.61 }, { \"id\": 9962360, \"amount\": 0.17 }, { \"id\": 9962361, \"amount\": 0.01 }, { \"id\": 9962371, \"amount\": 0.07 }, { \"id\": 9962372, \"amount\": 0.42 }, { \"id\": 9962375, \"amount\": 0.15 }, { \"id\": 9962376, \"amount\": 0.25 }, { \"id\": 9962378, \"amount\": 0.39 }, { \"id\": 9962379, \"amount\": 0.36 }, { \"id\": 9962380, \"amount\": 0.41 }, { \"id\": 9962381, \"amount\": 0.65 }, { \"id\": 9962386, \"amount\": 0.16 }, { \"id\": 9962389, \"amount\": 0.17 }, { \"id\": 9962390, \"amount\": 0.34 }, { \"id\": 9962394, \"amount\": 0.32 }, { \"id\": 9962404, \"amount\": 0.06 }, { \"id\": 9962405, \"amount\": 0.28 }, { \"id\": 9962406, \"amount\": 0.07 }, { \"id\": 9962408, \"amount\": 0.5 }, { \"id\": 9962410, \"amount\": 0.51 }, { \"id\": 9962412, \"amount\": 0.34 }, { \"id\": 9962413, \"amount\": 0.15 }, { \"id\": 9962414, \"amount\": 0.22 }, { \"id\": 9962417, \"amount\": 0.36 }, { \"id\": 9962418, \"amount\": 0.18 }, { \"id\": 9962425, \"amount\": 0.31 }, { \"id\": 9962429, \"amount\": 0.15 }, { \"id\": 9962436, \"amount\": 0.42 }, { \"id\": 9962439, \"amount\": 0.2 }, { \"id\": 9962448, \"amount\": 0.27 }, { \"id\": 9962449, \"amount\": 0.3 }, { \"id\": 9962450, \"amount\": 0.31 }, { \"id\": 9962452, \"amount\": 0.5 }, { \"id\": 9962464, \"amount\": 0.34 }, { \"id\": 9962471, \"amount\": 0.29 }, { \"id\": 9962476, \"amount\": 0.02 }, { \"id\": 9962490, \"amount\": 0.26 }, { \"id\": 9962494, \"amount\": 0.25 }, { \"id\": 9962495, \"amount\": 0.09 }, { \"id\": 9962499, \"amount\": 0.24 }, { \"id\": 9962502, \"amount\": 0.2 }, { \"id\": 9962504, \"amount\": 0.12 }, { \"id\": 9962508, \"amount\": 0.33 }, { \"id\": 9962509, \"amount\": 0.33 }, { \"id\": 9962512, \"amount\": 0.13 }, { \"id\": 9962513, \"amount\": 0.28 }, { \"id\": 9962516, \"amount\": 0.02 }, { \"id\": 9962518, \"amount\": 0.04 }, { \"id\": 9962519, \"amount\": 0.01 }, { \"id\": 9962533, \"amount\": 0.13 }, { \"id\": 9962534, \"amount\": 0.24 }, { \"id\": 9962535, \"amount\": 0.11 }, { \"id\": 9962542, \"amount\": 0.42 }, { \"id\": 9962545, \"amount\": 0.02 }, { \"id\": 9962546, \"amount\": 0.19 }, { \"id\": 9962548, \"amount\": 0.31 }, { \"id\": 9962550, \"amount\": 0.19 }, { \"id\": 9962551, \"amount\": 0.38 }, { \"id\": 9962555, \"amount\": 0.23 }, { \"id\": 9962557, \"amount\": 0.13 }, { \"id\": 9962560, \"amount\": 0.71 }, { \"id\": 9962561, \"amount\": 0.26 }, { \"id\": 9962562, \"amount\": 0.11 }, { \"id\": 9962564, \"amount\": 0.33 }, { \"id\": 9962573, \"amount\": 0.48 }, { \"id\": 9962576, \"amount\": 0.68 }, { \"id\": 9962578, \"amount\": 0.1 }, { \"id\": 9962580, \"amount\": 0.12 }, { \"id\": 9962582, \"amount\": 0.17 }, { \"id\": 9962584, \"amount\": 0.55 }, { \"id\": 9962587, \"amount\": 0.32 }, { \"id\": 9962588, \"amount\": 0.08 }, { \"id\": 9962589, \"amount\": 0.31 }, { \"id\": 9962590, \"amount\": 0.12 }, { \"id\": 9962593, \"amount\": 0.03 }, { \"id\": 9962594, \"amount\": 0.23 }, { \"id\": 9962595, \"amount\": 0.08 }, { \"id\": 9962598, \"amount\": 0.21 }, { \"id\": 9962600, \"amount\": 0.07 }, { \"id\": 9962604, \"amount\": 0.16 }, { \"id\": 9962609, \"amount\": 0.46 }, { \"id\": 9962611, \"amount\": 0.01 }, { \"id\": 9962612, \"amount\": 0.1 }, { \"id\": 9962617, \"amount\": 0.0 }, { \"id\": 9962618, \"amount\": 0.12 }, { \"id\": 9962620, \"amount\": 0.29 }, { \"id\": 9962626, \"amount\": 0.33 }, { \"id\": 9962631, \"amount\": 0.4 }, { \"id\": 9962647, \"amount\": 0.38 }, { \"id\": 9962649, \"amount\": 0.71 }, { \"id\": 9962650, \"amount\": 0.2 }, { \"id\": 9962651, \"amount\": 0.02 }, { \"id\": 9962652, \"amount\": 0.28 }, { \"id\": 9962654, \"amount\": 0.02 }, { \"id\": 9962656, \"amount\": 0.09 }, { \"id\": 9962658, \"amount\": 0.16 }, { \"id\": 9962660, \"amount\": 0.12 }, { \"id\": 9962663, \"amount\": 0.41 }, { \"id\": 9962665, \"amount\": 0.3 }, { \"id\": 9962668, \"amount\": 0.11 }, { \"id\": 9962670, \"amount\": 0.3 }, { \"id\": 9962671, \"amount\": 0.06 }, { \"id\": 9962673, \"amount\": 0.01 }, { \"id\": 9962675, \"amount\": 0.33 }, { \"id\": 9962677, \"amount\": 0.15 }, { \"id\": 9962678, \"amount\": 0.55 }, { \"id\": 9962680, \"amount\": 0.42 }, { \"id\": 9962681, \"amount\": 0.07 }, { \"id\": 9962688, \"amount\": 0.01 }, { \"id\": 9962689, \"amount\": 0.18 }, { \"id\": 9962690, \"amount\": 0.22 }, { \"id\": 9962692, \"amount\": 0.07 }, { \"id\": 9962694, \"amount\": 0.05 }, { \"id\": 9962699, \"amount\": 0.17 }, { \"id\": 9962701, \"amount\": 0.04 }, { \"id\": 9962708, \"amount\": 0.16 }, { \"id\": 9962713, \"amount\": 0.17 }, { \"id\": 9962719, \"amount\": 0.02 }, { \"id\": 9962720, \"amount\": 0.1 }, { \"id\": 9962726, \"amount\": 0.08 }, { \"id\": 9962727, \"amount\": 0.22 }, { \"id\": 9962731, \"amount\": 0.16 }, { \"id\": 9962739, \"amount\": 0.5 }, { \"id\": 9962740, \"amount\": 0.18 }, { \"id\": 9962743, \"amount\": 0.23 }, { \"id\": 9962745, \"amount\": 0.08 }, { \"id\": 9962748, \"amount\": 0.24 }, { \"id\": 9962750, \"amount\": 0.04 }, { \"id\": 9962752, \"amount\": 0.54 }, { \"id\": 9962758, \"amount\": 0.09 }, { \"id\": 9962763, \"amount\": 0.09 }, { \"id\": 9962765, \"amount\": 0.22 }, { \"id\": 9962768, \"amount\": 0.14 }, { \"id\": 9962773, \"amount\": 0.12 }, { \"id\": 9962775, \"amount\": 0.12 }, { \"id\": 9962776, \"amount\": 0.09 }, { \"id\": 9962777, \"amount\": 0.2 }, { \"id\": 9962788, \"amount\": 0.42 }, { \"id\": 9962789, \"amount\": 0.45 }, { \"id\": 9962790, \"amount\": 0.07 }, { \"id\": 9962796, \"amount\": 0.0 }, { \"id\": 9962798, \"amount\": 0.06 }, { \"id\": 9962799, \"amount\": 0.08 }, { \"id\": 9962806, \"amount\": 0.49 }, { \"id\": 9962812, \"amount\": 0.06 }, { \"id\": 9962818, \"amount\": 0.3 }, { \"id\": 9962821, \"amount\": 0.3 }, { \"id\": 9962823, \"amount\": 0.01 }, { \"id\": 9962824, \"amount\": 0.51 }, { \"id\": 9962826, \"amount\": 0.12 }, { \"id\": 9962828, \"amount\": 0.08 }, { \"id\": 9962829, \"amount\": 0.09 }, { \"id\": 9962835, \"amount\": 0.02 }, { \"id\": 9962839, \"amount\": 0.15 }, { \"id\": 9962841, \"amount\": 0.11 }, { \"id\": 9962842, \"amount\": 0.51 }, { \"id\": 9962846, \"amount\": 0.57 }, { \"id\": 9962855, \"amount\": 0.08 }, { \"id\": 9962856, \"amount\": 0.04 }, { \"id\": 9962857, \"amount\": 0.31 }, { \"id\": 9962858, \"amount\": 0.32 }, { \"id\": 9962866, \"amount\": 0.19 }, { \"id\": 9962872, \"amount\": 0.16 }, { \"id\": 9962876, \"amount\": 0.05 }, { \"id\": 9962877, \"amount\": 1.0 }, { \"id\": 9962883, \"amount\": 0.1 }, { \"id\": 9962889, \"amount\": 0.14 }, { \"id\": 9962891, \"amount\": 0.27 }, { \"id\": 9962898, \"amount\": 0.18 }, { \"id\": 9962902, \"amount\": 0.61 }, { \"id\": 9962904, \"amount\": 0.68 }, { \"id\": 9962907, \"amount\": 0.05 }, { \"id\": 9962908, \"amount\": 0.26 }, { \"id\": 9962912, \"amount\": 0.08 }, { \"id\": 9962915, \"amount\": 0.3 }, { \"id\": 9962919, \"amount\": 0.3 }, { \"id\": 9962920, \"amount\": 0.28 }, { \"id\": 9962924, \"amount\": 0.07 }, { \"id\": 9962925, \"amount\": 0.59 }, { \"id\": 9962928, \"amount\": 0.07 }, { \"id\": 9962932, \"amount\": 0.64 }, { \"id\": 9962934, \"amount\": 0.38 }, { \"id\": 9962935, \"amount\": 0.08 }, { \"id\": 9962936, \"amount\": 0.28 }, { \"id\": 9962938, \"amount\": 0.1 }, { \"id\": 9962948, \"amount\": 0.35 }, { \"id\": 9962952, \"amount\": 0.3 }, { \"id\": 9962953, \"amount\": 0.07 }, { \"id\": 9962956, \"amount\": 0.66 }, { \"id\": 9962958, \"amount\": 0.01 }, { \"id\": 9962968, \"amount\": 0.12 }, { \"id\": 9962971, \"amount\": 0.33 }, { \"id\": 9962974, \"amount\": 0.04 }, { \"id\": 9962977, \"amount\": 0.37 }, { \"id\": 9962978, \"amount\": 0.46 }, { \"id\": 9962985, \"amount\": 0.39 }, { \"id\": 9962991, \"amount\": 0.51 }, { \"id\": 9962994, \"amount\": 0.51 }, { \"id\": 9962997, \"amount\": 0.11 }, { \"id\": 9963001, \"amount\": 0.38 }, { \"id\": 9963005, \"amount\": 0.2 }, { \"id\": 9963007, \"amount\": 0.63 }, { \"id\": 9963013, \"amount\": 0.52 }, { \"id\": 9963016, \"amount\": 0.09 }, { \"id\": 9963017, \"amount\": 0.12 }, { \"id\": 9963019, \"amount\": 0.22 }, { \"id\": 9963026, \"amount\": 0.12 }, { \"id\": 9963027, \"amount\": 0.24 }, { \"id\": 9963031, \"amount\": 0.28 }, { \"id\": 9963035, \"amount\": 0.23 }, { \"id\": 9963038, \"amount\": 0.18 }, { \"id\": 9963041, \"amount\": 0.1 }, { \"id\": 9963043, \"amount\": 0.64 }, { \"id\": 9963048, \"amount\": 0.24 }, { \"id\": 9963049, \"amount\": 0.28 }, { \"id\": 9963060, \"amount\": 0.12 }, { \"id\": 9963061, \"amount\": 0.35 }, { \"id\": 9963078, \"amount\": 0.03 }, { \"id\": 9963086, \"amount\": 0.26 }, { \"id\": 9963088, \"amount\": 0.4 }, { \"id\": 9963089, \"amount\": 0.05 }, { \"id\": 9963097, \"amount\": 0.06 }, { \"id\": 9963102, \"amount\": 0.02 }, { \"id\": 9963104, \"amount\": 0.09 }, { \"id\": 9963114, \"amount\": 0.25 }, { \"id\": 9963118, \"amount\": 0.58 }, { \"id\": 9963124, \"amount\": 0.39 }, { \"id\": 9963133, \"amount\": 0.6 }, { \"id\": 9963135, \"amount\": 0.24 }, { \"id\": 9963136, \"amount\": 0.43 }, { \"id\": 9963140, \"amount\": 0.18 }, { \"id\": 9963145, \"amount\": 0.34 }, { \"id\": 9963148, \"amount\": 0.51 }, { \"id\": 9963155, \"amount\": 0.53 }, { \"id\": 9963156, \"amount\": 0.41 }, { \"id\": 9963157, \"amount\": 0.81 }, { \"id\": 9963161, \"amount\": 0.16 }, { \"id\": 9963165, \"amount\": 0.56 }, { \"id\": 9963170, \"amount\": 0.06 }, { \"id\": 9963172, \"amount\": 0.08 }, { \"id\": 9963174, \"amount\": 0.21 }, { \"id\": 9963175, \"amount\": 0.12 }, { \"id\": 9963179, \"amount\": 0.01 }, { \"id\": 9963184, \"amount\": 0.1 }, { \"id\": 9963186, \"amount\": 0.57 }, { \"id\": 9963188, \"amount\": 0.19 }, { \"id\": 9963189, \"amount\": 0.11 }, { \"id\": 9963191, \"amount\": 0.43 }, { \"id\": 9963194, \"amount\": 0.09 }, { \"id\": 9963198, \"amount\": 0.04 }, { \"id\": 9963201, \"amount\": 0.18 }, { \"id\": 9963209, \"amount\": 0.33 }, { \"id\": 9963214, \"amount\": 0.12 }, { \"id\": 9963219, \"amount\": 0.19 }, { \"id\": 9963226, \"amount\": 0.6 }, { \"id\": 9963227, \"amount\": 0.16 }, { \"id\": 9963235, \"amount\": 0.05 }, { \"id\": 9963240, \"amount\": 0.07 }, { \"id\": 9963243, \"amount\": 0.58 }, { \"id\": 9963247, \"amount\": 0.38 }, { \"id\": 9963252, \"amount\": 0.34 }, { \"id\": 9963253, \"amount\": 0.54 }, { \"id\": 9963255, \"amount\": 0.03 }, { \"id\": 9963262, \"amount\": 0.65 }, { \"id\": 9963264, \"amount\": 0.32 }, { \"id\": 9963269, \"amount\": 0.22 }, { \"id\": 9963271, \"amount\": 0.12 }, { \"id\": 9963274, \"amount\": 0.14 }, { \"id\": 9963278, \"amount\": 0.12 }, { \"id\": 9963283, \"amount\": 0.22 }, { \"id\": 9963285, \"amount\": 0.09 }, { \"id\": 9963295, \"amount\": 0.16 }, { \"id\": 9963298, \"amount\": 0.08 }, { \"id\": 9963299, \"amount\": 0.3 }, { \"id\": 9963302, \"amount\": 0.04 }, { \"id\": 9963305, \"amount\": 0.2 }, { \"id\": 9963306, \"amount\": 0.09 }, { \"id\": 9963308, \"amount\": 0.51 }, { \"id\": 9963309, \"amount\": 0.56 }, { \"id\": 9963310, \"amount\": 0.25 }, { \"id\": 9963312, \"amount\": 0.11 }, { \"id\": 9963314, \"amount\": 0.31 }, { \"id\": 9963315, \"amount\": 0.51 }, { \"id\": 9963325, \"amount\": 0.28 }, { \"id\": 9963331, \"amount\": 0.6 }, { \"id\": 9963332, \"amount\": 0.2 }, { \"id\": 9963333, \"amount\": 0.39 }, { \"id\": 9963337, \"amount\": 0.31 }, { \"id\": 9963342, \"amount\": 0.02 }, { \"id\": 9963345, \"amount\": 0.02 }, { \"id\": 9963347, \"amount\": 0.04 }, { \"id\": 9963348, \"amount\": 0.07 }, { \"id\": 9963352, \"amount\": 0.0 }, { \"id\": 9963356, \"amount\": 0.22 }, { \"id\": 9963359, \"amount\": 0.52 }, { \"id\": 9963361, \"amount\": 0.07 }, { \"id\": 9963365, \"amount\": 0.69 }, { \"id\": 9963367, \"amount\": 0.39 }, { \"id\": 9963372, \"amount\": 0.31 }, { \"id\": 9963382, \"amount\": 0.03 }, { \"id\": 9963383, \"amount\": 0.17 }, { \"id\": 9963389, \"amount\": 0.2 }, { \"id\": 9963395, \"amount\": 0.55 }, { \"id\": 9963402, \"amount\": 0.19 }, { \"id\": 9963408, \"amount\": 0.56 }, { \"id\": 9963412, \"amount\": 0.08 }, { \"id\": 9963413, \"amount\": 0.23 }, { \"id\": 9963418, \"amount\": 0.67 }, { \"id\": 9963420, \"amount\": 0.15 }, { \"id\": 9963421, \"amount\": 0.45 }, { \"id\": 9963434, \"amount\": 0.06 }, { \"id\": 9963436, \"amount\": 0.23 }, { \"id\": 9963452, \"amount\": 0.21 }, { \"id\": 9963455, \"amount\": 0.65 }, { \"id\": 9963458, \"amount\": 0.25 }, { \"id\": 9963461, \"amount\": 0.06 }, { \"id\": 9963462, \"amount\": 0.6 }, { \"id\": 9963463, \"amount\": 0.12 }, { \"id\": 9963467, \"amount\": 0.57 }, { \"id\": 9963474, \"amount\": 0.13 }, { \"id\": 9963486, \"amount\": 0.1 }, { \"id\": 9963490, \"amount\": 0.15 }, { \"id\": 9963494, \"amount\": 0.34 }, { \"id\": 9963496, \"amount\": 0.31 }, { \"id\": 9963498, \"amount\": 0.26 }, { \"id\": 9963503, \"amount\": 0.48 }, { \"id\": 9963505, \"amount\": 0.28 }, { \"id\": 9963508, \"amount\": 0.05 }, { \"id\": 9963510, \"amount\": 0.16 }, { \"id\": 9963512, \"amount\": 0.48 }, { \"id\": 9963516, \"amount\": 0.2 }, { \"id\": 9963520, \"amount\": 0.15 }, { \"id\": 9963524, \"amount\": 0.53 }, { \"id\": 9963526, \"amount\": 0.05 }, { \"id\": 9963528, \"amount\": 0.62 }, { \"id\": 9963532, \"amount\": 0.09 }, { \"id\": 9963538, \"amount\": 0.68 }, { \"id\": 9963539, \"amount\": 0.06 }, { \"id\": 9963540, \"amount\": 0.25 }, { \"id\": 9963541, \"amount\": 0.16 }, { \"id\": 9963542, \"amount\": 0.73 }, { \"id\": 9963550, \"amount\": 0.04 }, { \"id\": 9963554, \"amount\": 0.23 }, { \"id\": 9963556, \"amount\": 0.46 }, { \"id\": 9963559, \"amount\": 0.58 }, { \"id\": 9963561, \"amount\": 0.47 }, { \"id\": 9963571, \"amount\": 0.66 }, { \"id\": 9963572, \"amount\": 0.28 }, { \"id\": 9963575, \"amount\": 0.04 }, { \"id\": 9963591, \"amount\": 0.73 }, { \"id\": 9963593, \"amount\": 0.64 }, { \"id\": 9963595, \"amount\": 0.03 }, { \"id\": 9963605, \"amount\": 0.38 }, { \"id\": 9963611, \"amount\": 0.24 }, { \"id\": 9963614, \"amount\": 0.29 }, { \"id\": 9963618, \"amount\": 0.03 }, { \"id\": 9963620, \"amount\": 0.04 }, { \"id\": 9963626, \"amount\": 0.34 }, { \"id\": 9963633, \"amount\": 0.35 }, { \"id\": 9963634, \"amount\": 0.13 }, { \"id\": 9963636, \"amount\": 0.53 }, { \"id\": 9963637, \"amount\": 0.04 }, { \"id\": 9963640, \"amount\": 0.18 }, { \"id\": 9963643, \"amount\": 0.59 }, { \"id\": 9963647, \"amount\": 0.07 }, { \"id\": 9963648, \"amount\": 0.27 }, { \"id\": 9963650, \"amount\": 0.21 }, { \"id\": 9963651, \"amount\": 0.17 }, { \"id\": 9963652, \"amount\": 0.08 }, { \"id\": 9963657, \"amount\": 0.49 }, { \"id\": 9963660, \"amount\": 0.02 }, { \"id\": 9963669, \"amount\": 0.4 }, { \"id\": 9963671, \"amount\": 0.51 }, { \"id\": 9963677, \"amount\": 0.34 }, { \"id\": 9963685, \"amount\": 0.02 }, { \"id\": 9963690, \"amount\": 0.14 }, { \"id\": 9963693, \"amount\": 0.08 }, { \"id\": 9963696, \"amount\": 0.15 }, { \"id\": 9963703, \"amount\": 0.33 }, { \"id\": 9963704, \"amount\": 0.04 }, { \"id\": 9963705, \"amount\": 0.52 }, { \"id\": 9963708, \"amount\": 0.42 }, { \"id\": 9963710, \"amount\": 0.1 }, { \"id\": 9963715, \"amount\": 0.71 }, { \"id\": 9963716, \"amount\": 0.21 }, { \"id\": 9963717, \"amount\": 0.33 }, { \"id\": 9963722, \"amount\": 0.51 }, { \"id\": 9963734, \"amount\": 0.41 }, { \"id\": 9963735, \"amount\": 0.62 }, { \"id\": 9963737, \"amount\": 0.04 }, { \"id\": 9963750, \"amount\": 0.14 }, { \"id\": 9963751, \"amount\": 0.64 }, { \"id\": 9963756, \"amount\": 0.35 }, { \"id\": 9963757, \"amount\": 0.0 }, { \"id\": 9963768, \"amount\": 0.13 }, { \"id\": 9963773, \"amount\": 0.1 }, { \"id\": 9963779, \"amount\": 0.28 }, { \"id\": 9963780, \"amount\": 0.2 }, { \"id\": 9963788, \"amount\": 0.08 }, { \"id\": 9963793, \"amount\": 0.53 }, { \"id\": 9963795, \"amount\": 0.04 }, { \"id\": 9963815, \"amount\": 0.12 }, { \"id\": 9963818, \"amount\": 0.22 }, { \"id\": 9963819, \"amount\": 0.19 }, { \"id\": 9963825, \"amount\": 0.39 }, { \"id\": 9963833, \"amount\": 0.42 }, { \"id\": 9963846, \"amount\": 0.13 }, { \"id\": 9963849, \"amount\": 0.05 }, { \"id\": 9963850, \"amount\": 0.31 }, { \"id\": 9963854, \"amount\": 0.24 }, { \"id\": 9963856, \"amount\": 0.03 }, { \"id\": 9963859, \"amount\": 0.17 }, { \"id\": 9963866, \"amount\": 0.39 }, { \"id\": 9963872, \"amount\": 0.27 }, { \"id\": 9963874, \"amount\": 0.09 }, { \"id\": 9963878, \"amount\": 0.61 }, { \"id\": 9963888, \"amount\": 0.38 }, { \"id\": 9963889, \"amount\": 0.15 }, { \"id\": 9963890, \"amount\": 0.03 }, { \"id\": 9963896, \"amount\": 0.51 }, { \"id\": 9963897, \"amount\": 0.12 }, { \"id\": 9963901, \"amount\": 0.38 }, { \"id\": 9963904, \"amount\": 0.02 }, { \"id\": 9963908, \"amount\": 0.14 }, { \"id\": 9963918, \"amount\": 0.14 }, { \"id\": 9963921, \"amount\": 0.12 }, { \"id\": 9963930, \"amount\": 0.05 }, { \"id\": 9963932, \"amount\": 0.21 }, { \"id\": 9963933, \"amount\": 0.03 }, { \"id\": 9963951, \"amount\": 0.15 }, { \"id\": 9963954, \"amount\": 0.04 }, { \"id\": 9963957, \"amount\": 0.17 }, { \"id\": 9963981, \"amount\": 0.26 }, { \"id\": 9963986, \"amount\": 0.03 }, { \"id\": 9963989, \"amount\": 0.58 }, { \"id\": 9963992, \"amount\": 0.14 }, { \"id\": 9963998, \"amount\": 0.25 }, { \"id\": 9964002, \"amount\": 0.08 }, { \"id\": 9964004, \"amount\": 0.2 }, { \"id\": 9964010, \"amount\": 0.02 }, { \"id\": 9964011, \"amount\": 0.15 }, { \"id\": 9964023, \"amount\": 0.45 }, { \"id\": 9964025, \"amount\": 0.04 }, { \"id\": 9964028, \"amount\": 0.24 }, { \"id\": 9964032, \"amount\": 0.19 }, { \"id\": 9964038, \"amount\": 0.34 }, { \"id\": 9964039, \"amount\": 0.28 }, { \"id\": 9964048, \"amount\": 0.1 }, { \"id\": 9964049, \"amount\": 0.05 }, { \"id\": 9964053, \"amount\": 0.14 }, { \"id\": 9964054, \"amount\": 0.11 }, { \"id\": 9964058, \"amount\": 0.34 }, { \"id\": 9964061, \"amount\": 0.46 }, { \"id\": 9964062, \"amount\": 0.02 }, { \"id\": 9964063, \"amount\": 0.14 }, { \"id\": 9964064, \"amount\": 0.33 }, { \"id\": 9964069, \"amount\": 0.3 }, { \"id\": 9964072, \"amount\": 0.23 }, { \"id\": 9964075, \"amount\": 0.34 }, { \"id\": 9964078, \"amount\": 0.06 }, { \"id\": 9964079, \"amount\": 0.02 }, { \"id\": 9964080, \"amount\": 0.01 }, { \"id\": 9964083, \"amount\": 0.16 }, { \"id\": 9964088, \"amount\": 0.3 }, { \"id\": 9964089, \"amount\": 0.03 }, { \"id\": 9964090, \"amount\": 0.29 }, { \"id\": 9964097, \"amount\": 0.42 }, { \"id\": 9964100, \"amount\": 0.15 }, { \"id\": 9964103, \"amount\": 0.02 }, { \"id\": 9964104, \"amount\": 0.55 }, { \"id\": 9964107, \"amount\": 0.24 }, { \"id\": 9964111, \"amount\": 0.06 }, { \"id\": 9964116, \"amount\": 0.03 }, { \"id\": 9964117, \"amount\": 0.05 }, { \"id\": 9964119, \"amount\": 0.22 }, { \"id\": 9964122, \"amount\": 0.31 }, { \"id\": 9964130, \"amount\": 0.24 }, { \"id\": 9964136, \"amount\": 0.59 }, { \"id\": 9964137, \"amount\": 0.03 }, { \"id\": 9964139, \"amount\": 0.06 }, { \"id\": 9964143, \"amount\": 0.21 }, { \"id\": 9964151, \"amount\": 0.64 }, { \"id\": 9964153, \"amount\": 0.5 }, { \"id\": 9964155, \"amount\": 0.12 }, { \"id\": 9964158, \"amount\": 0.6 }, { \"id\": 9964162, \"amount\": 0.11 }, { \"id\": 9964165, \"amount\": 0.05 }, { \"id\": 9964168, \"amount\": 0.16 }, { \"id\": 9964174, \"amount\": 0.16 }, { \"id\": 9964175, \"amount\": 0.78 }, { \"id\": 9964182, \"amount\": 0.16 }, { \"id\": 9964183, \"amount\": 0.41 }, { \"id\": 9964200, \"amount\": 0.15 }, { \"id\": 9964201, \"amount\": 0.62 }, { \"id\": 9964202, \"amount\": 0.03 }, { \"id\": 9964207, \"amount\": 0.23 }, { \"id\": 9964215, \"amount\": 0.25 }, { \"id\": 9964216, \"amount\": 0.11 }, { \"id\": 9964222, \"amount\": 0.2 }, { \"id\": 9964226, \"amount\": 0.16 }, { \"id\": 9964230, \"amount\": 0.42 }, { \"id\": 9964232, \"amount\": 0.14 }, { \"id\": 9964238, \"amount\": 0.15 }, { \"id\": 9964241, \"amount\": 0.18 }, { \"id\": 9964251, \"amount\": 0.35 }, { \"id\": 9964262, \"amount\": 0.04 }, { \"id\": 9964270, \"amount\": 0.07 }, { \"id\": 9964274, \"amount\": 0.08 }, { \"id\": 9964275, \"amount\": 0.61 }, { \"id\": 9964276, \"amount\": 0.16 }, { \"id\": 9964287, \"amount\": 0.58 }, { \"id\": 9964300, \"amount\": 0.06 }, { \"id\": 9964302, \"amount\": 0.02 }, { \"id\": 9964303, \"amount\": 0.04 }, { \"id\": 9964304, \"amount\": 0.13 }, { \"id\": 9964306, \"amount\": 0.05 }, { \"id\": 9964308, \"amount\": 0.73 }, { \"id\": 9964311, \"amount\": 0.25 }, { \"id\": 9964313, \"amount\": 0.27 }, { \"id\": 9964318, \"amount\": 0.04 }, { \"id\": 9964320, \"amount\": 0.54 }, { \"id\": 9964322, \"amount\": 0.12 }, { \"id\": 9964327, \"amount\": 0.27 }, { \"id\": 9964339, \"amount\": 0.03 }, { \"id\": 9964342, \"amount\": 0.14 }, { \"id\": 9964345, \"amount\": 0.08 }, { \"id\": 9964347, \"amount\": 0.09 }, { \"id\": 9964351, \"amount\": 0.18 }, { \"id\": 9964354, \"amount\": 0.1 }, { \"id\": 9964364, \"amount\": 0.05 }, { \"id\": 9964369, \"amount\": 0.12 }, { \"id\": 9964370, \"amount\": 0.13 }, { \"id\": 9964372, \"amount\": 0.08 }, { \"id\": 9964374, \"amount\": 0.47 }, { \"id\": 9964380, \"amount\": 0.32 }, { \"id\": 9964381, \"amount\": 0.19 }, { \"id\": 9964388, \"amount\": 0.1 }, { \"id\": 9964394, \"amount\": 0.1 }, { \"id\": 9964404, \"amount\": 0.05 }, { \"id\": 9964411, \"amount\": 0.24 }, { \"id\": 9964416, \"amount\": 0.08 }, { \"id\": 9964422, \"amount\": 0.6 }, { \"id\": 9964424, \"amount\": 0.21 }, { \"id\": 9964425, \"amount\": 0.17 }, { \"id\": 9964426, \"amount\": 0.12 }, { \"id\": 9964429, \"amount\": 0.02 }, { \"id\": 9964445, \"amount\": 0.41 }, { \"id\": 9964452, \"amount\": 0.1 }, { \"id\": 9964453, \"amount\": 0.18 }, { \"id\": 9964454, \"amount\": 0.34 }, { \"id\": 9964455, \"amount\": 0.09 }, { \"id\": 9964463, \"amount\": 0.14 }, { \"id\": 9964467, \"amount\": 0.1 }, { \"id\": 9964469, \"amount\": 0.18 }, { \"id\": 9964478, \"amount\": 0.43 }, { \"id\": 9964479, \"amount\": 0.3 }, { \"id\": 9964484, \"amount\": 0.33 }, { \"id\": 9964486, \"amount\": 0.32 }, { \"id\": 9964487, \"amount\": 0.6 }, { \"id\": 9964491, \"amount\": 0.49 }, { \"id\": 9964495, \"amount\": 0.58 }, { \"id\": 9964497, \"amount\": 0.0 }, { \"id\": 9964510, \"amount\": 0.06 }, { \"id\": 9964512, \"amount\": 0.06 }, { \"id\": 9964513, \"amount\": 0.08 }, { \"id\": 9964517, \"amount\": 0.35 }, { \"id\": 9964521, \"amount\": 0.23 }, { \"id\": 9964526, \"amount\": 0.47 }, { \"id\": 9964532, \"amount\": 0.03 }, { \"id\": 9964535, \"amount\": 0.06 }, { \"id\": 9964537, \"amount\": 0.35 }, { \"id\": 9964541, \"amount\": 0.62 }, { \"id\": 9964542, \"amount\": 0.29 }, { \"id\": 9964543, \"amount\": 0.05 }, { \"id\": 9964548, \"amount\": 0.0 }, { \"id\": 9964549, \"amount\": 0.53 }, { \"id\": 9964551, \"amount\": 0.02 }, { \"id\": 9964554, \"amount\": 0.27 }, { \"id\": 9964557, \"amount\": 0.56 }, { \"id\": 9964562, \"amount\": 0.59 }, { \"id\": 9964563, \"amount\": 0.35 }, { \"id\": 9964578, \"amount\": 0.11 }, { \"id\": 9964581, \"amount\": 0.44 }, { \"id\": 9964586, \"amount\": 0.13 }, { \"id\": 9964587, \"amount\": 0.4 }, { \"id\": 9964589, \"amount\": 0.41 }, { \"id\": 9964591, \"amount\": 0.2 }, { \"id\": 9964593, \"amount\": 0.01 }, { \"id\": 9964597, \"amount\": 0.33 }, { \"id\": 9964603, \"amount\": 0.08 }, { \"id\": 9964610, \"amount\": 0.31 }, { \"id\": 9964626, \"amount\": 0.18 }, { \"id\": 9964627, \"amount\": 0.59 }, { \"id\": 9964629, \"amount\": 0.93 }, { \"id\": 9964631, \"amount\": 0.26 }, { \"id\": 9964635, \"amount\": 0.0 }, { \"id\": 9964636, \"amount\": 0.48 }, { \"id\": 9964644, \"amount\": 0.67 }, { \"id\": 9964653, \"amount\": 0.52 }, { \"id\": 9964659, \"amount\": 0.2 }, { \"id\": 9964660, \"amount\": 0.11 }, { \"id\": 9964662, \"amount\": 0.59 }, { \"id\": 9964675, \"amount\": 0.25 }, { \"id\": 9964686, \"amount\": 0.6 }, { \"id\": 9964691, \"amount\": 0.24 }, { \"id\": 9964693, \"amount\": 0.15 }, { \"id\": 9964698, \"amount\": 0.45 }, { \"id\": 9964707, \"amount\": 0.61 }, { \"id\": 9964711, \"amount\": 0.01 }, { \"id\": 9964717, \"amount\": 0.16 }, { \"id\": 9964720, \"amount\": 0.06 }, { \"id\": 9964721, \"amount\": 0.24 }, { \"id\": 9964724, \"amount\": 0.22 }, { \"id\": 9964733, \"amount\": 0.1 }, { \"id\": 9964734, \"amount\": 0.64 }, { \"id\": 9964744, \"amount\": 0.03 }, { \"id\": 9964746, \"amount\": 0.59 }, { \"id\": 9964751, \"amount\": 0.46 }, { \"id\": 9964752, \"amount\": 0.18 }, { \"id\": 9964756, \"amount\": 0.02 }, { \"id\": 9964758, \"amount\": 0.63 }, { \"id\": 9964764, \"amount\": 0.23 }, { \"id\": 9964766, \"amount\": 0.18 }, { \"id\": 9964771, \"amount\": 0.25 }, { \"id\": 9964781, \"amount\": 0.15 }, { \"id\": 9964785, \"amount\": 0.56 }, { \"id\": 9964786, \"amount\": 0.36 }, { \"id\": 9964788, \"amount\": 0.57 }, { \"id\": 9964792, \"amount\": 0.34 }, { \"id\": 9964801, \"amount\": 0.57 }, { \"id\": 9964803, \"amount\": 0.18 }, { \"id\": 9964807, \"amount\": 0.56 }, { \"id\": 9964809, \"amount\": 0.18 }, { \"id\": 9964811, \"amount\": 0.04 }, { \"id\": 9964816, \"amount\": 0.27 }, { \"id\": 9964817, \"amount\": 0.24 }, { \"id\": 9964819, \"amount\": 0.24 }, { \"id\": 9964820, \"amount\": 0.57 }, { \"id\": 9964821, \"amount\": 0.13 }, { \"id\": 9964822, \"amount\": 0.13 }, { \"id\": 9964824, \"amount\": 0.04 }, { \"id\": 9964826, \"amount\": 0.09 }, { \"id\": 9964829, \"amount\": 0.32 }, { \"id\": 9964831, \"amount\": 0.05 }, { \"id\": 9964832, \"amount\": 0.23 }, { \"id\": 9964836, \"amount\": 0.46 }, { \"id\": 9964839, \"amount\": 0.4 }, { \"id\": 9964854, \"amount\": 0.04 }, { \"id\": 9964859, \"amount\": 0.04 }, { \"id\": 9964863, \"amount\": 0.73 }, { \"id\": 9964868, \"amount\": 0.04 }, { \"id\": 9964877, \"amount\": 0.07 }, { \"id\": 9964881, \"amount\": 0.05 }, { \"id\": 9964890, \"amount\": 0.34 }, { \"id\": 9964891, \"amount\": 0.12 }, { \"id\": 9964894, \"amount\": 0.05 }, { \"id\": 9964897, \"amount\": 0.11 }, { \"id\": 9964900, \"amount\": 0.48 }, { \"id\": 9964901, \"amount\": 0.07 }, { \"id\": 9964904, \"amount\": 0.3 }, { \"id\": 9964908, \"amount\": 0.06 }, { \"id\": 9964909, \"amount\": 0.29 }, { \"id\": 9964910, \"amount\": 0.02 }, { \"id\": 9964913, \"amount\": 0.1 }, { \"id\": 9964922, \"amount\": 0.12 }, { \"id\": 9964926, \"amount\": 0.18 }, { \"id\": 9964927, \"amount\": 0.12 }, { \"id\": 9964933, \"amount\": 0.05 }, { \"id\": 9964937, \"amount\": 0.05 }, { \"id\": 9964943, \"amount\": 0.06 }, { \"id\": 9964946, \"amount\": 0.3 }, { \"id\": 9964948, \"amount\": 0.22 }, { \"id\": 9964949, \"amount\": 0.19 }, { \"id\": 9964952, \"amount\": 0.19 }, { \"id\": 9964956, \"amount\": 0.13 }, { \"id\": 9964962, \"amount\": 0.58 }, { \"id\": 9964963, \"amount\": 0.18 }, { \"id\": 9964966, \"amount\": 0.12 }, { \"id\": 9964975, \"amount\": 0.07 }, { \"id\": 9964977, \"amount\": 0.03 }, { \"id\": 9964979, \"amount\": 0.02 }, { \"id\": 9964982, \"amount\": 0.1 }, { \"id\": 9964983, \"amount\": 0.14 }, { \"id\": 9964988, \"amount\": 0.18 }, { \"id\": 9964994, \"amount\": 0.13 }, { \"id\": 9965000, \"amount\": 0.07 }, { \"id\": 9965010, \"amount\": 0.15 }, { \"id\": 9965018, \"amount\": 0.13 }, { \"id\": 9965019, \"amount\": 0.43 }, { \"id\": 9965020, \"amount\": 0.5 }, { \"id\": 9965024, \"amount\": 0.18 }, { \"id\": 9965028, \"amount\": 0.11 }, { \"id\": 9965029, \"amount\": 0.24 }, { \"id\": 9965031, \"amount\": 0.45 }, { \"id\": 9965047, \"amount\": 0.18 }, { \"id\": 9965055, \"amount\": 0.18 }, { \"id\": 9965059, \"amount\": 0.02 }, { \"id\": 9965060, \"amount\": 0.01 }, { \"id\": 9965064, \"amount\": 0.08 }, { \"id\": 9965073, \"amount\": 0.06 }, { \"id\": 9965079, \"amount\": 0.06 }, { \"id\": 9965086, \"amount\": 0.1 }, { \"id\": 9965089, \"amount\": 0.06 }, { \"id\": 9965093, \"amount\": 0.36 }, { \"id\": 9965098, \"amount\": 0.08 }, { \"id\": 9965099, \"amount\": 0.04 }, { \"id\": 9965102, \"amount\": 0.47 }, { \"id\": 9965103, \"amount\": 0.26 }, { \"id\": 9965104, \"amount\": 0.61 }, { \"id\": 9965123, \"amount\": 0.25 }, { \"id\": 9965125, \"amount\": 0.1 }, { \"id\": 9965128, \"amount\": 0.05 }, { \"id\": 9965130, \"amount\": 0.13 }, { \"id\": 9965135, \"amount\": 0.55 }, { \"id\": 9965142, \"amount\": 0.2 }, { \"id\": 9965148, \"amount\": 0.09 }, { \"id\": 9965166, \"amount\": 0.21 }, { \"id\": 9965171, \"amount\": 0.18 }, { \"id\": 9965175, \"amount\": 0.52 }, { \"id\": 9965181, \"amount\": 0.1 }, { \"id\": 9965186, \"amount\": 0.28 }, { \"id\": 9965187, \"amount\": 0.06 }, { \"id\": 9965193, \"amount\": 0.05 }, { \"id\": 9965200, \"amount\": 0.61 }, { \"id\": 9965201, \"amount\": 0.06 }, { \"id\": 9965207, \"amount\": 0.24 }, { \"id\": 9965216, \"amount\": 0.37 }, { \"id\": 9965221, \"amount\": 0.04 }, { \"id\": 9965232, \"amount\": 0.07 }, { \"id\": 9965233, \"amount\": 0.06 }, { \"id\": 9965234, \"amount\": 0.11 }, { \"id\": 9965241, \"amount\": 0.25 }, { \"id\": 9965250, \"amount\": 0.66 }, { \"id\": 9965251, \"amount\": 0.06 }, { \"id\": 9965254, \"amount\": 0.24 }, { \"id\": 9965258, \"amount\": 0.41 }, { \"id\": 9965271, \"amount\": 0.3 }, { \"id\": 9965273, \"amount\": 0.63 }, { \"id\": 9965279, \"amount\": 0.15 }, { \"id\": 9965284, \"amount\": 0.13 }, { \"id\": 9965286, \"amount\": 0.04 }, { \"id\": 9965288, \"amount\": 0.05 }, { \"id\": 9965289, \"amount\": 0.37 }, { \"id\": 9965290, \"amount\": 0.09 }, { \"id\": 9965306, \"amount\": 0.05 }, { \"id\": 9965308, \"amount\": 0.1 }, { \"id\": 9965315, \"amount\": 0.07 }, { \"id\": 9965317, \"amount\": 0.1 }, { \"id\": 9965320, \"amount\": 0.24 }, { \"id\": 9965321, \"amount\": 0.65 }, { \"id\": 9965322, \"amount\": 0.04 }, { \"id\": 9965324, \"amount\": 0.0 }, { \"id\": 9965328, \"amount\": 0.69 }, { \"id\": 9965329, \"amount\": 0.52 }, { \"id\": 9965330, \"amount\": 0.61 }, { \"id\": 9965333, \"amount\": 0.12 }, { \"id\": 9965335, \"amount\": 0.77 }, { \"id\": 9965355, \"amount\": 0.17 }, { \"id\": 9965358, \"amount\": 0.09 }, { \"id\": 9965363, \"amount\": 0.54 }, { \"id\": 9965364, \"amount\": 0.04 }, { \"id\": 9965367, \"amount\": 0.24 }, { \"id\": 9965368, \"amount\": 0.06 }, { \"id\": 9965370, \"amount\": 0.25 }, { \"id\": 9965373, \"amount\": 0.33 }, { \"id\": 9965377, \"amount\": 0.18 }, { \"id\": 9965384, \"amount\": 0.51 }, { \"id\": 9965386, \"amount\": 0.24 }, { \"id\": 9965399, \"amount\": 0.04 }, { \"id\": 9965401, \"amount\": 0.08 }, { \"id\": 9965402, \"amount\": 0.06 }, { \"id\": 9965407, \"amount\": 0.56 }, { \"id\": 9965411, \"amount\": 0.12 }, { \"id\": 9965416, \"amount\": 0.03 }, { \"id\": 9965421, \"amount\": 0.04 }, { \"id\": 9965423, \"amount\": 0.12 }, { \"id\": 9965424, \"amount\": 0.14 }, { \"id\": 9965426, \"amount\": 0.73 }, { \"id\": 9965428, \"amount\": 0.18 }, { \"id\": 9965433, \"amount\": 0.09 }, { \"id\": 9965446, \"amount\": 0.49 }, { \"id\": 9965453, \"amount\": 0.11 }, { \"id\": 9965455, \"amount\": 0.51 }, { \"id\": 9965458, \"amount\": 0.03 }, { \"id\": 9965461, \"amount\": 0.08 }, { \"id\": 9965467, \"amount\": 0.29 }, { \"id\": 9965470, \"amount\": 0.44 }, { \"id\": 9965475, \"amount\": 0.05 }, { \"id\": 9965476, \"amount\": 0.48 }, { \"id\": 9965480, \"amount\": 0.37 }, { \"id\": 9965483, \"amount\": 0.34 }, { \"id\": 9965487, \"amount\": 0.15 }, { \"id\": 9965488, \"amount\": 0.0 }, { \"id\": 9965489, \"amount\": 0.39 }, { \"id\": 9965491, \"amount\": 0.07 }, { \"id\": 9965493, \"amount\": 0.16 }, { \"id\": 9965496, \"amount\": 0.42 }, { \"id\": 9965510, \"amount\": 0.15 }, { \"id\": 9965512, \"amount\": 0.41 }, { \"id\": 9965515, \"amount\": 0.03 }, { \"id\": 9965517, \"amount\": 0.23 }, { \"id\": 9965529, \"amount\": 0.32 }, { \"id\": 9965531, \"amount\": 0.63 }, { \"id\": 9965540, \"amount\": 0.16 }, { \"id\": 9965542, \"amount\": 0.03 }, { \"id\": 9965543, \"amount\": 0.38 }, { \"id\": 9965547, \"amount\": 0.09 }, { \"id\": 9965548, \"amount\": 0.05 }, { \"id\": 9965549, \"amount\": 0.07 }, { \"id\": 9965552, \"amount\": 0.24 }, { \"id\": 9965559, \"amount\": 0.33 }, { \"id\": 9965564, \"amount\": 0.28 }, { \"id\": 9965568, \"amount\": 0.58 }, { \"id\": 9965579, \"amount\": 0.52 }, { \"id\": 9965583, \"amount\": 0.23 }, { \"id\": 9965593, \"amount\": 0.59 }, { \"id\": 9965597, \"amount\": 0.15 }, { \"id\": 9965604, \"amount\": 0.18 }, { \"id\": 9965606, \"amount\": 0.57 }, { \"id\": 9965610, \"amount\": 0.11 }, { \"id\": 9965620, \"amount\": 0.2 }, { \"id\": 9965623, \"amount\": 0.26 }, { \"id\": 9965624, \"amount\": 0.03 }, { \"id\": 9965626, \"amount\": 0.09 }, { \"id\": 9965629, \"amount\": 0.52 }, { \"id\": 9965630, \"amount\": 0.01 }, { \"id\": 9965631, \"amount\": 0.11 }, { \"id\": 9965635, \"amount\": 0.21 }, { \"id\": 9965642, \"amount\": 0.67 }, { \"id\": 9965643, \"amount\": 0.64 }, { \"id\": 9965646, \"amount\": 0.3 }, { \"id\": 9965655, \"amount\": 0.03 }, { \"id\": 9965656, \"amount\": 0.24 }, { \"id\": 9965658, \"amount\": 0.64 }, { \"id\": 9965660, \"amount\": 0.27 }, { \"id\": 9965668, \"amount\": 0.67 }, { \"id\": 9965671, \"amount\": 0.18 }, { \"id\": 9965672, \"amount\": 0.05 }, { \"id\": 9965674, \"amount\": 0.13 }, { \"id\": 9965677, \"amount\": 0.61 }, { \"id\": 9965679, \"amount\": 0.01 }, { \"id\": 9965683, \"amount\": 0.16 }, { \"id\": 9965684, \"amount\": 0.13 }, { \"id\": 9965685, \"amount\": 0.62 }, { \"id\": 9965691, \"amount\": 0.52 }, { \"id\": 9965695, \"amount\": 0.28 }, { \"id\": 9965716, \"amount\": 0.04 }, { \"id\": 9965733, \"amount\": 0.43 }, { \"id\": 9965734, \"amount\": 0.57 }, { \"id\": 9965736, \"amount\": 0.26 }, { \"id\": 9965739, \"amount\": 0.17 }, { \"id\": 9965742, \"amount\": 0.08 }, { \"id\": 9965746, \"amount\": 0.3 }, { \"id\": 9965748, \"amount\": 0.28 }, { \"id\": 9965750, \"amount\": 0.13 }, { \"id\": 9965751, \"amount\": 0.57 }, { \"id\": 9965752, \"amount\": 0.02 }, { \"id\": 9965753, \"amount\": 0.15 }, { \"id\": 9965754, \"amount\": 0.33 }, { \"id\": 9965755, \"amount\": 0.06 }, { \"id\": 9965768, \"amount\": 0.15 }, { \"id\": 9965772, \"amount\": 0.27 }, { \"id\": 9965778, \"amount\": 0.57 }, { \"id\": 9965783, \"amount\": 0.11 }, { \"id\": 9965787, \"amount\": 0.3 }, { \"id\": 9965805, \"amount\": 0.07 }, { \"id\": 9965812, \"amount\": 0.13 }, { \"id\": 9965816, \"amount\": 0.23 }, { \"id\": 9965820, \"amount\": 0.63 }, { \"id\": 9965829, \"amount\": 0.2 }, { \"id\": 9965831, \"amount\": 0.06 }, { \"id\": 9965832, \"amount\": 0.26 }, { \"id\": 9965834, \"amount\": 0.13 }, { \"id\": 9965836, \"amount\": 0.06 }, { \"id\": 9965841, \"amount\": 0.52 }, { \"id\": 9965844, \"amount\": 0.02 }, { \"id\": 9965846, \"amount\": 0.08 }, { \"id\": 9965848, \"amount\": 0.12 }, { \"id\": 9965853, \"amount\": 0.63 }, { \"id\": 9965860, \"amount\": 0.22 }, { \"id\": 9965865, \"amount\": 0.78 }, { \"id\": 9965872, \"amount\": 0.01 }, { \"id\": 9965873, \"amount\": 0.08 }, { \"id\": 9965878, \"amount\": 0.11 }, { \"id\": 9965881, \"amount\": 0.27 }, { \"id\": 9965882, \"amount\": 0.26 }, { \"id\": 9965883, \"amount\": 0.61 }, { \"id\": 9965884, \"amount\": 0.56 }, { \"id\": 9965889, \"amount\": 0.57 }, { \"id\": 9965891, \"amount\": 0.12 }, { \"id\": 9965899, \"amount\": 0.41 }, { \"id\": 9965909, \"amount\": 0.1 }, { \"id\": 9965912, \"amount\": 0.06 }, { \"id\": 9965913, \"amount\": 0.07 }, { \"id\": 9965918, \"amount\": 0.06 }, { \"id\": 9965922, \"amount\": 0.03 }, { \"id\": 9965924, \"amount\": 0.19 }, { \"id\": 9965927, \"amount\": 0.17 }, { \"id\": 9965929, \"amount\": 0.12 }, { \"id\": 9965938, \"amount\": 0.15 }, { \"id\": 9965939, \"amount\": 0.25 }, { \"id\": 9965942, \"amount\": 0.02 }, { \"id\": 9965946, \"amount\": 0.21 }, { \"id\": 9965948, \"amount\": 0.12 }, { \"id\": 9965949, \"amount\": 0.29 }, { \"id\": 9965950, \"amount\": 0.11 }, { \"id\": 9965955, \"amount\": 0.25 }, { \"id\": 9965963, \"amount\": 0.09 }, { \"id\": 9965965, \"amount\": 0.14 }, { \"id\": 9965966, \"amount\": 0.23 }, { \"id\": 9965972, \"amount\": 0.04 }, { \"id\": 9965974, \"amount\": 0.14 }, { \"id\": 9965977, \"amount\": 0.19 }, { \"id\": 9965980, \"amount\": 0.34 }, { \"id\": 9965983, \"amount\": 0.08 }, { \"id\": 9965985, \"amount\": 0.08 }, { \"id\": 9965986, \"amount\": 0.14 }, { \"id\": 9965990, \"amount\": 0.06 }, { \"id\": 9965994, \"amount\": 0.0 }, { \"id\": 9965997, \"amount\": 0.07 }, { \"id\": 9966004, \"amount\": 0.16 }, { \"id\": 9966008, \"amount\": 0.08 }, { \"id\": 9966016, \"amount\": 0.09 }, { \"id\": 9966017, \"amount\": 0.05 }, { \"id\": 9966020, \"amount\": 0.09 }, { \"id\": 9966023, \"amount\": 0.27 }, { \"id\": 9966028, \"amount\": 0.18 }, { \"id\": 9966031, \"amount\": 0.66 }, { \"id\": 9966033, \"amount\": 0.04 }, { \"id\": 9966042, \"amount\": 0.04 }, { \"id\": 9966049, \"amount\": 0.64 }, { \"id\": 9966050, \"amount\": 0.21 }, { \"id\": 9966052, \"amount\": 0.61 }, { \"id\": 9966056, \"amount\": 0.03 }, { \"id\": 9966059, \"amount\": 0.09 }, { \"id\": 9966063, \"amount\": 0.29 }, { \"id\": 9966068, \"amount\": 0.63 }, { \"id\": 9966077, \"amount\": 0.09 }, { \"id\": 9966078, \"amount\": 0.02 }, { \"id\": 9966087, \"amount\": 0.18 }, { \"id\": 9966089, \"amount\": 0.07 }, { \"id\": 9966093, \"amount\": 0.44 }, { \"id\": 9966094, \"amount\": 0.13 }, { \"id\": 9966096, \"amount\": 0.36 }, { \"id\": 9966102, \"amount\": 0.03 }, { \"id\": 9966104, \"amount\": 0.59 }, { \"id\": 9966114, \"amount\": 0.23 }, { \"id\": 9966116, \"amount\": 0.6 }, { \"id\": 9966121, \"amount\": 0.19 }, { \"id\": 9966124, \"amount\": 0.06 }, { \"id\": 9966128, \"amount\": 0.04 }, { \"id\": 9966134, \"amount\": 0.09 }, { \"id\": 9966137, \"amount\": 0.74 }, { \"id\": 9966140, \"amount\": 0.27 }, { \"id\": 9966148, \"amount\": 0.68 }, { \"id\": 9966149, \"amount\": 0.57 }, { \"id\": 9966155, \"amount\": 0.28 }, { \"id\": 9966158, \"amount\": 0.18 }, { \"id\": 9966167, \"amount\": 0.02 }, { \"id\": 9966168, \"amount\": 0.11 }, { \"id\": 9966175, \"amount\": 0.4 }, { \"id\": 9966186, \"amount\": 0.07 }, { \"id\": 9966190, \"amount\": 0.18 }, { \"id\": 9966195, \"amount\": 0.06 }, { \"id\": 9966205, \"amount\": 0.06 }, { \"id\": 9966206, \"amount\": 0.06 }, { \"id\": 9966209, \"amount\": 0.17 }, { \"id\": 9966212, \"amount\": 0.07 }, { \"id\": 9966217, \"amount\": 0.12 }, { \"id\": 9966225, \"amount\": 0.16 }, { \"id\": 9966226, \"amount\": 0.04 }, { \"id\": 9966231, \"amount\": 0.17 }, { \"id\": 9966233, \"amount\": 0.2 }, { \"id\": 9966236, \"amount\": 0.33 }, { \"id\": 9966237, \"amount\": 0.2 }, { \"id\": 9966238, \"amount\": 0.11 }, { \"id\": 9966239, \"amount\": 0.14 }, { \"id\": 9966241, \"amount\": 0.22 }, { \"id\": 9966243, \"amount\": 0.5 }, { \"id\": 9966253, \"amount\": 0.11 }, { \"id\": 9966260, \"amount\": 0.12 }, { \"id\": 9966266, \"amount\": 0.24 }, { \"id\": 9966269, \"amount\": 0.03 }, { \"id\": 9966272, \"amount\": 0.62 }, { \"id\": 9966275, \"amount\": 0.33 }, { \"id\": 9966276, \"amount\": 0.68 }, { \"id\": 9966279, \"amount\": 0.2 }, { \"id\": 9966281, \"amount\": 0.19 }, { \"id\": 9966285, \"amount\": 0.61 }, { \"id\": 9966303, \"amount\": 0.45 }, { \"id\": 9966307, \"amount\": 0.12 }, { \"id\": 9966308, \"amount\": 0.07 }, { \"id\": 9966324, \"amount\": 0.59 }, { \"id\": 9966329, \"amount\": 0.3 }, { \"id\": 9966330, \"amount\": 0.13 }, { \"id\": 9966336, \"amount\": 0.22 }, { \"id\": 9966338, \"amount\": 0.72 }, { \"id\": 9966345, \"amount\": 0.33 }, { \"id\": 9966346, \"amount\": 0.5 }, { \"id\": 9966348, \"amount\": 0.43 }, { \"id\": 9966351, \"amount\": 0.5 }, { \"id\": 9966353, \"amount\": 0.66 }, { \"id\": 9966354, \"amount\": 0.04 }, { \"id\": 9966364, \"amount\": 0.18 }, { \"id\": 9966369, \"amount\": 0.03 }, { \"id\": 9966370, \"amount\": 0.62 }, { \"id\": 9966371, \"amount\": 0.32 }, { \"id\": 9966374, \"amount\": 0.34 }, { \"id\": 9966379, \"amount\": 0.11 }, { \"id\": 9966380, \"amount\": 0.06 }, { \"id\": 9966396, \"amount\": 0.26 }, { \"id\": 9966397, \"amount\": 0.13 }, { \"id\": 9966398, \"amount\": 0.07 }, { \"id\": 9966399, \"amount\": 0.74 }, { \"id\": 9966403, \"amount\": 0.0 }, { \"id\": 9966407, \"amount\": 0.08 }, { \"id\": 9966416, \"amount\": 0.31 }, { \"id\": 9966419, \"amount\": 0.55 }, { \"id\": 9966422, \"amount\": 0.3 }, { \"id\": 9966423, \"amount\": 0.26 }, { \"id\": 9966424, \"amount\": 0.32 }, { \"id\": 9966425, \"amount\": 0.1 }, { \"id\": 9966426, \"amount\": 0.07 }, { \"id\": 9966433, \"amount\": 0.08 }, { \"id\": 9966439, \"amount\": 0.14 }, { \"id\": 9966443, \"amount\": 0.02 }, { \"id\": 9966444, \"amount\": 0.06 }, { \"id\": 9966454, \"amount\": 0.12 }, { \"id\": 9966455, \"amount\": 0.3 }, { \"id\": 9966460, \"amount\": 0.02 }, { \"id\": 9966463, \"amount\": 0.5 }, { \"id\": 9966465, \"amount\": 0.2 }, { \"id\": 9966468, \"amount\": 0.29 }, { \"id\": 9966472, \"amount\": 0.53 }, { \"id\": 9966478, \"amount\": 0.22 }, { \"id\": 9966486, \"amount\": 0.11 }, { \"id\": 9966488, \"amount\": 0.29 }, { \"id\": 9966492, \"amount\": 0.05 }, { \"id\": 9966505, \"amount\": 0.03 }, { \"id\": 9966507, \"amount\": 0.22 }, { \"id\": 9966508, \"amount\": 0.46 }, { \"id\": 9966510, \"amount\": 0.46 }, { \"id\": 9966515, \"amount\": 0.14 }, { \"id\": 9966518, \"amount\": 0.43 }, { \"id\": 9966525, \"amount\": 0.59 }, { \"id\": 9966526, \"amount\": 0.08 }, { \"id\": 9966531, \"amount\": 0.01 }, { \"id\": 9966532, \"amount\": 0.26 }, { \"id\": 9966535, \"amount\": 0.25 }, { \"id\": 9966540, \"amount\": 0.08 }, { \"id\": 9966541, \"amount\": 0.7 }, { \"id\": 9966542, \"amount\": 0.11 }, { \"id\": 9966544, \"amount\": 0.25 }, { \"id\": 9966550, \"amount\": 0.04 }, { \"id\": 9966555, \"amount\": 0.08 }, { \"id\": 9966559, \"amount\": 0.08 }, { \"id\": 9966562, \"amount\": 0.75 }, { \"id\": 9966566, \"amount\": 0.62 }, { \"id\": 9966572, \"amount\": 0.56 }, { \"id\": 9966573, \"amount\": 0.4 }, { \"id\": 9966581, \"amount\": 0.07 }, { \"id\": 9966592, \"amount\": 0.19 }, { \"id\": 9966606, \"amount\": 0.08 }, { \"id\": 9966615, \"amount\": 0.39 }, { \"id\": 9966622, \"amount\": 0.49 }, { \"id\": 9966626, \"amount\": 0.04 }, { \"id\": 9966635, \"amount\": 0.2 }, { \"id\": 9966636, \"amount\": 0.02 }, { \"id\": 9966641, \"amount\": 0.3 }, { \"id\": 9966646, \"amount\": 0.04 }, { \"id\": 9966650, \"amount\": 0.01 }, { \"id\": 9966655, \"amount\": 0.24 }, { \"id\": 9966656, \"amount\": 0.46 }, { \"id\": 9966661, \"amount\": 0.4 }, { \"id\": 9966667, \"amount\": 0.02 }, { \"id\": 9966668, \"amount\": 0.57 }, { \"id\": 9966678, \"amount\": 0.19 }, { \"id\": 9966682, \"amount\": 0.25 }, { \"id\": 9966685, \"amount\": 0.11 }, { \"id\": 9966692, \"amount\": 0.19 }, { \"id\": 9966700, \"amount\": 0.16 }, { \"id\": 9966706, \"amount\": 0.1 }, { \"id\": 9966709, \"amount\": 0.33 }, { \"id\": 9966712, \"amount\": 0.41 }, { \"id\": 9966716, \"amount\": 0.02 }, { \"id\": 9966720, \"amount\": 0.01 }, { \"id\": 9966729, \"amount\": 0.23 }, { \"id\": 9966730, \"amount\": 0.31 }, { \"id\": 9966732, \"amount\": 0.26 }, { \"id\": 9966736, \"amount\": 0.15 }, { \"id\": 9966737, \"amount\": 0.46 }, { \"id\": 9966743, \"amount\": 0.05 }, { \"id\": 9966744, \"amount\": 0.43 }, { \"id\": 9966752, \"amount\": 0.25 }, { \"id\": 9966756, \"amount\": 0.11 }, { \"id\": 9966757, \"amount\": 0.2 }, { \"id\": 9966758, \"amount\": 0.54 }, { \"id\": 9966766, \"amount\": 0.0 }, { \"id\": 9966767, \"amount\": 0.56 }, { \"id\": 9966771, \"amount\": 0.62 }, { \"id\": 9966773, \"amount\": 0.49 }, { \"id\": 9966775, \"amount\": 0.2 }, { \"id\": 9966778, \"amount\": 0.64 }, { \"id\": 9966779, \"amount\": 0.62 }, { \"id\": 9966789, \"amount\": 0.52 }, { \"id\": 9966798, \"amount\": 0.27 }, { \"id\": 9966800, \"amount\": 0.69 }, { \"id\": 9966803, \"amount\": 0.7 }, { \"id\": 9966811, \"amount\": 0.51 }, { \"id\": 9966823, \"amount\": 0.02 }, { \"id\": 9966829, \"amount\": 0.45 }, { \"id\": 9966847, \"amount\": 0.05 }, { \"id\": 9966855, \"amount\": 0.11 }, { \"id\": 9966857, \"amount\": 0.57 }, { \"id\": 9966860, \"amount\": 0.02 }, { \"id\": 9966862, \"amount\": 0.44 }, { \"id\": 9966867, \"amount\": 0.18 }, { \"id\": 9966871, \"amount\": 0.04 }, { \"id\": 9966872, \"amount\": 0.54 }, { \"id\": 9966877, \"amount\": 0.01 }, { \"id\": 9966879, \"amount\": 0.11 }, { \"id\": 9966881, \"amount\": 0.1 }, { \"id\": 9966883, \"amount\": 0.35 }, { \"id\": 9966888, \"amount\": 0.52 }, { \"id\": 9966899, \"amount\": 0.31 }, { \"id\": 9966900, \"amount\": 0.61 }, { \"id\": 9966902, \"amount\": 0.01 }, { \"id\": 9966903, \"amount\": 0.03 }, { \"id\": 9966906, \"amount\": 0.63 }, { \"id\": 9966907, \"amount\": 0.47 }, { \"id\": 9966909, \"amount\": 0.32 }, { \"id\": 9966911, \"amount\": 0.1 }, { \"id\": 9966912, \"amount\": 0.05 }, { \"id\": 9966913, \"amount\": 0.3 }, { \"id\": 9966914, \"amount\": 0.68 }, { \"id\": 9966916, \"amount\": 0.38 }, { \"id\": 9966917, \"amount\": 0.04 }, { \"id\": 9966921, \"amount\": 0.34 }, { \"id\": 9966922, \"amount\": 0.49 }, { \"id\": 9966925, \"amount\": 0.16 }, { \"id\": 9966927, \"amount\": 0.17 }, { \"id\": 9966932, \"amount\": 0.24 }, { \"id\": 9966933, \"amount\": 0.69 }, { \"id\": 9966935, \"amount\": 0.06 }, { \"id\": 9966945, \"amount\": 0.22 }, { \"id\": 9966951, \"amount\": 0.01 }, { \"id\": 9966955, \"amount\": 0.1 }, { \"id\": 9966957, \"amount\": 0.13 }, { \"id\": 9966958, \"amount\": 0.02 }, { \"id\": 9966964, \"amount\": 0.01 }, { \"id\": 9966968, \"amount\": 0.33 }, { \"id\": 9966970, \"amount\": 0.05 }, { \"id\": 9966971, \"amount\": 0.39 }, { \"id\": 9966977, \"amount\": 0.29 }, { \"id\": 9966984, \"amount\": 0.6 }, { \"id\": 9966985, \"amount\": 0.6 }, { \"id\": 9966987, \"amount\": 0.09 }, { \"id\": 9966996, \"amount\": 0.13 }, { \"id\": 9966998, \"amount\": 0.16 }, { \"id\": 9966999, \"amount\": 0.02 }, { \"id\": 9967009, \"amount\": 0.33 }, { \"id\": 9967011, \"amount\": 0.29 }, { \"id\": 9967012, \"amount\": 0.2 }, { \"id\": 9967014, \"amount\": 0.19 }, { \"id\": 9967015, \"amount\": 0.05 }, { \"id\": 9967016, \"amount\": 0.03 }, { \"id\": 9967017, \"amount\": 0.09 }, { \"id\": 9967019, \"amount\": 0.59 }, { \"id\": 9967025, \"amount\": 0.04 }, { \"id\": 9967027, \"amount\": 0.28 }, { \"id\": 9967045, \"amount\": 0.2 }, { \"id\": 9967046, \"amount\": 0.6 }, { \"id\": 9967049, \"amount\": 0.11 }, { \"id\": 9967059, \"amount\": 0.39 }, { \"id\": 9967062, \"amount\": 0.08 }, { \"id\": 9967067, \"amount\": 0.04 }, { \"id\": 9967071, \"amount\": 0.02 }, { \"id\": 9967072, \"amount\": 0.19 }, { \"id\": 9967073, \"amount\": 0.01 }, { \"id\": 9967074, \"amount\": 0.1 }, { \"id\": 9967076, \"amount\": 0.26 }, { \"id\": 9967077, \"amount\": 0.48 }, { \"id\": 9967081, \"amount\": 0.03 }, { \"id\": 9967086, \"amount\": 0.04 }, { \"id\": 9967088, \"amount\": 0.34 }, { \"id\": 9967095, \"amount\": 0.46 }, { \"id\": 9967102, \"amount\": 0.23 }, { \"id\": 9967125, \"amount\": 0.17 }, { \"id\": 9967128, \"amount\": 0.63 }, { \"id\": 9967130, \"amount\": 0.04 }, { \"id\": 9967136, \"amount\": 0.57 }, { \"id\": 9967137, \"amount\": 0.12 }, { \"id\": 9967153, \"amount\": 0.03 }, { \"id\": 9967154, \"amount\": 0.1 }, { \"id\": 9967159, \"amount\": 0.11 }, { \"id\": 9967167, \"amount\": 0.23 }, { \"id\": 9967171, \"amount\": 0.07 }, { \"id\": 9967176, \"amount\": 0.16 }, { \"id\": 9967178, \"amount\": 0.19 }, { \"id\": 9967179, \"amount\": 0.07 }, { \"id\": 9967180, \"amount\": 0.51 }, { \"id\": 9967182, \"amount\": 0.11 }, { \"id\": 9967188, \"amount\": 0.12 }, { \"id\": 9967195, \"amount\": 0.25 }, { \"id\": 9967201, \"amount\": 0.01 }, { \"id\": 9967210, \"amount\": 0.48 }, { \"id\": 9967211, \"amount\": 0.01 }, { \"id\": 9967217, \"amount\": 0.26 }, { \"id\": 9967218, \"amount\": 0.4 }, { \"id\": 9967226, \"amount\": 0.1 }, { \"id\": 9967229, \"amount\": 0.17 }, { \"id\": 9967230, \"amount\": 0.08 }, { \"id\": 9967234, \"amount\": 0.25 }, { \"id\": 9967235, \"amount\": 0.13 }, { \"id\": 9967239, \"amount\": 0.03 }, { \"id\": 9967245, \"amount\": 0.17 }, { \"id\": 9967248, \"amount\": 0.38 }, { \"id\": 9967263, \"amount\": 0.31 }, { \"id\": 9967271, \"amount\": 0.45 }, { \"id\": 9967278, \"amount\": 0.11 }, { \"id\": 9967280, \"amount\": 0.66 }, { \"id\": 9967283, \"amount\": 0.24 }, { \"id\": 9967289, \"amount\": 0.25 }, { \"id\": 9967299, \"amount\": 0.15 }, { \"id\": 9967301, \"amount\": 0.18 }, { \"id\": 9967307, \"amount\": 0.25 }, { \"id\": 9967310, \"amount\": 0.27 }, { \"id\": 9967319, \"amount\": 0.43 }, { \"id\": 9967327, \"amount\": 0.11 }, { \"id\": 9967337, \"amount\": 0.41 }, { \"id\": 9967338, \"amount\": 0.65 }, { \"id\": 9967340, \"amount\": 0.67 }, { \"id\": 9967341, \"amount\": 0.14 }, { \"id\": 9967348, \"amount\": 0.15 }, { \"id\": 9967349, \"amount\": 0.07 }, { \"id\": 9967350, \"amount\": 0.54 }, { \"id\": 9967354, \"amount\": 0.06 }, { \"id\": 9967361, \"amount\": 0.37 }, { \"id\": 9967364, \"amount\": 0.03 }, { \"id\": 9967377, \"amount\": 0.1 }, { \"id\": 9967385, \"amount\": 0.19 }, { \"id\": 9967386, \"amount\": 0.63 }, { \"id\": 9967391, \"amount\": 0.08 }, { \"id\": 9967395, \"amount\": 0.55 }, { \"id\": 9967396, \"amount\": 0.36 }, { \"id\": 9967403, \"amount\": 0.16 }, { \"id\": 9967407, \"amount\": 0.13 }, { \"id\": 9967412, \"amount\": 0.39 }, { \"id\": 9967419, \"amount\": 0.33 }, { \"id\": 9967420, \"amount\": 0.11 }, { \"id\": 9967422, \"amount\": 0.3 }, { \"id\": 9967423, \"amount\": 0.5 }, { \"id\": 9967427, \"amount\": 0.03 }, { \"id\": 9967432, \"amount\": 0.57 }, { \"id\": 9967437, \"amount\": 0.08 }, { \"id\": 9967438, \"amount\": 0.32 }, { \"id\": 9967441, \"amount\": 0.53 }, { \"id\": 9967443, \"amount\": 0.34 }, { \"id\": 9967449, \"amount\": 0.09 }, { \"id\": 9967452, \"amount\": 0.4 }, { \"id\": 9967454, \"amount\": 0.02 }, { \"id\": 9967455, \"amount\": 0.29 }, { \"id\": 9967459, \"amount\": 0.54 }, { \"id\": 9967463, \"amount\": 0.14 }, { \"id\": 9967464, \"amount\": 0.11 }, { \"id\": 9967470, \"amount\": 0.05 }, { \"id\": 9967472, \"amount\": 0.4 }, { \"id\": 9967482, \"amount\": 0.09 }, { \"id\": 9967487, \"amount\": 0.0 }, { \"id\": 9967490, \"amount\": 0.43 }, { \"id\": 9967493, \"amount\": 0.0 }, { \"id\": 9967496, \"amount\": 0.17 }, { \"id\": 9967498, \"amount\": 0.63 }, { \"id\": 9967500, \"amount\": 0.27 }, { \"id\": 9967502, \"amount\": 0.07 }, { \"id\": 9967505, \"amount\": 0.55 }, { \"id\": 9967508, \"amount\": 0.07 }, { \"id\": 9967510, \"amount\": 0.08 }, { \"id\": 9967513, \"amount\": 0.12 }, { \"id\": 9967514, \"amount\": 0.28 }, { \"id\": 9967515, \"amount\": 0.35 }, { \"id\": 9967519, \"amount\": 0.08 }, { \"id\": 9967521, \"amount\": 0.12 }, { \"id\": 9967524, \"amount\": 0.19 }, { \"id\": 9967529, \"amount\": 0.03 }, { \"id\": 9967535, \"amount\": 0.11 }, { \"id\": 9967541, \"amount\": 0.01 }, { \"id\": 9967551, \"amount\": 0.25 }, { \"id\": 9967557, \"amount\": 0.0 }, { \"id\": 9967563, \"amount\": 0.12 }, { \"id\": 9967564, \"amount\": 0.32 }, { \"id\": 9967570, \"amount\": 0.44 }, { \"id\": 9967572, \"amount\": 0.28 }, { \"id\": 9967581, \"amount\": 0.22 }, { \"id\": 9967589, \"amount\": 0.08 }, { \"id\": 9967601, \"amount\": 0.03 }, { \"id\": 9967608, \"amount\": 0.06 }, { \"id\": 9967609, \"amount\": 0.68 }, { \"id\": 9967611, \"amount\": 0.08 }, { \"id\": 9967616, \"amount\": 0.13 }, { \"id\": 9967625, \"amount\": 0.53 }, { \"id\": 9967627, \"amount\": 0.51 }, { \"id\": 9967637, \"amount\": 0.18 }, { \"id\": 9967641, \"amount\": 0.1 }, { \"id\": 9967647, \"amount\": 0.81 }, { \"id\": 9967655, \"amount\": 0.3 }, { \"id\": 9967660, \"amount\": 0.61 }, { \"id\": 9967667, \"amount\": 0.29 }, { \"id\": 9967673, \"amount\": 0.15 }, { \"id\": 9967685, \"amount\": 0.24 }, { \"id\": 9967694, \"amount\": 0.15 }, { \"id\": 9967696, \"amount\": 0.1 }, { \"id\": 9967699, \"amount\": 0.13 }, { \"id\": 9967703, \"amount\": 0.22 }, { \"id\": 9967709, \"amount\": 0.13 }, { \"id\": 9967724, \"amount\": 0.26 }, { \"id\": 9967734, \"amount\": 0.25 }, { \"id\": 9967736, \"amount\": 0.63 }, { \"id\": 9967738, \"amount\": 0.58 }, { \"id\": 9967740, \"amount\": 0.27 }, { \"id\": 9967744, \"amount\": 0.03 }, { \"id\": 9967747, \"amount\": 0.39 }, { \"id\": 9967754, \"amount\": 0.32 }, { \"id\": 9967758, \"amount\": 0.36 }, { \"id\": 9967763, \"amount\": 0.57 }, { \"id\": 9967765, \"amount\": 0.24 }, { \"id\": 9967773, \"amount\": 0.61 }, { \"id\": 9967779, \"amount\": 0.14 }, { \"id\": 9967783, \"amount\": 0.6 }, { \"id\": 9967786, \"amount\": 0.36 }, { \"id\": 9967787, \"amount\": 0.31 }, { \"id\": 9967788, \"amount\": 0.35 }, { \"id\": 9967792, \"amount\": 0.62 }, { \"id\": 9967795, \"amount\": 0.1 }, { \"id\": 9967806, \"amount\": 0.28 }, { \"id\": 9967835, \"amount\": 0.1 }, { \"id\": 9967849, \"amount\": 0.32 }, { \"id\": 9967852, \"amount\": 0.13 }, { \"id\": 9967856, \"amount\": 0.2 }, { \"id\": 9967866, \"amount\": 0.01 }, { \"id\": 9967871, \"amount\": 0.04 }, { \"id\": 9967873, \"amount\": 0.07 }, { \"id\": 9967874, \"amount\": 0.09 }, { \"id\": 9967882, \"amount\": 0.13 }, { \"id\": 9967884, \"amount\": 0.21 }, { \"id\": 9967893, \"amount\": 0.4 }, { \"id\": 9967899, \"amount\": 0.1 }, { \"id\": 9967903, \"amount\": 0.23 }, { \"id\": 9967916, \"amount\": 0.28 }, { \"id\": 9967920, \"amount\": 0.71 }, { \"id\": 9967930, \"amount\": 0.24 }, { \"id\": 9967937, \"amount\": 0.41 }, { \"id\": 9967938, \"amount\": 0.1 }, { \"id\": 9967943, \"amount\": 0.13 }, { \"id\": 9967949, \"amount\": 0.06 }, { \"id\": 9967952, \"amount\": 0.45 }, { \"id\": 9967957, \"amount\": 0.02 }, { \"id\": 9967958, \"amount\": 0.18 }, { \"id\": 9967960, \"amount\": 0.25 }, { \"id\": 9967965, \"amount\": 0.03 }, { \"id\": 9967967, \"amount\": 0.64 }, { \"id\": 9967968, \"amount\": 0.21 }, { \"id\": 9967979, \"amount\": 0.05 }, { \"id\": 9967980, \"amount\": 0.12 }, { \"id\": 9967985, \"amount\": 0.58 }, { \"id\": 9967986, \"amount\": 0.04 }, { \"id\": 9967993, \"amount\": 0.6 }, { \"id\": 9968001, \"amount\": 0.31 }, { \"id\": 9968007, \"amount\": 0.09 }, { \"id\": 9968009, \"amount\": 0.05 }, { \"id\": 9968017, \"amount\": 0.06 }, { \"id\": 9968084, \"amount\": 0.46 }, { \"id\": 9968088, \"amount\": 0.12 }, { \"id\": 9968092, \"amount\": 0.09 }, { \"id\": 9968093, \"amount\": 0.07 }, { \"id\": 9968097, \"amount\": 0.3 }, { \"id\": 9968099, \"amount\": 0.43 }, { \"id\": 9968101, \"amount\": 0.2 }, { \"id\": 9968112, \"amount\": 0.43 }, { \"id\": 9968115, \"amount\": 0.37 }, { \"id\": 9968116, \"amount\": 0.56 }, { \"id\": 9968117, \"amount\": 0.11 }, { \"id\": 9968135, \"amount\": 0.59 }, { \"id\": 9968136, \"amount\": 0.54 }, { \"id\": 9968153, \"amount\": 0.04 }, { \"id\": 9968164, \"amount\": 0.29 }, { \"id\": 9968178, \"amount\": 0.19 }, { \"id\": 9968182, \"amount\": 0.21 }, { \"id\": 9968191, \"amount\": 0.24 }, { \"id\": 9968193, \"amount\": 0.65 }, { \"id\": 9968200, \"amount\": 0.04 }, { \"id\": 9968207, \"amount\": 0.29 }, { \"id\": 9968208, \"amount\": 0.12 }, { \"id\": 9968211, \"amount\": 0.06 }, { \"id\": 9968220, \"amount\": 0.14 }, { \"id\": 9968221, \"amount\": 0.29 }, { \"id\": 9968227, \"amount\": 0.07 }, { \"id\": 9968237, \"amount\": 0.1 }, { \"id\": 9968239, \"amount\": 0.72 }, { \"id\": 9968256, \"amount\": 0.33 }, { \"id\": 9968266, \"amount\": 0.08 }, { \"id\": 9968281, \"amount\": 0.2 }, { \"id\": 9968285, \"amount\": 0.11 }, { \"id\": 9968292, \"amount\": 0.28 }, { \"id\": 9968300, \"amount\": 0.14 }, { \"id\": 9968307, \"amount\": 0.06 }, { \"id\": 9968313, \"amount\": 0.16 }, { \"id\": 9968314, \"amount\": 0.08 }, { \"id\": 9968327, \"amount\": 0.57 }, { \"id\": 9968329, \"amount\": 0.89 }, { \"id\": 9968333, \"amount\": 0.18 }, { \"id\": 9968334, \"amount\": 0.04 }, { \"id\": 9968335, \"amount\": 0.03 }, { \"id\": 9968341, \"amount\": 0.0 }, { \"id\": 9968350, \"amount\": 0.5 }, { \"id\": 9968351, \"amount\": 0.19 }, { \"id\": 9968352, \"amount\": 0.13 }, { \"id\": 9968363, \"amount\": 0.29 }, { \"id\": 9968368, \"amount\": 0.59 }, { \"id\": 9968383, \"amount\": 0.22 }, { \"id\": 9968384, \"amount\": 0.52 }, { \"id\": 9968393, \"amount\": 0.05 }, { \"id\": 9968400, \"amount\": 0.43 }, { \"id\": 9968407, \"amount\": 0.24 }, { \"id\": 9968411, \"amount\": 0.25 }, { \"id\": 9968417, \"amount\": 0.16 }, { \"id\": 9968418, \"amount\": 0.22 }, { \"id\": 9968447, \"amount\": 0.49 }, { \"id\": 9968449, \"amount\": 0.09 }, { \"id\": 9968452, \"amount\": 0.16 }, { \"id\": 9968474, \"amount\": 0.19 }, { \"id\": 9968482, \"amount\": 0.02 }, { \"id\": 9968483, \"amount\": 0.03 }, { \"id\": 9968496, \"amount\": 0.14 }, { \"id\": 9968498, \"amount\": 0.28 }, { \"id\": 9968500, \"amount\": 0.07 }, { \"id\": 9968517, \"amount\": 0.12 }, { \"id\": 9968518, \"amount\": 0.05 }, { \"id\": 9968520, \"amount\": 0.14 }, { \"id\": 9968528, \"amount\": 0.3 }, { \"id\": 9968555, \"amount\": 0.16 }, { \"id\": 9968571, \"amount\": 0.51 }, { \"id\": 9968584, \"amount\": 0.37 }, { \"id\": 9968606, \"amount\": 0.15 }, { \"id\": 9968615, \"amount\": 0.14 }, { \"id\": 9968624, \"amount\": 0.37 }, { \"id\": 9968632, \"amount\": 0.21 }, { \"id\": 9968636, \"amount\": 0.08 }, { \"id\": 9968655, \"amount\": 0.34 }, { \"id\": 9968656, \"amount\": 0.08 }, { \"id\": 9968663, \"amount\": 0.09 }, { \"id\": 9968666, \"amount\": 0.65 }, { \"id\": 9968673, \"amount\": 0.31 }, { \"id\": 9968677, \"amount\": 0.05 }, { \"id\": 9968685, \"amount\": 0.22 }, { \"id\": 9968693, \"amount\": 0.14 }, { \"id\": 9968699, \"amount\": 0.11 }, { \"id\": 9968703, \"amount\": 0.38 }, { \"id\": 9968705, \"amount\": 0.2 }, { \"id\": 9968707, \"amount\": 0.08 }, { \"id\": 9968708, \"amount\": 0.09 }, { \"id\": 9968709, \"amount\": 0.47 }, { \"id\": 9968718, \"amount\": 0.27 }, { \"id\": 9968719, \"amount\": 0.71 }, { \"id\": 9968722, \"amount\": 0.04 }, { \"id\": 9968725, \"amount\": 0.31 }, { \"id\": 9968729, \"amount\": 0.04 }, { \"id\": 9968736, \"amount\": 0.03 }, { \"id\": 9968738, \"amount\": 0.25 }, { \"id\": 9968746, \"amount\": 0.36 }, { \"id\": 9968747, \"amount\": 0.59 }, { \"id\": 9968748, \"amount\": 0.34 }, { \"id\": 9968749, \"amount\": 0.48 }, { \"id\": 9968750, \"amount\": 0.11 }, { \"id\": 9968753, \"amount\": 0.55 }, { \"id\": 9968757, \"amount\": 0.24 }, { \"id\": 9968760, \"amount\": 0.06 }, { \"id\": 9968765, \"amount\": 0.02 }, { \"id\": 9968771, \"amount\": 0.36 }, { \"id\": 9968775, \"amount\": 0.06 }, { \"id\": 9968782, \"amount\": 0.08 }, { \"id\": 9968784, \"amount\": 0.05 }, { \"id\": 9968785, \"amount\": 0.01 }, { \"id\": 9968788, \"amount\": 0.34 }, { \"id\": 9968789, \"amount\": 0.04 }, { \"id\": 9968791, \"amount\": 0.08 }, { \"id\": 9968799, \"amount\": 0.33 }, { \"id\": 9968803, \"amount\": 0.29 }, { \"id\": 9968804, \"amount\": 0.42 }, { \"id\": 9968810, \"amount\": 0.29 }, { \"id\": 9968817, \"amount\": 0.17 }, { \"id\": 9968821, \"amount\": 0.24 }, { \"id\": 9968831, \"amount\": 0.22 }, { \"id\": 9968833, \"amount\": 0.06 }, { \"id\": 9968841, \"amount\": 0.06 }, { \"id\": 9968846, \"amount\": 0.22 }, { \"id\": 9968866, \"amount\": 0.39 }, { \"id\": 9968868, \"amount\": 0.06 }, { \"id\": 9968874, \"amount\": 0.1 }, { \"id\": 9968877, \"amount\": 0.03 }, { \"id\": 9968878, \"amount\": 0.6 }, { \"id\": 9968885, \"amount\": 0.25 }, { \"id\": 9968891, \"amount\": 0.1 }, { \"id\": 9968894, \"amount\": 0.38 }, { \"id\": 9968900, \"amount\": 0.37 }, { \"id\": 9968902, \"amount\": 0.29 }, { \"id\": 9968903, \"amount\": 0.18 }, { \"id\": 9968907, \"amount\": 0.14 }, { \"id\": 9968910, \"amount\": 0.67 }, { \"id\": 9968913, \"amount\": 0.02 }, { \"id\": 9968916, \"amount\": 0.29 }, { \"id\": 9968919, \"amount\": 0.14 }, { \"id\": 9968920, \"amount\": 0.13 }, { \"id\": 9968922, \"amount\": 0.46 }, { \"id\": 9968924, \"amount\": 0.65 }, { \"id\": 9968933, \"amount\": 0.06 }, { \"id\": 9968940, \"amount\": 0.39 }, { \"id\": 9968943, \"amount\": 0.02 }, { \"id\": 9968954, \"amount\": 0.31 }, { \"id\": 9968965, \"amount\": 0.59 }, { \"id\": 9968969, \"amount\": 0.1 }, { \"id\": 9968981, \"amount\": 0.65 }, { \"id\": 9968983, \"amount\": 0.59 }, { \"id\": 9968991, \"amount\": 0.07 }, { \"id\": 9968997, \"amount\": 0.22 }, { \"id\": 9969010, \"amount\": 0.03 }, { \"id\": 9969026, \"amount\": 0.3 }, { \"id\": 9969027, \"amount\": 0.08 }, { \"id\": 9969035, \"amount\": 0.34 }, { \"id\": 9969040, \"amount\": 0.51 }, { \"id\": 9969042, \"amount\": 0.24 }, { \"id\": 9969048, \"amount\": 0.43 }, { \"id\": 9969049, \"amount\": 0.23 }, { \"id\": 9969053, \"amount\": 0.79 }, { \"id\": 9969060, \"amount\": 0.15 }, { \"id\": 9969071, \"amount\": 0.16 }, { \"id\": 9969072, \"amount\": 0.18 }, { \"id\": 9969074, \"amount\": 0.46 }, { \"id\": 9969085, \"amount\": 0.03 }, { \"id\": 9969086, \"amount\": 0.08 }, { \"id\": 9969087, \"amount\": 0.36 }, { \"id\": 9969088, \"amount\": 0.21 }, { \"id\": 9969093, \"amount\": 0.36 }, { \"id\": 9969103, \"amount\": 0.38 }, { \"id\": 9969106, \"amount\": 0.15 }, { \"id\": 9969109, \"amount\": 0.08 }, { \"id\": 9969136, \"amount\": 0.28 }, { \"id\": 9969142, \"amount\": 0.07 }, { \"id\": 9969154, \"amount\": 0.01 }, { \"id\": 9969156, \"amount\": 0.11 }, { \"id\": 9969164, \"amount\": 0.26 }, { \"id\": 9969176, \"amount\": 0.11 }, { \"id\": 9969177, \"amount\": 0.08 }, { \"id\": 9969179, \"amount\": 0.05 }, { \"id\": 9969181, \"amount\": 0.07 }, { \"id\": 9969182, \"amount\": 0.63 }, { \"id\": 9969186, \"amount\": 0.05 }, { \"id\": 9969194, \"amount\": 0.16 }, { \"id\": 9969197, \"amount\": 0.1 }, { \"id\": 9969201, \"amount\": 0.51 }, { \"id\": 9969203, \"amount\": 0.34 }, { \"id\": 9969209, \"amount\": 0.63 }, { \"id\": 9969232, \"amount\": 0.1 }, { \"id\": 9969241, \"amount\": 0.01 }, { \"id\": 9969243, \"amount\": 0.0 }, { \"id\": 9969261, \"amount\": 0.27 }, { \"id\": 9969271, \"amount\": 0.28 }, { \"id\": 9969280, \"amount\": 0.07 }, { \"id\": 9969281, \"amount\": 0.3 }, { \"id\": 9969297, \"amount\": 0.13 }, { \"id\": 9969311, \"amount\": 0.62 }, { \"id\": 9969312, \"amount\": 0.03 }, { \"id\": 9969314, \"amount\": 0.22 }, { \"id\": 9969325, \"amount\": 0.08 }, { \"id\": 9969331, \"amount\": 0.11 }, { \"id\": 9969333, \"amount\": 0.23 }, { \"id\": 9969355, \"amount\": 0.5 }, { \"id\": 9969361, \"amount\": 0.61 }, { \"id\": 9969369, \"amount\": 0.19 }, { \"id\": 9969370, \"amount\": 0.61 }, { \"id\": 9969373, \"amount\": 0.44 }, { \"id\": 9969377, \"amount\": 0.22 }, { \"id\": 9969381, \"amount\": 0.27 }, { \"id\": 9969392, \"amount\": 0.16 }, { \"id\": 9969396, \"amount\": 0.56 }, { \"id\": 9969401, \"amount\": 0.0 }, { \"id\": 9969407, \"amount\": 0.49 }, { \"id\": 9969408, \"amount\": 0.71 }, { \"id\": 9969409, \"amount\": 0.12 }, { \"id\": 9969415, \"amount\": 0.52 }, { \"id\": 9969418, \"amount\": 0.56 }, { \"id\": 9969427, \"amount\": 0.74 }, { \"id\": 9969430, \"amount\": 0.72 }, { \"id\": 9969433, \"amount\": 0.55 }, { \"id\": 9969437, \"amount\": 0.32 }, { \"id\": 9969445, \"amount\": 0.71 }, { \"id\": 9969447, \"amount\": 0.75 }, { \"id\": 9969456, \"amount\": 0.14 }, { \"id\": 9969461, \"amount\": 0.33 }, { \"id\": 9969464, \"amount\": 0.32 }, { \"id\": 9969469, \"amount\": 0.26 }, { \"id\": 9969475, \"amount\": 0.05 }, { \"id\": 9969497, \"amount\": 0.02 }, { \"id\": 9969503, \"amount\": 0.26 }, { \"id\": 9969511, \"amount\": 0.43 }, { \"id\": 9969518, \"amount\": 0.03 }, { \"id\": 9969527, \"amount\": 0.05 }, { \"id\": 9969535, \"amount\": 0.04 }, { \"id\": 9969547, \"amount\": 0.04 }, { \"id\": 9969553, \"amount\": 0.38 }, { \"id\": 9969554, \"amount\": 0.09 }, { \"id\": 9969583, \"amount\": 0.56 }, { \"id\": 9969585, \"amount\": 0.46 }, { \"id\": 9969591, \"amount\": 0.12 }, { \"id\": 9969593, \"amount\": 0.02 }, { \"id\": 9969599, \"amount\": 0.11 }, { \"id\": 9969601, \"amount\": 0.41 }, { \"id\": 9969620, \"amount\": 0.63 }, { \"id\": 9969624, \"amount\": 0.23 }, { \"id\": 9969637, \"amount\": 0.32 }, { \"id\": 9969638, \"amount\": 0.14 }, { \"id\": 9969639, \"amount\": 0.2 }, { \"id\": 9969656, \"amount\": 0.1 }, { \"id\": 9969671, \"amount\": 0.11 }, { \"id\": 9969676, \"amount\": 0.62 }, { \"id\": 9969698, \"amount\": 0.15 }, { \"id\": 9969708, \"amount\": 0.14 }, { \"id\": 9969727, \"amount\": 0.0 }, { \"id\": 9969730, \"amount\": 0.02 }, { \"id\": 9969735, \"amount\": 0.18 }, { \"id\": 9969741, \"amount\": 0.42 }, { \"id\": 9969765, \"amount\": 0.35 }, { \"id\": 9969769, \"amount\": 0.06 }, { \"id\": 9969777, \"amount\": 0.07 }, { \"id\": 9969782, \"amount\": 0.21 }, { \"id\": 9969790, \"amount\": 0.03 }, { \"id\": 9969813, \"amount\": 0.23 }, { \"id\": 9969843, \"amount\": 0.64 }, { \"id\": 9969852, \"amount\": 0.11 }, { \"id\": 9969869, \"amount\": 0.38 }, { \"id\": 9969898, \"amount\": 0.53 }, { \"id\": 9969901, \"amount\": 0.36 }, { \"id\": 9969912, \"amount\": 0.07 }, { \"id\": 9969920, \"amount\": 0.09 }, { \"id\": 9969927, \"amount\": 0.04 }, { \"id\": 9969944, \"amount\": 0.57 }, { \"id\": 9969958, \"amount\": 0.12 }, { \"id\": 9970016, \"amount\": 0.63 }, { \"id\": 9970018, \"amount\": 0.53 }, { \"id\": 9970032, \"amount\": 0.27 }, { \"id\": 9970040, \"amount\": 0.02 }, { \"id\": 9970063, \"amount\": 0.24 }, { \"id\": 9970074, \"amount\": 0.49 }, { \"id\": 9970089, \"amount\": 0.52 }, { \"id\": 9970105, \"amount\": 0.56 }, { \"id\": 9970123, \"amount\": 0.07 }, { \"id\": 9970141, \"amount\": 0.3 }, { \"id\": 9970142, \"amount\": 0.13 }, { \"id\": 9970157, \"amount\": 0.05 }, { \"id\": 9970235, \"amount\": 0.05 }, { \"id\": 9970305, \"amount\": 0.27 }, { \"id\": 9970334, \"amount\": 0.1 }, { \"id\": 9970345, \"amount\": 0.64 }, { \"id\": 9970368, \"amount\": 0.16 }, { \"id\": 9970421, \"amount\": 0.28 }, { \"id\": 9970442, \"amount\": 0.34 }, { \"id\": 9970450, \"amount\": 0.33 }, { \"id\": 9970469, \"amount\": 0.63 }, { \"id\": 9970473, \"amount\": 0.57 }, { \"id\": 9970477, \"amount\": 0.15 }, { \"id\": 9970503, \"amount\": 0.56 }, { \"id\": 9970521, \"amount\": 0.28 }, { \"id\": 9970582, \"amount\": 0.17 }, { \"id\": 9970604, \"amount\": 0.22 }, { \"id\": 9970636, \"amount\": 0.28 }, { \"id\": 9970641, \"amount\": 0.36 }, { \"id\": 9970688, \"amount\": 0.15 }, { \"id\": 9970697, \"amount\": 0.15 }, { \"id\": 9970707, \"amount\": 0.08 }, { \"id\": 9970709, \"amount\": 0.14 }, { \"id\": 9970720, \"amount\": 0.25 }, { \"id\": 9970736, \"amount\": 0.68 }, { \"id\": 9970773, \"amount\": 0.02 }, { \"id\": 9970796, \"amount\": 0.77 }, { \"id\": 9970802, \"amount\": 0.25 }, { \"id\": 9970822, \"amount\": 0.58 }, { \"id\": 9970841, \"amount\": 0.07 }, { \"id\": 9970854, \"amount\": 0.39 }, { \"id\": 9970855, \"amount\": 0.39 }, { \"id\": 9970870, \"amount\": 0.04 }, { \"id\": 9970900, \"amount\": 0.34 }, { \"id\": 9970911, \"amount\": 0.18 }, { \"id\": 9970919, \"amount\": 0.3 }, { \"id\": 9970972, \"amount\": 0.39 }, { \"id\": 9970981, \"amount\": 0.15 }, { \"id\": 9971038, \"amount\": 0.15 }, { \"id\": 9971047, \"amount\": 0.11 }, { \"id\": 9971055, \"amount\": 0.72 }, { \"id\": 9971084, \"amount\": 0.1 }, { \"id\": 9971087, \"amount\": 0.13 }, { \"id\": 9971092, \"amount\": 0.35 }, { \"id\": 9971107, \"amount\": 0.09 }, { \"id\": 9971113, \"amount\": 0.3 }, { \"id\": 9971120, \"amount\": 0.0 }, { \"id\": 9971152, \"amount\": 0.22 }, { \"id\": 9971169, \"amount\": 0.18 }, { \"id\": 9971179, \"amount\": 0.19 }, { \"id\": 9971184, \"amount\": 0.15 }, { \"id\": 9971192, \"amount\": 0.14 }, { \"id\": 9971208, \"amount\": 0.36 }, { \"id\": 9971209, \"amount\": 0.37 }, { \"id\": 9971213, \"amount\": 0.02 }, { \"id\": 9971251, \"amount\": 0.73 }, { \"id\": 9971310, \"amount\": 0.61 }, { \"id\": 9971330, \"amount\": 0.1 }, { \"id\": 9971331, \"amount\": 0.31 }, { \"id\": 9971357, \"amount\": 0.28 }, { \"id\": 9971360, \"amount\": 0.63 }, { \"id\": 9971387, \"amount\": 0.06 }, { \"id\": 9971445, \"amount\": 0.18 }, { \"id\": 9971463, \"amount\": 0.03 }, { \"id\": 9971467, \"amount\": 0.62 }, { \"id\": 9971538, \"amount\": 0.18 }, { \"id\": 9971539, \"amount\": 0.62 }, { \"id\": 9971551, \"amount\": 0.13 }, { \"id\": 9971566, \"amount\": 0.04 }, { \"id\": 9971589, \"amount\": 0.33 }, { \"id\": 9971597, \"amount\": 0.31 }, { \"id\": 9971641, \"amount\": 0.38 }, { \"id\": 9971652, \"amount\": 0.38 }, { \"id\": 9971665, \"amount\": 0.38 }, { \"id\": 9971689, \"amount\": 0.16 }, { \"id\": 9971742, \"amount\": 0.38 }, { \"id\": 9971751, \"amount\": 0.5 }, { \"id\": 9971753, \"amount\": 0.1 }, { \"id\": 9971756, \"amount\": 0.22 }, { \"id\": 9971764, \"amount\": 0.14 }, { \"id\": 9971768, \"amount\": 0.15 }, { \"id\": 9971816, \"amount\": 0.68 }, { \"id\": 9971836, \"amount\": 0.04 }, { \"id\": 9971919, \"amount\": 0.16 }, { \"id\": 9971927, \"amount\": 0.21 }, { \"id\": 9971959, \"amount\": 0.1 }, { \"id\": 9971963, \"amount\": 0.64 }, { \"id\": 9972005, \"amount\": 0.28 }, { \"id\": 9972031, \"amount\": 0.23 }, { \"id\": 9972061, \"amount\": 0.23 }, { \"id\": 9972076, \"amount\": 0.03 }, { \"id\": 9972085, \"amount\": 0.09 }, { \"id\": 9972100, \"amount\": 0.39 }, { \"id\": 9972124, \"amount\": 0.3 }, { \"id\": 9972133, \"amount\": 0.22 }, { \"id\": 9972154, \"amount\": 0.14 }, { \"id\": 9972162, \"amount\": 0.17 }, { \"id\": 9972193, \"amount\": 0.43 }, { \"id\": 9972196, \"amount\": 0.25 }, { \"id\": 9972205, \"amount\": 0.58 }, { \"id\": 9972212, \"amount\": 0.25 }, { \"id\": 9972216, \"amount\": 0.19 }, { \"id\": 9972220, \"amount\": 0.14 }, { \"id\": 9972224, \"amount\": 0.28 }, { \"id\": 9972248, \"amount\": 0.04 }, { \"id\": 9972282, \"amount\": 0.04 }, { \"id\": 9972313, \"amount\": 0.23 }, { \"id\": 9972326, \"amount\": 0.01 }, { \"id\": 9972367, \"amount\": 0.05 }, { \"id\": 9972370, \"amount\": 0.01 }, { \"id\": 9972372, \"amount\": 0.29 }, { \"id\": 9972385, \"amount\": 0.0 }, { \"id\": 9972388, \"amount\": 0.01 }, { \"id\": 9972390, \"amount\": 0.06 }, { \"id\": 9972453, \"amount\": 0.7 }, { \"id\": 9972468, \"amount\": 0.09 }, { \"id\": 9972474, \"amount\": 0.25 }, { \"id\": 9972476, \"amount\": 0.07 }, { \"id\": 9972511, \"amount\": 0.5 }, { \"id\": 9972567, \"amount\": 0.52 }, { \"id\": 9972570, \"amount\": 0.09 }, { \"id\": 9972614, \"amount\": 0.27 }, { \"id\": 9972637, \"amount\": 0.48 }, { \"id\": 9972651, \"amount\": 0.06 }, { \"id\": 9972680, \"amount\": 0.26 }, { \"id\": 9972682, \"amount\": 0.07 }, { \"id\": 9972684, \"amount\": 0.44 }, { \"id\": 9972710, \"amount\": 0.31 }, { \"id\": 9972711, \"amount\": 0.04 }, { \"id\": 9972720, \"amount\": 0.41 }, { \"id\": 9972738, \"amount\": 0.28 }, { \"id\": 9972760, \"amount\": 0.52 }, { \"id\": 9972762, \"amount\": 0.15 }, { \"id\": 9972798, \"amount\": 0.04 }, { \"id\": 9972820, \"amount\": 0.07 }, { \"id\": 9972828, \"amount\": 0.05 }, { \"id\": 9972829, \"amount\": 0.54 }, { \"id\": 9972833, \"amount\": 0.1 }, { \"id\": 9972860, \"amount\": 0.52 }, { \"id\": 9972868, \"amount\": 0.21 }, { \"id\": 9972881, \"amount\": 0.12 }, { \"id\": 9972911, \"amount\": 0.47 }, { \"id\": 9972947, \"amount\": 0.57 }, { \"id\": 9973013, \"amount\": 0.1 }, { \"id\": 9973021, \"amount\": 0.29 }, { \"id\": 9973034, \"amount\": 0.13 }, { \"id\": 9973046, \"amount\": 0.45 }, { \"id\": 9973048, \"amount\": 0.39 }, { \"id\": 9973061, \"amount\": 0.42 }, { \"id\": 9973068, \"amount\": 0.59 }, { \"id\": 9973070, \"amount\": 0.16 }]\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/DotChart/index.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nexport { DotChart } from './DotChart';\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/ErrorRoot.tsx",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport React from 'react';\n\nimport { COMMON_STYLES } from '../stylesx';\nimport { css } from '../utilx';\nimport { SPLASH_STYLES, SplashRoot } from './SplashRoot';\n\ninterface IErrorRootProps {\n  errorMessage: string;\n  retry(): void;\n}\n\nexport function ErrorRoot(props: React.PropsWithChildren<IErrorRootProps>) {\n  return (\n    <SplashRoot>\n      <div key=\"errors\" {...css(SPLASH_STYLES.errors, COMMON_STYLES.fadeIn)}>\n        <p key=\"message\">{props.errorMessage}</p>\n        <p key=\"try-again\" {...css(SPLASH_STYLES.errorsTryAgain)}>\n          <a key=\"try-again\" onClick={props.retry} {...css(SPLASH_STYLES.link)}>Try Again</a>\n        </p>\n      </div>\n    </SplashRoot>\n  );\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/ErrorRootStory.tsx",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { storiesOf } from '@storybook/react';\nimport { ErrorRoot } from './ErrorRoot';\n\nfunction noop() {/**/}\n\nstoriesOf('Errors', module)\n  .add('Error page', () => {\n    return <ErrorRoot errorMessage=\"Hello there\" retry={noop}/>;\n  });\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/FlagsSummary.tsx",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport React from 'react';\n\nimport {FLAGS_COUNT, ICommentModel, RECOMMENDATIONS_COUNT, UNRESOLVED_FLAGS_COUNT} from '../../models';\n\nexport function FlagsSummary(props: {\n  comment: ICommentModel;\n  full?: boolean;\n}) {\n  const {\n    comment,\n    full,\n  } = props;\n\n  if (!comment.flagsSummary || comment.flagsSummary.size === 0) {\n    return null;\n  }\n\n  const summary = comment.flagsSummary;\n  const flags = Array.from(summary.keys())\n    .sort((a, b) => summary.get(b)[FLAGS_COUNT] - summary.get(a)[FLAGS_COUNT])\n    .filter((a) => summary.get(a)[RECOMMENDATIONS_COUNT] === 0);\n  const approves = Array.from(summary.keys())\n    .sort((a, b) => summary.get(b)[FLAGS_COUNT] - summary.get(a)[FLAGS_COUNT])\n    .filter((a) => summary.get(a)[RECOMMENDATIONS_COUNT] > 0);\n\n  function oneFlag(label: string) {\n    const f = summary.get(label);\n    const total = f[FLAGS_COUNT];\n    if (full) {\n      const un = f[UNRESOLVED_FLAGS_COUNT];\n      const unresolvedStr = (un > 0) ? `(${un})` : '';\n      return (<span key={label}>&bull; {label}: {total} {unresolvedStr}</span>);\n    }\n    return (<span key={label}>{label}: {total}</span>);\n  }\n\n  const unresolved = comment.unresolvedFlagsCount > 0 ?\n    <span key=\"__unresolved\">unresolved: {comment.unresolvedFlagsCount}</span> : '';\n\n  if (full) {\n    return (\n      <span>\n        &bull; Flags: {unresolved} {flags.map(oneFlag)} {approves.map(oneFlag)}\n      </span>\n    );\n  }\n\n  const topFlag = flags.length > 0 ? oneFlag(flags[0]) : '';\n  const topApprove = approves.length > 0 ? oneFlag(approves[0]) : '';\n  const theresMore = (flags.length > 1 || approves.length > 1) ? '...' : '';\n  return (\n    <span>\n      &bull; {unresolved} {topFlag} {topApprove} {theresMore}\n    </span>\n  );\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/HeaderBar.tsx",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport React from 'react';\nimport { Link } from 'react-router-dom';\n\nimport { AssignmentInd, Home, Menu, OpenInNew, Person, Search } from '@material-ui/icons';\n\nimport { ICategoryModel } from '../../models';\nimport { logout } from '../auth';\nimport { useRouteContext } from '../injectors/contextInjector';\nimport { dashboardLink, searchLink } from '../scenes/routes';\nimport {\n  GUTTER_DEFAULT_SPACING,\n  HEADER_HEIGHT,\n  HEADLINE_TYPE,\n  LIGHT_PRIMARY_TEXT_COLOR,\n  NICE_DARK_BLUE,\n  NICE_MIDDLE_BLUE,\n} from '../styles';\nimport { COMMON_STYLES } from '../stylesx';\nimport { css, stylesheet } from '../utilx';\n\nconst STYLES = stylesheet({\n  header: {\n    alignItems: 'center',\n    background: NICE_DARK_BLUE,\n    color: LIGHT_PRIMARY_TEXT_COLOR,\n    boxSizing: 'border-box',\n    display: 'flex',\n    width: '100%',\n    height: `${HEADER_HEIGHT}px`,\n  },\n\n  headerItem: {\n    color: LIGHT_PRIMARY_TEXT_COLOR,\n    textAlign: 'center',\n    paddingLeft: `${GUTTER_DEFAULT_SPACING}px`,\n    paddingRight: `${GUTTER_DEFAULT_SPACING}px`,\n    paddingTop: `${4}px`,\n    marginTop: `${3}px`,\n    flexGrow: 0,\n    height: `${HEADER_HEIGHT - 10 - 3}px`,\n  },\n\n  headerItemSelected: {\n    background: NICE_MIDDLE_BLUE,\n    borderTopLeftRadius: `${6}px`,\n    borderTopRightRadius: `${6}px`,\n  },\n\n  headerLink: {\n    color: LIGHT_PRIMARY_TEXT_COLOR,\n    textDecoration: 'none',\n    ':hover': {\n      textDecoration: 'underline',\n    },\n  },\n\n  headerText: {\n    fontSize: '10px',\n    color: LIGHT_PRIMARY_TEXT_COLOR,\n  },\n\n  menuIcon: {\n    color: LIGHT_PRIMARY_TEXT_COLOR,\n    margin: `0 10px  0 20px`,\n  },\n\n  title: {\n    ...HEADLINE_TYPE,\n    fontSize: '18px',\n    fontWeight: 600,\n    margin: '0 30px',\n  },\n});\n\nexport interface IHeaderBarProps {\n  category?: ICategoryModel;\n  title?: string;\n  homeLink?: boolean;\n  showSidebar?(): void;\n}\n\nexport function HeaderBar(props: IHeaderBarProps) {\n  function renderHeaderItem(icon: any, text: string, link: string) {\n    return (\n      <div key={text} {...css(STYLES.headerItem)}>\n        <Link to={link} aria-label={text} {...css(STYLES.headerLink)}>\n          <div>{icon}</div>\n          <div {...css(STYLES.headerText)}>{text}</div>\n        </Link>\n      </div>\n    );\n  }\n\n  const {\n    showSidebar,\n    homeLink,\n    title,\n  } = props;\n\n  const {category, article} = useRouteContext();\n  const categoryToUse = props.category || category;\n\n  const categoryStr = title ? title :\n    article ? `Article: ${article.title}` :\n      categoryToUse ? `Section: ${categoryToUse.label}` :\n        'All Sections';\n\n  const articleId = article && article.id;\n\n  return (\n    <header key=\"header\" role=\"banner\" {...css(STYLES.header)}>\n      {showSidebar && (\n        <div key=\"appName\" onClick={showSidebar}>\n          <span key=\"icon\" {...css(STYLES.menuIcon)}><Menu  style={{ fontSize: 30 }} /></span>\n        </div>\n      )}\n      {homeLink && renderHeaderItem(<Home/>, 'Dashboard', dashboardLink({}))}\n      <span key=\"cat\" {...css(STYLES.title)}>\n        {categoryStr}\n        {article && article.url && (\n          <div style={{display: 'inline-block', margin: '0 10px', position: 'relative', top: '3px'}}>\n            <a href={article.url} target=\"_blank\" {...css(COMMON_STYLES.cellLink)}>\n              <OpenInNew fontSize=\"small\"/>\n            </a>\n          </div>\n        )}\n      </span>\n      <div key=\"spacer\" style={{flexGrow: 1}}/>\n      {renderHeaderItem(<Search/>, 'Search', searchLink({articleId}))}\n      {renderHeaderItem(<AssignmentInd/>, 'By author', searchLink({articleId, searchByAuthor: true}))}\n      <div key=\"logout\" {...css(STYLES.headerItem)}>\n        <div {...css(STYLES.headerLink)} aria-label=\"Logout\" onClick={logout}>\n          <div><Person/></div>\n          <div {...css(STYLES.headerText)}>Logout</div>\n        </div>\n      </div>\n    </header>\n  );\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/Icons/IconBase.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport PropTypes from 'prop-types';\nimport React, { ReactNode } from 'react';\n\nimport { css } from '../../utilx';\n\nconst defaultSize = '24';\n\nexport interface IIconBaseProps {\n  size?: string | number;\n  style: any;\n}\n\nconst IconBase: React.FunctionComponent<any> = (\n  props: IIconBaseProps & { children?: ReactNode },\n  { reactIconBase },\n) => {\n  const { children, size, style, ...remainingProps } = props;\n\n  const computedSize = size ? size :\n                       (reactIconBase && reactIconBase.size || defaultSize);\n  const computedStyle = {\n    ...style,\n    verticalAlign: 'middle',\n    ...(reactIconBase ? (reactIconBase.style || {}) : {}),\n  };\n\n  return (\n    <svg\n      viewBox=\"0 0 24 24\"\n      fill=\"currentColor\"\n      preserveAspectRatio=\"xMidYMid meet\"\n      height={computedSize}\n      width={computedSize}\n      {...reactIconBase}\n      {...remainingProps}\n      {...css(computedStyle)}\n    >\n      {children}\n    </svg>\n  );\n};\n\nIconBase.contextTypes = {\n  reactIconBase: PropTypes.object,\n};\n\nexport { IconBase };\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/Icons/index.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport React from 'react';\nimport { IconBase } from './IconBase';\n\nconst icons: Array<React.ComponentClass<any>> = [];\n\nfunction makeIcon(contents: JSX.Element): React.ComponentClass<any> {\n  const icon = class extends React.PureComponent<any> {\n    render() {\n      return (\n        <IconBase {...this.props}>\n          {contents}\n        </IconBase>\n      );\n    }\n  };\n  icons.push(icon);\n  return icon;\n}\n\n/* tslint:disable:max-line-length */\nexport const AddIcon = makeIcon(<g><path d=\"M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z\"/><path d=\"M0 0h24v24H0z\" fill=\"none\"/></g>);\n\nexport const ApproveIcon = makeIcon(<g><path d=\"M0,0H24V24H0V0Z\" fill=\"none\" /><path d=\"M9,16.17L4.83,12,3.41,13.41,9,19,21,7,19.59,5.59Z\"/></g>);\n\nexport const ArrowIcon = makeIcon(<g><path d=\"M16,7H3.83L9.42,1.41,8,0,0,8l8,8,1.41-1.41L3.83,9H16Z\" transform=\"translate(4 4)\"/></g>);\nexport const ArrowFIcon = makeIcon(<g><path d=\"M16,7H3.83L9.42,1.41,8,0,0,8l8,8,1.41-1.41L3.83,9H16Z\" transform=\"matrix(-1 0 0 1 20 4)\"/></g>);\n\nexport const BatchIcon = makeIcon(<g><path d=\"M3,17.5A1.5,1.5,0,1,1,1.5,16,1.5,1.5,0,0,1,3,17.5\" /><path d=\"M3,13.5A1.5,1.5,0,1,1,1.5,12,1.5,1.5,0,0,1,3,13.5\" /><path d=\"M3,9.5A1.5,1.5,0,1,1,1.5,8,1.5,1.5,0,0,1,3,9.5\" /><path d=\"M7,9.5A1.5,1.5,0,1,1,5.5,8,1.5,1.5,0,0,1,7,9.5\" /><path d=\"M7,5.5A1.5,1.5,0,1,1,5.5,4,1.5,1.5,0,0,1,7,5.5\" /><path d=\"M7,13.5A1.5,1.5,0,1,1,5.5,12,1.5,1.5,0,0,1,7,13.5\" /><path d=\"M7,17.5A1.5,1.5,0,1,1,5.5,16,1.5,1.5,0,0,1,7,17.5\" /><path d=\"M11,17.5A1.5,1.5,0,1,1,9.5,16,1.5,1.5,0,0,1,11,17.5\" /><path d=\"M11,13.5A1.5,1.5,0,1,1,9.5,12,1.5,1.5,0,0,1,11,13.5\" /><path d=\"M11,9.5A1.5,1.5,0,1,1,9.5,8,1.5,1.5,0,0,1,11,9.5\" /><path d=\"M15,17.5A1.5,1.5,0,1,1,13.5,16,1.5,1.5,0,0,1,15,17.5\" /><path d=\"M15,13.5A1.5,1.5,0,1,1,13.5,12,1.5,1.5,0,0,1,15,13.5\" /><path d=\"M15,9.5A1.5,1.5,0,1,1,13.5,8,1.5,1.5,0,0,1,15,9.5\" /><path d=\"M15,5.5A1.5,1.5,0,1,1,13.5,4,1.5,1.5,0,0,1,15,5.5\" /><path d=\"M19,5.5A1.5,1.5,0,1,1,17.5,4,1.5,1.5,0,0,1,19,5.5\" /><path d=\"M19,9.5A1.5,1.5,0,1,1,17.5,8,1.5,1.5,0,0,1,19,9.5\" /><path d=\"M19,13.5A1.5,1.5,0,1,1,17.5,12,1.5,1.5,0,0,1,19,13.5\" /><path d=\"M19,17.5A1.5,1.5,0,1,1,17.5,16,1.5,1.5,0,0,1,19,17.5\" /><path d=\"M15,1.5A1.5,1.5,0,1,1,13.5,0,1.5,1.5,0,0,1,15,1.5\" /></g>);\n\nexport const ClockIcon = makeIcon(<g><path d=\"M10,0A10,10,0,1,0,20,10,10,10,0,0,0,10,0Zm0,18a8,8,0,1,1,8-8A8,8,0,0,1,10,18Z\" transform=\"translate(2 2)\"/><path d=\"M-2-2H22V22H-2Z\" transform=\"translate(2 2)\" fill=\"none\"/><path d=\"M10.5,5H9v6l5.25,3.15L15,12.92l-4.5-2.67Z\" transform=\"translate(2 2)\"/></g>);\n\nexport const CloseIcon = makeIcon(<g><path d=\"M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z\"/><path d=\"M0 0h24v24H0z\" fill=\"none\"/></g>);\n\nexport const DeferIcon = makeIcon(<g><path d=\"M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z\"/><path d=\"M0 0h24v24H0z\" fill=\"none\"/><path d=\"M12.5 7H11v6l5.25 3.15.75-1.23-4.5-2.67z\"/></g>);\n\nexport const DeleteIcon = makeIcon(<g><path d=\"M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z\"/><path d=\"M0 0h24v24H0z\" fill=\"none\"/></g>);\n\nexport const EditIcon = makeIcon(<path d=\"M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z\"/>);\n\nexport const EmailIcon = makeIcon(<g><path d=\"M0,0H24V24H0V0Z\" fill=\"none\"/><path d=\"M20,4H4A2,2,0,0,0,2,6V18a2,2,0,0,0,2,2H20a2,2,0,0,0,2-2V6A2,2,0,0,0,20,4Zm0,14H4V8l8,5,8-5V18Zm-8-7L4,6H20Z\"/></g>);\n\nexport const EyeIcon = makeIcon(<g><path d=\"M0 0h24v24H0z\" fill=\"none\"/><path d=\"M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z\"/></g>);\n\nexport const FaceIcon = makeIcon(<g><path d=\"M9 11.75c-.69 0-1.25.56-1.25 1.25s.56 1.25 1.25 1.25 1.25-.56 1.25-1.25-.56-1.25-1.25-1.25zm6 0c-.69 0-1.25.56-1.25 1.25s.56 1.25 1.25 1.25 1.25-.56 1.25-1.25-.56-1.25-1.25-1.25zM12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8 0-.29.02-.58.05-.86 2.36-1.05 4.23-2.98 5.21-5.37C11.07 8.33 14.05 10 17.42 10c.78 0 1.53-.09 2.25-.26.21.71.33 1.47.33 2.26 0 4.41-3.59 8-8 8z\"/><path d=\"M0 0h24v24H0z\" fill=\"none\"/></g>);\n\nexport const FilterIcon = makeIcon(<g><path d=\"M4.25,5.66 C4.35,5.79 9.99,12.99 9.99,12.99 L9.99,19 C9.99,19.55 10.44,20 11,20 L13.01,20 C13.56,20 14.02,19.55 14.02,19 L14.02,12.98 C14.02,12.98 19.51,5.96 19.77,5.64 C20.03,5.32 20,5 20,5 C20,4.45 19.55,4 18.99,4 L5.01,4 C4.4,4 4,4.48 4,5 C4,5.2 4.06,5.44 4.25,5.66 Z\"/></g>);\n\nexport const FlagIcon = makeIcon(<g><path d=\"M-5-4H19V20H-5Z\" transform=\"translate(5 4)\" fill=\"none\"/><path d=\"M9.4,2,9,0H0V17H2V10H7.6L8,12h7V2Z\" transform=\"translate(5 4)\"/></g>);\n\nexport const HeartIcon = makeIcon(<g><path d=\"M0,0H24V24H0V0Z\" fill=\"none\"/><path d=\"M16.5,3A6,6,0,0,0,12,5.09,6,6,0,0,0,7.5,3,5.45,5.45,0,0,0,2,8.5C2,12.28,5.4,15.36,10.55,20L12,21.35,13.45,20C18.6,15.36,22,12.28,22,8.5A5.45,5.45,0,0,0,16.5,3ZM12.1,18.55l-0.1.1-0.1-.1C7.14,14.24,4,11.39,4,8.5A3.42,3.42,0,0,1,7.5,5a3.91,3.91,0,0,1,3.57,2.36h1.87A3.88,3.88,0,0,1,16.5,5,3.42,3.42,0,0,1,20,8.5C20,11.39,16.86,14.24,12.1,18.55Z\"/></g>);\n\nexport const HighlightIcon = makeIcon(<g><path d=\"M17,3H7A2,2,0,0,0,5,5V21l7-3,7,3V5A2,2,0,0,0,17,3Z\"/><path d=\"M0,0H24V24H0V0Z\" fill=\"none\"/></g>);\n\nexport const HomeIcon = makeIcon(<g><path d=\"M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z\"/><path d=\"M0 0h24v24H0z\" fill=\"none\"/></g>);\n\nexport const IdIcon = makeIcon(<g><path d=\"M20 5H4c-1.1 0-1.99.9-1.99 2L2 17c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm-9 3h2v2h-2V8zm0 3h2v2h-2v-2zM8 8h2v2H8V8zm0 3h2v2H8v-2zm-1 2H5v-2h2v2zm0-3H5V8h2v2zm9 7H8v-2h8v2zm0-4h-2v-2h2v2zm0-3h-2V8h2v2zm3 3h-2v-2h2v2zm0-3h-2V8h2v2z\"/><path d=\"M0 0h24v24H0zm0 0h24v24H0z\" fill=\"none\"/></g>);\n\nexport const InfoIcon = makeIcon(<g><path d=\"M0 0h24v24H0z\" fill=\"none\"/><path d=\"M11 17h2v-6h-2v6zm1-15C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zM11 9h2V7h-2v2z\"/></g>);\n\nexport const KeyDownIcon = makeIcon(<g><path d=\"M7 10l5 5 5-5z\"/><path d=\"M0 0h24v24H0z\" fill=\"none\"/></g>);\n\nexport const KeyUpIcon = makeIcon(<g><path d=\"M7 14l5-5 5 5z\"/><path d=\"M0 0h24v24H0z\" fill=\"none\"/></g>);\n\nexport const ListIcon = makeIcon(<g><path d=\"M3 13h2v-2H3v2zm0 4h2v-2H3v2zm0-8h2V7H3v2zm4 4h14v-2H7v2zm0 4h14v-2H7v2zM7 7v2h14V7H7z\"/><path d=\"M0 0h24v24H0z\" fill=\"none\"/></g>);\n\nexport const LoadMoreIcon = makeIcon(<g><path d=\"M14.91,2.91A2.91,2.91,0,1,1,12,0a2.91,2.91,0,0,1,2.91,2.91\"/><path d=\"M14.91,21.09A2.91,2.91,0,1,1,12,18.18a2.91,2.91,0,0,1,2.91,2.91\"/><path d=\"M21.09,14.91A2.91,2.91,0,1,1,24,12a2.91,2.91,0,0,1-2.91,2.91\"/><path d=\"M2.91,14.91A2.91,2.91,0,1,1,5.82,12a2.91,2.91,0,0,1-2.91,2.91\"/><path d=\"M20.49,7.63a2.91,2.91,0,1,1,0-4.11,2.91,2.91,0,0,1,0,4.11\"/><path d=\"M7.63,20.49a2.91,2.91,0,1,1,0-4.11,2.91,2.91,0,0,1,0,4.11\"/><path d=\"M16.37,20.49a2.91,2.91,0,1,1,4.11,0,2.91,2.91,0,0,1-4.11,0\"/><path d=\"M3.51,7.63a2.91,2.91,0,1,1,4.11,0,2.91,2.91,0,0,1-4.11,0\"/></g>);\n\nexport const MenuIcon = makeIcon(<g><path d=\"M 3 5 A 1.0001 1.0001 0 1 0 3 7 L 21 7 A 1.0001 1.0001 0 1 0 21 5 L 3 5 z M 3 11 A 1.0001 1.0001 0 1 0 3 13 L 21 13 A 1.0001 1.0001 0 1 0 21 11 L 3 11 z M 3 17 A 1.0001 1.0001 0 1 0 3 19 L 21 19 A 1.0001 1.0001 0 1 0 21 17 L 3 17 z\"/></g>);\n\nexport const MoreHorizontalIcon = makeIcon(<g><path d=\"M0 0h24v24H0z\" fill=\"none\"/><path d=\"M6 10c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm12 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm-6 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z\"/></g>);\n\nexport const MoreVerticalIcon = makeIcon(<g><path d=\"M0,0H24V24H0V0Z\" fill=\"none\"/><path d=\"M12,8a2,2,0,1,0-2-2A2,2,0,0,0,12,8Zm0,2a2,2,0,1,0,2,2A2,2,0,0,0,12,10Zm0,6a2,2,0,1,0,2,2A2,2,0,0,0,12,16Z\"/></g>);\n\nexport const OpenIcon = makeIcon(<g><path d=\"M-3-3H21V21H-3Z\" transform=\"translate(3 3)\" fill=\"none\"/><path d=\"M16,16H2V2H9V0H2A2,2,0,0,0,0,2V16a2,2,0,0,0,2,2H16a2,2,0,0,0,2-2V9H16ZM11,0V2h3.59L4.76,11.83l1.41,1.41L16,3.41V7h2V0Z\" transform=\"translate(3 3)\"/></g>);\n\nexport const RefreshIcon = makeIcon(<g><path d=\"M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z\"/><path d=\"M0 0h24v24H0z\" fill=\"none\"/></g>);\n\nexport const RejectIcon = makeIcon(<g><path d=\"M19,6.41L17.59,5,12,10.59,6.41,5,5,6.41,10.59,12,5,17.59,6.41,19,12,13.41,17.59,19,19,17.59,13.41,12Z\"/><path d=\"M0,0H24V24H0V0Z\" fill=\"none\"/></g>);\n\nexport const ReplyIcon = makeIcon(<g><path d=\"M10 9V5l-7 7 7 7v-4.1c5 0 8.5 1.6 11 5.1-1-5-4-10-11-11z\"/><path d=\"M0 0h24v24H0z\" fill=\"none\"/></g>);\n\nexport const ReputationIcon = makeIcon(<g><path d=\"M0,0H24V24H0V0Z\" fill=\"none\"/><path d=\"M9,16.17L4.83,12,3.41,13.41,9,19,21,7,19.59,5.59Z\"/></g>);\n\nexport const RoboIcon = makeIcon(<g><rect x=\"7\" y=\"15\" width=\"10\" height=\"2\"/><path d=\"M16,4H10V2h4V0H4V2H8V4H0V18H18V4Zm0,12H2V6H16Z\" transform=\"translate(3 3)\"/><circle cx=\"8\" cy=\"12\" r=\"1\"/><circle cx=\"16\" cy=\"12\" r=\"1\"/><rect width=\"24\" height=\"24\" fill=\"none\"/></g>);\n\nexport const SearchIcon = makeIcon(<g><path d=\"M12.5,11h-.79l-.28-.27a6.51,6.51,0,1,0-.7.7l.27.28v.79l5,5L17.49,16Zm-6,0A4.5,4.5,0,1,1,11,6.5,4.49,4.49,0,0,1,6.5,11Z\" transform=\"translate(3 3)\"/><path d=\"M-3-3H21V21H-3Z\" transform=\"translate(3 3)\" fill=\"none\"/></g>);\n\nexport const SelectAnotherIcon = makeIcon(<g><path d=\"M6.4,20.8a3.2,3.2,0,1,1-3.2-3.2,3.2,3.2,0,0,1,3.2,3.2\" /><path d=\"M6.4,12A3.2,3.2,0,1,1,3.2,8.8,3.2,3.2,0,0,1,6.4,12\" /><path d=\"M15.2,12A3.2,3.2,0,1,1,12,8.8,3.2,3.2,0,0,1,15.2,12\" /><path d=\"M15.2,3.2A3.2,3.2,0,1,1,12,0a3.2,3.2,0,0,1,3.2,3.2\" /><path d=\"M15.2,20.8A3.2,3.2,0,1,1,12,17.6a3.2,3.2,0,0,1,3.2,3.2\" /><path d=\"M24,20.8a3.2,3.2,0,1,1-3.2-3.2A3.2,3.2,0,0,1,24,20.8\" /></g>);\n\nexport const SettingsIcon = makeIcon(<g><path d=\"M 11.46875 0.96875 L 10.90625 4.53125 C 10.050781 4.742188 9.234375 5.058594 8.5 5.5 L 5.5625 3.40625 L 3.4375 5.53125 L 5.5 8.46875 C 5.054688 9.207031 4.714844 10.015625 4.5 10.875 L 0.96875 11.46875 L 0.96875 14.46875 L 4.5 15.09375 C 4.714844 15.953125 5.054688 16.761719 5.5 17.5 L 3.40625 20.4375 L 5.53125 22.5625 L 8.46875 20.5 C 9.203125 20.941406 10.019531 21.257813 10.875 21.46875 L 11.46875 25.03125 L 14.46875 25.03125 L 15.125 21.46875 C 15.976563 21.253906 16.769531 20.914063 17.5 20.46875 L 20.46875 22.5625 L 22.59375 20.4375 L 20.46875 17.5 C 20.90625 16.769531 21.257813 15.972656 21.46875 15.125 L 25.03125 14.46875 L 25.03125 11.46875 L 21.46875 10.875 C 21.257813 10.027344 20.90625 9.230469 20.46875 8.5 L 22.5625 5.53125 L 20.4375 3.40625 L 17.5 5.53125 C 16.769531 5.089844 15.949219 4.746094 15.09375 4.53125 L 14.46875 0.96875 Z M 13 6.46875 C 16.605469 6.46875 19.53125 9.394531 19.53125 13 C 19.53125 16.605469 16.605469 19.53125 13 19.53125 C 9.394531 19.53125 6.46875 16.601563 6.46875 13 C 6.46875 9.398438 9.394531 6.46875 13 6.46875 Z M 13 8.0625 C 10.28125 8.0625 8.0625 10.28125 8.0625 13 C 8.0625 15.71875 10.28125 17.9375 13 17.9375 C 15.71875 17.9375 17.9375 15.71875 17.9375 13 C 17.9375 10.28125 15.71875 8.0625 13 8.0625 Z M 12.96875 10.9375 C 14.113281 10.9375 15.0625 11.851563 15.0625 13 C 15.0625 14.144531 14.113281 15.0625 12.96875 15.0625 C 11.824219 15.0625 10.90625 14.144531 10.90625 13 C 10.90625 11.851563 11.824219 10.9375 12.96875 10.9375 Z \"/></g>);\n\nexport const SpeechBubbleIcon = makeIcon(<g><path d=\"M21.99 4c0-1.1-.89-2-1.99-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h14l4 4-.01-18z\"/></g>);\nexport const SpeechBubbleIconCircle = makeIcon(<g><path d=\"M 4 2 C 2.9 2 2 2.9 2 4 L 2 16 C 2 17.1 2.9 18 4 18 L 18 18 L 22 22 L 22 4 C 22 2.9 21.1 2 20 2 L 4 2 z M 12 7 A 3 3 0 0 1 15 10 A 3 3 0 0 1 12 13 A 3 3 0 0 1 9 10 A 3 3 0 0 1 12 7 z \"/></g>);\n\nexport const ThumbUpIcon = makeIcon(<g><path d=\"M0 0h24v24H0z\" fill=\"none\"/><path d=\"M1 21h4V9H1v12zm22-11c0-1.1-.9-2-2-2h-6.31l.95-4.57.03-.32c0-.41-.17-.79-.44-1.06L14.17 1 7.59 7.59C7.22 7.95 7 8.45 7 9v10c0 1.1.9 2 2 2h9c.83 0 1.54-.5 1.84-1.22l3.02-7.05c.09-.23.14-.47.14-.73v-1.91l-.01-.01L23 10z\"/></g>);\n\nexport const UndoIcon = makeIcon(<g><path d=\"M0 0h24v24H0z\" fill=\"none\"/><path d=\"M12.5 8c-2.65 0-5.05.99-6.9 2.6L2 7v9h9l-3.62-3.62c1.39-1.16 3.16-1.88 5.12-1.88 3.54 0 6.55 2.31 7.6 5.5l2.37-.78C21.08 11.03 17.15 8 12.5 8z\"/></g>);\n\nexport const UserIcon = makeIcon(<g><path d=\"M12,12A4,4,0,1,0,8,8,4,4,0,0,0,12,12Zm0,2c-2.67,0-8,1.34-8,4v2H20V18C20,15.34,14.67,14,12,14Z\"/><path d=\"M0,0H24V24H0V0Z\" fill=\"none\"/></g>);\n\nexport const UserPlusIcon = makeIcon(<g><path d=\"M22.2727273,20.7272727 C24.6836364,20.7272727 26.6363636,18.7745455 26.6363636,16.3636364 C26.6363636,13.9527273 24.6836364,12 22.2727273,12 C19.8618182,12 17.9090909,13.9527273 17.9090909,16.3636364 C17.9090909,18.7745455 19.8618182,20.7272727 22.2727273,20.7272727 Z M12.4545455,18.5454545 L12.4545455,15.2727273 L10.2727273,15.2727273 L10.2727273,18.5454545 L7,18.5454545 L7,20.7272727 L10.2727273,20.7272727 L10.2727273,24 L12.4545455,24 L12.4545455,20.7272727 L15.7272727,20.7272727 L15.7272727,18.5454545 L12.4545455,18.5454545 Z M22.2727273,22.9090909 C19.36,22.9090909 13.5454545,24.3709091 13.5454545,27.2727273 L13.5454545,29.4545455 L31,29.4545455 L31,27.2727273 C31,24.3709091 25.1854545,22.9090909 22.2727273,22.9090909 Z\" transform=\"scale(0.85, 0.85), translate(-7,-7)\"/></g>);\n/* tslint:enable:max-line-length */\n\nexport function renderSwatch() {\n  console.log(icons.length);\n  return (<div>{icons.map((I, idx) => (<I key={'iconkey' + idx}/>))}</div>);\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/LazyLoadComment/CommentBodyStory.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { storiesOf } from '@storybook/react';\nimport faker from 'faker';\nimport { Map as IMap } from 'immutable';\nimport React from 'react';\nimport { Provider } from 'react-redux';\nimport { MemoryRouter } from 'react-router-dom';\nimport { createStore } from 'redux';\n\nimport { IAuthorModel } from '../../../models';\nimport { fakeArticleModel, fakeCommentModel } from '../../../models/fake';\nimport { BasicBody, LinkedBasicBody } from './LazyLoadComment';\n\nconst author = {\n  email: 'name@email.com',\n  location: 'NYC',\n  name: 'Bridie Skiles V',\n  avatar: faker.internet.avatar(),\n} as IAuthorModel;\n\nconst article = fakeArticleModel();\nconst comment = fakeCommentModel({\n  id: '-1',\n  articleId: article.id,\n  replyId: null,\n  isAccepted: true,\n  isDeferred: false,\n  isModerated: true,\n  isHighlighted: false,\n  sourceCreatedAt: null,\n  authorSourceId: 'author1',\n  author,\n  unresolvedFlagsCount: 1,\n  flagsSummary: new Map([['red', [1, 1, 0]]]),\n  text: 'Founded in 1965 by Albert Griffiths, The Gladiators has released some of the most mythical songs of Jamaican reggae. Their first hit, the single Hello Carol, was released in 1968. In 1976, thanks to their signature at Virgin, the trilogy Trenchtown Mix Up, Proverbial Reggae and Naturality has been distributed all around the world and some of the songs of these albums have become classics of the reggae as Mix Up and Roots Natty Roots.',\n});\n\nexport const store = createStore(\n  (s, _a) => s,\n  {global: {articles: {index: IMap([[article.id, article]])}}},\n);\n\nconst returnEmpty = () => '';\nconst returnFalse = () => false;\nasync function doNothing() {/**/}\n\nstoriesOf('CommentBody', module)\n  .addDecorator((story) => (\n    <MemoryRouter initialEntries={['/']}>{story()}</MemoryRouter>\n  ))\n  .add('DontTest:Basic', () => {\n    return (\n      <div>\n        <BasicBody\n          comment={comment}\n          dispatchConfirmedAction={returnFalse}\n          handleAssignTagsSubmit={doNothing}\n        />\n      </div>\n    );\n  })\n  .add('Linked', () => {\n    return (\n      <div>\n        <LinkedBasicBody\n          getLinkTarget={returnEmpty}\n          commentId={comment.id}\n          dispatchConfirmedAction={returnFalse}\n          handleAssignTagsSubmit={doNothing}\n        />\n      </div>\n    );\n  })\n  .add('DontTest:Hide Comment Action', () => {\n    return (\n      <div>\n        <BasicBody\n          hideCommentAction\n          comment={comment}\n          dispatchConfirmedAction={returnFalse}\n          handleAssignTagsSubmit={doNothing}\n        />\n      </div>\n    );\n  })\n  .add('DontTest:Show Article', () => {\n    return (\n      <Provider store={store}>\n        <div>\n          <BasicBody\n            comment={comment}\n            dispatchConfirmedAction={returnFalse}\n            handleAssignTagsSubmit={doNothing}\n            displayArticleTitle\n          />\n        </div>\n      </Provider>\n    );\n  });\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/LazyLoadComment/LazyLoadComment.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { autobind } from 'core-decorators';\nimport {formatDistanceToNow, parseISO} from 'date-fns';\nimport { List, Set } from 'immutable';\nimport React from 'react';\nimport { Link } from 'react-router-dom';\n\nimport { getTopScore, getTopScoreForTag, ICommentModel, ITagModel, ModelId } from '../../../models';\nimport {\n  IConfirmationAction,\n  IModerationAction,\n} from '../../../types';\nimport { REQUIRE_REASON_TO_REJECT } from '../../config';\nimport { useCachedComment } from '../../injectors/commentInjector';\nimport {\n  articleBase,\n  commentRepliesDetailsLink,\n  searchLink,\n} from '../../scenes/routes';\nimport {\n  NICE_MIDDLE_BLUE,\n} from '../../styles';\nimport { maybeCallback, partial } from '../../util';\nimport { css } from '../../utilx';\nimport { Avatar } from '../Avatar';\nimport { CommentText } from '../CommentText';\nimport {\n  ConfirmationCircle,\n} from '../ConfirmationCircle';\nimport { FlagsSummary } from '../FlagsSummary';\nimport {\n  MoreVerticalIcon,\n  ReplyIcon,\n} from '../Icons';\nimport { ModerateButtons } from '../ModerateButtons';\nimport { ROW_STYLES } from '../styles';\nimport { ArticleTitle } from './components';\n\nconst AVATAR_SIZE = 24;\n\nexport type ILinkTargetGetter = (commentId: ModelId) => string;\n\nexport interface IBasicBodyProps {\n  comment: ICommentModel;\n  selectedTag?: ITagModel;\n  hideCommentAction?: boolean;\n  showActions?: boolean;\n  dispatchConfirmedAction?(action: IConfirmationAction, ids: Array<string>): void;\n  commentLinkTarget?: string;\n  onCommentClick?(commentId: string): any;\n  searchTerm?: string;\n  displayArticleTitle?: boolean;\n  handleAssignTagsSubmit(commentId: ModelId, selectedTagIds: Set<ModelId>, rejectedTagIds: Set<ModelId>): Promise<void>;\n}\n\nexport interface IBasicBodyState {\n  hover: boolean;\n  popupOpen: boolean;\n}\n\nexport class BasicBody extends React.PureComponent<IBasicBodyProps, IBasicBodyState> {\n  state = {\n    hover: false,\n    popupOpen: false,\n  };\n\n  @autobind\n  popupOpen(isOpen: boolean) {\n    this.setState({popupOpen: isOpen});\n  }\n\n  @autobind\n  mouseEnter() {\n    this.setState({hover: true});\n  }\n\n  @autobind\n  mouseLeave() {\n    this.setState({hover: false});\n  }\n\n  @autobind\n  onModerateButtonClick(comment: ICommentModel, action: IConfirmationAction): void {\n    this.props.dispatchConfirmedAction(action, [comment.id]);\n  }\n\n  getActiveButtons(): List<IModerationAction> {\n    const { comment } = this.props;\n    const activeCommentStates = [];\n\n    if (comment.isAccepted === true) { activeCommentStates.push('approve'); }\n    if (comment.isAccepted === false || this.state.popupOpen) { activeCommentStates.push('reject'); }\n    if (comment.isHighlighted) { activeCommentStates.push('highlight'); }\n    if (comment.isDeferred) { activeCommentStates.push('defer'); }\n\n    return List<IModerationAction>(activeCommentStates);\n  }\n\n  isModerated() {\n    const { comment } = this.props;\n\n    return comment.isAccepted === true || comment.isAccepted === false;\n  }\n\n  @autobind\n  onClickModerateActions(action: IConfirmationAction): void {\n\n    const activeCommentStates = this.getActiveButtons();\n\n    const shouldReset = this.isModerated() && activeCommentStates.includes(action as IModerationAction);\n\n    // Highlight action already contains special reset function\n    const newAction = shouldReset\n      ? action === 'highlight' ? 'highlight' : 'reset'\n      : action;\n\n    this.onModerateButtonClick(\n      this.props.comment,\n      newAction,\n    );\n  }\n\n  render() {\n    const {\n      comment,\n      selectedTag,\n      hideCommentAction,\n      commentLinkTarget,\n      onCommentClick,\n      displayArticleTitle,\n      handleAssignTagsSubmit,\n    } = this.props;\n\n    const actionsAreVisible = this.state.hover || this.state.popupOpen;\n    const activeButtons = this.getActiveButtons();\n\n    let topScore = null;\n\n    switch (selectedTag && selectedTag.key) {\n      case undefined:\n        break;\n      case 'DATE':\n        break;\n      case 'SUMMARY_SCORE':\n        topScore = getTopScore(comment);\n        break;\n      default:\n        topScore = getTopScoreForTag(comment, selectedTag.id);\n        break;\n    }\n    const dateStr = comment.sourceCreatedAt ? formatDistanceToNow(parseISO(comment.sourceCreatedAt)) : '--';\n    return(\n      <div\n        onMouseEnter={this.mouseEnter}\n        onMouseLeave={this.mouseLeave}\n      >\n        {displayArticleTitle && <ArticleTitle articleId={comment.articleId}/>}\n        <div key=\"body\" {...css(ROW_STYLES.meta)}>\n          <div key=\"text\" {...css(ROW_STYLES.authorRow)}>\n            { comment.replyToSourceId && (\n              <Link\n                to={commentRepliesDetailsLink({\n                  context: articleBase,\n                  contextId: comment.articleId,\n                  commentId: comment.replyId,\n                })}\n                {...css(ROW_STYLES.reply)}\n                onClick={partial(maybeCallback(onCommentClick), comment.id)}\n              >\n                <ReplyIcon {...css({fill: NICE_MIDDLE_BLUE})} size={24} />\n              </Link>\n              )}\n\n            { comment.author?.avatar && (\n              <span {...css({marginRight: '8px'})}>\n                <Avatar size={AVATAR_SIZE} target={comment.author} />\n              </span>\n            )}\n            { comment.author?.name && (\n              <Link to={searchLink({searchByAuthor: true, term: comment.author.name})} {...css({ color: NICE_MIDDLE_BLUE })}>{comment.author.name}&nbsp;</Link>\n            )}\n            {comment.author?.location && (\n              <span>from {comment.author.location}&nbsp;</span>\n            )}\n            <span {...css({textDecoration: 'none'})}> &bull; {dateStr} ago&nbsp;</span>\n            <FlagsSummary comment={comment}/>\n            {actionsAreVisible && (\n              <Link\n                {...css(ROW_STYLES.detailsButton)}\n                to={commentLinkTarget}\n                onClick={partial(maybeCallback(onCommentClick), comment.id)}\n              >\n                View Details\n              </Link>\n            )}\n          </div>\n\n          { !hideCommentAction && actionsAreVisible ? (\n            <div\n              key=\"actions\"\n              {...css({ marginRight: '10px' })}\n            >\n              <ModerateButtons\n                activeButtons={activeButtons}\n                darkOnLight\n                hideLabel\n                containerSize={36}\n                onClick={this.onClickModerateActions}\n                requireReasonForReject={REQUIRE_REASON_TO_REJECT}\n                comment={comment}\n                handleAssignTagsSubmit={handleAssignTagsSubmit}\n                popupOpen={this.popupOpen}\n              />\n            </div>\n            ) : (\n              <div key=\"actions2\" {...css(ROW_STYLES.actionContainer)}>\n                { activeButtons && activeButtons.includes('approve') && (\n                  <div key=\"approve\" {...css({ marginRight: '10px' })}>\n                    <ConfirmationCircle\n                      backgroundColor={NICE_MIDDLE_BLUE}\n                      action=\"approve\"\n                      size={36}\n                      iconSize={20}\n                    />\n                  </div>\n                )\n              }\n              { activeButtons && activeButtons.includes('highlight') && (\n                <div key=\"highlight\" {...css({ marginRight: '10px' })}>\n                  <ConfirmationCircle\n                    backgroundColor={NICE_MIDDLE_BLUE}\n                    action=\"highlight\"\n                    size={36}\n                    iconSize={20}\n                  />\n                </div>\n              )}\n              { activeButtons && activeButtons.includes('reject') && (\n                <div key=\"reject\" {...css({ marginRight: '10px' })}>\n                  <ConfirmationCircle\n                    backgroundColor={NICE_MIDDLE_BLUE}\n                    action=\"reject\"\n                    size={36}\n                    iconSize={20}\n                  />\n                </div>\n              )}\n              { activeButtons && activeButtons.includes('defer') && (\n                <div key=\"defer\" {...css({ marginRight: '10px' })}>\n                  <ConfirmationCircle\n                    backgroundColor={NICE_MIDDLE_BLUE}\n                    action=\"defer\"\n                    size={36}\n                    iconSize={20}\n                  />\n                </div>\n              )}\n              { !hideCommentAction && (\n                <button key=\"moreactons\" {...css(ROW_STYLES.actionToggle)} type=\"button\">\n                  <MoreVerticalIcon size={20} />\n                </button>\n              )}\n            </div>\n          )}\n        </div>\n        <div key=\"text\" {...css(ROW_STYLES.commentContainer)}>\n          <div {...css(ROW_STYLES.comment)}>\n            { commentLinkTarget ? (\n              <div {...css({ display: 'flex', flexDirection: 'column' })}>\n                <p>\n                  <CommentText text={comment.text} highlight={topScore}/>\n                </p>\n              </div>\n            ) :\n              <CommentText text={comment.text} highlight={topScore}/>\n            }\n          </div>\n        </div>\n      </div>\n    );\n  }\n}\n\nexport interface ILinkedBasicBodyProps extends Omit<IBasicBodyProps, 'comment'> {\n  commentId: ModelId;\n  getLinkTarget: ILinkTargetGetter;\n}\n\nexport function LinkedBasicBody(props: ILinkedBasicBodyProps) {\n  const {\n    commentId,\n    getLinkTarget,\n  } = props;\n\n  const {comment} = useCachedComment(commentId);\n\n  return (\n    <div key={`${commentId}`}>\n      <BasicBody {...props} comment={comment} commentLinkTarget={getLinkTarget(commentId)}/>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/LazyLoadComment/components.tsx",
    "content": "/*\nCopyright 2020 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\nimport React from 'react';\nimport { Link } from 'react-router-dom';\n\nimport { OpenInNew } from '@material-ui/icons';\n\nimport { ModelId } from '../../../models';\nimport { useCachedArticle } from '../../injectors/articleInjector';\nimport { articleBase, NEW_COMMENTS_DEFAULT_TAG, newCommentsPageLink } from '../../scenes/routes';\nimport { ARTICLE_HEADLINE_TYPE } from '../../styles';\nimport { COMMON_STYLES } from '../../stylesx';\nimport { css } from '../../utilx';\n\nexport function ArticleTitle({articleId}: {articleId: ModelId}) {\n  const {article} = useCachedArticle(articleId);\n  return (\n    <div key=\"title\" style={{display: 'flex'}}>\n      <Link\n        key=\"text\"\n        {...css(COMMON_STYLES.articleLink)}\n        to={newCommentsPageLink({\n          context: articleBase,\n          contextId: articleId,\n          tag: NEW_COMMENTS_DEFAULT_TAG,\n        })}\n      >\n        <h4 {...css(ARTICLE_HEADLINE_TYPE, { marginBottom: '0px', marginTop: '0px'  })}>\n          {article?.title}\n        </h4>\n      </Link>\n      {article?.url && (\n        <div key=\"link\" style={{display: 'inline-block', margin: '0 10px', position: 'relative', top: '3px'}}>\n          <a href={article?.url} target=\"_blank\" {...css(COMMON_STYLES.cellLink)}>\n            <OpenInNew fontSize=\"small\"/>\n          </a>\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/LazyLoadComment/index.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nexport * from './LazyLoadComment';\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/MagicTimestamp.tsx",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport React, { useEffect, useState } from 'react';\n\nimport { getTimestring } from '../util/time';\n\nexport function MagicTimestamp(props: {\n  timestamp: string;\n  inFuture?: boolean;\n}) {\n  const {timestamp, inFuture} = props;\n  const [timeoutId, setTimeoutId] = useState<ReturnType<typeof setTimeout>>();\n  function doUpdate() {\n    setTimeoutId(null);\n  }\n\n  useEffect(() => {\n    return () => {\n        if (timeoutId) {\n          clearTimeout(timeoutId);\n        }\n    };\n  });\n\n  const {text, redrawIn, isRelative} = getTimestring(timestamp, inFuture);\n\n  if (redrawIn > 0 && redrawIn < 60 * 60 * 24 && !timeoutId) {\n    setTimeoutId(setTimeout(doUpdate, redrawIn * 1000));\n  }\n\n  let disp = text;\n  if (isRelative) {\n    if (inFuture) {\n      disp = `in ` + text;\n    }\n    else {\n      disp += ` ago`;\n    }\n  }\n\n  return (<span>{disp}</span>);\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/MagicTimestampStory.tsx",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { storiesOf } from '@storybook/react';\nimport React from 'react';\n\nimport { MagicTimestamp } from './MagicTimestamp';\n\nstoriesOf('MagicTimestamps', module)\n  .add('MagicTimestamps', () => {\n    const now = Date.now();\n\n    return (\n      <div>\n        <MagicTimestamp timestamp={(new Date(now - 20 * 1000)).toISOString()}/><br/>\n        <MagicTimestamp timestamp={(new Date(now - 100 * 1000)).toISOString()}/><br/>\n        <MagicTimestamp timestamp={(new Date(now - 30 * 60000)).toISOString()}/><br/>\n        <MagicTimestamp timestamp={(new Date(now - 100 * 60000)).toISOString()}/><br/>\n        <MagicTimestamp timestamp={(new Date(now - 12 * 3600000)).toISOString()}/><br/>\n        <MagicTimestamp timestamp={(new Date(now - 36 * 3600000)).toISOString()}/><br/>\n        <MagicTimestamp timestamp={(new Date(now - 50 * 3600000)).toISOString()}/><br/>\n        <MagicTimestamp timestamp={(new Date(now + 50 * 3600000)).toISOString()}/><br/>\n        <MagicTimestamp timestamp={(new Date(now + 50 * 3600000)).toISOString()} inFuture/><br/>\n        <MagicTimestamp timestamp={`${(new Date(now)).getFullYear()}-01-01T12:30:00Z`}/><br/>\n        <MagicTimestamp timestamp={'2017-06-24T12:30:00Z'}/>\n      </div>\n    );\n  });\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/ModerateButtons/ModerateButtons.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { List, Set } from 'immutable';\nimport React, {useRef, useState} from 'react';\n\nimport {\n  Popper,\n} from '@material-ui/core';\n\nimport { ICommentModel, ModelId } from '../../../models';\nimport { IModerationAction } from '../../../types';\nimport {\n  GUTTER_DEFAULT_SPACING,\n  LIGHT_PRIMARY_TEXT_COLOR,\n  NICE_MIDDLE_BLUE,\n} from '../../styles';\nimport { maybeCallback, partial } from '../../util';\nimport {css, stylesheet, useBindEscape} from '../../utilx';\nimport { AssignTagsForm } from '../AssignTagsForm';\nimport { CommentActionButton } from '../CommentActionButton';\nimport { ConfirmationCircle } from '../ConfirmationCircle';\nimport {\n  ApproveIcon,\n  DeferIcon,\n  HighlightIcon,\n  RejectIcon,\n} from '../Icons';\n\nconst STYLES = stylesheet({\n  container: {\n    position: 'relative',\n    display: 'flex',\n    alignItems: 'center',\n  },\n\n  isVertical: {\n    flexDirection: 'column',\n    justifyContent: 'space-between',\n    width: `58px`,\n  },\n\n  toolTipWithTagsContainer: {\n    width: 250,\n  },\n\n  toolTipWithTagsUl: {\n    listStyle: 'none',\n    margin: 0,\n    padding: `${GUTTER_DEFAULT_SPACING}px 0`,\n  },\n\n  toolTipWithTagsButton: {\n    backgroundColor: 'transparent',\n    border: 'none',\n    borderRadius: 0,\n    color: NICE_MIDDLE_BLUE,\n    cursor: 'pointer',\n    padding: '8px 20px',\n    textAlign: 'left',\n    width: '100%',\n\n    ':hover': {\n      backgroundColor: NICE_MIDDLE_BLUE,\n      color: LIGHT_PRIMARY_TEXT_COLOR,\n    },\n\n    ':focus': {\n      backgroundColor: NICE_MIDDLE_BLUE,\n      color: LIGHT_PRIMARY_TEXT_COLOR,\n    },\n  },\n});\n\nexport interface IModerateButtonsProps {\n  hideLabel?: boolean;\n  vertical?: boolean;\n  darkOnLight?: boolean;\n  onClick?(action: IModerationAction): any;\n  containerSize?: number;\n  activeButtons?: List<IModerationAction>;\n  disabled?: boolean;\n  requireReasonForReject?: boolean;\n  handleAssignTagsSubmit?(commentId: ModelId, selectedTagIds: Set<ModelId>, rejectedTagIds: Set<ModelId>): Promise<void>;\n  comment?: ICommentModel;\n  popupOpen?(isOpen: boolean): void;\n}\n\nexport function ModerateButtons(props: IModerateButtonsProps) {\n  const [rejectChooseTags, setRejectChooseTags] = useState(false);\n  function clearPopups() {\n    setRejectChooseTags(false);\n    props.popupOpen && props.popupOpen(false);\n  }\n\n  useBindEscape(clearPopups);\n\n  const rejectButtonAnchor = useRef(null);\n\n  function handleReject() {\n    if (requireReasonForReject) {\n      setRejectChooseTags(true);\n      props.popupOpen && props.popupOpen(true);\n      return;\n    }\n\n    onClick('reject');\n  }\n\n  const {\n    vertical,\n    darkOnLight,\n    hideLabel,\n    containerSize,\n    activeButtons,\n    disabled,\n    onClick,\n    requireReasonForReject,\n    handleAssignTagsSubmit,\n    comment,\n  } = props;\n\n  const ICON_COLOR = (vertical || darkOnLight) ? NICE_MIDDLE_BLUE : LIGHT_PRIMARY_TEXT_COLOR;\n\n  const buttonContainerSize = containerSize || 48;\n\n  return (\n    <div\n      {...css(\n        STYLES.container,\n        vertical && STYLES.isVertical,\n      )}\n    >\n      <CommentActionButton\n        label=\"Approve\"\n        isActive={activeButtons && activeButtons.includes('approve')}\n        hideLabel={hideLabel || vertical}\n        disabled={disabled}\n        icon={(\n          <ApproveIcon\n            {...css({\n              fill: ICON_COLOR,\n              width: `${buttonContainerSize / 2}px`,\n              height: `${buttonContainerSize / 2}px`,\n            })}\n          />\n        )}\n        style={{\n          width: buttonContainerSize + 10,\n          height: buttonContainerSize + 10,\n          padding: `5px 0px`,\n        }}\n        iconHovered={(\n          <ConfirmationCircle\n            backgroundColor={ICON_COLOR}\n            action=\"approve\"\n            size={buttonContainerSize}\n            iconSize={buttonContainerSize / 2}\n          />\n        )}\n        onClick={partial(maybeCallback(onClick), 'approve')}\n      />\n\n      <div key=\"buttonAnchor\" ref={rejectButtonAnchor}>\n        <CommentActionButton\n          label=\"Reject\"\n          isActive={activeButtons && activeButtons.includes('reject')}\n          hideLabel={hideLabel || vertical}\n          disabled={disabled}\n          icon={(\n            <RejectIcon\n              {...css({\n                fill: ICON_COLOR,\n                width: `${buttonContainerSize / 2}px`,\n                height: `${buttonContainerSize / 2}px`,\n              })}\n            />\n          )}\n          style={{\n            width: buttonContainerSize + 10,\n            height: buttonContainerSize + 10,\n            padding: `5px 0px`,\n          }}\n          iconHovered={(\n            <ConfirmationCircle\n              backgroundColor={ICON_COLOR}\n              action=\"reject\"\n              size={buttonContainerSize}\n              iconSize={buttonContainerSize / 2}\n            />\n          )}\n          onClick={handleReject}\n        />\n      </div>\n      {requireReasonForReject && (\n        <Popper\n          key=\"popper\"\n          open={rejectChooseTags}\n          anchorEl={rejectButtonAnchor.current}\n          placement={vertical ? 'left' : 'bottom'}\n          modifiers={{\n            preventOverflow: {\n              enabled: true,\n              boundariesElement: 'viewport',\n            },\n          }}\n          style={{zIndex: 2}}\n        >\n          <AssignTagsForm\n            articleId={comment.articleId}\n            comment={comment}\n            clearPopups={clearPopups}\n            submit={handleAssignTagsSubmit}\n          />\n        </Popper>\n      )}\n\n      <CommentActionButton\n        label=\"Highlight\"\n        isActive={activeButtons && activeButtons.includes('highlight')}\n        hideLabel={hideLabel || vertical}\n        disabled={disabled}\n        icon={(\n          <HighlightIcon\n            {...css({\n              fill: ICON_COLOR,\n              width: `${buttonContainerSize / 2}px`,\n              height: `${buttonContainerSize / 2}px`,\n            })}\n          />\n        )}\n        style={{\n          width: buttonContainerSize + 10,\n          height: buttonContainerSize + 10,\n          padding: `5px 0px`,\n        }}\n        iconHovered={(\n          <ConfirmationCircle\n            backgroundColor={ICON_COLOR}\n            action=\"highlight\"\n            size={buttonContainerSize}\n            iconSize={buttonContainerSize / 2}\n          />\n        )}\n        onClick={partial(maybeCallback(onClick), 'highlight')}\n      />\n\n      <CommentActionButton\n        label=\"Defer\"\n        isActive={activeButtons && activeButtons.includes('defer')}\n        hideLabel={hideLabel || vertical}\n        disabled={disabled}\n        icon={(\n          <DeferIcon\n            {...css({\n              fill: ICON_COLOR,\n              width: `${buttonContainerSize / 2}px`,\n              height: `${buttonContainerSize / 2}px`,\n            })}\n          />\n        )}\n        style={{\n          width: buttonContainerSize + 10,\n          height: buttonContainerSize + 10,\n          padding: `5px 0px`,\n        }}\n        iconHovered={(\n          <ConfirmationCircle\n            backgroundColor={ICON_COLOR}\n            action=\"defer\"\n            size={buttonContainerSize}\n            iconSize={buttonContainerSize / 2}\n          />\n        )}\n        onClick={partial(maybeCallback(onClick), 'defer')}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/ModerateButtons/ModerateButtonsStory.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { storiesOf } from '@storybook/react';\n\nimport { List } from 'immutable';\nimport { IModerationAction } from '../../../types';\nimport { ModerateButtons } from './ModerateButtons';\n\nstoriesOf('ModerateButtons', module)\n  .add('Horizontal', () => (\n    <div>\n      <ModerateButtons\n        darkOnLight\n        hideLabel\n        containerSize={36}\n      />\n    </div>\n  ))\n  .add('Vertical', () => (\n    <div>\n      <ModerateButtons vertical />\n    </div>\n  ))\n  .add('Vertical Approve', () => (\n    <div>\n      <ModerateButtons\n        activeButtons={List(['approve']) as List<IModerationAction>}\n        vertical\n      />\n    </div>\n  ))\n  .add('Vertical Reject', () => (\n    <div>\n      <ModerateButtons\n        activeButtons={List(['reject']) as List<IModerationAction>}\n        vertical\n      />\n    </div>\n  ))\n  .add('Vertical Highlight', () => (\n    <div>\n      <ModerateButtons\n        activeButtons={List(['approve', 'highlight']) as List<IModerationAction>}\n        vertical\n      />\n    </div>\n  ))\n  .add('Vertical Defer', () => (\n    <div>\n      <ModerateButtons\n        activeButtons={List(['defer']) as List<IModerationAction>}\n        vertical\n      />\n    </div>\n  ));\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/ModerateButtons/index.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nexport { ModerateButtons } from './ModerateButtons';\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/NavigationTab/NavigationTab.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport React from 'react';\nimport {\n  ARTICLE_CATEGORY_TYPE,\n  BOX_DEFAULT_SPACING,\n  CAPTION_TYPE,\n  DARK_PRIMARY_TEXT_COLOR,\n  DARK_SECONDARY_TEXT_COLOR,\n  LIGHT_PRIMARY_TEXT_COLOR,\n  LIGHT_SECONDARY_TEXT_COLOR,\n  SHORT_SCREEN_QUERY,\n} from '../../styles';\nimport { css, IStyle, stylesheet } from '../../utilx';\n\nconst STYLES = stylesheet({\n  base: {\n    position: 'relative',\n    display: 'flex',\n    flexWrap: 'wrap',\n    justifyContent: 'center',\n    alignItems: 'stretch',\n    width: 'auto',\n  },\n\n  hasIcon: {\n    padding: '30px 32px 40px',\n    [SHORT_SCREEN_QUERY]: {\n        padding: '14px 22px 14px',\n    },\n  },\n\n  row: {\n    display: 'flex',\n  },\n\n  icon: {\n    width: '100%',\n    marginBottom: '10px',\n    display: 'flex',\n    justifyContent: 'center',\n  },\n\n  label: {\n    ...ARTICLE_CATEGORY_TYPE,\n    fontSize: '14px',\n    color: LIGHT_PRIMARY_TEXT_COLOR,\n  },\n\n  darkLabel: {\n    color: DARK_PRIMARY_TEXT_COLOR,\n  },\n\n  smallLabel: {\n    ...CAPTION_TYPE,\n  },\n\n  count: {\n    ...ARTICLE_CATEGORY_TYPE,\n    fontSize: '14px',\n    color: LIGHT_SECONDARY_TEXT_COLOR,\n    marginLeft: `${BOX_DEFAULT_SPACING}px`,\n  },\n\n  darkCount: {\n    color: DARK_SECONDARY_TEXT_COLOR,\n  },\n\n  disable: {\n    opacity: 0.5,\n  },\n\n  isFocused: {\n    borderBottom: '2px solid white',\n  },\n});\n\nexport interface INavigationTabProps {\n  style?: IStyle;\n  label: string;\n  count: number;\n  icon?: JSX.Element;\n  darkText?: boolean;\n  isFocused?: boolean;\n}\n\nexport class NavigationTab\n    extends React.PureComponent<INavigationTabProps> {\n\n  render() {\n    const { label, count, icon, darkText, style, isFocused } = this.props;\n    const hasIcon = !!icon;\n    const dark = darkText;\n\n    return(\n      <div\n        {...css(\n          STYLES.base,\n          hasIcon && STYLES.hasIcon,\n          style,\n        )}\n      >\n        {icon && <div {...css(STYLES.icon)}>{icon}</div>}\n        <div {...css(STYLES.row)}>\n          <div\n            {...css(\n              STYLES.label,\n              dark && STYLES.darkLabel,\n              hasIcon && STYLES.smallLabel,\n              isFocused && STYLES.isFocused,\n            )}\n          >\n            {label}\n          </div>\n          <div\n            {...css(\n              STYLES.count,\n              dark && STYLES.darkCount,\n              hasIcon && STYLES.smallLabel,\n            )}\n          >\n            {count}\n          </div>\n        </div>\n      </div>\n    );\n  }\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/NavigationTab/NavigationTabStory.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { action } from '@storybook/addon-actions';\nimport { storiesOf } from '@storybook/react';\n\nimport {\n  DARK_PRIMARY_TEXT_COLOR,\n  GUTTER_DEFAULT_SPACING,\n  HEADER_HEIGHT,\n  LIGHT_PRIMARY_TEXT_COLOR,\n  MEDIUM_COLOR,\n} from '../../styles';\nimport { css } from '../../utilx';\nimport {\n  ApproveIcon,\n  DeferIcon,\n  FlagIcon,\n  HighlightIcon,\n  RejectIcon,\n} from '../Icons';\nimport { NavigationTab } from '../NavigationTab';\n\nconst STYLES = {\n  mainDark: {\n    background: MEDIUM_COLOR,\n    display: 'inline-block',\n  },\n\n  mainLight: {\n    display: 'inline-block',\n  },\n\n  button:  {\n    background: 'transparent',\n    border: 0,\n    margin: 0,\n    padding: 0,\n    cursor: 'pointer',\n  },\n};\n\nstoriesOf('NavigationTab', module)\n  .add('Default', () => (\n    <div {...css(STYLES.mainDark)}>\n      <button\n        {...css(STYLES.button, { height: `${HEADER_HEIGHT}px` })}\n        onClick={action('Nav Clicked')}\n        aria-label=\"New\"\n      >\n        <NavigationTab\n          style={{ padding: `0 ${GUTTER_DEFAULT_SPACING}px`}}\n          label=\"New\"\n          count={542}\n        />\n      </button>\n      <button\n        {...css({ ...STYLES.button, height: `${HEADER_HEIGHT}px` })}\n        onClick={action('Nav Clicked')}\n        aria-label=\"Moderated\"\n      >\n        <NavigationTab\n          style={{ padding: `0 ${GUTTER_DEFAULT_SPACING}px`}}\n          label=\"Moderated\"\n          count={923}\n        />\n      </button>\n    </div>\n  ))\n  .add('With Icons Light', () => (\n    <div {...css(STYLES.mainLight)}>\n      <button\n        {...css(STYLES.button)}\n        onClick={action('Nav Clicked')}\n        aria-label=\"Approved\"\n      >\n        <NavigationTab\n          label=\"Approved\"\n          darkText\n          count={25}\n          icon={<ApproveIcon {...css({ fill: DARK_PRIMARY_TEXT_COLOR })} />}\n        />\n      </button>\n      <button\n        {...css(STYLES.button)}\n        onClick={action('Nav Clicked')}\n        aria-label=\"Highlight\"\n      >\n        <NavigationTab\n          label=\"Highlight\"\n          darkText\n          count={25}\n          icon={<HighlightIcon {...css({ fill: DARK_PRIMARY_TEXT_COLOR })} />}\n        />\n      </button>\n      <button\n        {...css(STYLES.button)}\n        onClick={action('Nav Clicked')}\n        aria-label=\"Rejected\"\n      >\n        <NavigationTab\n          label=\"Rejected\"\n          darkText\n          count={25}\n          icon={<RejectIcon {...css({ fill: DARK_PRIMARY_TEXT_COLOR })} />}\n        />\n      </button>\n      <button\n        {...css(STYLES.button)}\n        onClick={action('Nav Clicked')}\n        aria-label=\"Deferred\"\n      >\n        <NavigationTab\n          label=\"Deferred\"\n          darkText\n          count={25}\n          icon={<DeferIcon {...css({ fill: DARK_PRIMARY_TEXT_COLOR })} />}\n        />\n      </button>\n      <button\n        {...css(STYLES.button)}\n        onClick={action('Nav Clicked')}\n        aria-label=\"Flagged\"\n      >\n        <NavigationTab\n          label=\"Flagged\"\n          darkText\n          count={25}\n          icon={<FlagIcon {...css({ fill: DARK_PRIMARY_TEXT_COLOR })} />}\n        />\n      </button>\n    </div>\n  ))\n  .add('With Icons Dark', () => (\n    <div {...css(STYLES.mainDark)}>\n      <button\n        {...css(STYLES.button)}\n        onClick={action('Nav Clicked')}\n        aria-label=\"Approved\"\n      >\n        <NavigationTab\n          label=\"Approved\"\n          count={25}\n          icon={<ApproveIcon {...css({ fill: LIGHT_PRIMARY_TEXT_COLOR })} />}\n        />\n      </button>\n      <button\n        {...css(STYLES.button)}\n        onClick={action('Nav Clicked')}\n        aria-label=\"Highlight\"\n      >\n        <NavigationTab\n          label=\"Highlight\"\n          count={25}\n          icon={<HighlightIcon {...css({ fill: LIGHT_PRIMARY_TEXT_COLOR })} />}\n        />\n      </button>\n      <button\n        {...css(STYLES.button)}\n        onClick={action('Nav Clicked')}\n        aria-label=\"Rejected\"\n      >\n        <NavigationTab\n          label=\"Rejected\"\n          count={25}\n          icon={<RejectIcon {...css({ fill: LIGHT_PRIMARY_TEXT_COLOR })} />}\n        />\n      </button>\n      <button\n        {...css(STYLES.button)}\n        onClick={action('Nav Clicked')}\n        aria-label=\"Deferred\"\n      >\n        <NavigationTab\n          label=\"Deferred\"\n          count={25}\n          icon={<DeferIcon {...css({ fill: LIGHT_PRIMARY_TEXT_COLOR })} />}\n        />\n      </button>\n      <button\n        {...css(STYLES.button)}\n        onClick={action('Nav Clicked')}\n        aria-label=\"Flagged\"\n      >\n        <NavigationTab\n          label=\"Flagged\"\n          count={25}\n          icon={<FlagIcon {...css({ fill: LIGHT_PRIMARY_TEXT_COLOR })} />}\n        />\n      </button>\n    </div>\n  ));\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/NavigationTab/__spec__/.gitkeep",
    "content": ""
  },
  {
    "path": "packages/frontend-web/src/app/components/NavigationTab/index.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nexport { NavigationTab } from './NavigationTab';\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/OverflowContainer/OverflowContainer.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport React, { MouseEvent,  ReactNode } from 'react';\n\nimport {\n  Button,\n} from '@material-ui/core';\nimport {\n  Close,\n} from '@material-ui/icons';\n\nimport {\n  ARTICLE_HEADLINE_TYPE,\n  BODY_TEXT_TYPE,\n  DARK_PRIMARY_TEXT_COLOR,\n  HEADLINE_TYPE,\n} from '../../styles';\nimport { css, stylesheet } from '../../utilx';\n\nconst STYLES = stylesheet({\n  container: {\n    display: 'flex',\n    flexDirection: 'column',\n    height: '100%',\n  },\n\n  header: {\n    ...HEADLINE_TYPE,\n    color: DARK_PRIMARY_TEXT_COLOR,\n  },\n\n  body: {\n    ...BODY_TEXT_TYPE,\n    flex: '1 1 auto',\n    overflowY: 'auto',\n    WebkitOverflowScrolling: 'touch',\n    overflowX: 'hidden',\n    marginLeft: 0,\n    marginRight: 0,\n    padding: 0,\n    minHeight: 0,\n    color: DARK_PRIMARY_TEXT_COLOR,\n  },\n\n  footer: {\n    ...ARTICLE_HEADLINE_TYPE,\n    color: DARK_PRIMARY_TEXT_COLOR,\n  },\n\n  h1: {\n    ...HEADLINE_TYPE,\n    margin: 0,\n    color: DARK_PRIMARY_TEXT_COLOR,\n  },\n\n  closeButtonContainer: {\n    display: 'flex',\n    flexDirection: 'row',\n    justifyContent: 'space-between',\n  },\n\n  saveButtonContainer: {\n    textAlign: 'right',\n  },\n});\n\nexport interface IContainerHeaderProps {\n  children?: ReactNode;\n  onClickClose: React.EventHandler<any>;\n}\n\nexport function ContainerHeader(props: IContainerHeaderProps) {\n  const { children, onClickClose } = props;\n\n  return (\n    <div {...css(STYLES.closeButtonContainer)}>\n      <h1 key=\"label\" {...css(STYLES.h1)}>{children}</h1>\n      <Close onClick={onClickClose}/>\n    </div>\n  );\n}\n\nexport interface IContainerFooterProps {\n  onClick(): void;\n  disabled?: boolean;\n}\n\nexport function ContainerFooter(props: IContainerFooterProps) {\n  const { onClick, disabled } = props;\n\n  function onClickWrapper(e: MouseEvent) {\n    e.preventDefault();\n    onClick();\n  }\n\n  return (\n    <div {...css(STYLES.saveButtonContainer)}>\n      <Button variant=\"contained\" color=\"primary\" disabled={disabled} onClick={onClickWrapper}>Save</Button>\n    </div>\n  );\n}\n\nexport interface IOverflowContainerProps {\n  header?: JSX.Element;\n  body: JSX.Element;\n  footer?: JSX.Element;\n}\n\nexport class OverflowContainer extends React.PureComponent<IOverflowContainerProps> {\n  render() {\n    const {\n      header,\n      body,\n      footer,\n    } = this.props;\n\n    return (\n      <div {...css(STYLES.container)}>\n        <div {...css(STYLES.header)}>\n          {header}\n        </div>\n        <div {...css(STYLES.body)}>\n          {body}\n        </div>\n        <div {...css(STYLES.footer)}>\n          {footer}\n        </div>\n      </div>\n    );\n  }\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/OverflowContainer/OverflowContainerStory.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { storiesOf } from '@storybook/react';\n\nimport { OverflowContainer } from './OverflowContainer';\n\nconst header = (<h1>OverflowContainer header</h1>);\n/* tslint:disable:max-line-length */\nconst body = (<p>OverflowContainer body Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</p>);\n/* tslint:enable:max-line-length */\nconst footer = (<h2>OverflowContainer footer</h2>);\n\nstoriesOf('OverflowContainer', module)\n    .add('Default', () => (\n      <OverflowContainer header={header} body={body} footer={footer} />\n    ))\n    .add('No Footer', () => (\n      <OverflowContainer header={header} body={body} />\n    ))\n    .add('No SearchHeader', () => (\n      <OverflowContainer body={body} footer={footer} />\n    ));\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/OverflowContainer/index.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nexport * from './OverflowContainer';\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/RuleBars/RuleBars.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { List } from 'immutable';\nimport React from 'react';\n\nimport { IRuleModel } from '../../../models';\nimport {\n  CENTER_CONTENT,\n  NICE_LIGHT_BLUE,\n  NICE_MIDDLE_BLUE,\n} from '../../styles';\nimport { css, stylesheet } from '../../utilx';\nimport { ConfirmationCircle } from '../ConfirmationCircle';\n\nconst STYLES = stylesheet({\n  button: {\n    background: NICE_MIDDLE_BLUE,\n    border: '0',\n    cursor: 'pointer',\n    width: 32,\n    height: 32,\n    borderRadius: 32,\n    padding: 0,\n    display: 'flex',\n    alignItems: 'center',\n    justifyContent: 'center',\n    marginTop: -16,\n  },\n\n  wrapper: {\n    position: 'absolute',\n    top: 0,\n    right: 0,\n    bottom: 0,\n    left: 0,\n    pointerEvents: 'none',\n  },\n\n  inner: {\n    position: 'relative',\n    width: '100%',\n    height: '100%',\n  },\n\n  bar: {\n    position: 'absolute',\n    top: 0,\n    bottom: 0,\n    backgroundColor: NICE_LIGHT_BLUE,\n    border: `1px solid ${NICE_MIDDLE_BLUE}`,\n  },\n});\n\nfunction differentiateRules(rules: List<IRuleModel>): Array<IRuleModel> {\n  const sortedRules = rules.toArray().sort((a, b) => a.lowerThreshold - b.lowerThreshold);\n\n  return sortedRules.reduce((sum, currentRule, i, allRules) => {\n    const nextRule = allRules[i + 1];\n\n    if (nextRule && currentRule.upperThreshold > nextRule.lowerThreshold) {\n      sum.push({ ...currentRule, upperThreshold: nextRule.lowerThreshold});\n    } else {\n      sum.push(currentRule);\n    }\n\n    return sum;\n  }, []);\n}\n\nexport interface IRuleBarsProps {\n  rules?: List<IRuleModel>;\n  automatedRuleToast?(rule: IRuleModel): void;\n}\n\nexport class RuleBars extends React.Component<IRuleBarsProps> {\n  render() {\n    const {\n      rules,\n    } = this.props;\n\n    const rulesToDisplay = rules && differentiateRules(rules);\n\n    return (\n      <div {...css(STYLES.wrapper)}>\n        <div {...css(STYLES.inner)}>\n          { rulesToDisplay && rulesToDisplay.map((rule) => {\n            const width = rule.upperThreshold - rule.lowerThreshold;\n            const left = rule.lowerThreshold;\n\n            return (\n              <div\n                key={rule.id}\n                {...css(STYLES.bar, {\n                  width: `${(width) * 100}%`,\n                  left: `${(left) * 100}%`,\n                })}\n              >\n                <div {...css(CENTER_CONTENT)}>\n                  <span {...css(STYLES.button)}>\n                    <ConfirmationCircle\n                      backgroundColor={NICE_LIGHT_BLUE}\n                      action={rule.action.toLowerCase()}\n                      size={26}\n                      iconSize={13}\n                    />\n                  </span>\n                </div>\n              </div>\n            );\n          })}\n        </div>\n      </div>\n    );\n  }\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/RuleBars/index.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nexport { RuleBars } from './RuleBars';\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/ScoresList/ScoresList.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport React from 'react';\nimport { useSelector } from 'react-redux';\n\nimport {\n  ICommentModel,\n  ICommentScoreModel,\n  ITaggingSensitivityModel,\n} from '../../../models';\nimport { getTags } from '../../stores/tags';\nimport {\n  BUTTON_RESET,\n  DARK_SECONDARY_TEXT_COLOR,\n  DARK_TERTIARY_TEXT_COLOR,\n  GUTTER_DEFAULT_SPACING,\n  PALE_COLOR,\n} from '../../styles';\nimport { css, stylesheet } from '../../utilx';\nimport { RejectIcon } from '../Icons';\n\nconst SCORE_ROW_STYLES = stylesheet({\n  container: {\n    display: 'flex',\n    marginTop: `${GUTTER_DEFAULT_SPACING}px`,\n    flexDirection: 'row',\n  },\n\n  score: {\n    width: '120px',\n  },\n\n  text: {\n    flex: 1,\n  },\n});\n\nexport interface IScoreRowProps {\n  score: number;\n  text: string;\n  scoreColor?: string;\n}\n\nconst ScoreRow = (props: IScoreRowProps) => (\n  <div {...css(SCORE_ROW_STYLES.container)}>\n    <div\n      {...css({\n        width: '120px',\n        color: props.scoreColor ? props.scoreColor : DARK_TERTIARY_TEXT_COLOR,\n      })}\n    >\n      {`${(props.score * 100).toFixed()}%`}\n    </div>\n    <div {...css(SCORE_ROW_STYLES.text)}>{props.text}</div>\n  </div>\n);\n\nconst STYLES = stylesheet({\n  header: {\n    display: 'flex',\n    justifyContent: 'space-between',\n    flex: 'none',\n  },\n\n  body: {\n    overflowY: 'scroll',\n    height: '400px',\n  },\n\n  closeButton: {\n    ...BUTTON_RESET,\n    cursor: 'pointer',\n    width: '44px',\n    height: '44px',\n    ':focus': {\n      outline: 0,\n      backgroundColor: PALE_COLOR,\n    },\n  },\n\n  tableHeader: {\n    color: DARK_SECONDARY_TEXT_COLOR,\n    display: 'flex',\n    flexDirection: 'row',\n  },\n\n  scoreHeader: {\n    width: SCORE_ROW_STYLES.score.width,\n  },\n});\n\nexport interface IScoresListProps {\n  comment?: ICommentModel;\n  scores?: Array<ICommentScoreModel>;\n  threshold?: ITaggingSensitivityModel;\n  onClose(): any;\n}\n\nexport function ScoresList(props: IScoresListProps) {\n  const {\n    comment,\n    scores,\n    threshold,\n    onClose,\n  } = props;\n  const tags = useSelector(getTags);\n\n  function getTextByIndeces(annotationStart: number, annotationEnd: number): string {\n    if (annotationStart === null || annotationEnd === null) {\n      return comment.text;\n    }\n\n    return comment.text.slice(annotationStart, annotationEnd);\n  }\n\n  const exampleScore = scores[0] || null;\n  const tag = tags && tags.find((t) => (t.id === exampleScore.tagId));\n  const scoresAboveThreshold = threshold && scores && scores.filter((score) => score.score >= threshold.lowerThreshold);\n  const scoresBelowThreshold = threshold && scores && scores.filter((score) => score.score < threshold.lowerThreshold);\n\n  return (\n    <div>\n      <div key=\"Title\" {...css(STYLES.header)}>\n        <h3>Score details for \"{tag.label}\" </h3>\n        <button type=\"button\" onClick={onClose} aria-label=\"Close Scores Modal\" {...css(STYLES.closeButton)}>\n          <RejectIcon {...css({ color: DARK_SECONDARY_TEXT_COLOR })} />\n        </button>\n      </div>\n      <div key=\"SearchHeader\" {...css(STYLES.tableHeader)}>\n        <h4 {...css(STYLES.scoreHeader)}>SCORE</h4>\n        <h4>STRING</h4>\n      </div>\n      <div key=\"Thresholds\" {...css(STYLES.body)}>\n        {scoresAboveThreshold && scoresAboveThreshold.map(( score ) => (\n          <ScoreRow\n            key={score.id}\n            score={score.score}\n            scoreColor={tag && tag.color}\n            text={getTextByIndeces(score.annotationStart, score.annotationEnd)}\n          />\n        ))}\n        {scoresBelowThreshold && scoresBelowThreshold.map(( score ) => (\n          <ScoreRow\n            key={score.id}\n            score={score.score}\n            scoreColor={DARK_TERTIARY_TEXT_COLOR}\n            text={getTextByIndeces(score.annotationStart, score.annotationEnd)}\n          />\n        ))}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/ScoresList/index.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nexport { ScoresList } from './ScoresList';\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/Scrim/Scrim.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport React from 'react';\nimport {\n  SCRIM_Z_INDEX,\n} from '../../styles';\nimport { css, IStyle, stylesheet } from '../../utilx';\n\nconst STYLES = stylesheet({\n  background: {\n    position: 'fixed',\n    top: 0,\n    left: 0,\n    right: 0,\n    bottom: 0,\n    zIndex: SCRIM_Z_INDEX,\n  },\n\n  children: {\n    display: 'inline-block',\n    zIndex: SCRIM_Z_INDEX + 1,\n  },\n});\n\nexport interface IScrimProps extends React.HTMLProps<any> {\n  isVisible?: boolean;\n  onBackgroundClick?(e: React.MouseEvent<any>): any;\n  scrimStyles?: IStyle;\n  wrapperStyles?: IStyle;\n}\n\nexport class Scrim extends React.PureComponent<IScrimProps> {\n  render() {\n    const {\n      isVisible,\n      onBackgroundClick,\n      children,\n      scrimStyles,\n      wrapperStyles,\n    } = this.props;\n\n    return (\n      <div {...css(STYLES.background, scrimStyles, !isVisible && { display: 'none' })}>\n        <div\n          {...css(STYLES.background)}\n          onClick={onBackgroundClick}\n        />\n        <div {...css(STYLES.children, wrapperStyles)}>\n          {isVisible && children}\n        </div>\n      </div>\n    );\n  }\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/Scrim/ScrimStory.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { action } from '@storybook/addon-actions';\nimport { storiesOf } from '@storybook/react';\nimport { ARTICLE_HEADLINE_TYPE, DARK_SECONDARY_TEXT_COLOR, WHITE_COLOR } from '../../styles';\nimport { css } from '../../utilx';\nimport { Scrim } from '../Scrim';\n\nconst STYLES = {\n  base: {\n    ...ARTICLE_HEADLINE_TYPE,\n    color: WHITE_COLOR,\n  },\n};\n\nstoriesOf('Scrim', module)\n  .add('dark', () => {\n    return (\n      <Scrim\n        scrimStyles={{backgroundColor: DARK_SECONDARY_TEXT_COLOR}}\n        isVisible\n        onBackgroundClick={action('clicked bg')}\n      >\n        <div {...css(STYLES.base)}>Hi!</div>\n      </Scrim>\n    );\n  })\n  .add('red', () => {\n    return (\n      <Scrim\n        scrimStyles={{backgroundColor: 'rgba(255,0,0,0.5)'}}\n        isVisible\n        onBackgroundClick={action('clicked bg')}\n      >\n        <div {...css(STYLES.base)}>Hi!</div>\n      </Scrim>\n    );\n  });\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/Scrim/index.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nexport { Scrim } from './Scrim';\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/SearchAttribute/SearchAttribute.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport React from 'react';\nimport { css, stylesheet } from '../../utilx';\n\nimport { ARTICLE_HEADLINE_TYPE, BUTTON_RESET, LIGHT_HIGHLIGHT_COLOR, WHITE_COLOR } from '../../styles';\nimport { CanvasTruncate } from '../CanvasTruncate';\nimport { RejectIcon } from '../Icons';\n\nconst STYLES = stylesheet({\n  container: {\n    border: `2px solid ${WHITE_COLOR}`,\n    borderRadius: '16px',\n    width: '238px',\n    height: '32px',\n    boxSizing: 'border-box',\n    padding: '6px 10px 6px 16px',\n    display: 'flex',\n    justifyContent: 'space-between',\n    alignItems: 'center',\n  },\n\n  title: {\n    ...ARTICLE_HEADLINE_TYPE,\n    color: WHITE_COLOR,\n    width: '184px',\n  },\n\n  closeButton: {\n    ...BUTTON_RESET,\n    cursor: 'pointer',\n    ':focus': {\n      outline: 0,\n      background: LIGHT_HIGHLIGHT_COLOR,\n    },\n  },\n});\n\nexport interface ISearchAttributeProps {\n  title: string;\n  onClose?(): any;\n}\n\nexport class SearchAttribute extends React.PureComponent<ISearchAttributeProps> {\n  render() {\n    const { title, onClose } = this.props;\n\n    return (\n      <div {...css(STYLES.container)}>\n        <div {...css(STYLES.title)}>\n          <CanvasTruncate lines={1} text={title} fontStyles={STYLES.title} />\n        </div>\n        <button type=\"button\" onClick={onClose} aria-label={`Remove search attribute ${title}`} {...css(STYLES.closeButton)}>\n          <RejectIcon {...css({ color: WHITE_COLOR })} />\n        </button>\n      </div>\n    );\n  }\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/SearchAttribute/index.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nexport { SearchAttribute } from './SearchAttribute';\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/SearchHeader/SearchHeader.tsx",
    "content": "/*\nCopyright 2020 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport React from 'react';\n\nimport {\n  HEADER_HEIGHT,\n  LIGHT_PRIMARY_TEXT_COLOR,\n  NICE_MIDDLE_BLUE,\n  OFFSCREEN,\n} from '../../styles';\nimport { maybeCallback } from '../../util';\nimport { css, stylesheet } from '../../utilx';\nimport { RejectIcon, SearchIcon, UserIcon } from '../Icons';\n\nconst STYLES = stylesheet({\n  bar: {\n    alignItems: 'center',\n    background: NICE_MIDDLE_BLUE,\n    boxSizing: 'border-box',\n    display: 'flex',\n    justifyContent: 'space-between',\n    width: '100%',\n    height: `${HEADER_HEIGHT + 12}px`,\n  },\n\n  button: {\n    background: 'transparent',\n    border: 'none',\n    cursor: 'pointer',\n    display: 'flex',\n    flexDirection: 'column',\n    alignItems: 'center',\n    justifyContent: 'center',\n    height: HEADER_HEIGHT,\n    width: HEADER_HEIGHT,\n    ':hover': {\n      borderBottom: '3px solid white',\n    },\n  },\n  buttonSelected: {\n    borderBottom: '3px solid white',\n  },\n\n  buttonText: {\n    fontSize: '10px',\n    color: LIGHT_PRIMARY_TEXT_COLOR,\n  },\n\n  childrenContainer: {\n    display: 'flex',\n    alignItems: 'center',\n    height: '100%',\n    flex: 1,\n  },\n\n  offscreen: OFFSCREEN,\n\n  iconStyle: {\n    fill: LIGHT_PRIMARY_TEXT_COLOR,\n    borderBottom: '2px solid transparent',\n  },\n\n});\n\nfunction HeaderItem(props: React.PropsWithChildren<{\n  label: string,\n  selected?: boolean,\n  onClick(): void,\n}>) {\n  return (\n    <button\n      aria-label={props.label}\n      {...css(STYLES.button, props.selected ? STYLES.buttonSelected : null)}\n      onClick={props.onClick}\n    >\n      {props.children}\n    </button>\n    );\n}\n\nexport function SearchHeader(props: React.PropsWithChildren<{\n  searchByAuthor: boolean;\n  setSearchByAuthor(value: boolean): void;\n  cancelSearch(): void;\n}>) {\n\n  function setAuthor() {\n    props.setSearchByAuthor(true);\n  }\n\n  function setText() {\n    props.setSearchByAuthor(false);\n  }\n\n  return (\n    <header role=\"banner\">\n      <div {...css(STYLES.bar)}>\n        <div {...css(STYLES.childrenContainer)}>\n          {props.children}\n        </div>\n        <HeaderItem\n          label=\"Author search\"\n          selected={props.searchByAuthor}\n          onClick={setAuthor}\n        >\n          <UserIcon key=\"icon\" {...css(STYLES.iconStyle)} />\n          <div key=\"text\" {...css(STYLES.buttonText)}>Author</div>\n        </HeaderItem>\n        <HeaderItem\n          label=\"Open comment search\"\n          selected={!props.searchByAuthor}\n          onClick={maybeCallback(setText)}\n        >\n          <SearchIcon  key=\"icon\" {...css(STYLES.iconStyle)} />\n          <div key=\"text\" {...css(STYLES.buttonText)}>Content</div>\n        </HeaderItem>\n        <HeaderItem\n          label=\"Close search\"\n          onClick={props.cancelSearch}\n        >\n          <RejectIcon key=\"icon\" {...css({fill: LIGHT_PRIMARY_TEXT_COLOR})} />\n          <div key=\"text\" {...css(STYLES.buttonText)}>Close</div>\n        </HeaderItem>\n      </div>\n    </header>\n  );\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/SearchHeader/SearchHeaderStory.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport React from 'react';\nimport { Link, MemoryRouter } from 'react-router-dom';\n\nimport { action } from '@storybook/addon-actions';\nimport { storiesOf } from '@storybook/react';\n\nimport {\n  ARTICLE_HEADLINE_TYPE,\n  GUTTER_DEFAULT_SPACING,\n  HEADLINE_TYPE,\n  LIGHT_PRIMARY_TEXT_COLOR,\n  VISUALLY_HIDDEN,\n} from '../../styles';\nimport { css } from '../../utilx';\nimport { HomeIcon } from '../Icons';\nimport { SearchHeader } from './SearchHeader';\n\nconst STORY_STYLES = {\n  main: {\n    display: 'flex',\n    alignItems: 'center',\n    width: '50%',\n  },\n\n  pageTitle: {\n    ...HEADLINE_TYPE,\n    color: LIGHT_PRIMARY_TEXT_COLOR,\n  },\n\n  articleTitle: {\n    ...ARTICLE_HEADLINE_TYPE,\n    color: LIGHT_PRIMARY_TEXT_COLOR,\n    whiteSpace: 'nowrap',\n    textOverflow: 'ellipsis',\n    width: '100%',\n    overflow: 'hidden',\n    marginTop: 0,\n    marginLeft: `${GUTTER_DEFAULT_SPACING}px`,\n    marginBottom: 0,\n    marginRight: 0,\n  },\n};\n\nstoriesOf('SearchHeader', module)\n  .addDecorator((story) => (\n    <MemoryRouter initialEntries={['/']}>{story()}</MemoryRouter>\n  ))\n  .add('main header', () => {\n    return (\n      <SearchHeader\n        cancelSearch={action('cancel search')}\n        searchByAuthor={false}\n        setSearchByAuthor={action('set search by author')}\n      >\n        <Link to=\"/\" {...css(STORY_STYLES.pageTitle)}>Moderator</Link>\n      </SearchHeader>\n    );\n  })\n  .add('article header', () => {\n    return (\n      <SearchHeader\n        cancelSearch={action('cancel search')}\n        searchByAuthor={false}\n        setSearchByAuthor={action('set search by author')}\n      >\n        <div {...css(STORY_STYLES.main)}>\n          <Link to=\"/\">\n            <span {...css(VISUALLY_HIDDEN)}>Home</span>\n            <HomeIcon size={24}/>\n          </Link>\n          <h1 {...css(STORY_STYLES.articleTitle)}>\n            At Hiroshima Memorial, Obama Says Nuclear Arms Require Moral Revolution\n          </h1>\n        </div>\n      </SearchHeader>\n    );\n  });\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/SearchHeader/__spec__/.gitkeep",
    "content": ""
  },
  {
    "path": "packages/frontend-web/src/app/components/SearchHeader/index.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nexport { SearchHeader } from './SearchHeader';\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/SingleComment/SingleComment.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { autobind } from 'core-decorators';\nimport {format, parseISO} from 'date-fns';\nimport { List } from 'immutable';\nimport React, {Fragment} from 'react';\nimport { Link } from 'react-router-dom';\n\nimport {\n  ICommentModel,\n  ICommentScoreModel,\n  ICommentSummaryScoreModel,\n  ITagModel,\n  IUserModel,\n} from '../../../models';\nimport { DATE_FORMAT_LONG } from '../../config';\nimport { searchLink } from '../../scenes/routes';\nimport { editAndRescoreComment } from '../../stores/commentActions';\nimport {\n  ARTICLE_CATEGORY_TYPE,\n  ARTICLE_HEADLINE_TYPE,\n  BOTTOM_BORDER_TRANSITION,\n  BOX_DEFAULT_SPACING,\n  BUTTON_LINK_TYPE,\n  BUTTON_RESET,\n  CAPTION_TYPE,\n  COMMENT_DETAIL_BODY_TEXT_TYPE,\n  COMMENT_DETAIL_DATE_TYPE,\n  DARK_PRIMARY_TEXT_COLOR,\n  DARK_SECONDARY_TEXT_COLOR,\n  DARK_TERTIARY_TEXT_COLOR,\n  DIVIDER_COLOR,\n  GREY_COLOR,\n  GUTTER_DEFAULT_SPACING,\n  LIGHT_PRIMARY_TEXT_COLOR,\n  NICE_MIDDLE_BLUE,\n  TAG_INCOHERENT_COLOR,\n  TAG_INFLAMMATORY_COLOR,\n  TAG_OBSCENE_COLOR,\n  TAG_OFF_TOPIC_COLOR,\n  TAG_OTHER_COLOR,\n  TAG_SPAM_COLOR,\n  TAG_UNSUBSTANTIAL_COLOR,\n  WHITE_COLOR,\n} from '../../styles';\nimport { css, stylesheet } from '../../utilx';\nimport { Avatar } from '../Avatar';\nimport { Button } from '../Button';\nimport { FlagsSummary } from '../FlagsSummary';\nimport {\n  EditIcon,\n} from '../Icons';\nimport { AnnotatedCommentText } from './components/AnnotatedCommentText';\nimport { AuthorCounts } from './components/AuthorCounts';\nimport { CommentTags } from './components/CommentTags';\nimport {\n  ApprovalRatingRow,\n  EmailRow,\n  ICON_SIZE,\n  IsSubscriberRow,\n  SourceIdRow,\n} from './components/DetailRow';\nimport { FlagsList } from './components/FlagsList';\nimport { SummaryScores } from './components/SummaryScore';\n\nconst AVATAR_SIZE = 60;\n// const COMMENT_WIDTH = 696;\nconst REPLY_WIDTH = 642;\n\n// Styling by class and inserting style element rather than inline styles\n// in order to style ::selection.\nconst COMMENT_BODY_STYLES = `\n  .comment-body a {\n    text-decoration: underline;\n  }\n\n  .comment-body b {\n    color: #f00;\n  }\n\n  .comment-body::selection,\n  .comment-body *::selection {\n    background: ${NICE_MIDDLE_BLUE};\n    borderColor: ${LIGHT_PRIMARY_TEXT_COLOR};\n    color: ${LIGHT_PRIMARY_TEXT_COLOR};\n  }\n\n  .tag {\n    border-bottom-width: 1px;\n    border-bottom-style: solid;\n  }\n\n  .tag-obscene {\n    border-bottom-color: ${TAG_OBSCENE_COLOR};\n    color: ${TAG_OBSCENE_COLOR};\n  }\n\n  .tag-incoherent {\n    border-bottom-color: ${TAG_INCOHERENT_COLOR};\n    color: ${TAG_INCOHERENT_COLOR};\n  }\n\n  .tag-spam {\n    border-bottom-color: ${TAG_SPAM_COLOR};\n    color: ${TAG_SPAM_COLOR};\n  }\n\n  .tag-off-topic {\n    border-bottom-color: ${TAG_OFF_TOPIC_COLOR};\n    color: ${TAG_OFF_TOPIC_COLOR};\n  }\n\n  .tag-inflammatory {\n    border-bottom-color: ${TAG_INFLAMMATORY_COLOR};\n    color: ${TAG_INFLAMMATORY_COLOR};\n  }\n\n  .tag-unsubstantial {\n    border-bottom-color: ${TAG_UNSUBSTANTIAL_COLOR};\n    color: ${TAG_UNSUBSTANTIAL_COLOR};\n  }\n\n  .tag-other {\n    border-bottom-color: ${TAG_OTHER_COLOR};\n    color: ${TAG_OTHER_COLOR};\n  }\n`;\n\nconst STYLES = stylesheet({\n  threaded: {\n    flexBasis: '100%',\n    maxWidth: `${REPLY_WIDTH}px`,\n  },\n\n  editButton: {\n    ...BUTTON_RESET,\n    ...CAPTION_TYPE,\n    color: DARK_SECONDARY_TEXT_COLOR,\n    borderRadius: 2,\n    marginTop: '10px',\n    height: '36px',\n    width: '36px',\n    padding: '6px',\n    cursor: 'pointer',\n    marginLeft: '10px',\n\n    ':hover': {\n      backgroundColor: NICE_MIDDLE_BLUE,\n    },\n\n    ':focus': {\n      backgroundColor: NICE_MIDDLE_BLUE,\n      outline: 0,\n    },\n  },\n\n  contentEditableContainer: {\n    display: 'inline-block',\n    outline: `2px solid ${DIVIDER_COLOR}`,\n    outlineOffset: '2px',\n    userSelect: 'text',\n    ':focus': {\n      outline: `2px solid ${GREY_COLOR}`,\n    },\n  },\n\n  commentTaggingContainer: {\n    display: 'flex',\n    justifyContent: 'flex-end',\n    alignItems: 'center',\n  },\n\n  buttonGroup: {\n    display: 'flex',\n    flexDirection: 'row',\n    backgroundColor: WHITE_COLOR,\n    justifyContent: 'flex-end',\n    marginTop: `${GUTTER_DEFAULT_SPACING}px`,\n  },\n\n  cancel: {\n    backgroundColor: WHITE_COLOR,\n    color: DARK_PRIMARY_TEXT_COLOR,\n    border: `1px solid ${DIVIDER_COLOR}`,\n    marginLeft: `${GUTTER_DEFAULT_SPACING}px`,\n    padding: '8px 17px 7px 17px',\n    cursor: 'pointer',\n    ':active': {\n      backgroundColor: DIVIDER_COLOR,\n    },\n    ':focus': {\n      backgroundColor: DIVIDER_COLOR,\n    },\n  },\n\n  save: {\n    backgroundColor: NICE_MIDDLE_BLUE,\n    color: WHITE_COLOR,\n    padding: '8px 17px 7px 17px',\n    cursor: 'pointer',\n  },\n});\n\nconst PROFILE_STYLES = stylesheet({\n  base: {\n    width: '100%',\n    display: 'flex',\n    flexWrap: 'no-wrap',\n    alignItems: 'baseline',\n    padding: `${GUTTER_DEFAULT_SPACING}px 0`,\n    borderBottom: '2px solid ' + DIVIDER_COLOR,\n  },\n\n  noBorder: {\n    borderBottom: 'none',\n  },\n\n  header: {\n    display: 'flex',\n    width: '100%',\n    alignItems: 'center',\n  },\n\n  avatar: {\n    width: AVATAR_SIZE,\n    height: AVATAR_SIZE,\n    overflow: 'hidden',\n    background: DIVIDER_COLOR,\n    marginRight: `${GUTTER_DEFAULT_SPACING}px`,\n  },\n\n  nameColumn: {\n    marginLeft: '35px',\n    display: 'flex',\n    flexDirection: 'column',\n    justifyContent: 'flex-end',\n    flex: 1,\n  },\n\n  name: {\n    ...ARTICLE_HEADLINE_TYPE,\n    color: DARK_PRIMARY_TEXT_COLOR,\n    display: 'flex',\n    justifyContent: 'space-between',\n    alignItems: 'center',\n  },\n\n  meta: {\n    display: 'flex',\n    flexWrap: 'wrap',\n    justifyContent: 'space-between',\n    alignItems: 'flex-end',\n    marginTop: '5px',\n  },\n\n  authorName: {\n    color: DARK_PRIMARY_TEXT_COLOR,\n    userSelect: 'text',\n    ':focus': {\n      outline: 0,\n      textDecoration: 'underline',\n    },\n  },\n\n  location: {\n    ...CAPTION_TYPE,\n    color: DARK_SECONDARY_TEXT_COLOR,\n    marginRight: `${BOX_DEFAULT_SPACING}px`,\n    userSelect: 'text',\n  },\n\n  details: {\n    display: 'flex',\n    flexWrap: 'no-wrap',\n  },\n});\n\nconst COMMENT_STYLES = stylesheet({\n  base: {\n    display: 'flex',\n    flexDirection: 'column',\n  },\n\n  meta: {\n    display: 'flex',\n    marginTop: `${GUTTER_DEFAULT_SPACING}px`,\n    marginBottom: `${GUTTER_DEFAULT_SPACING}px`,\n  },\n\n  bullet: {\n    ...ARTICLE_CATEGORY_TYPE,\n    color: DARK_TERTIARY_TEXT_COLOR,\n    margin: '0 5px',\n  },\n\n  link: {\n    color: NICE_MIDDLE_BLUE,\n    textDecoration: 'none',\n    ':focus': {\n      outline: 0,\n      textDecoration: 'underline',\n    },\n  },\n\n  flags: {\n    ...ARTICLE_CATEGORY_TYPE,\n    color: DARK_TERTIARY_TEXT_COLOR,\n    textTransform: 'uppercase',\n  },\n\n  body: {\n    ...COMMENT_DETAIL_BODY_TEXT_TYPE,\n    color: DARK_PRIMARY_TEXT_COLOR,\n    fontSize: '20px',\n    position: 'relative',\n    wordWrap: 'break-word',\n    marginBottom: `${GUTTER_DEFAULT_SPACING * 4}px`,\n    whiteSpace: 'pre-wrap',\n  },\n\n  scoreDetails: {\n    ...BUTTON_LINK_TYPE, BOTTOM_BORDER_TRANSITION,\n    color: NICE_MIDDLE_BLUE,\n    marginTop: `${GUTTER_DEFAULT_SPACING}px`,\n    display: 'block',\n    maxWidth: 115,\n    ':hover': {\n      transition: 'all 0.3 ease',\n      borderBottomColor: NICE_MIDDLE_BLUE,\n    },\n    ':focus': {\n      borderBottomColor: NICE_MIDDLE_BLUE,\n    },\n  },\n\n  metaType: {\n    ...COMMENT_DETAIL_DATE_TYPE,\n    color: DARK_SECONDARY_TEXT_COLOR,\n  },\n});\n\nexport interface ISingleCommentProps {\n  comment: ICommentModel;\n  allScores?: Array<ICommentScoreModel>;\n  allScoresAboveThreshold?: Array<ICommentScoreModel>;\n  reducedScoresAboveThreshold?: Array<ICommentScoreModel>;\n  isThreadedComment?: boolean;\n  isReply?: boolean;\n  availableTags?: List<ITagModel>;\n  onScoreClick?(score: ICommentSummaryScoreModel): void;\n  onTagButtonClick?(tagId: string): Promise<any>;\n  onCommentTagClick?(commentScore: ICommentScoreModel): void;\n  onAnnotateTagButtonClick?(tag: string, start: number, end: number): Promise<any>;\n  url?: string;\n  loadScores?(commentId: string): void;\n  getUserById?(id: string): IUserModel;\n  currentUser?: IUserModel;\n  commentEditingEnabled?: boolean;\n}\n\nexport interface ISingleCommentState {\n  inEditMode: boolean;\n  isEditHovered: boolean;\n  isEditFocused: boolean;\n}\n\nexport class SingleComment extends React.PureComponent<ISingleCommentProps, ISingleCommentState> {\n\n  state = {\n    inEditMode: false,\n    isEditHovered: false,\n    isEditFocused: false,\n  };\n\n  authorLocation: HTMLDivElement = null;\n  authorName: HTMLSpanElement = null;\n  commentText: HTMLDivElement = null;\n\n  @autobind\n  handleEditCommentClick() {\n    this.setState({\n      inEditMode: !this.state.inEditMode,\n    });\n  }\n\n  @autobind\n  saveAuthorLocationRef(elem: HTMLDivElement) {\n    this.authorLocation = elem;\n  }\n\n  @autobind\n  saveAuthorNameRef(elem: HTMLSpanElement) {\n    this.authorName = elem;\n  }\n\n  @autobind\n  saveCommentTextRef(elem: HTMLDivElement) {\n    this.commentText = elem;\n  }\n\n  @autobind\n  saveEditedCommentText(e: React.FormEvent<any>) {\n    e.preventDefault();\n    const {\n      comment,\n    } = this.props;\n\n    // grab new author name and location text\n    const authorName = this.authorName.innerText;\n    const authorLoc = this.authorLocation.innerText;\n    const commentText = this.commentText.innerText;\n\n    // reset comment text and author\n    const author = {...comment.author, name: authorName, location: authorLoc};\n    // send comment text to be update to publisher\n    editAndRescoreComment(comment.id, commentText, author);\n\n    this.setState({\n      inEditMode: false,\n    });\n  }\n\n  @autobind\n  cancelEditedCommentText(e: React.FormEvent<any>) {\n    e.preventDefault();\n    this.setState({\n      inEditMode: false,\n    });\n  }\n\n  @autobind\n  onEditMouseEnter() {\n    this.setState({ isEditHovered: true });\n  }\n\n  @autobind\n  onEditMouseLeave() {\n    this.setState({ isEditHovered: false });\n  }\n\n  @autobind\n  onEditFocus() {\n    this.setState({ isEditFocused: true });\n  }\n\n  @autobind\n  onEditBlur() {\n    this.setState({ isEditFocused: false });\n  }\n\n  @autobind\n  focusText() {\n    this.commentText.focus();\n  }\n\n  @autobind\n  focusName() {\n    this.authorName.focus();\n  }\n\n  @autobind\n  focusLocation() {\n    this.authorLocation.focus();\n  }\n\n  renderAuthor() {\n    const {comment} = this.props;\n    const {inEditMode} = this.state;\n\n    const { author } = this.props.comment;\n    if (!author) {\n      return null;\n    }\n    return (\n      <Fragment>\n        {author.avatar && <Avatar key=\"avatarColumn\" target={author} size={60}/>}\n        <div key=\"nameColumn\" {...css(PROFILE_STYLES.nameColumn)}>\n          <div {...css(PROFILE_STYLES.name)}>\n            {!inEditMode ? (\n              <Link\n                to={searchLink({searchByAuthor: true, term: author.name})}\n                key=\"authorName\"\n                {...css(PROFILE_STYLES.authorName)}\n              >\n                {author.name}\n              </Link>\n            ) : (\n              <span\n                key=\"authorNameEditable\"\n                contentEditable\n                suppressContentEditableWarning\n                ref={this.saveAuthorNameRef}\n                onClick={this.focusName}\n                {...css(STYLES.contentEditableContainer, {minWidth: '300px'})}\n              >\n                {author.name}\n              </span>\n            )}\n          </div>\n          <div {...css(PROFILE_STYLES.meta)}>\n            <div>\n              {author.location && (\n                <div key=\"location\" {...css(PROFILE_STYLES.location)}>\n                  {!inEditMode ? (\n                    <span key=\"authorLocation\">{author.location}</span>\n                  ) : (\n                    <span\n                      key=\"authorLocationEditable\"\n                      contentEditable\n                      suppressContentEditableWarning\n                      ref={this.saveAuthorLocationRef}\n                      onClick={this.focusLocation}\n                      {...css(STYLES.contentEditableContainer)}\n                    >\n                      {author.location}\n                    </span>\n                  )}\n                </div>\n              )}\n            </div>\n            <div {...css(PROFILE_STYLES.details)}>\n              <AuthorCounts authorSourceId={comment.authorSourceId}/>\n              {author.approvalRating && (<ApprovalRatingRow approvalRating={author.approvalRating}/>)}\n              {author.isSubscriber && (<IsSubscriberRow/>)}\n              {author.email && (<EmailRow author={author}/>)}\n              {comment.authorSourceId && (<SourceIdRow authorSourceId={comment.authorSourceId}/>)}\n            </div>\n          </div>\n        </div>\n      </Fragment>\n    );\n  }\n\n  render() {\n    const {\n      comment,\n      allScoresAboveThreshold,\n      reducedScoresAboveThreshold,\n      availableTags,\n      onTagButtonClick,\n      onCommentTagClick,\n      onAnnotateTagButtonClick,\n      url,\n      isReply,\n      isThreadedComment,\n      onScoreClick,\n      loadScores,\n      getUserById,\n      currentUser,\n      commentEditingEnabled,\n    } = this.props;\n\n    const {\n      inEditMode,\n      isEditHovered,\n      isEditFocused,\n    } = this.state;\n\n    const created_at = comment.sourceCreatedAt ? format(parseISO(comment.sourceCreatedAt), DATE_FORMAT_LONG) : '';\n\n    const bodyStyling = css(COMMENT_STYLES.body);\n    const className = bodyStyling.className ? bodyStyling.className + ' comment-body' : 'comment-body';\n\n    return (\n      <div {...css(isThreadedComment && isReply && STYLES.threaded)}>\n        <div\n          {...css(\n            PROFILE_STYLES.base,\n            isThreadedComment && PROFILE_STYLES.noBorder,\n          )}\n        >\n          <div {...css(PROFILE_STYLES.header)}>\n            {this.renderAuthor()}\n          </div>\n        </div>\n        <div {...css(COMMENT_STYLES.base)}>\n          <div {...css(STYLES.commentTaggingContainer)}>\n            <CommentTags\n              scores={reducedScoresAboveThreshold}\n              availableTags={availableTags}\n              onClick={onTagButtonClick}\n              onCommentTagClick={onCommentTagClick}\n            />\n\n            {commentEditingEnabled &&\n              (\n                <button\n                  aria-label=\"Edit Comment Text\"\n                  onMouseEnter={this.onEditMouseEnter}\n                  onMouseLeave={this.onEditMouseLeave}\n                  onFocus={this.onEditFocus}\n                  onBlur={this.onEditBlur}\n                  {...css(STYLES.editButton)}\n                  onClick={this.handleEditCommentClick}\n                >\n                  <EditIcon\n                    {...css({\n                      fill: isEditHovered || isEditFocused\n                          ? LIGHT_PRIMARY_TEXT_COLOR\n                          : NICE_MIDDLE_BLUE,\n                    })}\n                    size={ICON_SIZE}\n                  />\n                </button>\n              )\n            }\n          </div>\n          <div\n            {...css(\n              COMMENT_STYLES.meta,\n              /* url && COMMENT_STYLES.metaModerating, */\n            )}\n          >\n            <div {...css(COMMENT_STYLES.metaType)}>\n              {url ? (\n                <Link key=\"submittedAt\" to={url} {...css(COMMENT_STYLES.link)}>{created_at} </Link>\n              ) : (\n                <span key=\"submittedAt\">{created_at} </span>\n              )}\n              <FlagsSummary comment={comment} full/>\n            </div>\n          </div>\n          <style>{COMMENT_BODY_STYLES}</style>\n          <div className={className} style={bodyStyling.style}>\n            {inEditMode ? (\n              <div>\n                <div\n                  key=\"content\"\n                  contentEditable\n                  suppressContentEditableWarning\n                  ref={this.saveCommentTextRef}\n                  onClick={this.focusText}\n                  {...css(STYLES.contentEditableContainer)}\n                >\n                  {comment.text}\n                </div>\n                <div\n                  key=\"buttons\"\n                  {...css(STYLES.buttonGroup)}\n                >\n                  <Button\n                    key=\"save\"\n                    label=\"Save\"\n                    onClick={this.saveEditedCommentText}\n                    buttonStyles={STYLES.save}\n                  />\n                  <Button\n                    key=\"cancel\"\n                    label=\"Cancel\"\n                    onClick={this.cancelEditedCommentText}\n                    buttonStyles={STYLES.cancel}\n                  />\n                </div>\n              </div>\n            ) : (\n              <AnnotatedCommentText\n                scores={allScoresAboveThreshold}\n                availableTags={availableTags}\n                text={comment.text}\n                loadScores={loadScores}\n                onClick={onAnnotateTagButtonClick}\n                getUserById={getUserById}\n                currentUser={currentUser}\n              />\n            )}\n          </div>\n          <SummaryScores comment={comment} onScoreClick={onScoreClick}/>\n          <FlagsList commentId={comment.id}/>\n        </div>\n      </div>\n    );\n  }\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/SingleComment/SingleCommentStory.tsx",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\nimport faker from 'faker';\nimport { List } from 'immutable';\nimport React from 'react';\nimport { MemoryRouter } from 'react-router-dom';\n\nimport { storiesOf } from '@storybook/react';\n\nimport { IAuthorModel, ITagModel } from '../../../models';\nimport { fakeCommentModel, fakeTagModel } from '../../../models/fake';\nimport { css } from '../../utilx';\nimport { SingleComment } from './SingleComment';\n\nconst date = new Date(2016, 10, 30);\n\nfaker.seed(789);\n\nconst author = {\n  email: 'name@email.com',\n  location: 'NYC',\n  name: 'Bridie Skiles IV',\n  avatar: faker.internet.avatar(),\n} as IAuthorModel;\n\nconst comment = fakeCommentModel({\n  authorSourceId: 'test',\n  author,\n  sourceCreatedAt: date.toString(),\n  unresolvedFlagsCount: 2,\n  flagsSummary: new Map([['red', [1, 0, 0]], ['green', [2, 2, 2]]]),\n});\n\nconst availableTags = List<ITagModel>().push(fakeTagModel({}), fakeTagModel({}));\n\nconst STORY_STYLES = {\n  base: {\n    width: '100%',\n    background: 'rgba(0, 0, 0, 0.1)',\n    padding: '20px 0',\n  },\n\n  detail: {\n    maxWidth: '700px',\n    margin: '0 auto',\n    background: '#fff',\n  },\n};\n\nstoriesOf('SingleComment', module)\n  .addDecorator((story) => (\n    <MemoryRouter initialEntries={['/']}>{story()}</MemoryRouter>\n  ))\n  .add('base', () => {\n    return (\n      <div {...css(STORY_STYLES.base)}>\n        <div {...css(STORY_STYLES.detail)}>\n          <SingleComment\n            comment={comment}\n          />\n        </div>\n      </div>\n    );\n  })\n  .add('has url', () => {\n    return (\n      <div {...css(STORY_STYLES.base)}>\n        <div {...css(STORY_STYLES.detail)}>\n          <SingleComment\n            comment={comment}\n            url=\"http://www.example.com/\"\n          />\n        </div>\n      </div>\n    );\n  })\n  .add('can edit comment', () => {\n    return (\n      <div {...css(STORY_STYLES.base)}>\n        <div {...css(STORY_STYLES.detail)}>\n          <SingleComment\n            comment={comment}\n            commentEditingEnabled\n          />\n        </div>\n      </div>\n    );\n  })\n  .add('tags to add', () => {\n    return (\n      <div {...css(STORY_STYLES.base)}>\n        <div {...css(STORY_STYLES.detail)}>\n          <SingleComment\n            comment={comment}\n            availableTags={availableTags}\n          />\n        </div>\n      </div>\n    );\n  })\n;\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/SingleComment/__spec__/.gitkeep",
    "content": ""
  },
  {
    "path": "packages/frontend-web/src/app/components/SingleComment/components/AnnotatedCommentText.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { autobind } from 'core-decorators';\nimport FocusTrap from 'focus-trap-react';\nimport { List } from 'immutable';\nimport keyboardJS from 'keyboardjs';\nimport React from 'react';\nimport ReactDOM from 'react-dom';\n\nimport { ICommentScoreModel, ITagModel, IUserModel } from '../../../../models';\nimport {\n  confirmCommentScore,\n  rejectCommentScore,\n  resetCommentScore,\n  untagComment,\n} from '../../../stores/commentActions';\nimport {\n  ARTICLE_CAPTION_TYPE,\n  GREY_COLOR,\n  GUTTER_DEFAULT_SPACING,\n  LIGHT_PRIMARY_TEXT_COLOR,\n  LIGHT_SECONDARY_TEXT_COLOR,\n  NICE_MIDDLE_BLUE,\n  SCRIM_Z_INDEX,\n  SEMI_BOLD_TYPE,\n  WHITE_COLOR,\n} from '../../../styles';\nimport { partial } from '../../../util';\nimport { css, stylesheet } from '../../../utilx';\nimport { ToolTip } from '../../ToolTip';\n\nconst TOOLTIP_ARROW_SIZE = 16;\n\nconst STYLES = stylesheet({\n  confirmBase: {\n    ...SEMI_BOLD_TYPE,\n  },\n\n  confirmed: {\n    borderColor: 'transparent',\n  },\n\n  removed: {},\n\n  toolTipWithTagsContainer: {\n    userSelect: 'none',\n    width: 250,\n  },\n\n  toolTipWithTagsUl: {\n    listStyle: 'none',\n    margin: 0,\n    padding: `${GUTTER_DEFAULT_SPACING}px 0`,\n  },\n\n  toolTipWithTagsButton: {\n    backgroundColor: 'transparent',\n    border: 'none',\n    borderRadius: 0,\n    color: NICE_MIDDLE_BLUE,\n    cursor: 'pointer',\n    padding: '8px 20px',\n    textAlign: 'left',\n    width: '100%',\n    ':focus': {\n      outline: 0,\n    },\n    ':hover': {\n      backgroundColor: NICE_MIDDLE_BLUE,\n      color: LIGHT_PRIMARY_TEXT_COLOR,\n    },\n  },\n\n  confirmContainer: {\n    ...ARTICLE_CAPTION_TYPE,\n    color: LIGHT_PRIMARY_TEXT_COLOR,\n    fontSize: 16,\n    padding: `${GUTTER_DEFAULT_SPACING}px  ${GUTTER_DEFAULT_SPACING * 1.5}px`,\n    textAlign: 'center',\n    userSelect: 'none',\n  },\n\n  confirmTagWrapper: {\n    marginBottom: 10,\n    marginTop: 4,\n  },\n\n  confirmTag: {\n    paddingBottom: 2,\n    textTransform: 'capitalize',\n  },\n\n  confirmMeta: {\n    color: LIGHT_SECONDARY_TEXT_COLOR,\n    fontSize: 14,\n    marginTop: 0,\n    whiteSpace: 'nowrap',\n  },\n\n  confirmButton: {\n    backgroundColor: 'transparent',\n    border: 'none',\n    color: LIGHT_PRIMARY_TEXT_COLOR,\n    cursor: 'pointer',\n    margin: '0 8px',\n    padding: '6px 14px',\n\n    ':hover': {\n      textDecoration: 'underline',\n    },\n\n    ':focus': {\n      outline: 0,\n      textDecoration: 'underline',\n    },\n  },\n});\n\nexport interface IAnnotatedCommentTextProps {\n  text: string;\n  scores: Array<ICommentScoreModel>;\n  availableTags: List<ITagModel>;\n  onClick?(tag: string, start: number, end: number): Promise<any>;\n  loadScores?(commentId: string): void;\n  getUserById?(id: string): IUserModel;\n  currentUser: IUserModel;\n}\n\nexport interface IAnnotatedCommentTextState {\n  isTaggingToolTipVisible?: boolean;\n  taggingToolTipPosition?: {\n    top: number,\n    left: number,\n  };\n  isConfirmationToolTipVisible?: boolean;\n  confirmationToolTipPosition?: {\n    top: number,\n    left: number,\n  };\n  confirmationToolTipColor?: string;\n  confirmationTagType?: string;\n  confirmationAuthor?: string;\n  confirmationTarget?: JSX.Element;\n  confirmationStatus?: string;\n  confirmationSource?: string;\n  confirmationScore?: ICommentScoreModel;\n}\n\nexport class AnnotatedCommentText extends React.PureComponent<IAnnotatedCommentTextProps, IAnnotatedCommentTextState> {\n\n  currentSelection = {\n    start: 0,\n    end: 0,\n  };\n  confirmationRef: HTMLElement = null;\n  taggingRef: HTMLElement = null;\n\n  state: IAnnotatedCommentTextState = {\n    isTaggingToolTipVisible: false,\n    taggingToolTipPosition: {\n      top: 0,\n      left: 0,\n    },\n    isConfirmationToolTipVisible: false,\n    confirmationToolTipPosition: {\n      top: 0,\n      left: 0,\n    },\n    confirmationToolTipColor: NICE_MIDDLE_BLUE,\n    confirmationTagType: 'other',\n    confirmationAuthor: '',\n    confirmationTarget: null,\n    confirmationStatus: '',\n    confirmationSource: '',\n    confirmationScore: null,\n  };\n\n  componentDidMount() {\n    keyboardJS.bind('escape', this.onPressEscape);\n    window.addEventListener('mouseup', this.onGlobalClick);\n    window.addEventListener('touchend', this.onGlobalClick);\n    window.addEventListener('touchMove', this.onTouchMove);\n  }\n\n  componentWillUnmount() {\n    keyboardJS.unbind('escape', this.onPressEscape);\n    window.removeEventListener('mouseup', this.onGlobalClick);\n    window.removeEventListener('touchend', this.onGlobalClick);\n  }\n\n  render() {\n    const {\n      availableTags,\n      text,\n      scores,\n    } = this.props;\n\n    const {\n      isTaggingToolTipVisible,\n      taggingToolTipPosition,\n      isConfirmationToolTipVisible,\n      confirmationToolTipPosition,\n      confirmationToolTipColor,\n      confirmationTagType,\n      confirmationAuthor,\n      confirmationStatus,\n      confirmationSource,\n      confirmationScore,\n    } = this.state;\n\n    const taggifiedText = this.taggifyText(text, scores);\n\n    let confirmationToolTipContent;\n    const confirmed = confirmationScore && confirmationScore.isConfirmed;\n    if (confirmed !== null) {\n\n      const actionString = confirmationSource !== 'Machine' &&\n          confirmationStatus === 'confirmed' ? 'Tagged' : confirmationStatus;\n\n      confirmationToolTipContent = (\n        <div key=\"confirmationTooltipConfirmed\">\n          <p key=\"question\" {...css(STYLES.confirmTagWrapper)}>\n            <span {...css(STYLES.confirmTag)}>{confirmationTagType}</span>\n          </p>\n          <p key=\"text\" {...css(STYLES.confirmMeta)}>\n            <span>{actionString} by {confirmationAuthor}</span>\n          </p>\n          <button key=\"confirm-undo\" {...css(STYLES.confirmButton)} onClick={this.undoTag}>\n            Undo\n          </button>\n        </div>\n      );\n    } else {\n      confirmationToolTipContent = (\n        <div key=\"confirmationTooltip\">\n          <p key=\"question\" {...css(STYLES.confirmTagWrapper)}>\n            Is this \"<span {...css(STYLES.confirmTag)}>{confirmationTagType}</span>\"?\n          </p>\n          <p key=\"text\" {...css(STYLES.confirmMeta)}>Flagged by {confirmationAuthor}</p>\n          <button key=\"confirm-yes\" {...css(STYLES.confirmButton)} onClick={this.confirmTag}>\n            Yes\n          </button>\n          <button key=\"confirm-no\" {...css(STYLES.confirmButton)} onClick={this.removeTag}>\n            No\n          </button>\n        </div>\n      );\n    }\n\n    return (\n      <div>\n        <div\n          onMouseDown={this.onMouseDown}\n          onTouchStart={this.onTouchStart}\n        >\n          {taggifiedText}\n        </div>\n        {isTaggingToolTipVisible && (\n          <ToolTip\n            key=\"tagging\"\n            arrowPosition=\"topCenter\"\n            backgroundColor={WHITE_COLOR}\n            hasDropShadow\n            isVisible={isTaggingToolTipVisible}\n            position={taggingToolTipPosition}\n            size={TOOLTIP_ARROW_SIZE}\n            width={250}\n            zIndex={SCRIM_Z_INDEX}\n          >\n            <FocusTrap>\n              <div {...css(STYLES.toolTipWithTagsContainer)} ref={this.saveTaggingRef}>\n                <ul {...css(STYLES.toolTipWithTagsUl)}>\n                  {availableTags && availableTags.map((t, i) => (\n                    <li key={t.id}>\n                      <button\n                        type=\"button\"\n                        onClick={partial(this.tagText, t.id)}\n                        key={`tag-${i}`}\n                        {...css(STYLES.toolTipWithTagsButton)}\n                      >\n                        {t.label}\n                      </button>\n                    </li>\n                  ))}\n                </ul>\n              </div>\n            </FocusTrap>\n          </ToolTip>\n        )}\n        {isConfirmationToolTipVisible && (\n          <ToolTip\n            key=\"focus\"\n            arrowPosition=\"bottomCenter\"\n            backgroundColor={confirmationToolTipColor}\n            isVisible={isConfirmationToolTipVisible}\n            position={confirmationToolTipPosition}\n            size={TOOLTIP_ARROW_SIZE}\n            width={250}\n            zIndex={SCRIM_Z_INDEX}\n          >\n            <FocusTrap>\n              <div {...css(STYLES.confirmContainer)} ref={this.saveConfirmationRef}>\n                {confirmationToolTipContent}\n              </div>\n            </FocusTrap>\n          </ToolTip>\n        )}\n      </div>\n    );\n  }\n\n  @autobind\n  saveConfirmationRef(el: HTMLElement) {\n    this.confirmationRef = el;\n  }\n\n  @autobind\n  saveTaggingRef(el: HTMLElement) {\n    this.taggingRef = el;\n  }\n\n  @autobind\n  onMouseDown() {\n    window.removeEventListener('mouseup', this.onGlobalClick);\n    window.addEventListener('mouseup', this.onMouseUp);\n  }\n\n  @autobind\n  onMouseUp() {\n    this.handleSelection();\n    window.removeEventListener('mouseup', this.onMouseUp);\n    window.addEventListener('mouseup', this.onGlobalClick);\n  }\n\n  @autobind\n  onTouchStart() {\n    window.removeEventListener('touchend', this.onGlobalClick);\n    window.addEventListener('touchend', this.onTouchEnd);\n  }\n\n  @autobind\n  onTouchMove() {\n    window.removeEventListener('touchend', this.onGlobalClick);\n  }\n\n  @autobind\n  onTouchEnd() {\n    this.handleSelection();\n    window.removeEventListener('touchend', this.onTouchEnd);\n    window.addEventListener('touchend', this.onGlobalClick);\n  }\n\n  @autobind\n  onPressEscape() {\n    if (this.state.isTaggingToolTipVisible || this.state.isConfirmationToolTipVisible) {\n      this.closeToolTips();\n    }\n  }\n\n  @autobind\n  onGlobalClick(e: any) {\n    if (\n      (!this.confirmationRef && !this.taggingRef) ||\n      (this.confirmationRef && this.confirmationRef.contains(e.target)) ||\n      (this.taggingRef && this.taggingRef.contains(e.target))\n    ) {\n      return;\n    }\n    if (this.state.isTaggingToolTipVisible || this.state.isConfirmationToolTipVisible) {\n      // Needs a delay so the events can fire through\n      setTimeout(() => this.closeToolTips(), 10);\n    }\n  }\n\n  @autobind\n  closeToolTips() {\n    if (this.state.isTaggingToolTipVisible) {\n      this.closeTaggingToolTip();\n    }\n    if (this.state.isConfirmationToolTipVisible) {\n      this.closeConfirmationToolTip();\n    }\n    this.clearSelection();\n  }\n\n  @autobind\n  closeTaggingToolTip() {\n    this.setState({ isTaggingToolTipVisible: false });\n    this.clearSelection();\n    // TODO: reset focus element.\n  }\n\n  clearSelection() {\n    if (window.getSelection) {\n      if (window.getSelection().empty) {  // Chrome\n        window.getSelection().empty();\n      }\n    }\n  }\n\n  @autobind\n  toggleConfirmationToolTip(score: ICommentScoreModel, event: React.MouseEvent<HTMLSpanElement>) {\n    const target: any = event.target;\n\n    if (target === this.state.confirmationTarget || window.getSelection().toString().length !== 0) {\n      this.closeConfirmationToolTip();\n\n      return;\n    }\n\n    const top = target.offsetTop;\n    const targetRect = target.getBoundingClientRect();\n    const container = ReactDOM.findDOMNode(this).parentElement;\n    const containerRect = container.getBoundingClientRect();\n    const left = (targetRect.left - containerRect.left) + (targetRect.width / 2);\n\n    this.setState({\n      confirmationScore: score,\n      confirmationTarget: target,\n      confirmationAuthor: target.dataset.author,\n      confirmationTagType: target.dataset.tag,\n      confirmationStatus: target.dataset.status,\n      confirmationSource: target.dataset.source,\n      confirmationToolTipColor: target.dataset.color,\n      confirmationToolTipPosition: { top, left },\n      isConfirmationToolTipVisible: true,\n    });\n  }\n\n  @autobind\n  closeConfirmationToolTip() {\n    this.setState({\n      confirmationTarget: null,\n      isConfirmationToolTipVisible: false,\n    });\n    // TODO: reset focus element.\n  }\n\n  @autobind\n  handleSelection(): void {\n    if (!window.getSelection) {\n      this.closeTaggingToolTip();\n\n      return;\n    }\n\n    const selection = window.getSelection();\n\n    // Delay to make sure selection type is updated.\n    setTimeout(() => {\n      const selectionString = selection.toString();\n      this.currentSelection.start = this.props.text.indexOf(selectionString);\n      this.currentSelection.end = this.currentSelection.start + selectionString.length;\n      if (this.currentSelection.start === this.currentSelection.end) {\n\n        return;\n      }\n      this.positionToolTip(selection);\n    }, 10);\n  }\n\n  @autobind\n  positionToolTip(selection: Selection): void {\n    // Get the union of all rects in range. Use this to calculate horizontal\n    // center of the selection.\n    const unionRect = selection.getRangeAt(0).getBoundingClientRect();\n\n    // Compute top and left positions based on offsetParent.\n    const container = ReactDOM.findDOMNode(this).parentElement;\n    const containerRect = container.getBoundingClientRect();\n    const top = unionRect.bottom - containerRect.top;\n    const left = (unionRect.left - containerRect.left) + (unionRect.width / 2);\n\n    this.setState({\n      isTaggingToolTipVisible: true,\n      taggingToolTipPosition: { top, left },\n    });\n  }\n\n  @autobind\n  async tagText(id: string) {\n    await this.props.onClick(id, this.currentSelection.start, this.currentSelection.end);\n\n    this.closeTaggingToolTip();\n\n    if (this.props.loadScores && this.state.confirmationScore) {\n      await this.props.loadScores(this.state.confirmationScore.commentId);\n    }\n  }\n\n  @autobind\n  async confirmTag() {\n    await confirmCommentScore(this.state.confirmationScore.commentId, this.state.confirmationScore.id);\n    this.closeConfirmationToolTip();\n  }\n\n  @autobind\n  async undoTag() {\n    if (\n      this.props.currentUser.name === this.state.confirmationAuthor &&\n      this.state.confirmationSource !== 'Machine'\n    ) {\n      await untagComment(this.state.confirmationScore.commentId, this.state.confirmationScore.id);\n      this.closeConfirmationToolTip();\n      return;\n    }\n\n    await resetCommentScore(this.state.confirmationScore.commentId, this.state.confirmationScore.id);\n    this.closeConfirmationToolTip();\n  }\n\n  @autobind\n  async removeTag() {\n    await rejectCommentScore(this.state.confirmationScore.commentId, this.state.confirmationScore.id);\n    this.closeConfirmationToolTip();\n  }\n\n  taggifyText(text: string, scores: Array<ICommentScoreModel>): JSX.Element {\n    if (typeof scores === 'undefined') {\n      return <div>{text}</div>;\n    }\n    const sortedNodes =\n        scores.sort((a, b) => a.annotationStart - b.annotationStart);\n    let currentIndex = 0;\n    const output: Array<any> = [];\n    sortedNodes.forEach((n, i) => {\n      if (n.annotationStart === null || n.annotationEnd  === null) { return; }\n\n      const startIndex =\n          n.annotationStart < currentIndex ? currentIndex : n.annotationStart;\n      const endIndex =\n          n.annotationEnd < currentIndex ? currentIndex : n.annotationEnd;\n      const confirmedUser = this.props.getUserById(n.confirmedUserId) && this.props.getUserById(n.confirmedUserId).name;\n      const author = confirmedUser ? confirmedUser : n.sourceType;\n\n      output.push(this.addRange(text, currentIndex, startIndex));\n\n      output.push(this.addRange(\n        text,\n        startIndex,\n        endIndex,\n        n.tagId,\n        n.isConfirmed,\n        author,\n        n.sourceType,\n        i,\n        n,\n      ));\n      currentIndex = endIndex;\n    });\n\n    output.push(this.addRange(\n      text,\n      currentIndex,\n      text.length,\n    ));\n\n    return <div>{output}</div>;\n  }\n\n  addRange(\n    originalString: string,\n    start: number,\n    end: number,\n    tagId?: string,\n    isConfirmed?: boolean,\n    author?: string,\n    source?: string,\n    key?: number,\n    score?: ICommentScoreModel,\n  ) {\n    const str = originalString.slice(start, end);\n    if (str.length > 0) {\n      if (tagId) {\n        const tag = this.props.availableTags.find((t) => (t.id === tagId));\n\n        if (author !== 'Machine' && author !== null) {\n          status = 'tagged';\n        } else if (isConfirmed === true) {\n          status = 'confirmed';\n        }\n        if (isConfirmed === false) {\n          status = 'rejected';\n        } else if (isConfirmed === null) {\n          status = 'unmoderated';\n        }\n\n        const color = status && status === 'rejected' || !tag ? GREY_COLOR : tag.color;\n\n        return (\n          <span\n            key={key}\n            role=\"button\"\n            tabIndex={0}\n            onClick={partial(this.toggleConfirmationToolTip, score)}\n            data-color={color}\n            data-tag={tag && tag.label}\n            data-author={author}\n            data-status={status}\n            data-source={source}\n            {...css(\n              !isConfirmed && status !== 'rejected' && STYLES.confirmBase,\n              { color },\n            )}\n          >\n            {str}\n          </span>\n        );\n      } else {\n        return (\n          <span>{str}</span>\n        );\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/SingleComment/components/AuthorCounts.tsx",
    "content": "/*\nCopyright 2020 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport React, {useEffect, useState} from 'react';\n\nimport {IAuthorCountsModel} from '../../../../models';\nimport {listAuthorCounts} from '../../../platform/dataService';\nimport {DARK_SECONDARY_TEXT_COLOR} from '../../../styles';\nimport {css} from '../../../utilx';\nimport {ApproveIcon} from '../../Icons';\nimport {DetailRow, ICON_SIZE} from './DetailRow';\n\nexport interface IAuthorCountsProps {\n  authorSourceId: string;\n}\n\nexport function AuthorCounts(props: IAuthorCountsProps) {\n  const [counts, setCounts] = useState<IAuthorCountsModel>(null);\n\n  async function fetchCount() {\n    const countsMap = await listAuthorCounts([props.authorSourceId]);\n    setCounts(countsMap.get(props.authorSourceId));\n  }\n  useEffect(() => {\n    fetchCount();\n  }, [props.authorSourceId]);\n\n  if (!counts) {\n    return null;\n  }\n\n  return (\n    <DetailRow\n      key=\"authorCounts\"\n      icon={(\n        <ApproveIcon\n          {...css({ fill: DARK_SECONDARY_TEXT_COLOR })}\n          size={ICON_SIZE}\n        />\n      )}\n      label={\n        `${counts.approvedCount} / ${(counts.approvedCount + counts.rejectedCount)}` +\n        ` approved`}\n    />\n  );\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/SingleComment/components/CommentTags.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { autobind } from 'core-decorators';\nimport FocusTrap from 'focus-trap-react';\nimport { List } from 'immutable';\nimport keyboardJS from 'keyboardjs';\nimport React from 'react';\n\nimport { ICommentScoreModel, ITagModel } from '../../../../models';\nimport {\n  GREY_COLOR,\n  GUTTER_DEFAULT_SPACING,\n  LIGHT_PRIMARY_TEXT_COLOR,\n  LIGHT_SECONDARY_TEXT_COLOR,\n  NICE_MIDDLE_BLUE,\n  OFFSCREEN,\n  TOOLTIP_Z_INDEX,\n  VISUALLY_HIDDEN,\n  WHITE_COLOR,\n} from '../../../styles';\nimport { identity, partial } from '../../../util';\nimport { css, stylesheet } from '../../../utilx';\nimport {\n  AddIcon,\n} from '../../Icons';\nimport { ToolTip } from '../../ToolTip';\n\nconst ICON_SIZE = 24;\n\nconst STYLES = stylesheet({\n  base: {\n    alignItems: 'center',\n    display: 'flex',\n    justifyContent: 'flex-end',\n    marginTop: `${GUTTER_DEFAULT_SPACING / 2}px`,\n    position: 'relative',\n  },\n\n  tagsContainer: {\n    marginRight: 4,\n  },\n\n  tag: {\n    borderRadius: 2,\n    color: LIGHT_SECONDARY_TEXT_COLOR,\n    fontSize: 14,\n    marginLeft: 4,\n    padding: 10,\n  },\n\n  button: {\n    backgroundColor: 'transparent',\n    border: 'none',\n    borderRadius: 2,\n    cursor: 'pointer',\n    marginBottom: 4,\n    padding: 6,\n\n    ':hover': {\n      color: LIGHT_PRIMARY_TEXT_COLOR,\n    },\n\n    ':focus': {\n      outline: 0,\n      color: LIGHT_PRIMARY_TEXT_COLOR,\n    },\n  },\n\n  addButton: {\n    ':hover': {\n      backgroundColor: NICE_MIDDLE_BLUE,\n    },\n\n    ':focus': {\n      backgroundColor: NICE_MIDDLE_BLUE,\n      outline: 0,\n    },\n  },\n\n  toolTipWithTagsContainer: {\n    width: 250,\n  },\n\n  toolTipWithTagsUl: {\n    listStyle: 'none',\n    margin: 0,\n    padding: `${GUTTER_DEFAULT_SPACING}px 0`,\n  },\n\n  toolTipWithTagsButton: {\n    backgroundColor: 'transparent',\n    border: 'none',\n    borderRadius: 0,\n    color: NICE_MIDDLE_BLUE,\n    cursor: 'pointer',\n    padding: '8px 20px',\n    textAlign: 'left',\n    width: '100%',\n\n    ':hover': {\n      backgroundColor: NICE_MIDDLE_BLUE,\n      color: LIGHT_PRIMARY_TEXT_COLOR,\n    },\n\n    ':focus': {\n      backgroundColor: NICE_MIDDLE_BLUE,\n      color: LIGHT_PRIMARY_TEXT_COLOR,\n    },\n  },\n\n  toolTipWithTagsTagAlreadySet: {\n    color: GREY_COLOR,\n  },\n\n  offscreen: OFFSCREEN,\n});\n\nexport interface ICommentTagsProps {\n  scores: Array<ICommentScoreModel>;\n  availableTags?: List<ITagModel>;\n  onClick?(tagId: string): Promise<any>;\n  onCommentTagClick?(commentScore: ICommentScoreModel): void;\n}\n\nexport interface ICommentTagsState {\n  isTaggingToolTipMetaVisible?: boolean;\n  taggingToolTipMetaPosition?: {\n    top: number,\n    left: number,\n  };\n  isMetaTagFocused?: boolean;\n  isMetaTagHovered?: boolean;\n}\n\nexport class CommentTags extends React.PureComponent<ICommentTagsProps, ICommentTagsState> {\n  taggingTooltip: HTMLElement;\n  taggingTooltipButton: HTMLButtonElement;\n\n  state: ICommentTagsState = {\n    isTaggingToolTipMetaVisible: false,\n    taggingToolTipMetaPosition: {\n      top: 0,\n      left: 0,\n    },\n    isMetaTagFocused: false,\n    isMetaTagHovered: false,\n  };\n\n  @autobind\n  saveTaggingTooltipButtonRef(ref: HTMLButtonElement) {\n    this.taggingTooltipButton = ref;\n  }\n\n  @autobind\n  saveTaggingTooltipRef(ref: HTMLDivElement) {\n    this.taggingTooltip = ref;\n  }\n\n  render() {\n    const {\n      availableTags,\n      scores,\n      onCommentTagClick,\n    } = this.props;\n\n    const {\n      isTaggingToolTipMetaVisible,\n      taggingToolTipMetaPosition,\n      isMetaTagHovered,\n      isMetaTagFocused,\n    } = this.state;\n\n    const canSetTags = availableTags && availableTags.size > 0;\n\n    return (\n      <div {...css(STYLES.base)}>\n        <div {...css(STYLES.tagsContainer)}>\n          <h4 {...css(STYLES.offscreen)}>Assigned tags</h4>\n          <p {...css(STYLES.offscreen)}>Click to remove.</p>\n\n          { scores && scores.map((s, i) => {\n            if (!s.tagId || s.annotationEnd || (s.sourceType === 'Machine' && !s.isConfirmed)) {\n              return;\n            }\n\n            const tag = availableTags.find((t) => (t.id === s.tagId));\n\n            return tag && (\n              <button\n                key={`${tag.label}-${i}`}\n                {...css(\n                  STYLES.button,\n                  STYLES.tag,\n                  { backgroundColor: tag.color },\n                )}\n                onClick={partial(onCommentTagClick, s)}\n              >\n                {tag.label}\n              </button>\n            );\n          })}\n        </div>\n        {canSetTags && (\n        <button\n          aria-label=\"Add tag to comment\"\n          ref={this.saveTaggingTooltipButtonRef}\n          onMouseEnter={this.onMetaTagMouseEnter}\n          onMouseLeave={this.onMetaTagMouseLeave}\n          onFocus={this.onMetaTagFocus}\n          onBlur={this.onMetaTagBlur}\n          {...css(STYLES.button, STYLES.addButton)}\n          onClick={this.toggleTaggingToolTip}\n        >\n          <span {...css(VISUALLY_HIDDEN)}>Add a comment wide tag</span>\n          <AddIcon\n            {...css({\n              fill: isMetaTagHovered || isMetaTagFocused\n                ? LIGHT_PRIMARY_TEXT_COLOR\n                : NICE_MIDDLE_BLUE,\n            })}\n            size={ICON_SIZE}\n          />\n        </button>\n        )}\n        {isTaggingToolTipMetaVisible && (\n          <ToolTip\n            arrowPosition=\"topCenter\"\n            backgroundColor={WHITE_COLOR}\n            hasDropShadow\n            isVisible={isTaggingToolTipMetaVisible}\n            onDeactivate={this.toggleTaggingToolTip}\n            position={taggingToolTipMetaPosition}\n            size={16}\n            width={250}\n            zIndex={TOOLTIP_Z_INDEX}\n          >\n            <FocusTrap\n              focusTrapOptions={{\n                clickOutsideDeactivates: true,\n                returnFocusOnDeactivate: false,\n              }}\n            >\n              <div {...css(STYLES.toolTipWithTagsContainer)} ref={this.saveTaggingTooltipRef}>\n                <h4 {...css(STYLES.offscreen)}>Available tags</h4>\n                <ul {...css(STYLES.toolTipWithTagsUl)}>\n                  {availableTags && availableTags.map((t, i) => {\n                    const tagAlreadySet = scores && scores.find((s) => (s.tagId === t.id && s.sourceType === 'Moderator'));\n\n                    return (\n                      <li key={t.id}>\n                        <button\n                          onClick={tagAlreadySet ? identity : partial(this.tagComment, t.id)}\n                          key={`tag-${i}`}\n                          {...css(\n                            STYLES.toolTipWithTagsButton,\n                            tagAlreadySet && STYLES.toolTipWithTagsTagAlreadySet,\n                          )}\n                        >\n                          {t.label}\n                        </button>\n                      </li>\n                    );\n                  })}\n                </ul>\n              </div>\n            </FocusTrap>\n          </ToolTip>\n        )}\n      </div>\n    );\n  }\n\n  @autobind\n  calculateTaggingTriggerPosition(ref?: HTMLElement) {\n    if (!ref) {\n      return;\n    }\n\n    const buttonRect = ref.getBoundingClientRect();\n\n    this.setState({\n      taggingToolTipMetaPosition: {\n        top: buttonRect.height,\n        left: (ref.parentNode as HTMLElement).offsetWidth - buttonRect.width / 2,\n      },\n    });\n  }\n\n  @autobind\n  onPressEscape() {\n    this.closeTaggingTooltip();\n  }\n\n  componentDidMount() {\n    keyboardJS.bind('escape', this.onPressEscape);\n    this.calculateTaggingTriggerPosition(this.taggingTooltipButton);\n    window.addEventListener('click', this.checkTaggingTooltipStatus);\n  }\n\n  componentWillUnmount() {\n    keyboardJS.unbind('escape', this.onPressEscape);\n    window.removeEventListener('click', this.checkTaggingTooltipStatus);\n  }\n\n  @autobind\n  toggleTaggingToolTip() {\n    if (!this.state.isTaggingToolTipMetaVisible) {\n      this.openTaggingTooltip();\n    } else {\n      this.closeTaggingTooltip();\n    }\n  }\n\n  @autobind\n  openTaggingTooltip() {\n    this.calculateTaggingTriggerPosition(this.taggingTooltipButton);\n    this.setState({\n      isTaggingToolTipMetaVisible: true,\n    });\n  }\n\n  @autobind\n  closeTaggingTooltip() {\n    this.setState({\n      isTaggingToolTipMetaVisible: false,\n    });\n  }\n\n  @autobind\n  checkTaggingTooltipStatus(e: Event) {\n    if (!this.state.isTaggingToolTipMetaVisible) { return; }\n\n    const target = e.target as HTMLElement;\n\n    if (!this.taggingTooltip.contains(target) && !this.taggingTooltipButton.contains(target) && this.taggingTooltipButton !== target) {\n      this.closeTaggingTooltip();\n    }\n  }\n\n  @autobind\n  tagComment(id: string): void {\n    this.props.onClick(id);\n    this.closeTaggingTooltip();\n  }\n\n  @autobind\n  onMetaTagMouseEnter() {\n    this.setState({ isMetaTagHovered: true });\n  }\n\n  @autobind\n  onMetaTagMouseLeave() {\n    this.setState({ isMetaTagHovered: false });\n  }\n\n  @autobind\n  onMetaTagFocus() {\n    this.setState({ isMetaTagFocused: true });\n  }\n\n  @autobind\n  onMetaTagBlur() {\n    this.setState({ isMetaTagFocused: false });\n  }\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/SingleComment/components/DetailRow.tsx",
    "content": "/*\nCopyright 2020 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\nimport React from 'react';\nimport {Link} from 'react-router-dom';\n\nimport {IAuthorAttributes} from '../../../../models';\nimport {searchLink} from '../../../scenes/routes';\nimport {BOX_DEFAULT_SPACING, CAPTION_TYPE, DARK_SECONDARY_TEXT_COLOR} from '../../../styles';\nimport {css, stylesheet} from '../../../utilx';\nimport {EmailIcon, FaceIcon, IdIcon, ReputationIcon} from '../../Icons';\n\nconst DETAIL_STYLES = stylesheet({\n  row: {\n    display: 'flex',\n    flexWrap: 'no-wrap',\n    alignItems: 'center',\n    marginRight: `${BOX_DEFAULT_SPACING}px`,\n    overflow: 'hidden',\n  },\n\n  icon: {\n    display: 'flex',\n    marginRight: '5px',\n  },\n\n  label: {\n    ...CAPTION_TYPE,\n    color: DARK_SECONDARY_TEXT_COLOR,\n    maxWidth: 120,\n    overflow: 'hidden',\n    whiteSpace: 'no-wrap',\n    textOverflow: 'ellipsis',\n  },\n\n  linkFocus: {\n    ':focus': {\n      outline: 0,\n      textDecoration: 'underline',\n    },\n  },\n});\n\nconst DETAIL_LINK = {\n  color: DARK_SECONDARY_TEXT_COLOR,\n  textDecoration: 'none',\n  ':focus': {\n    outline: 0,\n    textDecoration: 'underline',\n  },\n};\n\nexport const ICON_SIZE = 20;\n\nexport interface IDetailRowProps {\n  label: string | JSX.Element;\n  value?: string;\n  icon?: JSX.Element;\n}\n\nexport const DetailRow = ({ label, value, icon }: IDetailRowProps) => (\n  <div {...css(DETAIL_STYLES.row)}>\n    <div {...css(DETAIL_STYLES.icon)}>{value || icon}</div>\n    <div {...css(DETAIL_STYLES.label)}>{label}</div>\n  </div>\n);\n\nexport function EmailRow({author}: {author: IAuthorAttributes}) {\n  return (\n    <DetailRow\n      key=\"email\"\n      label={(\n        <a {...css(DETAIL_LINK)} href={'mailto:' + author.email}>\n          {author.email}\n        </a>\n      )}\n      icon={(\n        <EmailIcon\n          {...css({ fill: DARK_SECONDARY_TEXT_COLOR })}\n          size={ICON_SIZE}\n        />\n      )}\n    />\n\n  );\n}\n\nexport function SourceIdRow({authorSourceId}: {authorSourceId: string}) {\n  return (\n    <Link\n      key=\"authorSourceId\"\n      to={searchLink({searchByAuthor: true, term: authorSourceId.toString()})}\n      {...css(DETAIL_STYLES.linkFocus)}\n    >\n      <DetailRow\n        label={authorSourceId.toString()}\n        icon={(\n          <IdIcon\n            {...css({ fill: DARK_SECONDARY_TEXT_COLOR })}\n            size={ICON_SIZE}\n          />\n        )}\n      />\n    </Link>\n  );\n}\n\nexport function ApprovalRatingRow({approvalRating}: {approvalRating: string}) {\n  return (\n    <DetailRow\n      key=\"author\"\n      icon={(\n        <ReputationIcon\n          {...css({ fill: DARK_SECONDARY_TEXT_COLOR })}\n          size={ICON_SIZE}\n        />\n      )}\n      label={approvalRating}\n    />\n  );\n}\n\nexport function IsSubscriberRow() {\n  return (\n    <DetailRow\n      key=\"subscriber\"\n      label=\"Subscriber\"\n      icon={(\n        <FaceIcon\n          {...css({ fill: DARK_SECONDARY_TEXT_COLOR })}\n          size={ICON_SIZE}\n        />\n      )}\n    />\n  );\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/SingleComment/components/FlagsList.tsx",
    "content": "/*\nCopyright 2020 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\nimport React, {useEffect, useState} from 'react';\n\nimport {ICommentFlagModel, ModelId} from '../../../../models';\nimport {getCommentFlags} from '../../../platform/dataService';\nimport {CAPTION_TYPE, DARK_SECONDARY_TEXT_COLOR, DARK_TERTIARY_TEXT_COLOR} from '../../../styles';\nimport {css, stylesheet} from '../../../utilx';\n\nconst FLAGS_STYLES = stylesheet({\n  title: {\n    ...CAPTION_TYPE,\n    color: DARK_SECONDARY_TEXT_COLOR,\n    fontSize: '20px',\n  },\n  entry: {\n    padding: '10px',\n    margin: '10px 0',\n    backgroundColor: '#eee',\n  },\n  label: {\n    fontSize: '24px',\n  },\n  resolvedText: {\n    color: DARK_TERTIARY_TEXT_COLOR,\n    fontSize: '16px',\n    float: 'right',\n  },\n  detail: {\n    fontSize: '14px',\n  },\n});\n\nfunction Flag(props: {flag: ICommentFlagModel}) {\n  const {flag} = props;\n  let resolvedText = '';\n  if (flag.isResolved) {\n    resolvedText += 'Resolved';\n    if (flag.resolvedAt) {\n      resolvedText += ' on ' + (new Date(flag.resolvedAt)).toLocaleDateString();\n    }\n  }\n  else {\n    resolvedText += 'Unresolved';\n  }\n  return (\n    <div {...css(FLAGS_STYLES.entry)}>{resolvedText}\n      <div key=\"label\" {...css(FLAGS_STYLES.label)}>{flag.label} <span {...css(FLAGS_STYLES.resolvedText)}>{resolvedText}</span></div>\n      {flag.detail && <div key=\"description\" {...css(FLAGS_STYLES.detail)}>{flag.detail}</div>}\n    </div>\n  );\n}\n\nexport function FlagsList(props: {commentId: ModelId}) {\n  const [flags, setFlags] = useState<Array<ICommentFlagModel>>();\n\n  async function fetchFlags() {\n    const f = await getCommentFlags(props.commentId);\n    setFlags(f);\n  }\n\n  useEffect(() => { fetchFlags(); }, [props.commentId]);\n\n  if (!flags || flags.length === 0) {\n    return null;\n  }\n\n  return (\n    <div key=\"flags\">\n      <div key=\"__flags-title\" {...css(FLAGS_STYLES.title)}>Flags</div>\n      {flags.map((f) => (<Flag key={f.id} flag={f}/>))}\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/SingleComment/components/SummaryScore.tsx",
    "content": "/*\nCopyright 2020 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport React, {Fragment, useState} from 'react';\nimport {useSelector} from 'react-redux';\n\nimport {ICommentModel, ICommentSummaryScoreModel} from '../../../../models';\nimport {\n  getSensitivitiesForCategory,\n  getSummaryScoresAboveThreshold,\n  getSummaryScoresBelowThreshold,\n} from '../../../scenes/Comments/scoreFilters';\nimport {getTaggingSensitivities} from '../../../stores/taggingSensitivities';\nimport {getTags} from '../../../stores/tags';\nimport {\n  BOX_DEFAULT_SPACING, BUTTON_LINK_TYPE,\n  BUTTON_RESET,\n  COMMENT_DETAIL_TAG_LIST_BUTTON_TYPE,\n  DARK_TERTIARY_TEXT_COLOR,\n  GUTTER_DEFAULT_SPACING,\n  NICE_MIDDLE_BLUE,\n  PALE_COLOR,\n} from '../../../styles';\nimport {css, stylesheet} from '../../../utilx';\n\nconst STYLES = stylesheet({\n  tag: {\n    ...BUTTON_RESET,\n    ...COMMENT_DETAIL_TAG_LIST_BUTTON_TYPE,\n    color: DARK_TERTIARY_TEXT_COLOR,\n    marginRight: `${GUTTER_DEFAULT_SPACING}px`,\n    marginBottom: `${GUTTER_DEFAULT_SPACING / 4}px`,\n    display: 'flex',\n    cursor: 'pointer',\n    ':focus': {\n      outline: 0,\n      background: PALE_COLOR,\n    },\n  },\n\n  tags: {\n    display: 'flex',\n    flexWrap: 'wrap',\n  },\n\n  label: {\n    marginRight: `${BOX_DEFAULT_SPACING / 2}px`,\n  },\n\n  scoresLink: {\n    ...BUTTON_RESET,\n    ...BUTTON_LINK_TYPE,\n    color: NICE_MIDDLE_BLUE,\n    cursor: 'pointer',\n    textAlign: 'left',\n    marginTop: `${GUTTER_DEFAULT_SPACING}px`,\n    marginBottom: `${GUTTER_DEFAULT_SPACING}px`,\n    borderBottom: `2px solid transparent`,\n    alignSelf: 'flex-start',\n    ':focus': {\n      outline: 0,\n      borderBottom: `2px solid ${NICE_MIDDLE_BLUE}`,\n    },\n  },\n});\n\nexport interface ISummaryScoreProps {\n  score: ICommentSummaryScoreModel;\n  withColor?: boolean;\n  onScoreClick?(score: ICommentSummaryScoreModel): void;\n}\n\nfunction SummaryScore(props: ISummaryScoreProps) {\n  const {score, withColor, onScoreClick} = props;\n  const tags = useSelector(getTags);\n\n  const tag = tags.find((t) => (t.id === score.tagId));\n  if (!tag) {\n    return;\n  }\n  function onClick() {\n    onScoreClick && onScoreClick(score);\n  }\n\n  return (\n    <button\n      {...css(STYLES.tag, withColor ? { color : tag.color } : {})}\n      key={score.tagId}\n      onClick={onClick}\n    >\n      <div {...css(STYLES.label)}>{tag.label}</div>\n      <div>{(score.score * 100).toFixed()}%</div>\n    </button>\n  );\n}\n\nexport interface ISummaryScoresProps {\n  comment: ICommentModel;\n  onScoreClick?(score: ICommentSummaryScoreModel): void;\n}\n\nexport function SummaryScores(props: ISummaryScoresProps) {\n  const {comment, onScoreClick} = props;\n  const {categoryId, summaryScores} = comment;\n  const taggingSensitivities = useSelector(getTaggingSensitivities);\n  const [allVisible, setAllVisible] = useState(false);\n\n  const sensitivities = getSensitivitiesForCategory(categoryId, taggingSensitivities);\n  const summaryScoresAboveThreshold = getSummaryScoresAboveThreshold(sensitivities, summaryScores);\n  const summaryScoresBelowThreshold = getSummaryScoresBelowThreshold(sensitivities, summaryScores);\n\n  function toggleVisible() {\n    setAllVisible(!allVisible);\n  }\n\n  return (\n    <Fragment>\n      {summaryScoresAboveThreshold && (\n        <div key=\"above\" {...css(STYLES.tags)}>\n          {summaryScoresAboveThreshold.map((s) => (\n            <SummaryScore\n              key={s.tagId}\n              score={s}\n              onScoreClick={onScoreClick}\n              withColor\n            />\n          ))}\n        </div>\n      )}\n      {allVisible && summaryScoresBelowThreshold && (\n        <div key=\"below\" {...css(STYLES.tags)}>\n          {summaryScoresBelowThreshold.map((s) => (\n            <SummaryScore\n              key={s.tagId}\n              score={s}\n              onScoreClick={onScoreClick}\n            />\n          ))}\n        </div>\n      )}\n      {summaryScoresBelowThreshold && (\n        <button\n          key=\"button\"\n          aria-label={allVisible ? 'Hide tags' : 'View all tags'}\n          type=\"button\"\n          {...css(STYLES.scoresLink)}\n          onClick={toggleVisible}\n        >\n          {allVisible ? 'Hide tags' : 'View all tags'}\n        </button>\n      )}\n    </Fragment>\n  );\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/SingleComment/index.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nexport { SingleComment } from './SingleComment';\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/Slider/RangeBar.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { autobind } from 'core-decorators';\nimport React from 'react';\nimport ReactDOM from 'react-dom';\nimport {\n  LIGHT_PRIMARY_TEXT_COLOR,\n} from '../../styles';\nimport { css, stylesheet } from '../../utilx';\n\nconst BAR_HEIGHT = 6;\n\nconst STYLES = stylesheet({\n  base: {\n    background: LIGHT_PRIMARY_TEXT_COLOR,\n    height: `${BAR_HEIGHT}px`,\n    position: 'absolute',\n    width: '100%',\n  },\n});\n\nexport interface IRange {\n  start: number;\n  end: number;\n}\n\nexport interface IRangeBarProps extends React.HTMLProps<any> {\n  selectedRange?: IRange;\n}\n\nexport interface IRangeBarState {\n  parentWidth: number;\n}\n\nexport class RangeBar extends\n    React.PureComponent<IRangeBarProps, IRangeBarState> {\n  state: IRangeBarState = {\n    parentWidth: null,\n  };\n\n  render() {\n    const { selectedRange } = this.props;\n    const { parentWidth } = this.state;\n    const positionX = selectedRange.start * parentWidth;\n    const width = (selectedRange.end * parentWidth) - positionX;\n\n    return (\n      <div\n        {...css(STYLES.base, {\n          transform: `translateX(${positionX}px)`,\n          width,\n        })}\n      />\n    );\n  }\n\n  componentDidMount() {\n    window.addEventListener('resize', this.onResize);\n    // Wait for Aphrodite styles to start to parse\n    setTimeout(() => this.onResize(), 60);\n  }\n\n  componentWillUnmount() {\n    window.removeEventListener('resize', this.onResize);\n  }\n\n  @autobind\n  onResize() {\n    const node = ReactDOM.findDOMNode(this) as HTMLElement;\n    const parent = node.offsetParent;\n    const { width } = parent.getBoundingClientRect();\n\n    this.setState({ parentWidth: width });\n  }\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/Slider/Slider.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { autobind } from 'core-decorators';\nimport { clamp } from 'lodash';\nimport React from 'react';\nconst Draggable = require('react-draggable');\n\nimport {\n  HANDLE_LABEL_TYPE,\n  LIGHT_COLOR,\n  LIGHT_PRIMARY_TEXT_COLOR,\n  LIGHT_TERTIARY_TEXT_COLOR,\n  NICE_MIDDLE_BLUE,\n} from '../../styles';\n\nimport {\n  DOWN_ARROW_KEY,\n  LEFT_ARROW_KEY,\n  RIGHT_ARROW_KEY,\n  UP_ARROW_KEY,\n} from '../../utilx';\nimport { css, IStyle, stylesheet } from '../../utilx';\n\nconst HANDLE_SIZE = 22;\nconst HANDLE_HIT_CONTAINER_SIZE = 44;\nconst BAR_HEIGHT = 6;\n\nconst HANDLE_STYLES = stylesheet({\n  handleContainer: {\n    height: '0px',\n    width: '0px',\n    ':focus': {\n      outline: 0,\n    },\n  },\n\n  handle: {\n    width: `${HANDLE_HIT_CONTAINER_SIZE}px`,\n    height: `${HANDLE_HIT_CONTAINER_SIZE}px`,\n    position: 'relative',\n    transform: `translate(-${HANDLE_HIT_CONTAINER_SIZE / 2}px, -${(HANDLE_HIT_CONTAINER_SIZE - BAR_HEIGHT) / 2}px)`,\n    display: 'flex',\n    alignItems: 'center',\n    justifyContent: 'center',\n  },\n\n  handleDisplay: {\n    display: 'block',\n    borderRadius: '50%',\n    height: `${HANDLE_SIZE}px`,\n    width: `${HANDLE_SIZE}px`,\n    background: LIGHT_PRIMARY_TEXT_COLOR,\n  },\n\n  handleFocused: {\n    background: LIGHT_COLOR,\n  },\n\n  label: {\n    ...HANDLE_LABEL_TYPE,\n    background: NICE_MIDDLE_BLUE,\n    color: LIGHT_PRIMARY_TEXT_COLOR,\n    padding: '3px 3px 1px 3px',\n    position: 'absolute',\n    textAlign: 'left',\n    transform: `translate(-${HANDLE_SIZE / 2}px, 0)`,\n    top: `-${HANDLE_SIZE + 5}px`,\n    whiteSpace: 'nowrap',\n  },\n\n  rightHandle: {\n    transform: `translate(calc(-100% + ${HANDLE_HIT_CONTAINER_SIZE / 2}px), -${(HANDLE_HIT_CONTAINER_SIZE - BAR_HEIGHT) / 2}px)`,\n  },\n\n  rightLabel: {\n    transform: `translate(calc(-100% + ${HANDLE_SIZE / 2}px), 0)`,\n    textAlign: 'right',\n  },\n});\n\nexport interface IHandleProps extends React.HTMLProps<any> {\n  style?: IStyle;\n  label?: string;\n  positionOnRight?: boolean;\n  keyUp?(e: React.KeyboardEvent<any>): any;\n}\n\nexport interface IHandleState {\n  isFocused: boolean;\n}\n\nclass Handle extends React.PureComponent<IHandleProps, IHandleState> {\n  state = {\n    isFocused: false,\n  };\n\n  render() {\n    const { label, positionOnRight, keyUp, style, ...propsCleaned } = this.props;\n\n    const { isFocused } = this.state;\n\n    return (\n      <div\n        tabIndex={0}\n        onKeyUp={keyUp}\n        {...propsCleaned}\n        {...css(HANDLE_STYLES.handleContainer, style)}\n      >\n        <div\n          {...css(HANDLE_STYLES.label,\n              positionOnRight && HANDLE_STYLES.rightLabel)}\n          aria-live=\"polite\"\n        >\n          {label}\n        </div>\n        <div\n          {...css(\n            HANDLE_STYLES.handle,\n            positionOnRight && HANDLE_STYLES.rightHandle,\n          )}\n          onMouseEnter={this.onMouseEnter}\n          onMouseLeave={this.onMouseLeave}\n          onFocus={this.onFocus}\n          onBlur={this.onBlur}\n        >\n          <span\n            {...css(\n              HANDLE_STYLES.handleDisplay,\n              isFocused && HANDLE_STYLES.handleFocused,\n            )}\n          />\n        </div>\n      </div>\n    );\n  }\n\n  @autobind\n  onMouseEnter() {\n    if (document.body.style.cursor !== '-webkit-grabbing') {\n      document.body.style.cursor = '-webkit-grab';\n    }\n  }\n\n  @autobind\n  onMouseLeave() {\n    if (document.body.style.cursor === '-webkit-grab') {\n      document.body.style.cursor = '';\n    }\n  }\n\n  @autobind\n  onFocus() {\n    this.setState({ isFocused: true });\n  }\n\n  @autobind\n  onBlur() {\n    this.setState({ isFocused: false });\n  }\n}\n\nexport interface IDraggableHandleProps {\n  label?: string;\n  positionOnRight?: boolean;\n  position: number;\n  onChange?(position: number): void;\n  onChangeEnd?(position: number): void;\n}\n\nexport interface IDraggableHandleState {\n  parentWidth: number;\n}\n\nexport class DraggableHandle extends\n    React.PureComponent<IDraggableHandleProps, IDraggableHandleState> {\n  elem?: HTMLElement;\n\n  state: IDraggableHandleState = {\n    parentWidth: null,\n  };\n\n  @autobind\n  saveSliderRef(ref: HTMLDivElement) {\n    this.elem = ref;\n  }\n\n  render() {\n    const { label, positionOnRight, position } = this.props;\n    const { parentWidth } = this.state;\n\n    const x = position * parentWidth;\n\n    return (\n      <div ref={this.saveSliderRef}>\n        <Draggable\n          bounds=\"parent\"\n          axis=\"x\"\n          position={{ x, y: 0 }}\n          onStart={this.onDragStart}\n          onDrag={this.onDragUpdate}\n          onStop={this.onDragEnd}\n        >\n          <Handle keyUp={this.onKeyUp} label={label} positionOnRight={positionOnRight} />\n        </Draggable>\n      </div>\n    );\n  }\n\n  @autobind\n  onKeyUp(e: React.KeyboardEvent<any>) {\n    if (e.keyCode === LEFT_ARROW_KEY || e.keyCode === DOWN_ARROW_KEY) {\n      this.props.onChange(this.props.position - 0.01);\n    } else if (e.keyCode === RIGHT_ARROW_KEY || e.keyCode === UP_ARROW_KEY) {\n      this.props.onChange(this.props.position + 0.01);\n    }\n  }\n\n  componentDidMount() {\n    window.addEventListener('resize', this.onResize);\n    // Wait for Aphrodite styles to start to parse\n    setTimeout(() => this.onResize(), 60);\n  }\n\n  componentWillUnmount() {\n    window.removeEventListener('resize', this.onResize);\n  }\n\n  @autobind\n  onResize() {\n    if (!this.elem) { return; }\n\n    const parent = this.elem.offsetParent;\n    const { width } = parent.getBoundingClientRect();\n\n    this.setState({ parentWidth: width });\n  }\n\n  @autobind\n  onDragStart(_: any, { x }: { x: number }) {\n    document.body.style.cursor = '-webkit-grabbing';\n\n    if (this.props.onChange) {\n      this.props.onChange(this.convertToPercentage(x));\n    }\n  }\n\n  @autobind\n  onDragUpdate(_: any, { x }: { x: number }) {\n    const n = clamp(this.convertToPercentage(x), 0, 1);\n\n    if (this.props.onChange) {\n      this.props.onChange(n);\n    }\n  }\n\n  @autobind\n  onDragEnd(_: any, { x }: { x: number }) {\n    if (this.props.onChange) {\n      this.props.onChange(this.convertToPercentage(x));\n    }\n\n    if (this.props.onChangeEnd) {\n      this.props.onChangeEnd(this.convertToPercentage(x));\n    }\n\n    document.body.style.cursor = 'default';\n  }\n\n  convertToPercentage(x: number): number {\n    return x / this.state.parentWidth;\n  }\n}\n\nconst SLIDER_STYLES = {\n  base: {\n    background: LIGHT_TERTIARY_TEXT_COLOR,\n    height: `${BAR_HEIGHT}px`,\n    position: 'relative',\n    width: '100%',\n  },\n};\n\nexport interface ISliderProps extends React.HTMLProps<any> {\n  style?: any;\n}\n\nexport class Slider extends React.PureComponent<ISliderProps> {\n  render() {\n    const { children, style } = this.props;\n\n    return (\n      <div {...css(SLIDER_STYLES.base, style)}>\n        {children}\n      </div>\n    );\n  }\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/Slider/SliderStory.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { action } from '@storybook/addon-actions';\nimport { storiesOf } from '@storybook/react';\n\nimport { MEDIUM_COLOR } from '../../styles';\nimport { css } from '../../utilx';\nimport { DraggableHandle, Slider } from '../Slider';\n\nstoriesOf('Slider', module)\n  .add('base', () => (\n    <div {...css({ background: MEDIUM_COLOR, padding: '20px' })}>\n      <Slider>\n        <DraggableHandle\n          position={0.25}\n          onChange={action('handle a position')}\n        />\n        <DraggableHandle\n          position={0.75}\n          onChange={action('handle b position')}\n          positionOnRight\n        />\n      </Slider>\n    </div>\n  ));\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/Slider/index.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nexport * from './Slider';\nexport { RangeBar } from './RangeBar';\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/SplashRoot.tsx",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport React from 'react';\n\nimport { stylesheet } from '../utilx';\n\nexport const SPLASH_STYLES = stylesheet({\n  errors: {\n    position: 'absolute',\n    bottom: '15vh',\n    width: '100%',\n    padding: '0 20vw',\n    textAlign: 'center',\n    fontSize: '2vh',\n    color: 'white',\n  },\n\n  signIn: {\n    position: 'absolute',\n    bottom: '30vh',\n    width: '100%',\n    padding: '0 20vw',\n    textAlign: 'center',\n    fontSize: '3vh',\n    color: 'white',\n  },\n\n  errorsTryAgain: {\n    fontSize: '2.5vh',\n  },\n\n  link: {\n    color: 'white',\n    ':hover': {\n      textDecoration: 'underline',\n    },\n  },\n\n  inlineLink: {\n    color: 'white',\n    textDecoration: 'underline',\n    ':hover': {\n      textDecoration: 'underline',\n    },\n  },\n\n  header2Tag: {\n    position: 'absolute',\n    top: '2vh',\n    right: '2vh',\n    height: '3vh',\n    fontSize: '2.5vh',\n    paddingTop: '0.5vh',\n    fontWeight: 500,\n    color: 'white',\n  },\n});\n\nexport function Bubbles() {\n  function bubble(x: string) {\n    return (\n      <div key={x}>\n        <div className=\"landing_bubble\"/>\n      </div>\n    );\n  }\n\n  function blank(x: string) {\n    return (\n      <div key={x}/>\n    );\n  }\n\n  return (\n    <div key=\"bubbles\" className=\"landing_bubbleSet\">\n      {[bubble('1'), blank('a'),  blank('b'),  blank('c'),  bubble('l'),\n        bubble('2'), bubble('6'), blank('d'),  bubble('h'), bubble('m'),\n        bubble('3'), bubble('7'), bubble('e'), bubble('i'), bubble('n'),\n        bubble('4'), bubble('8'), bubble('f'), bubble('j'), bubble('o'),\n        bubble('5'), bubble('9'), bubble('g'), bubble('k'), bubble('p')]}\n    </div>\n  );\n}\n\nexport function SplashFrame(props: React.PropsWithChildren<{}>) {\n  return (\n    <div>\n      <div key=\"header\" className=\"landing_headerTag\">\n        Moderator\n      </div>\n      <div key=\"footer\" className=\"landing_footerTag\">\n        <a\n          href=\"https://conversationai.github.io/\"\n          target=\"_blank\"\n          className=\"landing_link\"\n        >\n          Learn more\n        </a> <span className=\"landing_extratext\">about Modereator.</span>\n      </div>\n      {props.children}\n    </div>\n  );\n}\n\nexport function SplashRoot(props: React.PropsWithChildren<{}>) {\n  return (\n    <SplashFrame>\n      <div key=\"root\" className=\"landing_centerOnPage\">\n        <Bubbles/>\n      </div>\n      {props.children}\n    </SplashFrame>\n  );\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/SplashRootStory.tsx",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { storiesOf } from '@storybook/react';\n\nimport { SplashRoot } from './SplashRoot';\n\nstoriesOf('Root', module)\n  .add('Splash page', () => {\n    return <SplashRoot />;\n  });\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/TagLabelRow/TagLabelRow.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { autobind } from 'core-decorators';\nimport React from 'react';\nimport { Link } from 'react-router-dom';\n\nimport { ITagModel } from '../../../models';\nimport { IContextPathParams, newCommentsPageLink } from '../../scenes/routes';\nimport {\n  ARTICLE_CATEGORY_TYPE,\n  DARK_COLOR,\n  DIVIDER_COLOR,\n  GUTTER_DEFAULT_SPACING,\n  LIGHT_PRIMARY_TEXT_COLOR,\n  LIGHT_SECONDARY_TEXT_COLOR,\n  LIGHT_TERTIARY_TEXT_COLOR,\n} from '../../styles';\nimport { css, stylesheet } from '../../utilx';\nimport { EyeIcon } from '../Icons';\n\nconst STYLES = stylesheet({\n  link: {\n    display: 'block',\n    background: 'transparent',\n    border: 0,\n    width: '100%',\n    padding: 0,\n  },\n\n  row: {\n    position: 'relative',\n    display: 'flex',\n    justifyContent: 'space-between',\n    alignItems: 'center',\n    border: '0px',\n    cursor: 'pointer',\n    paddingLeft: `${GUTTER_DEFAULT_SPACING}px`,\n    paddingRight: `${GUTTER_DEFAULT_SPACING * 8}px`,\n    ':focus': {\n      outline: 0,\n    },\n  },\n\n  selectedIcon: {\n    width: '24px',\n    paddingRight: `${GUTTER_DEFAULT_SPACING}px`,\n  },\n\n  disabled: {\n    background: DIVIDER_COLOR,\n    cursor: 'default',\n  },\n\n  labelContainer: {\n    flex: 1,\n    paddingRight: `${GUTTER_DEFAULT_SPACING}px`,\n  },\n\n  label: {\n    ...ARTICLE_CATEGORY_TYPE,\n    display: 'block',\n    color: LIGHT_PRIMARY_TEXT_COLOR,\n    fontSize: '16px',\n  },\n\n  description: {\n    ...ARTICLE_CATEGORY_TYPE,\n    display: 'block',\n    color: LIGHT_TERTIARY_TEXT_COLOR,\n  },\n\n  image: {\n    verticalAlign: 'bottom',\n  },\n\n  dotChart: {\n    paddingTop: '30px',\n  },\n});\n\nexport interface ITagLabelRowProps extends IContextPathParams {\n  tag: ITagModel;\n  isSelected?: boolean;\n  background?: string;\n  imagePath: string;\n  imageWidth: number;\n  imageHeight: number;\n}\n\nexport interface ITagLabelRowState {\n  isHovered?: boolean;\n}\n\nexport class TagLabelRow\n  extends React.PureComponent<ITagLabelRowProps, ITagLabelRowState> {\n\n  state = {\n    isHovered: false,\n  };\n\n  @autobind\n  handleRowEnter() {\n    this.setState({\n      isHovered: true,\n    });\n  }\n\n  @autobind\n  handleRowLeave() {\n    this.setState({\n      isHovered: false,\n    });\n  }\n\n  render() {\n    const {\n      tag,\n      background,\n      imagePath,\n      imageWidth,\n      imageHeight,\n      isSelected,\n      context,\n      contextId,\n    } = this.props;\n    const { isHovered } = this.state;\n\n    let backgroundColor;\n    if (isSelected) {\n      backgroundColor = DARK_COLOR;\n    } else {\n      backgroundColor = isHovered ? DARK_COLOR : background;\n    }\n\n    return (\n      <Link\n        {...css(STYLES.link)}\n        to={newCommentsPageLink({context, contextId, tag: tag.key})}\n        key={tag.key}\n        onMouseEnter={this.handleRowEnter}\n        onMouseLeave={this.handleRowLeave}\n        onFocus={this.handleRowEnter}\n        onBlur={this.handleRowLeave}\n        tabIndex={0}\n      >\n        <span\n          {...css(STYLES.row, { backgroundColor })}\n        >\n          <span {...css(STYLES.selectedIcon)}>\n            {isSelected && <EyeIcon {...css({fill: LIGHT_SECONDARY_TEXT_COLOR})}/>}\n          </span>\n          <div {...css(STYLES.labelContainer)}>\n            <span {...css(STYLES.label)}>{tag.label}</span>\n            {<span {...css(STYLES.description)}>{(isSelected || isHovered) && tag.description}</span>}\n          </div>\n          <span {...css(STYLES.dotChart)}>\n            <img\n              width={imageWidth}\n              height={imageHeight}\n              {...css(STYLES.image)}\n              src={imagePath}\n              alt={`chart displaying scores by tag ${tag.label}`}\n            />\n          </span>\n        </span>\n      </Link>\n    );\n  }\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/TagLabelRow/TagLabelRowStory.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { action } from '@storybook/addon-actions';\nimport { storiesOf } from '@storybook/react';\nimport React from 'react';\nimport { MemoryRouter } from 'react-router-dom';\n\nimport { fakeTagModel } from '../../../models/fake';\nimport { DARK_COLOR, MEDIUM_COLOR } from '../../styles';\nimport { css } from '../../utilx';\nimport { TagLabelRow } from '../TagLabelRow';\n\nconst tag1 = fakeTagModel({\n  color: '#ff0000',\n  description: 'Hello World',\n  key: 'INFLAMMATORY',\n  label: 'Inflammatory',\n});\nconst tag2 = fakeTagModel({\n  color: '#ff00ff',\n  description: 'Hello World',\n  key: 'OFF_TOPIC',\n  label: 'Off Topic',\n});\n\nconst ACTION_STYLES = {\n  button: {\n    background: 'transparent',\n    border: 0,\n    width: '100%',\n    padding: 0,\n  },\n};\n\nconst SNAPSHOT_WIDTH = 264;\nconst SNAPSHOT_HEIGHT = 76;\n\nstoriesOf('TagLabelRow', module)\n  .addDecorator((story) => (\n    <MemoryRouter initialEntries={['/']}>{story()}</MemoryRouter>\n  ))\n  .add('Default', () => (\n    <div>\n      <button\n        key=\"inflammatory\"\n        {...css(ACTION_STYLES.button)}\n        onClick={action('Tag Row Clicked')}\n        aria-label=\"Inflammatory\"\n      >\n        <TagLabelRow\n          imageHeight={SNAPSHOT_WIDTH}\n          imageWidth={SNAPSHOT_HEIGHT}\n          context=\"categories\"\n          contextId=\"1\"\n          tag={tag1}\n          imagePath=\"\"\n          background={MEDIUM_COLOR}\n        />\n      </button>\n      <button\n        key=\"off-topic\"\n        {...css(ACTION_STYLES.button)}\n        onClick={action('Tag Row Clicked')}\n        aria-label=\"Off Topic\"\n      >\n        <TagLabelRow\n          imageHeight={SNAPSHOT_WIDTH}\n          imageWidth={SNAPSHOT_HEIGHT}\n          context=\"categories\"\n          contextId=\"1\"\n          tag={tag2}\n          imagePath=\"\"\n          background={DARK_COLOR}\n        />\n      </button>\n    </div>\n  ));\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/TagLabelRow/index.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nexport { TagLabelRow } from './TagLabelRow';\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/ThemeRoot.tsx",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport React from 'react';\n\nimport {\n  createMuiTheme,\n  CssBaseline,\n} from '@material-ui/core';\nimport {\n  ThemeProvider,\n} from '@material-ui/styles';\n\nimport { NICE_MIDDLE_BLUE } from '../styles';\n\nconst theme = createMuiTheme({\n  typography: {\n    fontFamily: 'Libre Franklin',\n  },\n  palette: {\n    background: {\n      default: NICE_MIDDLE_BLUE,\n    },\n  },\n});\n\nexport class ThemeRoot extends React.Component {\n  render() {\n    return (\n      <ThemeProvider key=\"root\" theme={theme}>\n        <CssBaseline key=\"baseline\"/>\n        {this.props.children}\n      </ThemeProvider>\n    );\n  }\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/Toast/Toast.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport React from 'react';\nimport { DARK_TERTIARY_TEXT_COLOR } from '../../styles';\nimport { css, stylesheet } from '../../utilx';\n\nconst STYLES = stylesheet({\n  base: {\n    display: 'flex',\n    padding: 0,\n    borderRadius: '50%',\n    boxShadow: '0 0 20px ' + DARK_TERTIARY_TEXT_COLOR,\n    width: '280px',\n    height: '280px',\n    justifyContent: 'center',\n    alignItems: 'center',\n    boxSizing: 'border-box',\n  },\n\n  inner: {\n    width: '100%',\n    height: '100%',\n    padding: '10%',\n    boxSizing: 'border-box',\n    display: 'flex',\n    justifyContent: 'center',\n    alignItems: 'center',\n    flexDirection: 'column',\n  },\n});\n\nexport interface IToastProps {\n  backgroundColor: string;\n  size?: number;\n}\n\nexport class Toast extends React.PureComponent<IToastProps> {\n  render() {\n    const {\n      backgroundColor,\n      children,\n      size,\n    } = this.props;\n\n    return (\n      <div\n        {...css(\n          STYLES.base,\n          { backgroundColor },\n          size && { width: size, height: size },\n        )}\n        role=\"dialog\"\n        aria-labelledby=\"dialog-title\"\n      >\n        <div {...css(STYLES.inner)}>{children}</div>\n      </div>\n    );\n  }\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/Toast/ToastMessage.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport React from 'react';\nimport {\n  ARTICLE_CATEGORY_TYPE,\n  ARTICLE_HEADLINE_TYPE,\n  BUTTON_LINK_TYPE,\n  BUTTON_RESET,\n  DARK_LINK_TEXT_COLOR,\n  MODAL_DROP_SHADOW,\n} from '../../styles';\nimport { css, stylesheet } from '../../utilx';\n\nconst STYLES = stylesheet({\n  base: {\n    ...ARTICLE_CATEGORY_TYPE,\n    backgroundColor: 'white',\n    width: 230,\n    height: 230,\n    borderRadius: 115,\n    display: 'flex',\n    flexDirection: 'column',\n    justifyContent: 'center',\n    alignItems: 'center',\n    boxShadow: MODAL_DROP_SHADOW,\n  },\n\n  comments: {\n    ...ARTICLE_HEADLINE_TYPE,\n    textAlign: 'center',\n    marginBottom: 29,\n  },\n\n  button: {\n    ...BUTTON_RESET,\n    ...BUTTON_LINK_TYPE,\n    color: DARK_LINK_TEXT_COLOR,\n    display: 'flex',\n    alignItems: 'center',\n    cursor: 'pointer',\n  },\n});\n\nexport interface IToastMessageProps {\n  buttonLabel?: string;\n  onClick?(e: React.MouseEvent<any>): any;\n  icon?: JSX.Element;\n}\n\nexport class ToastMessage extends React.PureComponent<IToastMessageProps> {\n  render() {\n    const {\n      buttonLabel,\n      onClick,\n      icon,\n      children,\n    } = this.props;\n\n    return (\n      <div {...css(STYLES.base)}>\n        <div id=\"dialog-title\" {...css(STYLES.comments)}>{children}</div>\n        {buttonLabel ? (\n          <button\n            key=\"buttonLabel\"\n            {...css(STYLES.button)}\n            onClick={onClick}\n          >\n            {buttonLabel}\n          </button>\n          ) : (\n            <div key=\"icon\">{icon}</div>\n          )\n        }\n      </div>\n    );\n  }\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/Toast/ToastStory.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { action } from '@storybook/addon-actions';\nimport { storiesOf } from '@storybook/react';\n\nimport {\n  ARTICLE_CATEGORY_TYPE,\n  ARTICLE_HEADLINE_TYPE,\n  DARK_PRIMARY_TEXT_COLOR,\n  DIVIDER_COLOR,\n  HEADLINE_TYPE,\n  LIGHT_PRIMARY_TEXT_COLOR,\n  MEDIUM_COLOR,\n} from '../../styles';\nimport { css } from '../../utilx';\nimport {\n  ApproveIcon,\n  RefreshIcon,\n} from '../Icons';\nimport { Toast } from '../Toast';\nimport { ToastMessage } from './ToastMessage';\n\nconst STORY_STYLES = {\n  base: {\n    padding: '50px 10px',\n    background: DIVIDER_COLOR,\n  },\n\n  darkText: {\n    ...ARTICLE_CATEGORY_TYPE,\n    color: DARK_PRIMARY_TEXT_COLOR,\n  },\n\n  largeCount: {\n    ...HEADLINE_TYPE,\n  },\n\n  smallText: {\n    ...ARTICLE_CATEGORY_TYPE,\n  },\n\n  progress: {\n    ...ARTICLE_HEADLINE_TYPE,\n  },\n\n  progressMargin: {\n    marginTop: '10px',\n  },\n};\n\nstoriesOf('Toast', module)\n  .add('base', () => {\n    return (\n      <div {...css(STORY_STYLES.base)}>\n        <Toast backgroundColor={LIGHT_PRIMARY_TEXT_COLOR}>\n          <div {...css(STORY_STYLES.darkText)}>\n            CONTENT\n          </div>\n        </Toast>\n      </div>\n    );\n  })\n  .add('undo', () => {\n    return (\n      <div {...css(STORY_STYLES.base)}>\n        <ToastMessage\n          buttonLabel={'Undo'}\n          onClick={action('action undid')}\n        >\n          <div key={'Undo'}>\n            <div key=\"content\" {...css(STORY_STYLES.largeCount)}>\n              <ApproveIcon\n                width={30}\n                height={30}\n                {...css({ fill: MEDIUM_COLOR })}\n              />\n               135\n            </div>\n            <div key=\"footer\" {...css(STORY_STYLES.smallText)}>Comments approved</div>\n          </div>\n        </ToastMessage>\n      </div>\n    );\n  })\n  .add('refresh', () => {\n    return (\n      <div {...css(STORY_STYLES.base)}>\n        <ToastMessage\n          buttonLabel={'Refresh'}\n          onClick={action('action undid')}\n        >\n          <div key={'Refresh'}>\n            <div key=\"icon\">\n              <RefreshIcon key=\"icon\" {...css({ fill: MEDIUM_COLOR })} /> Refresh\n            </div>\n            <div key=\"progress\" {...css(STORY_STYLES.progress)}>\n              <div>Approval rating in progress.</div>\n              <div {...css(STORY_STYLES.progressMargin)}>75% complete.</div>\n            </div>\n          </div>\n        </ToastMessage>\n      </div>\n    );\n  });\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/Toast/__spec__/.gitkeep",
    "content": ""
  },
  {
    "path": "packages/frontend-web/src/app/components/Toast/index.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nexport { Toast } from './Toast';\nexport { ToastMessage } from './ToastMessage';\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/Toggle/__spec__/.gitkeep",
    "content": ""
  },
  {
    "path": "packages/frontend-web/src/app/components/ToolTip/ToolTip.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { autobind } from 'core-decorators';\nimport React from 'react';\nimport {\n  BODY_TEXT_TYPE,\n  TOOLTIP_Z_INDEX,\n} from '../../styles';\nimport { css, IStyle, stylesheet } from '../../utilx';\n\nconst BUFFER = 16;\n\nconst base = {\n  width: 0,\n  height: 0,\n  position: 'absolute',\n};\n\nfunction makeArrowStyles(direction: string, color: string, size: number): IStyle {\n  let arrowStyles;\n\n  if (direction === 'topLeft') {\n    arrowStyles = {\n      borderLeft: `${size}px solid transparent`,\n      borderRight: `${size}px solid transparent`,\n      borderBottom: `${size}px solid ${color}`,\n      borderTop: 0,\n      top: `-${size}px`,\n      left: `${size}px`,\n    };\n  }\n\n  if (direction === 'topCenter') {\n    arrowStyles = {\n      borderLeft: `${size}px solid transparent`,\n      borderRight: `${size}px solid transparent`,\n      borderBottom: `${size}px solid ${color}`,\n      borderTop: 0,\n      top: `-${size}px`,\n      left: `calc(50% - ${size}px)`,\n    };\n  }\n\n  if (direction === 'topRight') {\n    arrowStyles = {\n      borderLeft: `${size}px solid transparent`,\n      borderRight: `${size}px solid transparent`,\n      borderBottom: `${size}px solid ${color}`,\n      borderTop: 0,\n      top: `-${size}px`,\n      right: `${size}px`,\n    };\n  }\n\n  if (direction === 'rightTop') {\n    arrowStyles = {\n      borderLeft: `${size}px solid ${color}`,\n      borderRight: 0,\n      borderBottom: `${size}px solid transparent`,\n      borderTop: `${size}px solid transparent`,\n      right: `-${size}px`,\n      top: `${size}px`,\n    };\n  }\n\n  if (direction === 'rightCenter') {\n    arrowStyles = {\n      borderLeft: `${size}px solid ${color}`,\n      borderRight: 0,\n      borderBottom: `${size}px solid transparent`,\n      borderTop: `${size}px solid transparent`,\n      right: `-${size}px`,\n      top: `calc(50% - ${size}px)`,\n    };\n  }\n\n  if (direction === 'rightBottom') {\n    arrowStyles = {\n      borderLeft: `${size}px solid ${color}`,\n      borderRight: 0,\n      borderBottom: `${size}px solid transparent`,\n      borderTop: `${size}px solid transparent`,\n      right: `-${size}px`,\n      bottom: `${size}px`,\n    };\n  }\n\n  if (direction === 'bottomRight') {\n    arrowStyles = {\n      borderLeft: `${size}px solid transparent`,\n      borderRight: `${size}px solid transparent`,\n      borderBottom: 0,\n      borderTop: `${size}px solid ${color}`,\n      right: `${size}px`,\n      bottom: `-${size}px`,\n    };\n  }\n\n  if (direction === 'bottomCenter') {\n    arrowStyles = {\n      borderLeft: `${size}px solid transparent`,\n      borderRight: `${size}px solid transparent`,\n      borderBottom: 0,\n      borderTop: `${size}px solid ${color}`,\n      right: `calc(50% - ${size}px)`,\n      bottom: `-${size}px`,\n    };\n  }\n\n  if (direction === 'bottomLeft') {\n    arrowStyles = {\n      borderLeft: `${size}px solid transparent`,\n      borderRight: `${size}px solid transparent`,\n      borderBottom: 0,\n      borderTop: `${size}px solid ${color}`,\n      left: `${size}px`,\n      bottom: `-${size}px`,\n    };\n  }\n\n  if (direction === 'leftBottom') {\n    arrowStyles = {\n      borderLeft: 0,\n      borderRight: `${size}px solid ${color}`,\n      borderBottom: `${size}px solid transparent`,\n      borderTop: `${size}px solid transparent`,\n      left: `-${size}px`,\n      bottom: `${size}px`,\n    };\n  }\n\n  if (direction === 'leftCenter') {\n    arrowStyles = {\n      borderLeft: 0,\n      borderRight: `${size}px solid ${color}`,\n      borderBottom: `${size}px solid transparent`,\n      borderTop: `${size}px solid transparent`,\n      left: `-${size}px`,\n      bottom: `calc(50% - ${size}px)`,\n    };\n  }\n\n  if (direction === 'leftTop') {\n    arrowStyles = {\n      borderLeft: 0,\n      borderRight: `${size}px solid ${color}`,\n      borderBottom: `${size}px solid transparent`,\n      borderTop: `${size}px solid transparent`,\n      left: `-${size}px`,\n      top: `${size}px`,\n    };\n  }\n\n  return arrowStyles;\n}\n\nconst makeArrow = (direction: string, color: string, size: number): IStyle => {\n  return {\n    ...base,\n    ...makeArrowStyles(direction, color, size),\n  };\n};\n\nfunction setTranslation(direction = 'topCenter', size: number) {\n  let x = '0px';\n  let y = '0px';\n\n  if (direction === 'topLeft') {\n    x = -(size * 2) + 'px';\n    y = (size + BUFFER) + 'px';\n  }\n\n  if (direction === 'topCenter') {\n    x = '-50%';\n    y = (size + BUFFER) + 'px';\n  }\n\n  if (direction === 'topRight') {\n    x = 'calc(-100% + ' + (size * 2) + 'px)';\n    y = (size + BUFFER) + 'px';\n  }\n\n  if (direction === 'rightTop') {\n    x = 'calc(-100% + ' + (-size - BUFFER) + 'px)';\n    y = -(size * 2) + 'px';\n  }\n\n  if (direction === 'rightCenter') {\n    x = 'calc(-100% + ' + (-size - BUFFER) + 'px)';\n    y = '-50%';\n  }\n\n  if (direction === 'rightBottom') {\n    x = 'calc(-100% + ' + (-size - BUFFER) + 'px)';\n    y = 'calc(-100% + ' + (size * 2) + 'px)';\n  }\n\n  if (direction === 'bottomRight') {\n    x = 'calc(-100% + ' + (size * 2) + 'px)';\n    y = 'calc(-100% + ' + (-size - BUFFER) + 'px)';\n  }\n\n  if (direction === 'bottomCenter') {\n    x = '-50%';\n    y = 'calc(-100% + ' + (-size - BUFFER) + 'px)';\n  }\n\n  if (direction === 'bottomLeft') {\n    x = -(size * 2) + 'px';\n    y = 'calc(-100% + ' + (-size - BUFFER) + 'px)';\n  }\n\n  if (direction === 'leftBottom') {\n    x = (size + BUFFER) + 'px';\n    y = 'calc(-100% + ' + (size * 2) + 'px)';\n  }\n\n  if (direction === 'leftCenter') {\n    x = (size + BUFFER) + 'px';\n    y = '-50%';\n  }\n\n  if (direction === 'leftTop') {\n    x = (size + BUFFER) + 'px';\n    y = -(size * 2) + 'px';\n  }\n\n  return `translate(${x}, ${y})`;\n}\n\nconst STYLES = stylesheet({\n  base: {\n    ...BODY_TEXT_TYPE,\n    display: 'inline-block',\n    width: 340,\n    backfaceVisibility: 'hidden',\n  },\n});\n\nexport type ArrowPosition = 'topLeft' | 'topCenter' | 'topRight' | 'rightTop' |\n  'rightCenter' | 'rightBottom' | 'bottomRight' | 'bottomCenter' |\n  'bottomLeft' | 'leftBottom' | 'leftCenter' | 'leftTop';\n\nexport interface IToolTipProps {\n  arrowPosition?: ArrowPosition;\n  backgroundColor: string;\n  hasDropShadow?: boolean;\n  isVisible: boolean;\n  size: number;\n  position?: {\n    top: number,\n    left: number,\n  };\n  zIndex?: number;\n  onDeactivate?(): any;\n  width?: number;\n}\n\nexport interface IToolTipState {\n  isVisible: boolean;\n}\n\nexport class ToolTip extends React.PureComponent<IToolTipProps, IToolTipState> {\n  container: HTMLDivElement = null;\n\n  state = {\n    isVisible: this.props.isVisible,\n  };\n\n  componentDidMount() {\n    if (this.props.onDeactivate) {\n      setTimeout(() => window.addEventListener('click', this.checkClick), 60);\n    }\n  }\n\n  componentWillUpdate(nextProps: IToolTipProps) {\n    if (this.props.isVisible !== nextProps.isVisible) {\n      this.setState({\n        isVisible: nextProps.isVisible,\n      });\n    }\n  }\n\n  componentWillUnmount() {\n    window.removeEventListener('click', this.checkClick);\n  }\n\n  @autobind\n  saveContainerRef(el: HTMLDivElement) {\n    this.container = el;\n  }\n\n  @autobind\n  checkClick(e: any) {\n    e.preventDefault();\n    if (!this.container || this.container.contains(e.target as any)) {\n      return;\n    }\n    this.setState({\n      isVisible: false,\n    });\n    if (this.props.onDeactivate) {\n      this.props.onDeactivate();\n    }\n  }\n\n  render() {\n    const {\n      arrowPosition,\n      backgroundColor,\n      hasDropShadow,\n      children,\n      position,\n      size,\n      zIndex,\n      width,\n    } = this.props;\n\n    const {\n      isVisible,\n    } = this.state;\n\n    return (\n      <div\n        ref={this.saveContainerRef}\n        {...css(\n          STYLES.base,\n          { position: 'absolute', backgroundColor },\n          width && { width },\n          position && {\n            left: position.left,\n            top: position.top,\n            transform: setTranslation(arrowPosition, size),\n          },\n          !isVisible && { display: 'none' },\n          zIndex ? { zIndex } : { zIndex: TOOLTIP_Z_INDEX },\n          hasDropShadow && { filter: 'drop-shadow(0px 0px 25px rgba(0, 0, 0, 0.117647))' },\n        )}\n      >\n        {children}\n        <div {...css(makeArrow(this.props.arrowPosition, this.props.backgroundColor, this.props.size))} />\n      </div>\n    );\n  }\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/ToolTip/ToolTipStory.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { action } from '@storybook/addon-actions';\nimport { storiesOf } from '@storybook/react';\nimport React from 'react';\nimport {\n  BOX_DEFAULT_SPACING,\n  GUTTER_DEFAULT_SPACING,\n  LIGHT_COLOR,\n  LIGHT_PRIMARY_TEXT_COLOR,\n  MEDIUM_COLOR,\n} from '../../styles';\nimport { css } from '../../utilx';\nimport { ToolTip } from '../ToolTip';\n\nexport interface IToolTipTagProps {\n  tag?: string;\n}\n\nconst GREY_COLOR = '#efefef';\n\nconst TARGET_POSITION = {\n  left: 300,\n  top: 300,\n};\n\nconst CONTAINER_STYLES = {\n  textWrapper: {\n    padding: `${GUTTER_DEFAULT_SPACING}px`,\n    width: '100%',\n    display: 'flex',\n    alignItems: 'center',\n    justifyContent: ('center' as 'center'),\n    boxSizing: 'border-box',\n    position: 'relative',\n  },\n};\n\nconst MULTIPLE_TAG_STYLES = {\n  container: {\n    width: 250,\n  },\n  ul: {\n    margin: 0,\n    padding: `${GUTTER_DEFAULT_SPACING}px 0`,\n  },\n  li: {\n    listStyleType: 'none',\n  },\n  button: {\n    borderRadius: 0,\n    backgroundColor: 'transparent',\n    border: 'none',\n    color: MEDIUM_COLOR,\n    padding: '8px 20px',\n    width: '100%',\n    textAlign: 'left',\n    ':hover': {\n      backgroundColor: MEDIUM_COLOR,\n      color: LIGHT_PRIMARY_TEXT_COLOR,\n    },\n  },\n};\n\nconst SINGLE_TAG_STYLES = {\n  container: {\n    width: 250,\n  },\n  label: {\n    textAlign: 'center',\n    color: LIGHT_PRIMARY_TEXT_COLOR,\n  },\n  button: {\n    width: '50%',\n    backgroundColor: MEDIUM_COLOR,\n    color: LIGHT_PRIMARY_TEXT_COLOR,\n    borderRadius: 0,\n    padding: `${GUTTER_DEFAULT_SPACING}px`,\n    border: 'none',\n    marginBottom: `${GUTTER_DEFAULT_SPACING}px`,\n\n    ':hover': {\n      backgroundColor: LIGHT_COLOR,\n    },\n  },\n};\n\nconst INFO_TOOLTIP_STYLES = {\n  container: {\n    color: MEDIUM_COLOR,\n    margin: 0,\n    padding: `${GUTTER_DEFAULT_SPACING}px`,\n  },\n  link: {\n    listStyleType: 'none',\n    margin: `${GUTTER_DEFAULT_SPACING}px ${BOX_DEFAULT_SPACING}px`,\n  },\n};\n\nclass ToolTipWithTag extends React.PureComponent<IToolTipTagProps> {\n  render() {\n    const { tag } = this.props;\n\n    return (\n      <div {...css(SINGLE_TAG_STYLES.container)}>\n        <div {...css(CONTAINER_STYLES.textWrapper)}>\n          <p {...css(SINGLE_TAG_STYLES.label)}>{tag}</p>\n        </div>\n        <div>\n          <button\n            key=\"yes\"\n            {...css(SINGLE_TAG_STYLES.button)}\n            onClick={action('Yes')}\n          >\n            Yes\n          </button>\n          <button\n            key=\"no\"\n            {...css(SINGLE_TAG_STYLES.button)}\n            onClick={action('No')}\n          >\n            No\n          </button>\n        </div>\n      </div>\n    );\n  }\n}\n\nclass ToolTipWithTags extends React.PureComponent<object> {\n  render() {\n    return (\n      <div {...css(MULTIPLE_TAG_STYLES.container)}>\n        <ul {...css(MULTIPLE_TAG_STYLES.ul)}>\n          <li {...css(MULTIPLE_TAG_STYLES.li)}>\n            <button\n              key=\"obscene\"\n              {...css(MULTIPLE_TAG_STYLES.button)}\n              onClick={action('Obscene')}\n            >\n              Obscene\n            </button>\n          </li>\n          <li {...css(MULTIPLE_TAG_STYLES.li)}>\n            <button\n              key=\"incoherent\"\n              {...css(MULTIPLE_TAG_STYLES.button)}\n              onClick={action('Incoherent')}\n            >\n              Incoherent\n            </button>\n          </li>\n          <li {...css(MULTIPLE_TAG_STYLES.li)}>\n            <button\n              key=\"spam\"\n              {...css(MULTIPLE_TAG_STYLES.button)}\n              onClick={action('Spam')}\n            >\n              Spam\n            </button>\n          </li>\n          <li {...css(MULTIPLE_TAG_STYLES.li)}>\n            <button\n              key=\"off-topic\"\n              {...css(MULTIPLE_TAG_STYLES.button)}\n              onClick={action('Off-topic')}\n            >\n              Off-topic\n            </button>\n          </li>\n          <li {...css(MULTIPLE_TAG_STYLES.li)}>\n            <button\n              key=\"inflammatory\"\n              {...css(MULTIPLE_TAG_STYLES.button)}\n              onClick={action('Inflammatory')}\n            >\n              Inflammatory\n            </button>\n          </li>\n          <li {...css(MULTIPLE_TAG_STYLES.li)}>\n            <button\n              key=\"unsubstantial\"\n              {...css(MULTIPLE_TAG_STYLES.button)}\n              onClick={action('Unsubstantial')}\n            >\n              Unsubstantial\n            </button>\n          </li>\n        </ul>\n      </div>\n    );\n  }\n}\n\nclass InfoToolTip extends React.PureComponent<object> {\n  render() {\n    return (\n      <ul {...css(INFO_TOOLTIP_STYLES.container)}>\n        <li {...css(INFO_TOOLTIP_STYLES.link)}>Keyboard Shortcuts</li>\n        <li {...css(INFO_TOOLTIP_STYLES.link)}>Moderator Guidelines</li>\n        <li {...css(INFO_TOOLTIP_STYLES.link)}>Submit Feedback</li>\n      </ul>\n    );\n  }\n}\n\nconst TARGET_STYLE = {\n  backgroundColor: '#f00',\n  borderRadius: '50%',\n  height: '6px',\n  left: TARGET_POSITION.left + 'px',\n  position: 'absolute',\n  top: TARGET_POSITION.top + 'px',\n  transform: 'translate(-50%, -50%)',\n  width: '6px',\n};\n\nclass ToolTipTarget extends React.PureComponent<object> {\n  render() {\n    return (\n      <div {...css(TARGET_STYLE)} />\n    );\n  }\n}\n\nstoriesOf('ToolTip', module)\n  .add('topLeft arrow (multiple tags)', () => {\n    return (\n      <div>\n        <ToolTipTarget />\n        <ToolTip\n          backgroundColor={GREY_COLOR}\n          arrowPosition=\"topLeft\"\n          size={16}\n          isVisible\n          position={{\n            top: TARGET_POSITION.top,\n            left: TARGET_POSITION.left,\n          }}\n        >\n          <ToolTipWithTags />\n        </ToolTip>\n      </div>\n    );\n  })\n  .add('topCenter arrow (single tag)', () => {\n    return (\n      <div>\n        <ToolTipTarget />\n        <ToolTip\n          backgroundColor={MEDIUM_COLOR}\n          arrowPosition=\"topCenter\"\n          size={16}\n          isVisible\n          position={{\n            top: TARGET_POSITION.top,\n            left: TARGET_POSITION.left,\n          }}\n        >\n          <ToolTipWithTag tag=\"is this spam?\" />\n        </ToolTip>\n      </div>\n    );\n  })\n  .add('topRight arrow (single tag)', () => {\n    return (\n      <div>\n        <ToolTipTarget />\n        <ToolTip\n          backgroundColor={MEDIUM_COLOR}\n          arrowPosition=\"topRight\"\n          size={16}\n          isVisible\n          position={{\n            top: TARGET_POSITION.top,\n            left: TARGET_POSITION.left,\n          }}\n        >\n          <ToolTipWithTag tag=\"is this spam?\" />\n        </ToolTip>\n      </div>\n    );\n  })\n  .add('rightTop arrow (single tag)', () => {\n    return (\n      <div>\n        <ToolTipTarget />\n        <ToolTip\n          backgroundColor={MEDIUM_COLOR}\n          arrowPosition=\"rightTop\"\n          size={16}\n          isVisible\n          position={{\n            top: TARGET_POSITION.top,\n            left: TARGET_POSITION.left,\n          }}\n        >\n          <ToolTipWithTag tag=\"is this spam?\" />\n        </ToolTip>\n      </div>\n    );\n  })\n  .add('rightCenter arrow (single tag)', () => {\n    return (\n      <div>\n        <ToolTipTarget />\n        <ToolTip\n          backgroundColor={MEDIUM_COLOR}\n          arrowPosition=\"rightCenter\"\n          size={16}\n          isVisible\n          position={{\n            top: TARGET_POSITION.top,\n            left: TARGET_POSITION.left,\n          }}\n        >\n          <ToolTipWithTag tag=\"is this spam?\" />\n        </ToolTip>\n      </div>\n    );\n  })\n  .add('rightBottom arrow (single tag)', () => {\n    return (\n      <div>\n        <ToolTipTarget />\n        <ToolTip\n          backgroundColor={MEDIUM_COLOR}\n          arrowPosition=\"rightBottom\"\n          size={16}\n          isVisible\n          position={{\n            top: TARGET_POSITION.top,\n            left: TARGET_POSITION.left,\n          }}\n        >\n          <ToolTipWithTag tag=\"is this spam?\" />\n        </ToolTip>\n      </div>\n    );\n  })\n  .add('bottomRight arrow (single tag)', () => {\n    return (\n      <div>\n        <ToolTipTarget />\n        <ToolTip\n          backgroundColor={MEDIUM_COLOR}\n          arrowPosition=\"bottomRight\"\n          size={16}\n          isVisible\n          position={{\n            top: TARGET_POSITION.top,\n            left: TARGET_POSITION.left,\n          }}\n        >\n          <ToolTipWithTag tag=\"is this spam?\" />\n        </ToolTip>\n      </div>\n    );\n  })\n  .add('bottomCenter arrow (single tag)', () => {\n    return (\n      <div>\n        <ToolTipTarget />\n        <ToolTip\n          backgroundColor={MEDIUM_COLOR}\n          arrowPosition=\"bottomCenter\"\n          size={16}\n          isVisible\n          position={{\n            top: TARGET_POSITION.top,\n            left: TARGET_POSITION.left,\n          }}\n        >\n          <ToolTipWithTag tag=\"is this spam?\" />\n        </ToolTip>\n      </div>\n    );\n  })\n  .add('bottomLeft arrow (single tag)', () => {\n    return (\n      <div>\n        <ToolTipTarget />\n        <ToolTip\n          backgroundColor={MEDIUM_COLOR}\n          arrowPosition=\"bottomLeft\"\n          size={16}\n          isVisible\n          position={{\n            top: TARGET_POSITION.top,\n            left: TARGET_POSITION.left,\n          }}\n        >\n          <ToolTipWithTag tag=\"is this spam?\" />\n        </ToolTip>\n      </div>\n    );\n  })\n  .add('leftBottom arrow (single tag)', () => {\n    return (\n      <div>\n        <ToolTipTarget />\n        <ToolTip\n          backgroundColor={MEDIUM_COLOR}\n          arrowPosition=\"leftBottom\"\n          size={16}\n          isVisible\n          position={{\n            top: TARGET_POSITION.top,\n            left: TARGET_POSITION.left,\n          }}\n        >\n          <ToolTipWithTag tag=\"is this spam?\" />\n        </ToolTip>\n      </div>\n    );\n  })\n  .add('leftCenter arrow (single tag)', () => {\n    return (\n      <div>\n        <ToolTipTarget />\n        <ToolTip\n          backgroundColor={MEDIUM_COLOR}\n          arrowPosition=\"leftCenter\"\n          size={16}\n          isVisible\n          position={{\n            top: TARGET_POSITION.top,\n            left: TARGET_POSITION.left,\n          }}\n        >\n          <ToolTipWithTag tag=\"is this spam?\" />\n        </ToolTip>\n      </div>\n    );\n  })\n  .add('leftTop arrow (single tag)', () => {\n    return (\n      <div>\n        <ToolTipTarget />\n        <ToolTip\n          backgroundColor={MEDIUM_COLOR}\n          arrowPosition=\"leftTop\"\n          size={16}\n          isVisible\n          position={{\n            top: TARGET_POSITION.top,\n            left: TARGET_POSITION.left,\n          }}\n        >\n          <ToolTipWithTag tag=\"is this spam?\" />\n        </ToolTip>\n      </div>\n    );\n  })\n  .add('no arrow (single tag)', () => {\n    return (\n      <div>\n        <ToolTipTarget />\n        <ToolTip\n          backgroundColor={MEDIUM_COLOR}\n          arrowPosition={undefined}\n          size={16}\n          isVisible\n          position={{\n            top: TARGET_POSITION.top,\n            left: TARGET_POSITION.left,\n          }}\n        >\n          <ToolTipWithTag tag=\"is this spam?\" />\n        </ToolTip>\n      </div>\n    );\n  })\n  .add('info tooltip', () => {\n    return (\n      <div>\n        <ToolTipTarget />\n        <ToolTip\n          backgroundColor={MEDIUM_COLOR}\n          arrowPosition=\"leftCenter\"\n          size={16}\n          isVisible\n          position={{\n            top: TARGET_POSITION.top,\n            left: TARGET_POSITION.left,\n          }}\n        >\n          <InfoToolTip />\n        </ToolTip>\n      </div>\n    );\n  });\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/ToolTip/__spec__/.gitkeep",
    "content": ""
  },
  {
    "path": "packages/frontend-web/src/app/components/ToolTip/index.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nexport * from './ToolTip';\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/VirtualListScrollbar.tsx",
    "content": "/*\nCopyright 2020 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport React, {useCallback} from 'react';\nimport {ScrollbarProps, Scrollbars} from 'react-custom-scrollbars';\n\nconst CustomScrollbars = (\n  { onScroll, forwardedRef, style, children }: ScrollbarProps & { forwardedRef(view: any): void },\n) => {\n  const refSetter = useCallback((scrollbarsRef) => {\n    if (scrollbarsRef) {\n      forwardedRef(scrollbarsRef.view);\n    } else {\n      forwardedRef(null);\n    }\n  }, []);\n\n  return (\n    <Scrollbars\n      ref={refSetter}\n      style={{ ...style, overflow: 'hidden' }}\n      onScroll={onScroll}\n    >\n      {children}\n    </Scrollbars>\n  );\n};\n\nexport const CustomScrollbarsVirtualList = React.forwardRef((props, ref) => (\n  <CustomScrollbars {...props} forwardedRef={ref as (view: any) => void} />\n));\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/article_controls.tsx",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { autobind } from 'core-decorators';\nimport React from 'react';\n\nimport {\n  ClickAwayListener,\n  DialogTitle,\n  Popper,\n  Switch,\n} from '@material-ui/core';\n\nimport { IArticleModel } from '../../models';\nimport {\n  GREY_COLOR,\n  NICE_CONTROL_BLUE,\n  SCRIM_STYLE,\n} from '../styles';\nimport {\n  big,\n  ICON_STYLES,\n} from '../stylesx';\nimport { css } from '../utilx';\nimport * as icons from './Icons';\n\ninterface IControlFlagProps {\n  isCommentingEnabled?: boolean;\n  isAutoModerated?: boolean;\n}\n\nexport class ControlFlag extends React.Component<IControlFlagProps> {\n  render() {\n    let style: any;\n    let Icon: any;\n\n    if (this.props.isAutoModerated) {\n      Icon = icons.SpeechBubbleIconCircle;\n    }\n    else {\n      Icon = icons.SpeechBubbleIcon;\n    }\n\n    if (this.props.isCommentingEnabled) {\n      style = {color: NICE_CONTROL_BLUE};\n    }\n    else {\n      style = {color: GREY_COLOR};\n    }\n\n    return (<Icon {...css(style)}/>);\n  }\n}\n\nexport interface IControlPopupProps {\n  article: IArticleModel;\n  clearPopups(): void;\n  saveControls(isCommentingEnabled: boolean, isAutoModerated: boolean): void;\n}\n\nexport interface IControlPopupState {\n  isCommentingEnabled: boolean;\n  isAutoModerated: boolean;\n}\n\nexport class ArticleControlPopup extends React.Component<IControlPopupProps, IControlPopupState> {\n  constructor(props: Readonly<IControlPopupProps>) {\n    super(props);\n    this.state = {\n      isCommentingEnabled: this.props.article.isCommentingEnabled,\n      isAutoModerated: this.props.article.isAutoModerated,\n    };\n  }\n\n  @autobind\n  handleCommentingEnabledClicked() {\n    this.setState({isCommentingEnabled: !this.state.isCommentingEnabled});\n  }\n\n  @autobind\n  handleAutoModeratedClicked() {\n    if (!this.state.isCommentingEnabled) {\n      return;\n    }\n    this.setState({isAutoModerated: !this.state.isAutoModerated});\n  }\n\n  @autobind\n  saveControls() {\n    this.props.saveControls(this.state.isCommentingEnabled, this.state.isAutoModerated);\n  }\n\n  render() {\n    return (\n      <ClickAwayListener onClickAway={this.props.clearPopups}>\n        <div tabIndex={0} {...css(SCRIM_STYLE.popupMenu, {padding: '20px'})}>\n          <DialogTitle id=\"article-controls\">Moderation settings</DialogTitle>\n          <table key=\"main\" {...css({width: 'compute(100% - 50px)', margin: '4px 9px 4px 25px'})}>\n            <tbody>\n            <tr key=\"comments\" onClick={this.handleCommentingEnabledClicked}>\n              <td key=\"icon\">\n                <ControlFlag isCommentingEnabled={this.state.isCommentingEnabled}/>\n              </td>\n              <td key=\"text\" {...css({textAlign: 'left', padding: '15px 4px'})}>\n                <label {...css(SCRIM_STYLE.popupContent)}>\n                  Comments Enabled\n                </label>\n              </td>\n              <td key=\"toggle\" {...css({textAlign: 'right'})}>\n                <Switch checked={this.state.isCommentingEnabled} color=\"primary\"/>\n              </td>\n            </tr>\n            <tr key=\"automod\" onClick={this.handleAutoModeratedClicked} {...css(this.state.isCommentingEnabled ? {} : {opacity: 0.5})}>\n              <td key=\"icon\">\n                <ControlFlag isCommentingEnabled={this.state.isCommentingEnabled} isAutoModerated={this.state.isAutoModerated}/>\n              </td>\n              <td key=\"text\"  {...css({textAlign: 'left', padding: '15px 4px'})}>\n                <label {...css(SCRIM_STYLE.popupContent)}>\n                  Auto Moderation Enabled\n                </label>\n              </td>\n              <td key=\"toggle\" {...css({textAlign: 'right'})}>\n                <Switch checked={this.state.isAutoModerated} disabled={!this.state.isCommentingEnabled} color=\"primary\"/>\n              </td>\n            </tr>\n            </tbody>\n          </table>\n          <div key=\"footer\" {...css({textAlign: 'right', margin: '35px 25px 30px 25px'})}>\n            <span onClick={this.props.clearPopups} {...css({marginRight: '30px', opacity: '0.5'})}>Cancel</span>\n            <span onClick={this.saveControls} {...css({color: NICE_CONTROL_BLUE})}>Save</span>\n          </div>\n        </div>\n      </ClickAwayListener>\n    );\n  }\n}\n\ninterface IArticleControlIconProps {\n  article: IArticleModel;\n  open: boolean;\n  whiteBackground?: boolean;\n\n  clearPopups(): void;\n\n  openControls(article: IArticleModel): void;\n\n  saveControls(isCommentingEnabled: boolean, isAutoModerated: boolean): void;\n}\n\nexport class ArticleControlIcon extends React.Component<IArticleControlIconProps> {\n  anchorElement: any;\n\n  @autobind\n  setAnchorElement(node: any) {\n    this.anchorElement = node;\n  }\n\n  @autobind\n  setOpen() {\n    const {article, open, clearPopups, openControls} = this.props;\n    if (open) {\n      clearPopups();\n    }\n    else {\n      openControls(article);\n    }\n  }\n\n  render() {\n    const {article, open, whiteBackground, saveControls, clearPopups} = this.props;\n\n    return (\n      <div key=\"aci\">\n        <div\n          key=\"icon\"\n          {...css(open || whiteBackground ? ICON_STYLES.iconBackgroundCircle : big)}\n          ref={this.setAnchorElement}\n        >\n          <div onClick={this.setOpen} {...css(ICON_STYLES.iconCenter)}>\n            <ControlFlag isCommentingEnabled={article.isCommentingEnabled} isAutoModerated={article.isAutoModerated}/>\n          </div>\n        </div>\n        <Popper\n          key=\"popper\"\n          open={open}\n          anchorEl={this.anchorElement}\n          placement=\"left\"\n          modifiers={{\n            preventOverflow: {\n              enabled: true,\n              boundariesElement: 'viewport',\n            },\n          }}\n        >\n          <ArticleControlPopup\n            article={article}\n            saveControls={saveControls}\n            clearPopups={clearPopups}\n          />\n        </Popper>\n      </div>\n    );\n  }\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/index.ts",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nexport * from './Arrow';\nexport * from './AspectRatio';\nexport * from './AssignModerators/AssignModerators';\nexport * from './AssignTagsForm';\nexport * from './Avatar';\nexport * from './Button';\nexport * from './CanvasTruncate';\nexport * from './CheckboxRow';\nexport * from './CommentActionButton';\nexport * from './CommentList';\nexport * from './ConfirmationCircle';\nexport * from './DotChart';\nexport * from './ErrorRoot';\nexport * from './Icons';\nexport * from './CommentList';\nexport * from './LazyLoadComment';\nexport * from './MagicTimestamp';\nexport * from './ModerateButtons';\nexport * from './NavigationTab';\nexport * from './OverflowContainer';\nexport * from './RuleBars';\nexport * from './ScoresList';\nexport * from './Scrim';\nexport * from './SearchAttribute';\nexport * from './SearchHeader';\nexport * from './SingleComment';\nexport * from './Slider';\nexport * from './SplashRoot';\nexport * from './TagLabelRow';\nexport * from './ThemeRoot';\nexport * from './Toast';\nexport * from './ToolTip';\nexport * from './article_controls';\nexport * from './HeaderBar';\n"
  },
  {
    "path": "packages/frontend-web/src/app/components/styles.ts",
    "content": "import {\n  BODY_TEXT_TYPE, BUTTON_LINK_TYPE, BUTTON_RESET,\n  CAPTION_TYPE,\n  DARK_PRIMARY_TEXT_COLOR,\n  DARK_SECONDARY_TEXT_COLOR,\n  DARK_TERTIARY_TEXT_COLOR,\n  GUTTER_DEFAULT_SPACING,\n  NICE_MIDDLE_BLUE,\n} from '../styles';\nimport { stylesheet } from '../utilx';\n\nexport const COMMENT_HEADER_BACKGROUND_COLOR = '#F5F7F9';\n\nexport const ROW_STYLES = stylesheet({\n  meta: {\n    ...CAPTION_TYPE,\n    color: DARK_SECONDARY_TEXT_COLOR,\n    display: 'flex',\n    flexDirection: 'row',\n    justifyContent: 'space-between',\n    alignItems: 'center',\n    height: '46px',\n  },\n\n  authorRow: {\n    display: 'flex',\n    flex: 1,\n    alignItems: 'center',\n  },\n\n  commentContainer: {\n    display: 'flex',\n    flex: 1,\n    flexDirection: 'column',\n    paddingBottom: `${GUTTER_DEFAULT_SPACING / 2}px`,\n  },\n\n  comment: {\n    ...BODY_TEXT_TYPE,\n    color: DARK_PRIMARY_TEXT_COLOR,\n    paddingRight: `${GUTTER_DEFAULT_SPACING}px`,\n  },\n\n  reply: {\n    fill: DARK_TERTIARY_TEXT_COLOR,\n    marginBottom: 3,\n    marginRight: 4,\n    verticalAlign: 'top',\n  },\n\n  actionToggle: {\n    ...BUTTON_RESET,\n    padding: `${GUTTER_DEFAULT_SPACING / 2}px`,\n    marginRight: `${GUTTER_DEFAULT_SPACING / 4}px`,\n    ':focus': {\n      outline: 0,\n      backgroundColor: `${COMMENT_HEADER_BACKGROUND_COLOR}`,\n    },\n  },\n\n  detailsButton: {\n    ...BUTTON_LINK_TYPE,\n    flex: 1,\n    border: 'none',\n    borderRadius: 0,\n    color: NICE_MIDDLE_BLUE,\n    cursor: 'pointer',\n    textAlign: 'right',\n    fontSize: '12px',\n    marginRight: `${GUTTER_DEFAULT_SPACING}px`,\n    ':hover': {\n      textDecoration: 'underline',\n    },\n\n    ':focus': {\n      outline: 0,\n      textDecoration: 'underline',\n    },\n  },\n\n  actionContainer: {\n    display: 'flex',\n    flexDirection: 'row',\n    alignItems: 'center',\n  },\n});\n"
  },
  {
    "path": "packages/frontend-web/src/app/config.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\n// Config from webpack\ndeclare const __DEVELOPMENT__: boolean;\ndeclare const ENV_API_URL: string;\ndeclare const ENV_APP_NAME: string;\ndeclare const ENV_REQUIRE_REASON_TO_REJECT: boolean;\ndeclare const ENV_COMMENTS_EDITABLE_FLAG: boolean;\ndeclare const ENV_RESTRICT_TO_SESSION: boolean;\ndeclare const ENV_MODERATOR_GUIDELINES_URL: string;\ndeclare const ENV_SUBMIT_FEEDBACK_URL: string;\n// End config from webpack\n\n// Config from HTML template\n\nfunction get_config() {\n  if (typeof(window) !== 'undefined') {\n    return (window as any)['osmod_config'];\n  }\n  return (global as any)['osmod_config'];\n}\n\nfunction getString(key: string, fallback: string): string {\n  const osmod_config = get_config();\n  if (typeof osmod_config === 'undefined' || !(key in osmod_config)) {\n    return fallback;\n  }\n  const val = osmod_config[key] as string;\n\n  if (typeof val === 'undefined') {\n    return fallback;\n  }\n\n  if (val.startsWith('{{') || val.length === 0) {\n    return fallback;\n  }\n\n  return val;\n}\n\nfunction getBoolean(key: string, fallback: boolean): boolean {\n  const osmod_config = get_config();\n  if (typeof osmod_config === 'undefined' || !(key in osmod_config)) {\n    return fallback;\n  }\n\n  const val = osmod_config[key] as string;\n\n  if (typeof val === 'undefined') {\n    return fallback;\n  }\n\n  if (val.startsWith('{{') || val.length === 0) {\n    return fallback;\n  }\n\n  return val === 'true';\n}\n\n// End config from HTML template\n\n// Turn externally defined config into exports\nexport const DEVELOPMENT = (typeof __DEVELOPMENT__ !== 'undefined') ? __DEVELOPMENT__  : true;\nexport const API_URL = getString('API_URL', (typeof ENV_API_URL !== 'undefined') ? ENV_API_URL : '');\nexport const APP_NAME = getString('APP_NAME', (typeof ENV_APP_NAME !== 'undefined') ? ENV_APP_NAME : 'Moderator');\nexport const REQUIRE_REASON_TO_REJECT = getBoolean('REQUIRE_REASON_TO_REJECT', (typeof ENV_REQUIRE_REASON_TO_REJECT !== 'undefined') ? ENV_REQUIRE_REASON_TO_REJECT : false);\nexport const COMMENTS_EDITABLE_FLAG = getBoolean('COMMENTS_EDITABLE_FLAG', (typeof ENV_COMMENTS_EDITABLE_FLAG !== 'undefined') ? ENV_COMMENTS_EDITABLE_FLAG : true);\nexport const RESTRICT_TO_SESSION = getBoolean('RESTRICT_TO_SESSION', (typeof ENV_RESTRICT_TO_SESSION !== 'undefined') ? ENV_RESTRICT_TO_SESSION : false);\nexport const MODERATOR_GUIDELINES_URL = getString('MODERATOR_GUIDELINES_URL', (typeof ENV_MODERATOR_GUIDELINES_URL !== 'undefined') ? ENV_MODERATOR_GUIDELINES_URL : '');\nexport const SUBMIT_FEEDBACK_URL = getString('SUBMIT_FEEDBACK_URL', (typeof ENV_SUBMIT_FEEDBACK_URL !== 'undefined') ? ENV_SUBMIT_FEEDBACK_URL : '');\nexport const DEFAULT_DRAG_HANDLE_POS1 = 0;\nexport const DEFAULT_DRAG_HANDLE_POS2 = 0.2;\nexport const DATE_FORMAT_LONG = 'MMM. d, yyyy h:mm a';\nexport const DATE_FORMAT_MDY = 'MMM. d, yyyy';\nexport const DATE_FORMAT_HM = 'h:mm a';\nexport const COLCOUNT = 100;\n"
  },
  {
    "path": "packages/frontend-web/src/app/injectors/articleFetchQueue.ts",
    "content": "/*\nCopyright 2020 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\nimport {ArticleModel, IArticleModel, ModelId} from '../../models';\nimport {IAppState} from '../appstate';\nimport {getArticles} from '../platform/dataService';\nimport {store} from '../store';\nimport {articlesUpdated} from '../stores/articles';\n\nconst articleFetchQueue = new Set();\n\nexport interface IArticleCacheProps {\n  article: IArticleModel;\n  inCache: boolean;\n}\n\nasync function fetchArticle(articleId: ModelId) {\n  const articles = await getArticles([articleId]);\n  store.dispatch(articlesUpdated(articles));\n}\n\nfunction ensureCache(articleId: ModelId) {\n  if (!articleFetchQueue.has(articleId)) {\n    articleFetchQueue.add(articleId);\n    fetchArticle(articleId);\n  }\n}\n\nexport function getCachedArticle(state: IAppState, articleId: ModelId): IArticleCacheProps {\n  if (!articleId) {\n    return {article: null, inCache: false};\n  }\n\n  const article: IArticleModel = state.global.articles.index.get(articleId);\n  if (article) {\n    articleFetchQueue.delete(articleId);\n    return {article, inCache: true};\n  }\n\n  ensureCache(articleId);\n\n  return {\n    inCache: false,\n    article: ArticleModel({\n      id: articleId,\n      sourceCreatedAt: '',\n      updatedAt: '',\n      title: '',\n      text: '',\n      url: '',\n      categoryId: 'any',\n      allCount: 0,\n      unprocessedCount: 0,\n      unmoderatedCount: 0,\n      moderatedCount: 0,\n      deferredCount: 0,\n      approvedCount: 0,\n      highlightedCount: 0,\n      rejectedCount: 0,\n      flaggedCount: 0,\n      batchedCount: 0,\n      automatedCount: 0,\n      lastModeratedAt: '',\n      assignedModerators: new Array<ModelId>(),\n      isCommentingEnabled: true,\n      isAutoModerated: true,\n    }),\n  };\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/injectors/articleInjector.ts",
    "content": "/*\nCopyright 2020 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\nimport { useSelector } from 'react-redux';\n\nimport { ModelId } from '../../models';\nimport { IAppState } from '../appstate';\nimport { getCachedArticle, IArticleCacheProps } from './articleFetchQueue';\n\nexport function useCachedArticle(articleId: ModelId): IArticleCacheProps {\n  return useSelector((state: IAppState) => getCachedArticle(state, articleId));\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/injectors/commentFetchQueue.ts",
    "content": "/*\nCopyright 2020 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\nimport {CommentModel, ICommentModel, ModelId} from '../../models';\nimport {IAppState} from '../appstate';\nimport {fetchComments} from '../stores/commentActions';\nimport {ensureCache, getComment} from '../stores/comments';\n\nexport interface ICommentCacheProps {\n  comment: ICommentModel;\n  inCache: boolean;\n}\n\nexport function getCachedComment(state: IAppState, commentId: ModelId): ICommentCacheProps {\n  const comment: ICommentModel = getComment(state, commentId);\n  if (comment) {\n    return {comment, inCache: true};\n  }\n\n  ensureCache(commentId, fetchComments);\n\n  return {\n    inCache: false,\n    comment: CommentModel({\n      id: commentId,\n      sourceId: '',\n      sourceCreatedAt: '',\n      updatedAt: '',\n      replyId: null,\n      replyToSourceId: null,\n      authorSourceId: null,\n      text: '',\n      author: null,\n      isScored: null,\n      isModerated: null,\n      isAccepted: null,\n      isDeferred: null,\n      isHighlighted: null,\n      isBatchResolved: null,\n      isAutoResolved: null,\n      unresolvedFlagsCount: null,\n      flagsSummary: null,\n      sentForScoring: null,\n      articleId: null,\n      replies: null,\n      maxSummaryScore: null,\n      maxSummaryScoreTagId: null,\n    }),\n  };\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/injectors/commentInjector.ts",
    "content": "/*\nCopyright 2020 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\nimport { connect, useSelector } from 'react-redux';\nimport { RouteComponentProps } from 'react-router';\n\nimport { ModelId } from '../../models';\nimport { IAppState } from '../appstate';\nimport { ICommentDetailsPathParams } from '../scenes/routes';\nimport { getCachedComment, ICommentCacheProps } from './commentFetchQueue';\n\nexport interface ICommentInjectorInputProps {\n  commentId: ModelId;\n}\n\nfunction getCommentFromCommentId(state: IAppState, {commentId}: ICommentInjectorInputProps): ICommentCacheProps {\n  return getCachedComment(state, commentId);\n}\n\nexport const commentInjector = connect(getCommentFromCommentId);\n\nfunction getCommentFromRoute(state: IAppState, props: RouteComponentProps<ICommentDetailsPathParams>): ICommentCacheProps {\n  return getCachedComment(state, props.match.params.commentId);\n}\n\nexport const commentFromRouteInjector = connect(getCommentFromRoute);\n\nexport function useCachedComment(commentId: ModelId): ICommentCacheProps {\n  return useSelector((state: IAppState) => getCachedComment(state, commentId));\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/injectors/contextInjector.ts",
    "content": "/*\nCopyright 2020 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\nimport {connect, useSelector} from 'react-redux';\nimport {RouteComponentProps} from 'react-router';\nimport {useRouteMatch} from 'react-router-dom';\n\nimport {IArticleModel, ICategoryModel, ModelId} from '../../models';\nimport {IAppState} from '../appstate';\nimport {articleBase, categoryBase, IContextPathParams, isArticleContext} from '../scenes/routes';\nimport {getCategory} from '../stores/categories';\nimport {getCachedArticle} from './articleFetchQueue';\n\nexport interface IContextInjectorProps {\n  isArticleContext: boolean;\n  categoryId: ModelId;\n  category?: ICategoryModel;\n  articleId?: ModelId;\n  article?: IArticleModel;\n  inCache: boolean;\n}\n\nfunction mapStateToProps(\n  state: IAppState,\n  {match: {params}}: RouteComponentProps<IContextPathParams>,\n): IContextInjectorProps & {inCache: boolean} {\n  if (!isArticleContext(params)) {\n    return {\n      isArticleContext: false,\n      categoryId: params.contextId,\n      category: getCategory(state, params.contextId),\n      inCache: true,\n    };\n  }\n\n  const articleId = params.contextId;\n  const {article, inCache} = getCachedArticle(state, articleId);\n  return {\n    isArticleContext: true,\n    categoryId: article.categoryId,\n    category: getCategory(state, article.categoryId),\n    articleId,\n    article,\n    inCache,\n  };\n}\n\nexport const contextInjector = connect(mapStateToProps);\n\nexport function useRouteContext() {\n  const match = useRouteMatch<IContextPathParams>('/:context/:contextId');\n  return useSelector((state: IAppState) =>  {\n    if (!match) {\n      return {};\n    }\n\n    const {params} = match;\n\n    if (params.context === articleBase) {\n      const articleId = params.contextId;\n      const {article, inCache} = getCachedArticle(state, articleId);\n      return {\n        isArticleContext: true,\n        categoryId: article.categoryId,\n        category: getCategory(state, article.categoryId),\n        articleId,\n        article,\n        inCache,\n      };\n    }\n\n    if (params.context === categoryBase) {\n      return {\n        isArticleContext: false,\n        categoryId: params.contextId,\n        category: getCategory(state, params.contextId),\n        inCache: true,\n      };\n    }\n\n    return {};\n  });\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/main.tsx",
    "content": "/*\nCopyright 2020 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport React from 'react';\nimport ReactDOM from 'react-dom';\nimport { Provider } from 'react-redux';\nimport { RouteComponentProps, withRouter } from 'react-router';\nimport { BrowserRouter } from 'react-router-dom';\n\nimport {\n  AuthenticationStates,\n  ServerStates,\n  SystemStates,\n  WebsocketStates,\n} from '../types';\nimport {\n  start,\n} from './auth';\nimport { ErrorRoot, SPLASH_STYLES, SplashRoot, ThemeRoot } from './components';\nimport { APP_NAME } from './config';\nimport { AppRoot } from './scenes';\nimport { ConfigureOAuth, Login } from './scenes/Login';\nimport { store } from './store';\nimport { COMMON_STYLES } from './stylesx';\nimport { css } from './utilx';\n\nfunction _Root(props: React.PropsWithChildren<RouteComponentProps<{}>>) {\n  const [error, setError] = React.useState<string>(null);\n  const [serverState, setServerState] = React.useState<ServerStates>('s_connecting');\n  const [authState, setAuthState] = React.useState<AuthenticationStates>('initialising');\n  const [wsState, setWsState] = React.useState<WebsocketStates>('ws_connecting');\n\n  function setState(state: SystemStates) {\n    if (state.startsWith('s_')) {\n      setServerState(state as ServerStates);\n    }\n    else if (state.startsWith('ws_')) {\n      setWsState(state as WebsocketStates);\n    }\n    else {\n      setAuthState(state as AuthenticationStates);\n    }\n  }\n  function setRoute(route: string) {\n    props.history.replace(route);\n  }\n\n  React.useEffect(() => {\n    start(store.dispatch, setState, setRoute, setError);\n  }, []);\n\n  function retry() {\n    setState('initialising');\n    start(store.dispatch, setState, setRoute, setError);\n  }\n\n  if (error) {\n    return <ErrorRoot errorMessage={error} retry={retry}/>;\n  }\n\n  function message(msg: string) {\n    return (\n      <SplashRoot>\n        <div key=\"message\" {...css(SPLASH_STYLES.header2Tag, COMMON_STYLES.fadeIn)}>{msg}...</div>\n      </SplashRoot>\n    );\n  }\n\n  switch (serverState) {\n    case 's_connecting':\n      return message('Connecting');\n    case 's_unavailable':\n      return <ErrorRoot errorMessage=\"Server unavailable\" retry={retry}/>;\n    case 's_init_oauth':\n      return <ConfigureOAuth restart={retry}/>;\n    case 's_init_first_user':\n      function backToOAuth() {\n        setState('s_init_oauth');\n      }\n      return <Login firstUser backToOAuth={backToOAuth}/>;\n    case 's_init_check_oauth':\n      return <Login backToOAuth={backToOAuth}/>;\n  }\n\n  switch (authState) {\n    case 'initialising':\n      return message('Initialising');\n    case 'check_token':\n      return message('Checking');\n    case 'unauthenticated':\n      return <Login/>;\n  }\n\n  if (wsState === 'ws_connecting') {\n    return message('Connecting');\n  }\n  return <AppRoot/>;\n}\n\nconst Root = withRouter(_Root);\n\nReactDOM.render((\n    <Provider store={store}>\n      <ThemeRoot>\n        <BrowserRouter>\n          <Root/>\n        </BrowserRouter>\n      </ThemeRoot>\n    </Provider>\n  ),\n  document.getElementById('app'),\n);\n\n// Set window title.\nwindow.document.title = APP_NAME;\n"
  },
  {
    "path": "packages/frontend-web/src/app/platform/dataService.ts",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport axios from 'axios';\nimport { List } from 'immutable';\nimport { pick } from 'lodash';\nimport qs from 'qs';\n\nimport {\n  IParams,\n} from './types';\n\nimport {\n  ArticleModel,\n  CommentFlagModel,\n  CommentModel,\n  CommentScoreModel,\n  IArticleModel,\n  IAuthorCountsModel,\n  ICommentDate,\n  ICommentFlagModel,\n  ICommentModel,\n  ICommentScore,\n  ICommentScoreModel, IPreselectModel, IRuleModel, ITaggingSensitivityModel,\n  ITagModel,\n  IUserModel,\n  ModelId,\n  UserModel,\n} from '../../models';\nimport { ServerStates } from '../../types';\nimport { API_URL } from '../config';\n\nexport type IValidModelNames =\n    'moderation_rule' |\n    'preselect' |\n    'tagging_sensitivity' |\n    'tag';\n\n/**\n * Convert Partial<IParams> type to a query string.\n */\nfunction serializeParams(originalParams?: Partial<IParams> | null): string {\n  if (!originalParams) {\n    return '';\n  }\n\n  // Clone to avoid mutability issues.\n  const params = { ...originalParams } as any;\n\n  if (originalParams.sort) {\n    params.sort = originalParams.sort.join(',');\n  }\n\n  return '?' + qs.stringify(params, { encode: false });\n}\n\nconst BASE_RANGE_ATTRIBUTES = ['categoryId', 'tagId', 'lowerThreshold', 'upperThreshold'];\n\n/**\n * Base AUTH API Path\n */\nconst AUTH_URL = `/auth`;\n\n/**\n * Base Services API Path\n */\nconst SERVICES_URL = `/services`;\n\n/**\n * The URL of a service.\n */\nexport function serviceURL(service: string, path?: string | null, params?: Partial<IParams>): string {\n  return `${API_URL}${SERVICES_URL}/${service}${path ? path : ''}${serializeParams(params)}`;\n}\n\nexport async function listTextSizesByIds(\n  ids: Array<string>,\n  width: number,\n): Promise<Map<string, number>> {\n  const response: any = await axios.post(\n    serviceURL('textSizes', null, { width } as Partial<IParams>),\n    { data: ids },\n  );\n\n  const data = response.data.data;\n  return new Map<ModelId, number>(Object.entries(data));\n}\n\nfunction packCommentScoreData(data: Array<{ commentId: ModelId, score: number }>): Array<ICommentScore> {\n  return data.map(({commentId, score}) => ({commentId, score}));\n}\n\nfunction packCommentDateData(data: Array<{ commentId: ModelId, date: string }>): Array<ICommentDate> {\n  return data.map(({date, commentId}) => ({\n    commentId,\n    date: new Date(date),\n  }));\n}\n\nexport async function listHistogramScoresByArticle(\n  articleId: string,\n  tagId: string | 'DATE',\n  sort: Array<string>,\n): Promise<Array<ICommentScore>> {\n  const response: any = await axios.get(\n    serviceURL('histogramScores', `/articles/${articleId}/tags/${tagId}`, { sort }),\n  );\n\n  return packCommentScoreData(response.data.data);\n}\n\nexport async function listMaxSummaryScoreByArticle(\n  articleId: string,\n  sort: Array<string>,\n): Promise<Array<ICommentScore>> {\n  const response: any = await axios.get(\n    serviceURL('histogramScores', `/articles/${articleId}/summaryScore`, { sort }),\n  );\n\n  return packCommentScoreData(response.data.data);\n}\n\nexport async function listHistogramScoresByArticleByDate(\n  articleId: ModelId,\n  sort: Array<string>,\n): Promise<Array<ICommentDate>> {\n  const response: any = await axios.get(\n    serviceURL('histogramScores', `/articles/${articleId}/byDate`, { sort }),\n  );\n\n  return packCommentDateData(response.data.data);\n}\n\nexport async function listHistogramScoresByCategory(\n  categoryId: string | 'all',\n  tagId: string,\n  sort: Array<string>,\n): Promise<Array<ICommentScore>> {\n  const response: any = await axios.get(\n    serviceURL('histogramScores', `/categories/${categoryId}/tags/${tagId}`, { sort }),\n  );\n\n  return packCommentScoreData(response.data.data);\n}\n\nexport async function listMaxHistogramScoresByCategory(\n  categoryId: string | 'all',\n  sort: Array<string>,\n): Promise<Array<ICommentScore>> {\n  const response: any = await axios.get(\n    serviceURL('histogramScores', `/categories/${categoryId}/summaryScore`, { sort }),\n  );\n\n  return packCommentScoreData(response.data.data);\n}\n\nexport async function listHistogramScoresByCategoryByDate(\n  categoryId: ModelId | 'all',\n  sort: Array<string>,\n): Promise<Array<ICommentDate>> {\n  const response: any = await axios.get(\n    serviceURL('histogramScores', `/categories/${categoryId}/byDate`, { sort }),\n  );\n\n  return packCommentDateData(response.data.data);\n}\n\nexport async function getComments(\n  commentIds: Array<ModelId>,\n): Promise<Array<ICommentModel>> {\n  const url = serviceURL('simple', `/comments`);\n  const response = await axios.post(url, commentIds);\n  return response.data.map((a: any) => (CommentModel(a)));\n}\n\n/**\n * Search comment models.\n */\nexport async function search(\n  query: string,\n  params?: Partial<IParams>,\n): Promise<Array<string>> {\n  const requestParams = {\n    ...params,\n    term: query,\n  };\n\n  const { data }: any = await axios.get(serviceURL('search', null, requestParams));\n\n  return data.data;\n}\n\n/**\n * Send updated comment text and rescore comment.\n */\nexport async function editAndRescoreCommentRequest(\n  commentId: string,\n  text: string,\n  authorName: string,\n  authorLocation: string,\n): Promise<void> {\n  await axios.patch(\n    serviceURL('editComment', null),\n    {\n      data: {\n        commentId,\n        text,\n        authorName,\n        authorLocation,\n      },\n    });\n}\n\nexport async function updateCategoryModerators(categoryId: ModelId, moderatorIds: Array<ModelId>): Promise<void> {\n  const url = serviceURL('assignments', `/categories/${categoryId}`);\n  await axios.post(url, { data: moderatorIds });\n}\n\nexport async function updateArticleModerators(articleId: ModelId, moderatorIds: Array<ModelId>): Promise<void> {\n  const url = serviceURL('assignments', `/article/${articleId}`);\n  await axios.post(url, { data: moderatorIds });\n}\n\nexport type IModeratedComments = Readonly<{\n  approved: Array<ModelId>;\n  highlighted: Array<ModelId>;\n  rejected: Array<ModelId>;\n  deferred: Array<ModelId>;\n  flagged: Array<ModelId>;\n  batched: Array<ModelId>;\n  automated: Array<ModelId>;\n  [key: string]: Array<ModelId>;\n}>;\n\nexport async function getModeratedCommentIdsForArticle(\n  articleId: ModelId,\n  sort: Array<string>,\n): Promise<IModeratedComments> {\n  const { data }: any = await axios.get(\n    serviceURL('moderatedCounts', `/articles/${articleId}`, { sort }),\n  );\n\n  return data.data;\n}\n\nexport async function getModeratedCommentIdsForCategory(\n  categoryId: ModelId | 'all',\n  sort: Array<string>,\n): Promise<IModeratedComments> {\n  const { data }: any = await axios.get(\n    serviceURL('moderatedCounts', `/categories/${categoryId}`, { sort }),\n  );\n\n  return data.data;\n}\n\nexport async function getArticles(ids: Array<ModelId>): Promise<Array<IArticleModel>> {\n  const url = serviceURL('simple', `/articles`);\n  const response = await axios.post(url, ids);\n  return response.data.map((a: any) => (ArticleModel(a)));\n}\n\nexport async function getArticleText(id: ModelId) {\n  const url = serviceURL('simple', `/article/${id}/text`);\n  const response = await axios.get(url);\n  return response.data.text;\n}\n\nexport async function updateArticle(id: string, isCommentingEnabled: boolean, isAutoModerated: boolean) {\n  const url = serviceURL('simple', `/article/${id}`);\n  await axios.post(url, {isCommentingEnabled, isAutoModerated});\n}\n\nasync function createThing(url: string, attributes: {[key: string]: string | number | boolean}) {\n  try {\n    await axios.post(url, attributes);\n  } catch (e) {\n    if (e.response) {\n      throw new Error(e.response.data);\n    }\n    throw e;\n  }\n}\n\nasync function updateThing(url: string, attributes: {[key: string]: string | number | boolean}) {\n  try {\n    await axios.patch(url, attributes);\n  } catch (e) {\n    if (e.response) {\n      throw new Error(e.response.data);\n    }\n    throw e;\n  }\n}\n\nexport async function createUser(user: IUserModel) {\n  const url = serviceURL('simple', `/user`);\n  const attributes = pick(user, ['name', 'email', 'group', 'isActive']);\n  return createThing(url, attributes);\n}\n\nexport async function createTag(tag: ITagModel) {\n  const url = serviceURL('simple', `/tag`);\n  const attributes = pick(tag, ['color', 'description', 'key', 'label',\n    'isInBatchView', 'inSummaryScore', 'isTaggable']);\n  return createThing(url, attributes);\n}\n\nexport async function createRule(rule: IRuleModel) {\n  const url = serviceURL('simple', `/moderation_rule`);\n  const attributes = pick(rule, [...BASE_RANGE_ATTRIBUTES, 'action']);\n  return createThing(url, attributes);\n}\n\nexport async function createPreselect(preselect: IPreselectModel) {\n  const url = serviceURL('simple', `/preselect`);\n  const attributes = pick(preselect, BASE_RANGE_ATTRIBUTES);\n  return createThing(url, attributes);\n}\n\nexport async function createSensitivity(sensitivity: ITaggingSensitivityModel) {\n  const url = serviceURL('simple', `/tagging_sensitivity`);\n  const attributes = pick(sensitivity, BASE_RANGE_ATTRIBUTES);\n  return createThing(url, attributes);\n}\n\nexport async function updateUser(user: IUserModel) {\n  const url = serviceURL('simple', `/user/${user.id}`);\n  const attributes = pick(user, ['name', 'email', 'group', 'isActive']);\n  await axios.post(url, attributes);\n}\n\nexport async function updateTag(tag: ITagModel) {\n  const url = serviceURL('simple', `/tag/${tag.id}`);\n  const attributes = pick(tag, ['color', 'description', 'key', 'label',\n    'isInBatchView', 'inSummaryScore', 'isTaggable']);\n  return updateThing(url, attributes);\n}\n\nexport async function updateRule(rule: IRuleModel) {\n  const url = serviceURL('simple', `/moderation_rule/${rule.id}`);\n  const attributes = pick(rule, [...BASE_RANGE_ATTRIBUTES, 'action']);\n  return updateThing(url, attributes);\n}\n\nexport async function updatePreselect(preselect: IPreselectModel) {\n  const url = serviceURL('simple', `/preselect/${preselect.id}`);\n  const attributes = pick(preselect, BASE_RANGE_ATTRIBUTES);\n  return updateThing(url, attributes);\n}\n\nexport async function updateSensitivity(sensitivity: ITaggingSensitivityModel) {\n  const url = serviceURL('simple', `/tagging_sensitivity/${sensitivity.id}`);\n  const attributes = pick(sensitivity, BASE_RANGE_ATTRIBUTES);\n  return updateThing(url, attributes);\n}\n\n/**\n * Destroy a model.\n */\nexport async function destroyModel(\n  type: IValidModelNames,\n  id: string,\n): Promise<void> {\n  await axios.delete(serviceURL('simple', `/${type}/${id}`));\n}\n\nexport async function getCommentScores(commentId: string): Promise<Array<ICommentScoreModel>> {\n  const url = serviceURL('simple', `/comment/${commentId}/scores`);\n  const response = await axios.get(url);\n  return response.data.map((s: any) => (CommentScoreModel(s)));\n}\n\nexport async function getCommentFlags(commentId: string): Promise<Array<ICommentFlagModel>> {\n  const url = serviceURL('simple', `/comment/${commentId}/flags`);\n  const response = await axios.get(url);\n  return response.data.map((s: any) => (CommentFlagModel(s)));\n}\n\nexport async function checkServerStatus(): Promise<ServerStates> {\n  const response = await axios.get(\n    `${API_URL}${AUTH_URL}/healthcheck`,\n  );\n  if (response.status === 218) {\n    switch (response.data) {\n      case 'init_oauth':\n        return 's_init_oauth';\n      case 'init_first_user':\n        return 's_init_first_user';\n      case 'init_check_oauth':\n        return 's_init_check_oauth';\n    }\n  }\n  return 's_gtg';\n}\n\n/**\n * Ping the backend to see if the auth succeeds.\n */\nexport async function checkAuthorization(): Promise<void> {\n  await axios.get(\n    `${API_URL}${AUTH_URL}/test`,\n  );\n}\n\nasync function makeCommentAction(path: string, ids: Array<string>): Promise<void> {\n  if (ids.length <= 0) { return; }\n  const idUserArray =  ids.map((commentId) => {\n    return {\n      commentId,\n    };\n  });\n  const url = serviceURL('commentActions', path);\n  await axios.post(url, { data: idUserArray, runImmediately: true });\n}\n\nasync function makeCommentActionForId(path: string, commentId: string): Promise<void> {\n  const url = serviceURL('commentActions', path);\n  await axios.post(url, { data: { commentId } });\n}\n\nexport async function deleteCommentScoreRequest(commentId: string, commentScoreId: string): Promise<void> {\n  const url = serviceURL('commentActions', `/${commentId}/scores/${commentScoreId}`);\n  await axios.delete(url);\n}\n\nexport function highlightCommentsRequest(ids: Array<string>): Promise<void> {\n  return makeCommentAction('/highlight', ids);\n}\n\nexport function resetCommentsRequest(ids: Array<string>): Promise<void> {\n  return makeCommentAction('/reset', ids);\n}\n\nexport function approveCommentsRequest(ids: Array<string>): Promise<void> {\n  return makeCommentAction('/approve', ids);\n}\n\nexport function approveFlagsAndCommentsRequest(ids: Array<string>): Promise<void> {\n  return makeCommentAction('/approve-flags', ids);\n}\n\nexport function resolveFlagsRequest(ids: Array<string>): Promise<void> {\n  return makeCommentAction('/resolve-flags', ids);\n}\n\nexport function deferCommentsRequest(ids: Array<string>): Promise<void> {\n  return makeCommentAction('/defer', ids);\n}\n\nexport function rejectCommentsRequest(ids: Array<string>): Promise<void> {\n  return makeCommentAction('/reject', ids);\n}\n\nexport function rejectFlagsAndCommentsRequest(ids: Array<string>): Promise<void> {\n  return makeCommentAction('/reject-flags', ids);\n}\n\nexport function tagCommentsRequest(ids: Array<string>, tagId: string): Promise<void> {\n  return makeCommentAction(`/tag/${tagId}`, ids);\n}\n\nexport function tagCommentSummaryScoresRequest(ids: Array<string>, tagId: string): Promise<void> {\n  return makeCommentAction(`/tagCommentSummaryScores/${tagId}`, ids);\n}\n\nexport async function confirmCommentSummaryScoreRequest(commentId: string, tagId: string): Promise<void> {\n  return makeCommentActionForId(\n    `/${commentId}/tagCommentSummaryScores/${tagId}/confirm`, commentId);\n}\n\nexport async function rejectCommentSummaryScoreRequest(commentId: string, tagId: string): Promise<void> {\n  return makeCommentActionForId(`/${commentId}/tagCommentSummaryScores/${tagId}/reject`, commentId);\n}\n\nexport async function tagCommentsAnnotationRequest(commentId: string, tagId: string, start: number, end: number): Promise<void> {\n  const url = serviceURL('commentActions', `/${commentId}/scores`);\n\n  await axios.post(url, {\n    data: {\n      tagId,\n      annotationStart: start,\n      annotationEnd: end,\n    },\n  });\n}\n\nexport async function confirmCommentScoreRequest(commentId: string, commentScoreId: string): Promise<void> {\n  const url = serviceURL('commentActions', `/${commentId}/scores/${commentScoreId}/confirm`);\n  await axios.post(url);\n}\n\nexport async function rejectCommentScoreRequest(commentId: string, commentScoreId: string): Promise<void> {\n  const url = serviceURL('commentActions', `/${commentId}/scores/${commentScoreId}/reject`);\n  await axios.post(url);\n}\n\nexport async function resetCommentScoreRequest(commentId: string, commentScoreId: string): Promise<void> {\n  const url = serviceURL('commentActions', `/${commentId}/scores/${commentScoreId}/reset`);\n  await axios.post(url);\n}\n\nexport async function listAuthorCounts(\n  authorSourceIds: Array<string>,\n): Promise<Map<string, IAuthorCountsModel>> {\n  const response: any = await axios.post(\n    serviceURL('authorCounts'),\n    { data: authorSourceIds },\n  );\n\n  return new Map<string, IAuthorCountsModel>(Object.entries(response.data.data));\n}\n\nexport async function listSystemUsers(type: string): Promise<List<IUserModel>> {\n  const response: any = await axios.get(serviceURL('simple', `/systemUsers/${type}`));\n  return List<IUserModel>(response.data.users.map((u: any) => {\n    u.id = u.id.toString();\n    return UserModel(u);\n  }));\n}\n\nexport async function kickProcessor(type: string): Promise<void> {\n  await axios.get(serviceURL('processing', `/trigger/${type}`));\n}\n\nexport async function activateCommentSource(categoryId: ModelId, activate: boolean): Promise<void> {\n  await axios.post(serviceURL('comment_sources', `/activate/${categoryId}`), { data: {activate} });\n}\n\nexport async function syncCommentSource(categoryId: ModelId): Promise<void> {\n  await axios.get(serviceURL('comment_sources', `/sync/${categoryId}`));\n}\n\nexport interface IApiConfiguration {\n  id: string;\n  secret: string;\n}\n\nexport async function getOAuthConfig(): Promise<IApiConfiguration> {\n  const response: any = await axios.get(`${API_URL}${AUTH_URL}/config`);\n  return response.data.google_oauth_config as IApiConfiguration;\n}\n\nexport async function updateOAuthConfig(config: IApiConfiguration): Promise<void> {\n  await axios.post(`${API_URL}${AUTH_URL}/config`, {data: config});\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/platform/localStore.ts",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\n/**\n * Provide an abstraction on top of the local/session storage on the browser.\n * On non-browser platforms, provide a fake interface that just stores stuff\n * in a javascript object.\n */\nimport { RESTRICT_TO_SESSION } from '../config';\n\nconst LOCAL_STORAGE_AUTH_TOKEN_KEY = 'moderator/auth_token';\n\n// TODO: One day, we'll need to flesh out this object with the full Storage API\n// Can't add a more specific type as the Storage type doesn't exist when building outside the browser.\nconst javascriptStorage: any = {};\n\nconst storage = () => {\n  if (typeof localStorage === 'undefined') {\n    return javascriptStorage;\n  }\n  if (RESTRICT_TO_SESSION) {\n    return sessionStorage;\n  }\n  return localStorage;\n};\n\nexport function getToken(): string | undefined {\n  return storage()[LOCAL_STORAGE_AUTH_TOKEN_KEY];\n}\n\nexport function saveToken(token: string | null): string {\n  if (token) {\n    storage()[LOCAL_STORAGE_AUTH_TOKEN_KEY] = token;\n  }\n  else {\n    delete storage()[LOCAL_STORAGE_AUTH_TOKEN_KEY];\n  }\n\n  return token;\n}\n\nfunction versionKey(key: string) {\n  return `moderator/${key}-version`;\n}\nfunction dataKey(key: string) {\n  return `moderator/${key}-data`;\n}\n\nexport function getStoreItem(key: string, version: number) {\n  const versionToCheck = storage()[versionKey(key)];\n  if (!versionToCheck || parseInt(versionToCheck, 10) !== version) {\n    return null;\n  }\n\n  const data = storage()[dataKey(key)];\n  if (!data) {\n    return null;\n  }\n\n  return data;\n}\n\nexport function saveStoreItem(key: string, version: number, data: string) {\n  storage()[versionKey(key)] = version;\n  storage()[dataKey(key)] = data;\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/platform/types.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nexport interface IParams {\n  sort: Array<string>;\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/platform/websocketService.ts",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { List } from 'immutable';\n\nimport {\n  IArticleModel,\n  ICategoryModel,\n  IPreselectModel,\n  IRuleModel,\n  ITaggingSensitivityModel,\n  ITagModel,\n  IUserModel,\n} from '../../models';\nimport {\n  ArticleModel,\n  CategoryModel,\n  PreselectModel,\n  RuleModel,\n  TaggingSensitivityModel,\n  TagModel,\n  UserModel,\n} from '../../models';\nimport { serviceURL } from './dataService';\nimport { getToken } from './localStore';\n\n// TODO: Is it possible to nail down the types of this object?\n// The WebSocket type is subtly different between browser and non-browser implementation, which makes this difficult.\nlet myws: any;\n\nif (typeof(WebSocket) === 'undefined') {\n  myws = require('ws');\n}\nelse {\n  myws = WebSocket;\n}\n\nlet ws: WebSocket = null;\nlet intervalTimer: NodeJS.Timer;\n\nexport const STATUS_DOWN = 'down';\nexport const STATUS_UP = 'up';\nexport const STATUS_RESET = 'reset';\n\nexport interface ISystemData {\n  users: List<IUserModel>;\n  tags: List<ITagModel>;\n  taggingSensitivities: List<ITaggingSensitivityModel>;\n  rules: List<IRuleModel>;\n  preselects: List<IPreselectModel>;\n}\n\nexport interface IAllArticlesData {\n  categories: Array<ICategoryModel>;\n  articles: Array<IArticleModel>;\n}\n\nexport interface IArticleUpdate {\n  categories?: Array<ICategoryModel>;\n  articles?: Array<IArticleModel>;\n}\n\nexport interface IPerUserData {\n  assignments: number;\n}\n\n// TODO: Ideally we'd have a type file describing types sent over the wire.\n//       When this is availabe, replace the \"any\" types in the code below.\nfunction packSystemData(data: any): ISystemData {\n  return {\n    users: List<IUserModel>(data.users.map((u: any) => {\n      return UserModel(u);\n    })),\n    tags: List<ITagModel>(data.tags.map((t: any) => {\n      return TagModel(t);\n    })),\n    taggingSensitivities: List<ITaggingSensitivityModel>(data.taggingSensitivities.map((t: any) => {\n      return TaggingSensitivityModel(t);\n    })),\n    rules: List<IRuleModel>(data.rules.map((r: any) => {\n      return RuleModel(r);\n    })),\n    preselects: List<IPreselectModel>(data.preselects.map((p: any) => {\n      return PreselectModel(p);\n    })),\n  };\n}\n\nfunction packArticleData(data: any): IAllArticlesData {\n\n  const categories = data.categories.map((c: any) => {\n    return CategoryModel(c);\n  });\n\n  const articles = data.articles.map((a: any) => {\n    return ArticleModel(a);\n  });\n\n  return {\n    categories: categories,\n    articles: articles,\n  };\n}\n\nfunction packArticleUpdate(data: any): IArticleUpdate {\n  let categories;\n  let articles;\n  if (data.categories) {\n    categories = data.categories.map((c: any) => {\n      return CategoryModel(c);\n    });\n  }\n\n  if (data.articles) {\n    articles = data.articles.map((a: any) => {\n      return ArticleModel(a);\n    });\n  }\n\n  return {\n    categories: categories,\n    articles: articles,\n  };\n}\n\nlet gotSystem = false;\nlet gotArticles = false;\nlet gotUser = false;\nlet socketUp = false;\n\nexport function connectNotifier(\n  websocketStateHandler: (status: string) => void,\n  systemDataHandler: (data: ISystemData) => void,\n  allArticlesDataHandler: (data: IAllArticlesData) => void,\n  articleUpdateHandler: (data: IArticleUpdate) => void,\n  perUserDataHandler: (data: IPerUserData) => void) {\n  function checkSocketAlive() {\n    if (!ws || ws.readyState !== myws.OPEN) {\n      const token = getToken();\n      const baseurl = serviceURL(`updates/summary/?token=${token}`);\n      const url = baseurl.replace(/^http/, 'ws');\n\n      ws = new myws(url);\n      ws.onopen = () => {\n        console.log('opened websocket');\n\n        ws.onclose = (e: {code: number}) => {\n          console.log('websocket closed', e.code);\n\n          if (e.code === 1005) {\n            // Although the meaning of these codes is not clear, it seems that this code means that the server\n            // is up and running but rejecting our request, probably due to an authentication issue.\n            // We'll need to reset everything and start again.\n            websocketStateHandler(STATUS_RESET);\n            return;\n          }\n\n          socketUp = false;\n          if (!gotSystem && !gotArticles && !gotUser) {\n            // Never got a message.  Server is rejecting our advances.  Log out and try logging in again.\n            websocketStateHandler(STATUS_RESET);\n          }\n          else {\n            websocketStateHandler(STATUS_DOWN);\n          }\n          ws = null;\n        };\n      };\n\n      ws.onmessage = (message: {data: string}) => {\n        const body: any = JSON.parse(message.data);\n\n        if (body.type === 'system') {\n          systemDataHandler(packSystemData(body.data));\n          gotSystem = true;\n        }\n        else if (body.type === 'global') {\n          allArticlesDataHandler(packArticleData(body.data));\n          gotArticles = true;\n        }\n        else if (body.type === 'user') {\n          perUserDataHandler(body.data as IPerUserData);\n          gotUser = true;\n        }\n        else if (body.type === 'article-update') {\n          articleUpdateHandler(packArticleUpdate(body.data));\n        }\n        if (gotSystem && gotArticles && gotUser && !socketUp) {\n          websocketStateHandler(STATUS_UP);\n          socketUp = true;\n        }\n      };\n    }\n  }\n\n  checkSocketAlive();\n  intervalTimer = setInterval(checkSocketAlive, 10000);\n}\n\nexport function disconnectNotifier() {\n  clearInterval(intervalTimer);\n  ws.close();\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Comments/Comments.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport React from 'react';\nimport {Redirect, Route, Switch, useRouteMatch} from 'react-router';\n\nimport {\n  HeaderBar,\n} from '../../components';\nimport {\n  HEADER_HEIGHT,\n  WHITE_COLOR,\n} from '../../styles';\nimport { css, stylesheet } from '../../utilx';\nimport {\n  NEW_COMMENTS_DEFAULT_TAG,\n} from '../routes';\nimport { CommentDetail } from './components/CommentDetail';\nimport { ModeratedComments } from './components/ModeratedComments';\nimport { NewComments } from './components/NewComments';\nimport { SubheaderBar } from './components/SubheaderBar';\nimport { ThreadedCommentDetail } from './components/ThreadedCommentDetail';\n\nfunction redirect(to: string) {\n  return () => {\n    return <Redirect to={to}/>;\n  };\n}\n\nconst STYLES = stylesheet({\n  main: {\n    display: 'flex',\n    flexDirection: 'column',\n    height: '100%',\n  },\n});\n\nexport function Comments(_props: { }) {\n  const {url, path} = useRouteMatch('/:context/:contextId');\n\n  return (\n    <div {...css({height: '100%'})}>\n      <div {...css(STYLES.main)}>\n        <HeaderBar homeLink/>\n        <Route path={`${path}/:pt1/:pt2`}>\n          <SubheaderBar/>\n        </Route>\n        <div\n          {...css({\n            background: WHITE_COLOR,\n            height: `calc(100% - ${HEADER_HEIGHT * 2 + 12}px)`,\n            position: 'relative',\n            overflow: 'hidden',\n            WebkitOverflowScrolling: 'touch',\n          })}\n        >\n          <Switch>\n            <Route exact path={`${path}`} render={redirect(`${url}/new/${NEW_COMMENTS_DEFAULT_TAG}`)} />\n            <Route exact path={`${path}/new`} render={redirect(`${url}/new/${NEW_COMMENTS_DEFAULT_TAG}`)} />\n            <Route exact path={`${path}/moderated`} render={redirect(`${url}/moderated/approved`)} />\n            <Route path={`${path}/new/:tag`} component={NewComments}/>\n            <Route path={`${path}/moderated/:disposition`} component={ModeratedComments}/>\n            <Route path={`${path}/comments/:commentId/replies`} component={ThreadedCommentDetail}/>\n            <Route path={`${path}/comments/:commentId`} component={CommentDetail}/>\n          </Switch>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Comments/components/CommentDetail/CommentDetail.tsx",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { autobind } from 'core-decorators';\nimport FocusTrap from 'focus-trap-react';\nimport { List, Set } from 'immutable';\nimport keyboardJS from 'keyboardjs';\nimport qs from 'query-string';\nimport React from 'react';\nimport { RouteComponentProps, withRouter } from 'react-router';\nimport { Link } from 'react-router-dom';\n\nimport {\n  ICommentModel,\n  ICommentScoreModel,\n  ICommentSummaryScoreModel,\n  ITaggingSensitivityModel,\n  ITagModel,\n  IUserModel,\n  ModelId,\n} from '../../../../../models';\nimport {\n  IConfirmationAction,\n  IModerationAction,\n} from '../../../../../types';\nimport {\n  Arrow,\n  ArrowIcon,\n  ConfirmationCircle,\n  InfoIcon,\n  ModerateButtons,\n  ReplyIcon,\n  ScoresList,\n  Scrim,\n  SingleComment,\n  ToolTip,\n} from '../../../../components';\nimport { REQUIRE_REASON_TO_REJECT } from '../../../../config';\nimport {\n  COMMENTS_EDITABLE_FLAG,\n  MODERATOR_GUIDELINES_URL,\n  SUBMIT_FEEDBACK_URL,\n} from '../../../../config';\nimport { ICommentCacheProps } from '../../../../injectors/commentFetchQueue';\nimport { commentInjector } from '../../../../injectors/commentInjector';\nimport {\n  approveComments,\n  deferComments,\n  highlightComments,\n  ICommentActionFunction,\n  rejectComments,\n  resetComments,\n  tagComment,\n  tagCommentSummaryScores,\n  tagCommentWithAnnotation,\n  untagComment,\n} from '../../../../stores/commentActions';\nimport {\n  BASE_Z_INDEX,\n  BOTTOM_BORDER_TRANSITION,\n  BOX_DEFAULT_SPACING,\n  BUTTON_LINK_TYPE,\n  BUTTON_RESET,\n  DARK_COLOR,\n  DARK_SECONDARY_TEXT_COLOR,\n  DARK_TERTIARY_TEXT_COLOR,\n  DIVIDER_COLOR,\n  GUTTER_DEFAULT_SPACING,\n  HEADER_HEIGHT,\n  NICE_MIDDLE_BLUE,\n  PALE_COLOR,\n  SCRIM_STYLE,\n  SCRIM_Z_INDEX,\n  SELECT_Z_INDEX,\n  TEXT_OFFSET_DEFAULT_SPACING,\n  VISUALLY_HIDDEN,\n  WHITE_COLOR,\n} from '../../../../styles';\nimport { clearReturnSavedCommentRow, partial, setReturnSavedCommentRow, timeout } from '../../../../util';\nimport { css, stylesheet } from '../../../../utilx';\nimport {\n  commentDetailsPageLink,\n  commentRepliesDetailsLink,\n  ICommentDetailsPathParams,\n  isArticleContext,\n  NEW_COMMENTS_DEFAULT_TAG,\n  newCommentsPageLink,\n} from '../../../routes';\nimport {\n  getReducedScoresAboveThreshold,\n  getScoresAboveThreshold,\n  getSensitivitiesForCategory,\n} from '../../scoreFilters';\nimport { Shortcuts } from '../Shortcuts';\n\nconst actionMap: {\n  [key: string]: ICommentActionFunction;\n} = {\n  highlight: highlightComments,\n  approve: approveComments,\n  defer: deferComments,\n  reject: rejectComments,\n  reset: resetComments,\n};\n\nconst COMMENT_WRAPPER_WIDTH = 696;\nconst KEYBOARD_SHORTCUTS_POPUP_ID = 'keyboard-shortcuts';\nconst SCORES_POPUP_ID = 'scores-popup';\nconst CONFIRMATION_POPUP_ID = 'confirmation-popup';\nconst INFO_DROPDOWN_ID = 'info-dropdown';\nconst APPROVE_SHORTCUT = 'alt + a';\nconst REJECT_SHORTCUT = 'alt + r';\nconst DEFER_SHORTCUT = 'alt + d';\nconst HIGHLIGHT_SHORTCUT = 'alt + h';\nconst ESCAPE_SHORTCUT = 'escape';\nconst PREV_SHORTCUT = 'alt + up';\nconst NEXT_SHORTCUT = 'alt + down';\n\nconst STYLES = stylesheet({\n  wrapper: {\n    height: '100%',\n  },\n\n  commentWrapper: {\n    display: 'flex',\n    position: 'relative',\n    boxSizing: 'border-box',\n    padding: '0 142px 0 76px',\n    height: '100%',\n    overflowY: 'scroll',\n  },\n\n  comment: {\n    padding: `${TEXT_OFFSET_DEFAULT_SPACING}px 0`,\n    width: '100%',\n    maxWidth: `${COMMENT_WRAPPER_WIDTH}px`,\n    margin: '0 auto',\n  },\n\n  sidebar: {\n    position: 'fixed',\n    display: 'flex',\n    top: HEADER_HEIGHT,\n    bottom: 10,\n    right: GUTTER_DEFAULT_SPACING,\n    zIndex: SELECT_Z_INDEX,\n  },\n\n  buttons: {\n    alignSelf: 'center',\n  },\n\n  pagers: {\n    width: '58px',\n    position: 'absolute',\n    bottom: '0px',\n    right: '0px',\n  },\n\n  popup: {\n    ...SCRIM_STYLE.popup,\n    width: '100%',\n    minWidth: '500px',\n    maxHeight: '600px',\n  },\n\n  infoTrigger: {\n    position: 'fixed',\n    bottom: '24px',\n    left: '24px',\n    background: 'none',\n    border: '0px',\n    cursor: 'pointer',\n    ':focus': {\n      outline: 0,\n    },\n  },\n\n  infoList: {\n    listStyle: 'none',\n    padding: '5px',\n    width: '200px',\n  },\n\n  infoTooltipButton: {\n    ...BUTTON_LINK_TYPE,\n    width: '100%',\n    textAlign: 'left',\n    paddingLeft: `${BOX_DEFAULT_SPACING}px`,\n    paddingRight: `${BOX_DEFAULT_SPACING}px`,\n    background: 'none',\n    border: '0px',\n    color: DARK_COLOR,\n    cursor: 'pointer',\n  },\n\n  scrim: {\n    zIndex: SCRIM_Z_INDEX,\n  },\n\n  scrim1: {\n    zIndex: BASE_Z_INDEX,\n  },\n\n  subHeading: {\n    ...BUTTON_RESET,\n    cursor: 'pointer',\n    display: 'flex',\n    alignItems: 'center',\n    background: PALE_COLOR,\n    height: HEADER_HEIGHT,\n    paddingLeft: GUTTER_DEFAULT_SPACING,\n    paddingRight: GUTTER_DEFAULT_SPACING,\n    position: 'absolute',\n    width: '100%',\n    zIndex: BASE_Z_INDEX,\n    textDecoration: 'none',\n    ':focus': {\n      outline: 0,\n      textDecoration: 'underline',\n    },\n  },\n\n  selectedInfo: {\n    ...BOTTOM_BORDER_TRANSITION,\n    marginLeft: GUTTER_DEFAULT_SPACING,\n    color: DARK_COLOR,\n  },\n\n  replyButton: {\n    border: 'none',\n    backgroundColor: 'transparent',\n    color: DARK_COLOR,\n    ':focus': {\n      outline: 0,\n      textDecoration: 'underline',\n    },\n  },\n\n  replyIcon: {\n    marginRight: `${BOX_DEFAULT_SPACING}px`,\n    display: 'inline-block',\n    transform: 'translateY(-4px)',\n  },\n\n  loadIcon: {\n    fill: DARK_COLOR,\n    display: 'flex',\n    margin: '50% auto 0 auto',\n  },\n\n  replyToContainer: {\n    borderBottom: `2px solid ${DIVIDER_COLOR}`,\n    height: HEADER_HEIGHT,\n    display: 'flex',\n    alignItems: 'center',\n  },\n\n  resultsHeader: {\n    alignItems: 'center',\n    backgroundColor: PALE_COLOR,\n    color: NICE_MIDDLE_BLUE,\n    display: 'flex',\n    flexWrap: 'no-wrap',\n    justifyContent: 'space-between',\n  },\n\n  resultsHeadline: {\n    marginLeft: '29px',\n  },\n\n  resultsLink: {\n    color: NICE_MIDDLE_BLUE,\n    cursor: 'pointer',\n  },\n\n  paginationArrow: {\n    display: 'block',\n    ':focus': {\n      outline: 0,\n      textDecoration: 'underline,',\n    },\n  },\n\n  confirmationPopup: {\n    ':focus': {\n      outline: 0,\n    },\n  },\n});\n\ninterface IReplyLinkProps extends RouteComponentProps<ICommentDetailsPathParams>, ICommentCacheProps {\n  commentId: ModelId;\n  parent: ICommentModel;\n}\n\nfunction _ReplyLink(props: IReplyLinkProps) {\n  const comment = props.comment;\n  return (\n    <div {...css(STYLES.replyToContainer)}>\n      <Link\n        to={commentRepliesDetailsLink({\n          context: props.match.params.context,\n          contextId: props.match.params.contextId,\n          commentId: comment.id,\n        })}\n        {...css(STYLES.replyButton)}\n      >\n        <div {...css(STYLES.replyIcon)}>\n          <ReplyIcon {...css({fill: DARK_COLOR})} size={24} />\n        </div>\n        This is a reply to {comment.author && comment.author.name}\n      </Link>\n    </div>\n  );\n}\n\nconst ReplyLink = withRouter(commentInjector(_ReplyLink));\n\nexport interface ICommentDetailProps extends RouteComponentProps<ICommentDetailsPathParams> {\n  comment: ICommentModel;\n  availableTags: List<ITagModel>;\n  allScores?: Array<ICommentScoreModel>;\n  taggingSensitivities: List<ITaggingSensitivityModel>;\n  currentCommentIndex?: number;\n  nextCommentId?: string;\n  previousCommentId?: string;\n  isFromBatch?: boolean;\n  loadData?(commentId: string): void;\n  loadScores?(commentId: string): void;\n  getUserById?(id: string | number): IUserModel;\n  currentUser: IUserModel;\n  detailSource?: string;\n  linkBackToList?: string;\n}\n\nexport interface ICommentDetailState {\n  taggingSensitivitiesInCategory?: List<ITaggingSensitivityModel>;\n  allScoresAboveThreshold?: Array<ICommentScoreModel>;\n  reducedScoresAboveThreshold?: Array<ICommentScoreModel>;\n  loadedCommentId?: string;\n  isKeyboardModalVisible?: boolean;\n  isConfirmationModalVisible?: boolean;\n  isScoresModalVisible?: boolean;\n  scoresSelectedByTag?: Array<ICommentScoreModel>;\n  thresholdByTag?: ITaggingSensitivityModel;\n  confirmationAction?: IConfirmationAction;\n  isInfoDropdownVisible?: boolean;\n  infoToolTipPosition?: {\n    top: number,\n    left: number,\n  };\n  upArrowIsFocused?: boolean;\n  downArrowIsFocused?: boolean;\n  infoIconFocused?: boolean;\n  selectedRow?: number;\n  taggingCommentId?: string;\n}\n\nexport class CommentDetail extends React.Component<ICommentDetailProps, ICommentDetailState> {\n\n  state: ICommentDetailState = {\n    isKeyboardModalVisible: false,\n    isConfirmationModalVisible: false,\n    confirmationAction: null,\n    isInfoDropdownVisible: false,\n    isScoresModalVisible: false,\n    scoresSelectedByTag: null,\n    thresholdByTag: null,\n    infoToolTipPosition: {\n      top: 0,\n      left: 0,\n    },\n    upArrowIsFocused: false,\n    downArrowIsFocused: false,\n    infoIconFocused: false,\n    taggingCommentId: null,\n  };\n\n  buttonRef: HTMLElement = null;\n\n  componentDidMount() {\n    this.attachEvents();\n  }\n\n  componentWillUnmount() {\n    this.detachEvents();\n  }\n\n  static getDerivedStateFromProps(nextProps: ICommentDetailProps, prevState: ICommentDetailState) {\n    const categoryId = (nextProps.comment && nextProps.comment.categoryId) || 'na';\n    const sensitivities = getSensitivitiesForCategory(categoryId, nextProps.taggingSensitivities);\n\n    const allScoresAboveThreshold = getScoresAboveThreshold(sensitivities, nextProps.allScores);\n    const reducedScoresAboveThreshold = getReducedScoresAboveThreshold(sensitivities, nextProps.allScores);\n\n    if (prevState.loadedCommentId !== nextProps.match.params.commentId) {\n      nextProps.loadData(nextProps.match.params.commentId);\n    }\n\n    return {\n      taggingSensitivitiesInCategory: sensitivities,\n      allScoresAboveThreshold,\n      reducedScoresAboveThreshold,\n      loadedCommentId: nextProps.match.params.commentId,\n    };\n  }\n\n  @autobind\n  onFocusUpArrow() {\n    this.setState({ upArrowIsFocused: true });\n  }\n\n  @autobind\n  onBlurUpArrow() {\n    this.setState({ upArrowIsFocused: false });\n  }\n\n  @autobind\n  onFocusDownArrow() {\n    this.setState({ downArrowIsFocused: true });\n  }\n\n  @autobind\n  onBlurDownArrow() {\n    this.setState({ downArrowIsFocused: false });\n  }\n\n  @autobind\n  onFocusInfoIcon() {\n    this.setState({ infoIconFocused: true });\n  }\n\n  @autobind\n  onBlurInfoIcon() {\n    this.setState({ infoIconFocused: false });\n  }\n\n  @autobind\n  saveButtonRef(ref: HTMLButtonElement) {\n    this.buttonRef = ref;\n  }\n\n  @autobind\n  saveReturnRow(commentId: string): void {\n    setReturnSavedCommentRow(commentId);\n  }\n\n  @autobind\n  async handleAssignTagsSubmit(commentId: ModelId, selectedTagIds: Set<ModelId>) {\n    selectedTagIds.forEach((tagId) => {\n      tagCommentSummaryScores([commentId], tagId);\n    });\n    this.moderateComment('reject');\n    this.setState({\n      taggingCommentId: null,\n    });\n  }\n\n  render() {\n    const {\n      comment,\n      availableTags,\n      allScores,\n      currentCommentIndex,\n      nextCommentId,\n      previousCommentId,\n      loadScores,\n      getUserById,\n      detailSource,\n      linkBackToList,\n      currentUser,\n      match: {params},\n    } = this.props;\n\n    const {\n      allScoresAboveThreshold,\n      reducedScoresAboveThreshold,\n      isKeyboardModalVisible,\n      isConfirmationModalVisible,\n      isScoresModalVisible,\n      scoresSelectedByTag,\n      thresholdByTag,\n      confirmationAction,\n      isInfoDropdownVisible,\n      infoToolTipPosition,\n      upArrowIsFocused,\n      downArrowIsFocused,\n      infoIconFocused,\n    } = this.state;\n\n    if (!comment) {\n      return null;\n    }\n\n    const activeButtons = this.getActiveButtons(this.props.comment);\n\n    const batchURL = newCommentsPageLink({\n      context: params.context,\n      contextId: params.contextId,\n      tag: NEW_COMMENTS_DEFAULT_TAG,\n    });\n\n    return (\n      <div {...css({ height: '100%' })}>\n        <div>\n          { detailSource && (typeof currentCommentIndex === 'number') ? (\n            <Link to={linkBackToList} {...css(STYLES.subHeading)}>\n              <ArrowIcon direction=\"left\" {...css({fill: DARK_COLOR, margin: 'auto 0'})} size={24} />\n              <p {...css(STYLES.selectedInfo)}>\n                {detailSource.replace('%i', (currentCommentIndex + 1).toString())}\n              </p>\n            </Link>\n          ) : (\n            <Link to={batchURL} {...css(STYLES.subHeading)}>\n              <ArrowIcon direction=\"left\" {...css({fill: DARK_COLOR, margin: 'auto 0'})} size={24} />\n              <p {...css(STYLES.selectedInfo)}>\n                {`Back to ${isArticleContext(params) ? 'article' : 'category'}`}\n              </p>\n            </Link>\n          )}\n        </div>\n\n        <div {...css(STYLES.wrapper)}>\n          <div {...css(STYLES.sidebar)}>\n            <div {...css(STYLES.buttons)}>\n              <ModerateButtons\n                vertical\n                activeButtons={activeButtons}\n                onClick={this.moderateComment}\n                requireReasonForReject={comment.isAccepted === false ? false : REQUIRE_REASON_TO_REJECT}\n                comment={comment}\n                handleAssignTagsSubmit={this.handleAssignTagsSubmit}\n              />\n            </div>\n            { (previousCommentId || nextCommentId) && (\n              <div {...css(STYLES.pagers)}>\n                { previousCommentId ? (\n                  <Link\n                    {...css(STYLES.paginationArrow)}\n                    to={this.generatePagingLink(previousCommentId)}\n                    onFocus={this.onFocusUpArrow}\n                    onBlur={this.onBlurUpArrow}\n                    onClick={partial(this.saveReturnRow, previousCommentId)}\n                  >\n                    <Arrow\n                      direction={'up'}\n                      label={'up arrow'}\n                      size={58}\n                      color={upArrowIsFocused ? NICE_MIDDLE_BLUE : DARK_TERTIARY_TEXT_COLOR}\n                      icon={<ArrowIcon {...css({ fill: upArrowIsFocused ? NICE_MIDDLE_BLUE : DARK_TERTIARY_TEXT_COLOR })} size={24} />}\n                    />\n                    <span {...css(VISUALLY_HIDDEN)}>Previous Comment</span>\n                  </Link>\n                ) : (\n                  <Arrow\n                    isDisabled\n                    direction={'up'}\n                    label={'up arrow'}\n                    size={58}\n                    color={DARK_TERTIARY_TEXT_COLOR}\n                    icon={<ArrowIcon {...css({ fill: DARK_TERTIARY_TEXT_COLOR })} size={24} />}\n                  />\n                )}\n\n                { nextCommentId ? (\n                  <Link\n                    {...css(STYLES.paginationArrow)}\n                    to={this.generatePagingLink(nextCommentId)}\n                    onFocus={this.onFocusDownArrow}\n                    onBlur={this.onBlurDownArrow}\n                    onClick={partial(this.saveReturnRow, nextCommentId)}\n                  >\n                    <Arrow\n                      direction={'down'}\n                      label={'down arrow'}\n                      size={58}\n                      color={upArrowIsFocused ? NICE_MIDDLE_BLUE : DARK_TERTIARY_TEXT_COLOR}\n                      icon={<ArrowIcon {...css({ fill: downArrowIsFocused ? NICE_MIDDLE_BLUE : DARK_TERTIARY_TEXT_COLOR })} size={24} />}\n                    />\n                    <span {...css(VISUALLY_HIDDEN)}>Next Comment</span>\n                  </Link>\n                ) : (\n                  <Arrow\n                    isDisabled\n                    direction={'down'}\n                    label={'down arrow'}\n                    size={58}\n                    color={DARK_TERTIARY_TEXT_COLOR}\n                    icon={<ArrowIcon {...css({fill: DARK_TERTIARY_TEXT_COLOR})} size={24} />}\n                  />\n                )}\n              </div>\n            )}\n          </div>\n\n          <div {...css(STYLES.commentWrapper)}>\n            <div {...css(STYLES.comment)}>\n              { comment.replyId && (\n                <ReplyLink parent={comment} commentId={comment.replyId}/>\n              )}\n              <SingleComment\n                comment={comment}\n                allScores={allScores}\n                allScoresAboveThreshold={allScoresAboveThreshold}\n                reducedScoresAboveThreshold={reducedScoresAboveThreshold}\n                availableTags={availableTags}\n                loadScores={loadScores}\n                getUserById={getUserById}\n                onScoreClick={this.handleScoreClick}\n                onTagButtonClick={this.onTagButtonClick}\n                onCommentTagClick={this.onCommentTagClick}\n                onAnnotateTagButtonClick={this.onAnnotateTagButtonClick}\n                currentUser={currentUser}\n                commentEditingEnabled={COMMENTS_EDITABLE_FLAG}\n              />\n            </div>\n          </div>\n\n          <Scrim\n            key=\"keyboardScrim\"\n            scrimStyles={{...STYLES.scrim, ...SCRIM_STYLE.scrim}}\n            isVisible={isKeyboardModalVisible}\n            onBackgroundClick={this.onKeyboardClose}\n          >\n            <FocusTrap\n              focusTrapOptions={{\n                clickOutsideDeactivates: true,\n              }}\n            >\n              <div key=\"keyboardContainer\" id={KEYBOARD_SHORTCUTS_POPUP_ID} {...css(STYLES.popup)}>\n                {/* keyboard shortcuts */}\n                <Shortcuts onClose={this.onKeyboardClose}/>\n              </div>\n            </FocusTrap>\n          </Scrim>\n\n          <Scrim\n            key=\"confirmationScrim\"\n            scrimStyles={{...STYLES.scrim, ...SCRIM_STYLE.scrim}}\n            isVisible={isConfirmationModalVisible}\n            onBackgroundClick={this.closeToast}\n          >\n            <div id={CONFIRMATION_POPUP_ID} tabIndex={0} {...css(STYLES.confirmationPopup)}>\n              {/* Confirmation popup */}\n              <ConfirmationCircle backgroundColor={DARK_COLOR} action={confirmationAction} size={120} iconSize={40} />\n            </div>\n          </Scrim>\n\n          {/* ToolTip and Scrim */}\n          <Scrim\n            key=\"tooltipScrim\"\n            scrimStyles={STYLES.scrim1}\n            isVisible={isInfoDropdownVisible}\n            onBackgroundClick={this.onDropdownClose}\n            id={INFO_DROPDOWN_ID}\n          >\n            <ToolTip\n              hasDropShadow\n              backgroundColor={WHITE_COLOR}\n              arrowPosition=\"leftBottom\"\n              size={16}\n              isVisible={isInfoDropdownVisible}\n              position={infoToolTipPosition}\n              zIndex={SCRIM_Z_INDEX}\n            >\n              <ul {...css(STYLES.infoList)}>\n                <li>\n                  <button\n                    {...css(STYLES.infoTooltipButton)}\n                    onClick={this.onKeyboardOpen}\n                  >\n                    Keyboard Shortcuts\n                  </button>\n                </li>\n                {MODERATOR_GUIDELINES_URL && (\n                  <li>\n                    <a\n                      {...css(STYLES.infoTooltipButton)}\n                      href={MODERATOR_GUIDELINES_URL}\n                      target=\"_blank\"\n                    >\n                      Moderator Guidelines\n                    </a>\n                  </li>\n                )}\n                {SUBMIT_FEEDBACK_URL && (\n                  <li>\n                    <a\n                      {...css(STYLES.infoTooltipButton)}\n                      href={SUBMIT_FEEDBACK_URL}\n                      target=\"_blank\"\n                    >\n                      Submit Feedback\n                    </a>\n                  </li>\n                )}\n              </ul>\n            </ToolTip>\n          </Scrim>\n          <button\n            tabIndex={0}\n            ref={this.saveButtonRef}\n            {...css(STYLES.infoTrigger)}\n            onClick={this.onDropdownOpen}\n            onFocus={this.onFocusInfoIcon}\n            onBlur={this.onBlurInfoIcon}\n          >\n            <InfoIcon {...css({fill: infoIconFocused ? NICE_MIDDLE_BLUE : DARK_SECONDARY_TEXT_COLOR})} />\n            <span {...css(VISUALLY_HIDDEN)}>Tag Information</span>\n          </button>\n        </div>\n\n        <Scrim\n          key=\"scoresScrim\"\n          scrimStyles={{...STYLES.scrim, ...SCRIM_STYLE.scrim}}\n          isVisible={isScoresModalVisible}\n          onBackgroundClick={this.onScoresModalClose}\n        >\n          <FocusTrap\n            focusTrapOptions={{\n              clickOutsideDeactivates: true,\n            }}\n          >\n            <div\n              key=\"scoresContainer\"\n              id={SCORES_POPUP_ID}\n              {...css(\n                STYLES.popup,\n                { width: `${COMMENT_WRAPPER_WIDTH}px` },\n              )}\n            >\n              {/* All scores popup */}\n              <ScoresList\n                comment={comment}\n                scores={scoresSelectedByTag}\n                threshold={thresholdByTag}\n                onClose={this.onScoresModalClose}\n              />\n            </div>\n          </FocusTrap>\n        </Scrim>\n      </div>\n    );\n  }\n\n  generatePagingLink(commentId: string) {\n    const pagingIdentifier: string = qs.parse(this.props.location.search).pagingIdentifier as string;\n    const params = this.props.match.params;\n    const urlParams = {\n      context: params.context,\n      contextId: params.contextId,\n      commentId,\n    };\n    const query = pagingIdentifier && {pagingIdentifier};\n    return commentDetailsPageLink(urlParams, query);\n  }\n\n  @autobind\n  calculateInfoTrigger(ref: any) {\n    if (!ref) {\n      return;\n    }\n\n    const infoIconRect = ref.getBoundingClientRect();\n\n    this.setState({\n      infoToolTipPosition: {\n        // get height of tooltip, use that to offset\n        top: (infoIconRect.bottom - 24),\n        left: infoIconRect.right,\n      },\n    });\n  }\n\n  @autobind\n  onResize() {\n    this.calculateInfoTrigger(this.buttonRef);\n  }\n\n  @autobind\n  onKeyboardOpen() {\n    this.setState({ isKeyboardModalVisible: true });\n  }\n\n  @autobind\n  onKeyboardClose() {\n    this.setState({ isKeyboardModalVisible: false });\n  }\n\n  @autobind\n  onDropdownOpen() {\n    this.setState({ isInfoDropdownVisible: true });\n    this.calculateInfoTrigger(this.buttonRef);\n  }\n\n  @autobind\n  onDropdownClose() {\n    this.setState({ isInfoDropdownVisible: false });\n  }\n\n  @autobind\n  onScoresModalClose() {\n    this.setState({ isScoresModalVisible: false });\n  }\n\n  @autobind\n  attachEvents() {\n    keyboardJS.bind(APPROVE_SHORTCUT, this.approveComment);\n    keyboardJS.bind(REJECT_SHORTCUT, this.rejectComment);\n    keyboardJS.bind(DEFER_SHORTCUT, this.deferComment);\n    keyboardJS.bind(HIGHLIGHT_SHORTCUT, this.highlightComment);\n    keyboardJS.bind(ESCAPE_SHORTCUT, this.onPressEscape);\n    keyboardJS.bind(PREV_SHORTCUT, this.goToPrevComment);\n    keyboardJS.bind(NEXT_SHORTCUT, this.goToNextComment);\n\n    window.addEventListener('resize', this.onResize);\n  }\n\n  @autobind\n  detachEvents() {\n    keyboardJS.unbind(APPROVE_SHORTCUT, this.approveComment);\n    keyboardJS.unbind(REJECT_SHORTCUT, this.rejectComment);\n    keyboardJS.unbind(DEFER_SHORTCUT, this.deferComment);\n    keyboardJS.unbind(HIGHLIGHT_SHORTCUT, this.highlightComment);\n    keyboardJS.unbind(ESCAPE_SHORTCUT, this.onPressEscape);\n    keyboardJS.unbind(PREV_SHORTCUT, this.goToPrevComment);\n    keyboardJS.unbind(NEXT_SHORTCUT, this.goToNextComment);\n    window.removeEventListener('resize', this.onResize);\n  }\n\n  @autobind\n  onBackClick() {\n    window.history.back();\n  }\n\n  @autobind\n  async moderateComment(action: IModerationAction) {\n    const activeButtons = this.getActiveButtons(this.props.comment);\n    const shouldResetAction = activeButtons.includes(action);\n    const commentAction: IConfirmationAction = shouldResetAction ? 'reset' : action;\n    this.setState({\n      isConfirmationModalVisible: true,\n      confirmationAction: commentAction,\n    });\n\n    await actionMap[commentAction]([this.props.comment.id]);\n    await timeout(2000);\n\n    if (this.props.loadScores) {\n      await this.props.loadScores(this.props.comment.id);\n    }\n\n    this.closeToast();\n\n    // clear saved for batch view, since this one has now been moderated.\n    clearReturnSavedCommentRow();\n\n    if (this.props.isFromBatch) {\n      this.goToNextComment();\n    }\n  }\n\n  @autobind\n  approveComment() {\n    return this.moderateComment('approve');\n  }\n\n  @autobind\n  rejectComment() {\n    return this.moderateComment('reject');\n  }\n\n  @autobind\n  deferComment() {\n    return this.moderateComment('defer');\n  }\n\n  @autobind\n  highlightComment() {\n    return this.moderateComment('highlight');\n  }\n\n  @autobind\n  goToPrevComment() {\n    const { previousCommentId } = this.props;\n\n    if (!previousCommentId) {\n      return;\n    }\n\n    this.saveReturnRow(previousCommentId);\n    this.props.history.push(this.generatePagingLink(previousCommentId));\n  }\n\n  @autobind\n  goToNextComment() {\n    const { nextCommentId } = this.props;\n\n    if (!nextCommentId) {\n      return;\n    }\n\n    this.saveReturnRow(nextCommentId);\n    this.props.history.push(this.generatePagingLink(nextCommentId));\n  }\n\n  @autobind\n  async onTagButtonClick(tagId: string) {\n    await tagComment(this.props.comment.id, tagId);\n    await this.props.loadScores(this.props.comment.id);\n    this.closeToast();\n  }\n\n  @autobind\n  async onAnnotateTagButtonClick(tag: string, start: number, end: number): Promise<any> {\n    await tagCommentWithAnnotation(this.props.comment.id, tag, start, end);\n    await this.props.loadScores(this.props.comment.id);\n    this.closeToast();\n  }\n\n  @autobind\n  async onCommentTagClick(commentScore: ICommentScoreModel) {\n    await untagComment(this.props.comment.id, commentScore.id);\n    this.closeToast();\n  }\n\n  @autobind\n  closeToast() {\n    this.setState({isConfirmationModalVisible: false});\n  }\n\n  getActiveButtons(comment: ICommentModel): List<IModerationAction> {\n    if (!comment) {\n      return null;\n    }\n    let activeButtons = List();\n\n    if (comment.isAccepted === true) {\n      activeButtons = List(['approve']);\n    }\n    if (comment.isAccepted === false) {\n      activeButtons = List(['reject']);\n    }\n    if (comment.isHighlighted) {\n      activeButtons = activeButtons.push('highlight');\n    }\n    if (comment.isDeferred) {\n      activeButtons = List(['defer']);\n    }\n\n    return activeButtons as List<IModerationAction>;\n  }\n\n  @autobind\n  handleScoreClick(scoreClicked: ICommentSummaryScoreModel) {\n    const thresholdByTag = this.state.taggingSensitivitiesInCategory.find(\n      (ts) => ts.tagId === scoreClicked.tagId || ts.categoryId === null);\n    const scoresSelectedByTag = this.props.allScores.filter(\n      (score) => score.tagId === scoreClicked.tagId,\n    ).sort((a, b) => b.score - a.score);\n\n    this.setState({\n      isScoresModalVisible: true,\n      scoresSelectedByTag,\n      thresholdByTag,\n    });\n  }\n\n  @autobind\n  onPressEscape() {\n    if (this.state.isKeyboardModalVisible) {\n      this.onKeyboardClose();\n    }\n\n    if (this.state.isInfoDropdownVisible) {\n      this.onDropdownClose();\n    }\n\n    if (this.state.isScoresModalVisible) {\n      this.onScoresModalClose();\n    }\n  }\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Comments/components/CommentDetail/components/InfoButton/InfoButton.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Comments/components/CommentDetail/components/InfoButton/index.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Comments/components/CommentDetail/index.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { Location } from 'history';\nimport qs from 'query-string';\nimport { connect } from 'react-redux';\nimport { withRouter } from 'react-router';\nimport { compose } from 'redux';\nimport { createStructuredSelector } from 'reselect';\n\nimport { IAppDispatch, IAppState } from '../../../../appstate';\nimport { commentFromRouteInjector } from '../../../../injectors/commentInjector';\nimport { getTaggingSensitivities } from '../../../../stores/taggingSensitivities';\nimport { getTaggableTags } from '../../../../stores/tags';\nimport { getCurrentUser, getUser } from '../../../../stores/users';\nimport { CommentDetail as PureCommentDetail, ICommentDetailProps } from './CommentDetail';\nimport {\n  getCurrentCommentIndex,\n  getNextCommentId,\n  getPagingIsFromBatch,\n  getPagingLink,\n  getPagingSource,\n  getPreviousCommentId,\n  getScores,\n  loadScores,\n} from './store';\n\ntype ICommentDetailOwnProps = Pick<ICommentDetailProps, 'match' | 'location'>;\n\ntype ICommentDetailDispatchProps = Pick<\n  ICommentDetailProps,\n  'loadData' |\n  'loadScores'\n>;\n\nfunction getPagingIdentifier(location: Location): string | null {\n  const query = qs.parse(location.search);\n  return query.pagingIdentifier as string | null;\n}\n\nconst mapStateToProps = createStructuredSelector({\n  availableTags: getTaggableTags,\n  allScores: getScores,\n  taggingSensitivities: getTaggingSensitivities,\n\n  currentCommentIndex: (\n    state: IAppState,\n    { match: { params: { commentId }}, location }: ICommentDetailOwnProps,\n  ) => {\n    return getCurrentCommentIndex(state, getPagingIdentifier(location), commentId);\n  },\n\n  nextCommentId: (\n    state: IAppState,\n    { match: { params: { commentId }}, location }: ICommentDetailOwnProps,\n  ) => {\n    return getNextCommentId(state, getPagingIdentifier(location), commentId);\n  },\n\n  previousCommentId: (\n    state: IAppState,\n    { match: { params: { commentId }}, location }: ICommentDetailOwnProps,\n  ) => {\n    return getPreviousCommentId(state, getPagingIdentifier(location), commentId);\n  },\n\n  detailSource: (state: IAppState, { location }: ICommentDetailOwnProps) => {\n    return getPagingSource(state, getPagingIdentifier(location));\n  },\n\n  linkBackToList: (state: IAppState, { location }: ICommentDetailOwnProps) => {\n    return getPagingLink(state, getPagingIdentifier(location));\n  },\n\n  isFromBatch: getPagingIsFromBatch,\n\n  getUserById: (state: IAppState) => (userId: string) => getUser(state, userId),\n\n  currentUser: getCurrentUser,\n});\n\nfunction mapDispatchToProps(dispatch: IAppDispatch): ICommentDetailDispatchProps {\n  return {\n    loadData: (commentId: string) => {\n      return Promise.all([\n        loadScores(dispatch, commentId),\n      ]);\n    },\n\n    loadScores: (commentId: string) => loadScores(dispatch, commentId),\n  };\n}\n\nexport const CommentDetail: React.ComponentClass = compose(\n  withRouter,\n  commentFromRouteInjector,\n  connect(mapStateToProps, mapDispatchToProps),\n)(PureCommentDetail);\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Comments/components/CommentDetail/store.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { fromJS, Map } from 'immutable';\nimport { combineReducers } from 'redux';\nimport { Action, createAction, handleActions } from 'redux-actions';\n\nimport {ICommentScoreModel, ModelId} from '../../../../../models';\nimport {IAppDispatch, IAppState} from '../../../../appstate';\nimport {getCommentScores} from '../../../../platform/dataService';\nimport {\n  addCommentScore,\n  removeAllCommentScores,\n  removeCommentScore,\n  updateCommentScore,\n} from '../../../../stores/globalActions';\n\nconst loadCommentScoresStart =\n  createAction('comment-detail/LOAD_COMMENT_SCORE_START');\nconst loadCommentScoresComplete =\n  createAction<Array<ICommentScoreModel>>('comment-detail/LOAD_COMMENT_SCORE_COMPLETE');\nexport const clearCommentPagingOptions: () => Action<void> =\n  createAction('comment-detail/CLEAR_COMMENT_PAGING_OPTIONS');\nconst internalStoreCommentPagingOptions =\n  createAction<ICommentPagingState>('comment-detail/STORE_COMMENT_PAGING_OPTIONS');\n\nexport async function loadScores(dispatch: IAppDispatch, id: string) {\n  await dispatch(loadCommentScoresStart());\n  const data = await getCommentScores(id);\n  await dispatch(loadCommentScoresComplete(data));\n}\n\nexport interface ICommentScoreState {\n  items: Array<ICommentScoreModel>;\n}\n\nconst initialScoreState: ICommentScoreState = {\n  items: [],\n};\n\nconst commentScoresReducer = handleActions<\n  ICommentScoreState,\n  void   | // startEvent\n  Array<ICommentScoreModel> | // endEvent\n  ICommentScoreModel | ModelId // addRecord, updateRecord, removeRecord\n  >( {\n  [loadCommentScoresStart.toString()]: (_state) => (initialScoreState),\n\n  [loadCommentScoresComplete.toString()]: (_state, { payload }: Action<Array<ICommentScoreModel>>) => ({\n    items: payload,\n  }),\n\n  [addCommentScore.toString()]: (state, { payload }: Action<ICommentScoreModel>) => ({\n    items: [...state.items, payload],\n  }),\n\n  [updateCommentScore.toString()]: (state, { payload }: Action<{id: ModelId} & Partial<ICommentScoreModel>>) => {\n    return {\n      items: state.items.map((i) => (payload.id === i.id ? {...i, ...payload} : i)),\n    };\n  },\n\n  [removeCommentScore.toString()]: (state, { payload }: Action<ModelId>) => {\n    return {\n      items: state.items.filter((i) => (i.id !== payload)),\n    };\n  },\n\n  [removeAllCommentScores.toString()]: () => {\n    return {\n      items: [],\n    };\n  },\n}, initialScoreState);\n\nexport interface ICommentPagingState {\n  commentIds: Array<ModelId>;\n  fromBatch: boolean;\n  source: string;\n  link: string;\n  hash?: string;\n  indexById?: Map<ModelId, number>;\n}\n\nexport type ICommentPagingStateRecord = Readonly<ICommentPagingState>;\n\nconst initialState: ICommentPagingStateRecord = {\n  commentIds: [],\n  fromBatch: null,\n  source: null,\n  hash: null,\n  indexById: Map<ModelId, number>(),\n  link: null,\n};\n\n// tslint:disable no-bitwise\nfunction hashString(str: string): string {\n  let hash = 0;\n\n  if (str.length > 0) {\n    for (let i = 0; i < str.length; i++) {\n      const chr = str.charCodeAt(i);\n      hash = ((hash << 5) - hash) + chr;\n      hash |= 0; // Convert to 32bit integer\n    }\n  }\n\n  return hash.toString(16);\n}\n// tslint:enable no-bitwise\n\nexport const storeCommentPagingOptions = (data: ICommentPagingState) => async (dispatch: any) => {\n  const immutableData = fromJS(data);\n\n  const hash = hashString(JSON.stringify(immutableData.toJSON()));\n\n  dispatch(internalStoreCommentPagingOptions({\n    ...data,\n    hash,\n  }));\n\n  return hash;\n};\n\nexport const commentPagingReducer = handleActions<\n  ICommentPagingStateRecord,\n  void                | // clearCommentPagingOptions\n  ICommentPagingState   // internalStoreCommentPagingOptions\n>({\n  [clearCommentPagingOptions.toString()]: () => initialState,\n\n  [internalStoreCommentPagingOptions.toString()]: (_, { payload }: Action<ICommentPagingState>) => {\n    const indexById = payload['commentIds'].reduce((sum, id, index) => sum.set(id, index), Map<ModelId, number>());\n    return { ...payload, indexById };\n  },\n}, initialState);\n\nexport function getCommentPagingRecord(state: IAppState) {\n  return state.scenes.comments.commentDetail.paging;\n}\n\nexport function getPagingIsFromBatch(state: IAppState) {\n  const commentPaging =  getCommentPagingRecord(state);\n  return commentPaging && commentPaging.fromBatch;\n}\n\nexport function getPagingSource(state: IAppState, currentHash: string) {\n  const hash = getPagingHash(state);\n  if (hash !== currentHash) { return; }\n  const commentPaging =  getCommentPagingRecord(state);\n  return commentPaging && commentPaging.source;\n}\n\nexport function getPagingLink(state: IAppState, currentHash: string) {\n  const hash = getPagingHash(state);\n  if (hash !== currentHash) { return; }\n  const commentPaging =  getCommentPagingRecord(state);\n  return commentPaging && commentPaging.link;\n}\n\nexport function getPagingHash(state: IAppState) {\n  const commentPaging =  getCommentPagingRecord(state);\n  return commentPaging && commentPaging.hash;\n}\n\nexport function getPagingCommentIds(state: IAppState) {\n  const commentPaging = getCommentPagingRecord(state);\n  return commentPaging && commentPaging.commentIds;\n}\n\nexport function getPagingCommentIndexes(state: IAppState) {\n  const commentPaging =  getCommentPagingRecord(state);\n  return commentPaging && commentPaging.indexById;\n}\n\nexport function getCurrentCommentIndex(state: IAppState, currentHash: string, commentId: string) {\n  const hash = getPagingHash(state);\n  if (hash !== currentHash) { return; }\n\n  const index = getPagingCommentIndexes(state).get(commentId);\n\n  if (typeof index !== 'undefined') {\n    return index;\n  } else {\n    return null;\n  }\n}\n\nexport function getNextCommentId(state: IAppState, currentHash: string, commentId: string) {\n  const hash = getPagingHash(state);\n  if (hash !== currentHash) { return; }\n\n  const ids = getPagingCommentIds(state);\n  const index = getPagingCommentIndexes(state).get(commentId);\n\n  if (typeof index !== 'undefined') {\n    const nextIndex = index + 1;\n\n    if (nextIndex > (ids.length - 1)) { return null; }\n\n    return ids[nextIndex];\n  }\n}\n\nexport function getPreviousCommentId(state: IAppState, currentHash: string, commentId: string) {\n  const hash = getPagingHash(state);\n  if (hash !== currentHash) { return; }\n\n  const ids = getPagingCommentIds(state);\n  const index = getPagingCommentIndexes(state).get(commentId);\n\n  if (typeof index !== 'undefined') {\n    const nextIndex = index - 1;\n\n    if (nextIndex < 0) { return null; }\n\n    return ids[nextIndex];\n  }\n}\n\nexport type ICommentDetailState = Readonly<{\n  scores: ICommentScoreState;\n  paging: ICommentPagingState;\n}>;\n\nexport const reducer = combineReducers<ICommentDetailState>({\n  scores: commentScoresReducer,\n  paging: commentPagingReducer,\n});\n\n/* Set or delete items in the comment detail store created by makeRecordListReducer */\n\nexport function getScores(state: IAppState) {\n  return state.scenes.comments.commentDetail.scores.items;\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Comments/components/ModeratedComments/ModeratedComments.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { Collapse } from '@material-ui/core';\nimport { autobind } from 'core-decorators';\nimport FocusTrap from 'focus-trap-react';\nimport { List, Set } from 'immutable';\nimport keyboardJS from 'keyboardjs';\nimport { isEqual } from 'lodash';\nimport qs from 'query-string';\nimport React from 'react';\nimport { RouteComponentProps } from 'react-router';\nimport {\n  ITagModel,\n  ModelId,\n  TagModel,\n} from '../../../../../models';\nimport { ICommentAction, IConfirmationAction } from '../../../../../types';\nimport {\n  AddIcon,\n  ApproveIcon,\n  ArticleControlIcon,\n  CommentActionButton,\n  CommentList,\n  DeferIcon,\n  HighlightIcon,\n  RejectIcon,\n  Scrim,\n  ToastMessage,\n  ToolTip,\n} from '../../../../components';\nimport { IContextInjectorProps } from '../../../../injectors/contextInjector';\nimport { IModeratedComments, updateArticle } from '../../../../platform/dataService';\nimport {\n  approveComments,\n  approveFlagsAndComments,\n  deferComments,\n  highlightComments,\n  ICommentActionFunction,\n  rejectComments,\n  rejectFlagsAndComments,\n  resetComments,\n  tagCommentSummaryScores,\n} from '../../../../stores/commentActions';\nimport {\n  BASE_Z_INDEX,\n  BOX_DEFAULT_SPACING,\n  DARK_COLOR,\n  GUTTER_DEFAULT_SPACING,\n  HEADER_HEIGHT,\n  LIGHT_PRIMARY_TEXT_COLOR,\n  NICE_MIDDLE_BLUE,\n  SCRIM_STYLE,\n  SCRIM_Z_INDEX,\n  SELECT_ELEMENT,\n  SHORT_SCREEN_QUERY,\n  TOOLTIP_Z_INDEX,\n  WHITE_COLOR,\n} from '../../../../styles';\nimport { partial } from '../../../../util';\nimport { getDefaultSort, putDefaultSort } from '../../../../util/savedSorts';\nimport { css, stylesheet } from '../../../../utilx';\nimport {\n  commentDetailsPageLink,\n  IModeratedCommentsPathParams,\n  IModeratedCommentsQueryParams,\n  moderatedCommentsPageLink,\n} from '../../../routes';\n\nconst ARROW_SIZE = 6;\n// magic number = height of the moderation status dropdown and the row of tabs\nconst MODERATION_CONTAINER_HEIGHT = 269;\nconst MODERATION_CONTAINER_HEIGHT_SHORT = 202;\nconst TOAST_DELAY = 6000;\n\nconst sortOptions = List.of(\n  TagModel({\n    key: 'newest',\n    label: 'Newest',\n    color: null,\n  }),\n  TagModel({\n    key: 'oldest',\n    label: 'Oldest',\n    color: null,\n  }),\n  TagModel({\n    key: 'updated',\n    label: 'Last Modified',\n    color: null,\n  }),\n);\n\nconst ACTION_PLURAL: {\n  [key: string]: string;\n} = {\n  highlight: 'highlighted',\n  approve: 'approved',\n  defer: 'deferred',\n  reject: 'rejected',\n  tag: 'tagged',\n};\n\nconst STYLES = stylesheet({\n  row: {\n    display: 'flex',\n    justifyContent: 'space-between',\n    alignItems: 'center',\n    width: '100%',\n    backgroundColor: NICE_MIDDLE_BLUE,\n    paddingLeft: `${GUTTER_DEFAULT_SPACING}px`,\n    paddingRight: `${GUTTER_DEFAULT_SPACING}px`,\n    boxSizing: 'border-box',\n  },\n\n  moderatedInfo: {\n    color: LIGHT_PRIMARY_TEXT_COLOR,\n  },\n\n  actionLabel: {\n    display: 'inline-block',\n    textTransform: 'capitalize',\n    marginLeft: 4,\n  },\n\n  moderateButtons: {\n    display: 'flex',\n  },\n\n  topSelectRow: {\n    display: 'flex',\n    justifyContent: 'flex-end',\n    alignItems: 'center',\n    width: '100%',\n    paddingLeft: `${GUTTER_DEFAULT_SPACING}px`,\n    paddingRight: `${GUTTER_DEFAULT_SPACING}px`,\n    boxSizing: 'border-box',\n    backgroundColor: NICE_MIDDLE_BLUE,\n    height: HEADER_HEIGHT,\n    [SHORT_SCREEN_QUERY]: {\n      height: '56px',\n    },\n  },\n\n  dropdown: {\n    position: 'relative',\n    width: 170,\n  },\n\n  select: {\n    ...SELECT_ELEMENT,\n    paddingRight: `${(ARROW_SIZE * 2) + (BOX_DEFAULT_SPACING * 2)}px`,\n    position: 'relative',\n    zIndex: BASE_Z_INDEX,\n    color: LIGHT_PRIMARY_TEXT_COLOR,\n    appearance: 'none', // SELECT_ELEMENT mixin not passing this down?\n    WebkitAppearance: 'none', // Not getting prefixed either\n    borderBottom: `2px solid transparent`,\n    ':focus': {\n      outline: 0,\n      borderBottom: `2px solid ${WHITE_COLOR}`,\n      borderRadius: 0,\n    },\n  },\n\n  arrow: {\n    position: 'absolute',\n    zIndex: BASE_Z_INDEX,\n    right: '8px',\n    top: '8px',\n    borderLeft: `${ARROW_SIZE}px solid transparent`,\n    borderRight: `${ARROW_SIZE}px solid transparent`,\n    borderTop: `${ARROW_SIZE}px solid ${LIGHT_PRIMARY_TEXT_COLOR}`,\n    display: 'block',\n    height: 0,\n    width: 0,\n    marginLeft: `${BOX_DEFAULT_SPACING}px`,\n    marginRight: `${BOX_DEFAULT_SPACING}px`,\n  },\n\n  actionToastCount: {\n    display: 'flex',\n    alignItems: 'center',\n    justifyContent: 'center',\n    fontSize: 24,\n    lineHeight: 1.5,\n    textIndent: 4,\n  },\n\n  scrim: {\n    zIndex: SCRIM_Z_INDEX,\n    background: 'none',\n  },\n\n  toolTipWithTagsContainer: {\n    width: 250,\n  },\n\n  toolTipWithTagsUl: {\n    listStyle: 'none',\n    margin: 0,\n    padding: `${GUTTER_DEFAULT_SPACING}px 0`,\n  },\n\n  toolTipWithTagsButton: {\n    backgroundColor: 'transparent',\n    border: 'none',\n    borderRadius: 0,\n    color: NICE_MIDDLE_BLUE,\n    cursor: 'pointer',\n    padding: '8px 20px',\n    textAlign: 'left',\n    width: '100%',\n\n    ':hover': {\n      backgroundColor: NICE_MIDDLE_BLUE,\n      color: LIGHT_PRIMARY_TEXT_COLOR,\n    },\n\n    ':focus': {\n      backgroundColor: NICE_MIDDLE_BLUE,\n      color: LIGHT_PRIMARY_TEXT_COLOR,\n    },\n  },\n\n  commentsNotFoundMessaging: {\n    marginLeft: GUTTER_DEFAULT_SPACING,\n    padding: `${GUTTER_DEFAULT_SPACING}px 0`,\n  },\n});\n\nconst LOADING_COMMENTS_MESSAGING = 'Loading comments.';\nconst NO_COMMENTS_MESSAGING = 'No matching comments found.';\n\nconst actionMap: {\n  [key: string]: ICommentActionFunction;\n} = {\n  highlight: highlightComments,\n  highlightFlagged: highlightComments,\n  approve: approveComments,\n  approveFlagged: approveFlagsAndComments,\n  defer: deferComments,\n  deferFlagged: deferComments,\n  reject: rejectComments,\n  rejectFlagged: rejectFlagsAndComments,\n  tag: tagCommentSummaryScores,\n  tagFlagged: tagCommentSummaryScores,\n  reset: resetComments,\n  resetFlagged: resetComments,\n};\n\nexport interface IModeratedCommentsProps extends RouteComponentProps<IModeratedCommentsPathParams>, IContextInjectorProps {\n  isLoading: boolean;\n  tags: List<ITagModel>;\n  moderatedComments: IModeratedComments;\n  isItemChecked(id: string): boolean;\n  areNoneSelected?: boolean;\n  areAllSelected: boolean;\n  pagingIdentifier?: string;\n  loadData?(params: IModeratedCommentsPathParams, query: IModeratedCommentsQueryParams): void;\n  toggleSelectAll?(): any;\n  toggleSingleItem({ id }: { id: string }): any;\n  textSizes?: Map<ModelId, number>;\n  setCommentModerationStatus?(\n    props: IContextInjectorProps,\n    commentIds: Array<string>,\n    moderationAction: IConfirmationAction,\n    currentModeration: string,\n  ): void;\n}\n\nexport interface IModeratedCommentsState {\n  commentIds?: Array<ModelId>;\n  isConfirmationModalVisible?: boolean;\n  confirmationAction?: IConfirmationAction;\n  selectedItems?: any;\n  actionLabel: string;\n  actionText?: string;\n  actionCount?: number;\n  toastButtonLabel?: 'Undo' | 'Remove rule';\n  toastIcon?: JSX.Element;\n  showCount?: boolean;\n  isTaggingToolTipMetaVisible?: boolean;\n  taggingToolTipMetaPosition?: {\n    top: number;\n    left: number;\n  };\n  updatedItems?: any;\n  currentPathParams?: IModeratedCommentsPathParams;\n  articleControlOpen: boolean;\n  hideHistogram: boolean;\n  defaultSort?: string;\n  sort?: string;\n}\n\nexport class ModeratedComments\n  extends React.Component<IModeratedCommentsProps, IModeratedCommentsState> {\n\n  commentActionCancelled = false;\n\n  state: IModeratedCommentsState = {\n    isConfirmationModalVisible: false,\n    confirmationAction: null,\n    selectedItems: [],\n    actionLabel: '',\n    actionText: '',\n    actionCount: 0,\n    toastButtonLabel: null,\n    toastIcon: null,\n    showCount: false,\n    isTaggingToolTipMetaVisible: false,\n    taggingToolTipMetaPosition: {\n      top: 0,\n      left: 0,\n    },\n    updatedItems: [],\n    articleControlOpen: false,\n    hideHistogram: false,\n  };\n\n  componentDidMount() {\n    keyboardJS.bind('escape', this.onPressEscape);\n  }\n\n  componentWillUnmount() {\n    keyboardJS.unbind('escape', this.onPressEscape);\n  }\n\n  static getDerivedStateFromProps(props: IModeratedCommentsProps, state: IModeratedCommentsState) {\n    const actionLabel = props.match.params.disposition;\n    let defaultSort = state.sort;\n    let pathParamsChanged = false;\n    if (!state.currentPathParams || !isEqual(state.currentPathParams, props.match.params)) {\n      defaultSort = undefined;\n      pathParamsChanged = true;\n    }\n\n    if (!defaultSort) {\n      defaultSort = getDefaultSort(props.categoryId, 'moderated', actionLabel);\n    }\n\n    const query: IModeratedCommentsQueryParams = qs.parse(props.location.search);\n    const sort = query.sort || defaultSort;\n\n    if (pathParamsChanged || sort !== state.sort) {\n      props.loadData(props.match.params, {sort});\n    }\n\n    const commentIds = props.moderatedComments[props.match.params.disposition];\n\n    return {\n      actionLabel,\n      commentIds,\n      currentPathParams: props.match.params,\n      defaultSort,\n      sort,\n    };\n  }\n\n  render() {\n    const {\n      isArticleContext,\n      isLoading,\n      isItemChecked,\n      areNoneSelected,\n      areAllSelected,\n      tags,\n      moderatedComments,\n      textSizes,\n      match: { params },\n      pagingIdentifier,\n    } = this.props;\n\n    const {\n      commentIds,\n      isConfirmationModalVisible,\n      isTaggingToolTipMetaVisible,\n      taggingToolTipMetaPosition,\n      hideHistogram,\n    } = this.state;\n\n    function getLinkTarget(commentId: ModelId): string {\n      const urlParams = {\n        context: params.context,\n        contextId: params.contextId,\n        commentId: commentId,\n      };\n      const query = pagingIdentifier && {pagingIdentifier};\n      return commentDetailsPageLink(urlParams, query);\n    }\n\n    const selectedIdsLength = moderatedComments && this.getSelectedIDs().length;\n\n    let commentsMessaging = isLoading ? LOADING_COMMENTS_MESSAGING : null;\n    if (!isLoading && commentIds.length === 0) {\n      commentsMessaging = NO_COMMENTS_MESSAGING;\n    }\n\n    const listHeightOffset = window.matchMedia(SHORT_SCREEN_QUERY) ?\n      MODERATION_CONTAINER_HEIGHT_SHORT + HEADER_HEIGHT : MODERATION_CONTAINER_HEIGHT + HEADER_HEIGHT;\n\n    const showMessaging = !!commentsMessaging;\n\n    return (\n      <div {...css({height: '100%'})}>\n\n        <Collapse in={!hideHistogram}>\n          <div {...css(STYLES.topSelectRow)}>\n            {isArticleContext && (\n              <ArticleControlIcon\n                article={this.props.article}\n                open={this.state.articleControlOpen}\n                clearPopups={this.closePopup}\n                openControls={this.openPopup}\n                saveControls={this.applyRules}\n                whiteBackground\n              />\n            )}\n          </div>\n        </Collapse>\n\n        <div {...css(STYLES.row)}>\n          <div {...css(STYLES.moderatedInfo)}>{selectedIdsLength}\n            {selectedIdsLength === 1 ? ' comment ' : ' comments '}selected\n          </div>\n          <div {...css(STYLES.moderateButtons)}>\n            <CommentActionButton\n              disabled={areNoneSelected}\n              label=\"Approve\"\n              onClick={partial(\n                this.triggerActionToast,\n                'approve',\n                selectedIdsLength,\n                partial(this.dispatchConfirmedAction, 'approve', this.getSelectedIDs()),\n              )}\n              icon={(\n                <ApproveIcon {...css({fill: LIGHT_PRIMARY_TEXT_COLOR})} />\n              )}\n            />\n\n            <CommentActionButton\n              disabled={areNoneSelected}\n              label=\"Reject\"\n              onClick={partial(\n                this.triggerActionToast,\n                'reject',\n                selectedIdsLength,\n                partial(this.dispatchConfirmedAction, 'reject', this.getSelectedIDs()),\n              )}\n              icon={(\n                <RejectIcon {...css({fill: LIGHT_PRIMARY_TEXT_COLOR})} />\n              )}\n            />\n\n            <CommentActionButton\n              disabled={areNoneSelected}\n              label=\"Highlight\"\n              onClick={partial(\n                this.triggerActionToast,\n                'highlight',\n                selectedIdsLength,\n                partial(this.dispatchConfirmedAction, 'highlight', this.getSelectedIDs()),\n              )}\n              icon={(\n                <HighlightIcon {...css({fill: LIGHT_PRIMARY_TEXT_COLOR})} />\n              )}\n            />\n\n            <CommentActionButton\n              disabled={areNoneSelected}\n              label=\"Defer\"\n              onClick={partial(\n                this.triggerActionToast,\n                'defer',\n                selectedIdsLength,\n                partial(this.dispatchConfirmedAction, 'defer', this.getSelectedIDs()),\n              )}\n              icon={(\n                <DeferIcon {...css({fill: LIGHT_PRIMARY_TEXT_COLOR})} />\n              )}\n            />\n\n            <div {...css({position: 'relative'})}>\n              <CommentActionButton\n                disabled={areNoneSelected}\n                buttonRef={this.calculateTaggingTriggerPosition}\n                label=\"Tag\"\n                onClick={this.toggleTaggingToolTip}\n                icon={(\n                  <AddIcon {...css({fill: LIGHT_PRIMARY_TEXT_COLOR})} />\n                )}\n              />\n              {isTaggingToolTipMetaVisible && (\n                <ToolTip\n                  arrowPosition=\"topRight\"\n                  backgroundColor={WHITE_COLOR}\n                  hasDropShadow\n                  isVisible={isTaggingToolTipMetaVisible}\n                  onDeactivate={this.toggleTaggingToolTip}\n                  position={taggingToolTipMetaPosition}\n                  size={16}\n                  width={250}\n                  zIndex={TOOLTIP_Z_INDEX}\n                >\n                  <div {...css(STYLES.toolTipWithTagsContainer)}>\n                    <ul {...css(STYLES.toolTipWithTagsUl)}>\n                      {tags && tags.map((t, i) => (\n                        <li key={t.id}>\n                          <button\n                            onClick={partial(this.onTagButtonClick, t.id)}\n                            key={`tag-${i}`}\n                            {...css(STYLES.toolTipWithTagsButton)}\n                          >\n                            {t.label}\n                          </button>\n                        </li>\n                      ))}\n                    </ul>\n                  </div>\n                </ToolTip>\n              )}\n            </div>\n          </div>\n        </div>\n\n        {/* Table View */}\n        <div>\n          {showMessaging ? (\n            <p {...css(STYLES.commentsNotFoundMessaging)}>{commentsMessaging}</p>\n          ) : (\n            <CommentList\n              heightOffset={listHeightOffset}\n              textSizes={textSizes}\n              commentIds={commentIds}\n              areAllSelected={areAllSelected}\n              currentSort={this.state.sort}\n              getLinkTarget={getLinkTarget}\n              isItemChecked={isItemChecked}\n              onSelectAllChange={this.onSelectAllChange}\n              onSelectionChange={this.onSelectionChange}\n              onSortChange={this.onSortChange}\n              sortOptions={this.getSortOptions()}\n              totalItems={commentIds.length}\n              displayArticleTitle={!isArticleContext}\n              dispatchConfirmedAction={this.dispatchConfirmedAction}\n              handleAssignTagsSubmit={this.handleAssignTagsSubmit}\n              onTableScroll={this.onTableScroll}\n            />\n          )}\n        </div>\n\n        <Scrim\n          key=\"toastScrim\"\n          scrimStyles={{...STYLES.scrim, ...SCRIM_STYLE.scrim}}\n          isVisible={isConfirmationModalVisible}\n          onBackgroundClick={this.onConfirmationClose}\n        >\n          <FocusTrap\n            focusTrapOptions={{\n              clickOutsideDeactivates: true,\n            }}\n          >\n            <ToastMessage\n              icon={null}\n              buttonLabel={this.state.toastButtonLabel}\n              onClick={this.handleUndoClick}\n            >\n              <div key=\"toastContent\">\n                {this.state.showCount && (\n                  <span key=\"toastCount\" {...css(STYLES.actionToastCount)}>\n                  {this.state.toastIcon}\n                    {this.state.actionCount}\n                </span>\n                )}\n                <p key=\"actionText\">{this.state.actionText}</p>\n              </div>\n            </ToastMessage>\n          </FocusTrap>\n        </Scrim>\n      </div>\n    );\n  }\n\n  matchAction(action: string): any {\n    let showActionIcon;\n\n    if (action === 'approve') {\n      showActionIcon = <ApproveIcon {...css({ fill: DARK_COLOR })} />;\n    } else if (action === 'reject') {\n      showActionIcon = <RejectIcon {...css({ fill: DARK_COLOR })} />;\n    } else if (action === 'highlight') {\n      showActionIcon = <HighlightIcon {...css({ fill: DARK_COLOR })} />;\n    } else if (action === 'defer') {\n      showActionIcon = <DeferIcon {...css({ fill: DARK_COLOR })} />;\n    } else if (action === 'tag') {\n      showActionIcon = <AddIcon {...css({ fill: DARK_COLOR })} />;\n    }\n\n    return showActionIcon;\n  }\n\n  @autobind\n  onPressEscape() {\n    this.setState({\n      isConfirmationModalVisible: false,\n      isTaggingToolTipMetaVisible: false,\n    });\n  }\n\n  @autobind\n  getSelectedIDs(): Array<string> {\n    const { commentIds } = this.state;\n    const selectedIds = commentIds && commentIds.filter((id) => this.props.isItemChecked(id));\n\n    return selectedIds ? selectedIds : [];\n  }\n\n  @autobind\n  confirmationClose() {\n    this.setState({ isConfirmationModalVisible: false });\n  }\n\n  @autobind\n  triggerActionToast(action: ICommentAction, count: number, callback?: (action: ICommentAction) => void) {\n    this.setState({\n      isConfirmationModalVisible: true,\n      confirmationAction: action,\n      actionText: `Comments ` + ACTION_PLURAL[action],\n      actionCount: count,\n      toastButtonLabel: 'Undo',\n      toastIcon: this.matchAction(action),\n      showCount: true,\n    });\n    setTimeout(async () => {\n      if (this.commentActionCancelled) {\n        this.commentActionCancelled = false;\n        this.confirmationClose();\n\n        return false;\n      } else {\n        this.setState({\n          toastButtonLabel: null,\n        });\n        await callback(action);\n        this.confirmationClose();\n      }\n    }, TOAST_DELAY);\n  }\n\n  @autobind\n  onTagButtonClick(tagId: string) {\n    const ids = this.getSelectedIDs();\n    this.triggerActionToast('tag', ids.length, () => tagCommentSummaryScores(ids, tagId));\n    this.toggleTaggingToolTip();\n  }\n\n  @autobind\n  onTableScroll(position: number) {\n    this.setState({hideHistogram: position !== 0});\n    return true;\n  }\n\n  @autobind\n  calculateTaggingTriggerPosition(ref: any) {\n    if (!ref) {\n      return;\n    }\n\n    const buttonRect = ref.getBoundingClientRect();\n\n    this.setState({\n      taggingToolTipMetaPosition: {\n        top: buttonRect.height,\n        left: buttonRect.width - 10,\n      },\n    });\n  }\n\n  @autobind\n  toggleTaggingToolTip() {\n    this.setState({\n      isTaggingToolTipMetaVisible: !this.state.isTaggingToolTipMetaVisible,\n    });\n  }\n\n  @autobind\n  async handleAssignTagsSubmit(commentId: ModelId, selectedTagIds: Set<ModelId>) {\n    selectedTagIds.forEach((tagId) => {\n      tagCommentSummaryScores([commentId], tagId);\n    });\n    this.dispatchConfirmedAction('reject', [commentId]);\n  }\n\n  @autobind\n   async dispatchConfirmedAction(action: IConfirmationAction, ids: Array<string>) {\n    const mappedIds = ids.map((id) => {\n      return {\n        id,\n        action,\n      };\n    });\n\n    this.setState({\n      updatedItems: [\n        ...this.state.updatedItems,\n        ...mappedIds,\n      ],\n    });\n\n    this.props.setCommentModerationStatus(this.props, ids, action, this.state.actionLabel);\n\n    // Send event\n    const a = this.props.match.params.disposition === 'flagged' ? action + 'Flagged' : action;\n    await actionMap[a](ids);\n  }\n\n  @autobind\n  onConfirmationClose() {\n    this.setState({isConfirmationModalVisible: false });\n  }\n\n  @autobind\n  handleUndoClick() {\n    this.commentActionCancelled = true;\n    this.onConfirmationClose();\n  }\n\n  @autobind\n  onSortChange(event: React.FormEvent<any>) {\n    const sort: string = (event.target as any).value;\n    putDefaultSort(this.props.categoryId, 'moderated', this.state.actionLabel, sort);\n\n    if (sort === this.state.sort) {\n      return;\n    }\n\n    const query: IModeratedCommentsQueryParams = {};\n    if (sort !== this.state.defaultSort) {\n      query.sort = sort;\n    }\n    this.props.history.replace(moderatedCommentsPageLink(this.props.match.params, query));\n  }\n\n  @autobind\n  getSortOptions(): List<ITagModel> {\n    const { actionLabel } = this.state;\n    // Flagged is a special cases that can have a count associated with it\n    // as opposed to things like Approve and Reject which are binary.\n    // Here we're adding additional sort options for just that tab.\n\n    if (actionLabel === 'flagged') {\n      return sortOptions.unshift(TagModel({\n        key: `${actionLabel}`,\n        label: 'Unresolved flags',\n        color: null,\n      }));\n    }\n\n    return sortOptions;\n  }\n\n  @autobind\n  async onSelectAllChange() {\n    await this.props.toggleSelectAll();\n  }\n\n  @autobind\n  async onSelectionChange(id: string) {\n    await this.props.toggleSingleItem({ id });\n  }\n\n  @autobind\n  openPopup() {\n    this.setState({articleControlOpen: true});\n  }\n\n  @autobind\n  closePopup() {\n    this.setState({articleControlOpen: false});\n  }\n\n  @autobind\n  applyRules(isCommentingEnabled: boolean, isAutoModerated: boolean): void {\n    this.closePopup();\n    updateArticle(this.props.article.id, isCommentingEnabled, isAutoModerated);\n  }\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Comments/components/ModeratedComments/index.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { connect } from 'react-redux';\nimport { withRouter } from 'react-router';\nimport { compose } from 'redux';\nimport { createStructuredSelector } from 'reselect';\n\nimport { IAppDispatch, IAppState } from '../../../../appstate';\nimport { contextInjector, IContextInjectorProps } from '../../../../injectors/contextInjector';\nimport { getTaggableTags } from '../../../../stores/tags';\nimport { getTextSizes, getTextSizesIsLoading } from '../../../../stores/textSizes';\nimport { IModeratedCommentsPathParams, IModeratedCommentsQueryParams } from '../../../routes';\nimport {\n  IModeratedCommentsProps,\n  ModeratedComments as PureModeratedComments,\n} from './ModeratedComments';\nimport {\n  getAreAllSelected,\n  getAreAnyCommentsSelected,\n  getCurrentPagingIdentifier,\n  getIsItemChecked,\n  getIsLoading,\n  getModeratedComments,\n  loadCommentList,\n  setCommentsModerationStatus,\n  toggleSelectAll,\n  toggleSingleItem,\n} from './store';\n\ntype IModeratedCommentsDispatchProps = Pick<\n  IModeratedCommentsProps,\n  'toggleSelectAll' |\n  'toggleSingleItem' |\n  'setCommentModerationStatus' |\n  'loadData'\n>;\n\nconst mapStateToProps = createStructuredSelector({\n  isLoading: (state: IAppState) => (getIsLoading(state) || getTextSizesIsLoading(state)),\n\n  areNoneSelected: getAreAnyCommentsSelected,\n\n  areAllSelected: getAreAllSelected,\n\n  isItemChecked: (state: IAppState) => (id: string) => getIsItemChecked(state, id),\n\n  moderatedComments: (state: IAppState, props: IModeratedCommentsProps) => (\n    getModeratedComments(state, props.match.params)\n  ),\n\n  tags: getTaggableTags,\n\n  pagingIdentifier: getCurrentPagingIdentifier,\n\n  textSizes: getTextSizes,\n});\n\nfunction mapDispatchToProps(dispatch: IAppDispatch): IModeratedCommentsDispatchProps {\n  return {\n    loadData: (params: IModeratedCommentsPathParams, query: IModeratedCommentsQueryParams) => {\n      dispatch(loadCommentList(params, query));\n    },\n\n    toggleSelectAll: () => dispatch(toggleSelectAll()),\n\n    toggleSingleItem: ({ id }: { id: string }) => dispatch(toggleSingleItem({ id })),\n\n    setCommentModerationStatus: (\n      iprops: IContextInjectorProps,\n      commentIds: Array<string>,\n      moderationAction: string,\n      currentModeration: string,\n    ) =>\n        setCommentsModerationStatus(dispatch, iprops, commentIds, moderationAction, currentModeration),\n  };\n}\n\n// Add Redux data.\nexport const ModeratedComments = compose(\n  connect(\n    mapStateToProps,\n    mapDispatchToProps,\n  ),\n  withRouter,\n  contextInjector,\n)(PureModeratedComments);\n\nexport * from './store';\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Comments/components/ModeratedComments/store/checkedSelection.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { Action, Reducer } from 'redux-actions';\nimport { IAppState } from '../../../../../appstate';\nimport { ICheckedSelectionPayloads, ICheckedSelectionState, makeCheckedSelectionStore } from '../../../../../util';\n\nconst checkedSelectionStore = makeCheckedSelectionStore(\n  (state: IAppState) => {\n    return state.scenes.comments.moderatedComments.checkedSelection;\n  },\n  { defaultSelectionState: false },\n);\n\nconst checkedSelectionReducer: Reducer<ICheckedSelectionState, ICheckedSelectionPayloads> = checkedSelectionStore.reducer;\nconst getAreAllSelected: (state: IAppState) => boolean = checkedSelectionStore.getAreAllSelected;\nconst getAreAnyCommentsSelected: (state: IAppState) => boolean = checkedSelectionStore.getAreAnyCommentsSelected;\nconst getIsItemChecked: (state: IAppState, id: string) => boolean = checkedSelectionStore.getIsItemChecked;\nconst toggleSelectAll: () => Action<void> = checkedSelectionStore.toggleSelectAll;\nconst toggleSingleItem: (payload: { id: string }) => Action<{ id: string }> = checkedSelectionStore.toggleSingleItem;\n\nexport {\n  checkedSelectionReducer,\n  getAreAllSelected,\n  getAreAnyCommentsSelected,\n  getIsItemChecked,\n  toggleSelectAll,\n  toggleSingleItem,\n};\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Comments/components/ModeratedComments/store/commentListLoader.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { IThunkAction } from '../../../../../appstate';\nimport { clearCommentCache } from '../../../../../stores/globalActions';\nimport { loadTextSizesByIds } from '../../../../../stores/textSizes';\nimport { commentSortDefinitions,  } from '../../../../../utilx';\nimport {\n  IModeratedCommentsPathParams,\n  IModeratedCommentsQueryParams,\n  isArticleContext,\n  moderatedCommentsPageLink,\n} from '../../../../routes';\nimport { storeCommentPagingOptions } from '../../CommentDetail/store';\nimport { setCurrentPagingIdentifier } from './currentPagingIdentifier';\nimport {\n  getModeratedComments,\n  loadModeratedCommentsForArticle,\n  loadModeratedCommentsForCategory,\n} from './moderatedComments';\n\nexport function loadCommentList(\n  params: IModeratedCommentsPathParams,\n  query: IModeratedCommentsQueryParams,\n): IThunkAction<void> {\n  return async (dispatch, getState) => {\n    dispatch(clearCommentCache());\n    const columnSort = query.sort;\n    const sortDef = commentSortDefinitions[columnSort].sortInfo;\n    const isArticleDetail = isArticleContext(params);\n    const loader = isArticleDetail ? loadModeratedCommentsForArticle : loadModeratedCommentsForCategory;\n    await loader(dispatch, params.contextId, sortDef);\n    const commentIds = getModeratedComments(getState(), params)[params.disposition];\n\n    const bodyContentWidth = 696;\n\n    const link = moderatedCommentsPageLink(params);\n\n    const currentPagingIdentifier = await dispatch(storeCommentPagingOptions({\n      commentIds,\n      fromBatch: false,\n      source: `Comment %i of ${commentIds.length} from moderated comments with disposition \"${params.disposition}\"`,\n      link,\n    }));\n\n    dispatch(setCurrentPagingIdentifier({ currentPagingIdentifier }));\n\n    await dispatch(loadTextSizesByIds(commentIds, bodyContentWidth));\n  };\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Comments/components/ModeratedComments/store/currentPagingIdentifier.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { Action, Reducer } from 'redux-actions';\n\nimport { IAppState } from '../../../../../appstate';\nimport {\n  ICurrentPagingIdentifierPayload,\n  ICurrentPagingIdentifierState,\n  makeCurrentPagingIdentifierReducer,\n} from '../../../../../util';\n\nconst currentPagingIdentifier = makeCurrentPagingIdentifierReducer(\n  (state: IAppState) => {\n    return state.scenes.comments.moderatedComments.currentPagingIdentifier;\n  },\n);\n\nconst currentPagingIdentifierReducer: Reducer<ICurrentPagingIdentifierState, ICurrentPagingIdentifierPayload> = currentPagingIdentifier.reducer;\nconst setCurrentPagingIdentifier: (payload: ICurrentPagingIdentifierPayload) => Action<ICurrentPagingIdentifierPayload> = currentPagingIdentifier.setCurrentPagingIdentifier;\nconst getCurrentPagingIdentifier: (state: IAppState) => string = currentPagingIdentifier.getCurrentPagingIdentifier;\n\nexport {\n  currentPagingIdentifierReducer,\n  setCurrentPagingIdentifier,\n  getCurrentPagingIdentifier,\n};\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Comments/components/ModeratedComments/store/index.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { combineReducers } from 'redux';\n\nimport { ICheckedSelectionState, ICurrentPagingIdentifierState } from '../../../../../util';\nimport { checkedSelectionReducer } from './checkedSelection';\nimport { currentPagingIdentifierReducer } from './currentPagingIdentifier';\nimport { IModeratedCommentsState, moderatedCommentsReducer } from './moderatedComments';\n\nexport type IModeratedCommentsGlobalState = Readonly<{\n  currentPagingIdentifier: ICurrentPagingIdentifierState,\n  checkedSelection: ICheckedSelectionState,\n  moderatedComments: IModeratedCommentsState,\n}>;\n\nexport const reducer = combineReducers<IModeratedCommentsGlobalState>({\n  currentPagingIdentifier: currentPagingIdentifierReducer,\n  checkedSelection: checkedSelectionReducer,\n  moderatedComments: moderatedCommentsReducer,\n});\n\nexport * from './checkedSelection';\nexport * from './commentListLoader';\nexport * from './currentPagingIdentifier';\nexport * from './moderatedComments';\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Comments/components/ModeratedComments/store/moderatedComments.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { Map } from 'immutable';\nimport { Action, createAction, handleActions } from 'redux-actions';\n\nimport { ModelId } from '../../../../../../models';\nimport { IAppDispatch, IAppState } from '../../../../../appstate';\nimport { IContextInjectorProps } from '../../../../../injectors/contextInjector';\nimport {\n  getModeratedCommentIdsForArticle as fetchModeratedCommentIdsForArticle,\n  getModeratedCommentIdsForCategory as fetchModeratedCommentIdsForCategory,\n  IModeratedComments,\n} from '../../../../../platform/dataService';\nimport { IModeratedCommentsPathParams, isArticleContext } from '../../../../routes';\n\nconst ACTION_PLURAL: {\n  [key: string]: string;\n} = {\n  highlight: 'highlighted',\n  approve: 'approved',\n  defer: 'deferred',\n  reject: 'rejected',\n  tag: 'tagged',\n};\n\nconst loadModeratedCommentsStart = createAction(\n  'article-detail-moderatored/LOAD_MODERATED_COMMENTS_START',\n);\n\ntype ILoadModeratedCommentsForArticleCompletePayload = {\n  articleId: ModelId;\n  moderatedComments: IModeratedComments;\n};\nconst loadModeratedCommentsForArticleComplete = createAction<ILoadModeratedCommentsForArticleCompletePayload>(\n  'article-detail-moderatored/LOAD_MODERATED_COMMENTS_FOR_ARTICLE_COMPLETE',\n);\n\ntype ILoadModeratedCommentsForCategoriesCompletePayload = {\n  categoryId: ModelId | 'all';\n  moderatedComments: IModeratedComments;\n};\nconst loadModeratedCommentsForCategoryComplete = createAction<ILoadModeratedCommentsForCategoriesCompletePayload>(\n  'article-detail-moderatored/LOAD_MODERATED_COMMENTS_FOR_CATEGORY_COMPLETE',\n);\n\ntype ISetCommentsModerationForArticlesPayload = {\n  articleId: ModelId;\n  commentIds: Array<ModelId>;\n  moderationAction: string;\n  currentModeration: string;\n};\nconst setCommentsModerationForArticlesAction = createAction<ISetCommentsModerationForArticlesPayload>(\n  'article-detail-moderatored/SET_MODERATED_COMMENTS_STATUS_FOR_ARTICLES',\n);\n\ntype ISetCommentsModerationForCategoriesPayload = {\n  categoryId: ModelId | 'all';\n  commentIds: Array<ModelId>;\n  moderationAction: string;\n  currentModeration: string;\n};\nconst setCommentsModerationForCategoriesAction = createAction<ISetCommentsModerationForCategoriesPayload>(\n  'article-detail-moderatored/SET_MODERATED_COMMENTS_STATUS_FOR_CATEGORIES',\n);\n\nexport async function loadModeratedCommentsForArticle(\n  dispatch: IAppDispatch,\n  articleId: string,\n  sort: Array<string>,\n) {\n  await dispatch(loadModeratedCommentsStart());\n  const moderatedComments = await fetchModeratedCommentIdsForArticle(articleId, sort);\n  await dispatch(loadModeratedCommentsForArticleComplete({ articleId, moderatedComments }));\n}\n\nexport async function loadModeratedCommentsForCategory(\n  dispatch: IAppDispatch,\n  categoryId: ModelId | 'all',\n  sort: Array<string>,\n) {\n  await dispatch(loadModeratedCommentsStart());\n  const moderatedComments = await fetchModeratedCommentIdsForCategory(categoryId, sort);\n  await dispatch(loadModeratedCommentsForCategoryComplete({ categoryId, moderatedComments }));\n}\n\nexport function setCommentsModerationStatus(\n  dispatch: IAppDispatch,\n  contextProps: IContextInjectorProps,\n  commentIds: Array<ModelId>,\n  moderationAction: string,\n  currentModeration: string,\n) {\n  if (contextProps.isArticleContext) {\n    dispatch(setCommentsModerationForArticlesAction({\n      articleId: contextProps.articleId,\n      commentIds,\n      moderationAction,\n      currentModeration,\n    }));\n  }\n  else {\n    dispatch(setCommentsModerationForCategoriesAction({\n      categoryId: contextProps.categoryId,\n      commentIds,\n      moderationAction,\n      currentModeration,\n    }));\n  }\n}\n\nfunction shouldRemoveFromList(currentModeration: string, moderationAction: string): boolean {\n  return (\n    currentModeration !== 'batched' &&\n    currentModeration !== 'automated' &&\n    ((currentModeration === 'highlighted' && moderationAction === 'highlight') ||\n      (currentModeration !== 'highlighted' && moderationAction !== 'highlight'))\n  );\n}\n\nexport type IModeratedCommentsState = Readonly<{\n  isLoading: boolean;\n  articles: Map<ModelId, IModeratedComments>;\n  categories: Map<ModelId, IModeratedComments>;\n}>;\n\nconst initialState = {\n  isLoading: true,\n  articles: Map<ModelId, IModeratedComments>(),\n  categories: Map<ModelId, IModeratedComments>(),\n};\n\nexport const moderatedCommentsReducer = handleActions<\n  IModeratedCommentsState,\n  void                                               | // loadModeratedCommentsStart\n  ILoadModeratedCommentsForArticleCompletePayload    | // loadModeratedCommentsForArticleComplete\n  ILoadModeratedCommentsForCategoriesCompletePayload | // loadModeratedCommentsForCategoryComplete\n  ISetCommentsModerationForArticlesPayload           | // setCommentsModerationForArticlesAction\n  ISetCommentsModerationForCategoriesPayload\n>({\n  [loadModeratedCommentsStart.toString()]: (state) => ({...state, isLoading: true}),\n\n  [loadModeratedCommentsForArticleComplete.toString()]: (\n    state,\n    { payload }: Action<ILoadModeratedCommentsForArticleCompletePayload>,\n  ) => {\n    const { articleId, moderatedComments } = payload;\n    return { ...state, isLoading: false, articles: state.articles.set(articleId, moderatedComments)};\n  },\n\n  [loadModeratedCommentsForCategoryComplete.toString()]: (\n    state,\n    { payload }: Action<ILoadModeratedCommentsForCategoriesCompletePayload>,\n  ) => {\n    const { categoryId, moderatedComments } = payload;\n    return { ...state, isLoading: false, categories: state.categories.set(categoryId, moderatedComments)};\n  },\n\n  [setCommentsModerationForArticlesAction.toString()]: (\n    state,\n    { payload }: Action<ISetCommentsModerationForArticlesPayload>,\n  ) => {\n    const { articleId, commentIds, moderationAction, currentModeration } = payload;\n    const newState = {...state};\n    commentIds.forEach((commentId: string) => {\n      if (shouldRemoveFromList(currentModeration, moderationAction)) {\n        newState.articles = newState.articles.updateIn([articleId, currentModeration],\n            (moderated) => moderated.delete(moderated.findIndex((item: string) => item === commentId)));\n      }\n\n      switch (moderationAction) {\n        case 'reject' || 'defer':\n          newState.articles.updateIn([articleId, ACTION_PLURAL[moderationAction]],\n                  (item) => item.push(commentId));\n          break;\n        case 'highlight':\n          if (currentModeration === 'highlighted' && moderationAction === 'highlight') {\n            break;\n          }\n\n          newState.articles = newState.articles\n            .updateIn([ articleId, ACTION_PLURAL[moderationAction]],\n                  (item) => item.push(commentId))\n            .updateIn(['articles', articleId, 'approved'],\n                  (item) => item.push(commentId));\n          break;\n        case 'reset':\n          break;\n        default:\n          newState.articles\n            .updateIn([articleId, ACTION_PLURAL[moderationAction]],\n                (item) => item.push(commentId));\n          break;\n      }\n    });\n\n    return newState;\n  },\n\n  [setCommentsModerationForCategoriesAction.toString()]: (state, { payload }: Action<ISetCommentsModerationForCategoriesPayload>) => {\n    const { categoryId, commentIds, moderationAction, currentModeration } = payload;\n    const newState = {...state};\n    commentIds.forEach((commentId: string) => {\n      if (shouldRemoveFromList(currentModeration, moderationAction)) {\n        newState.categories = newState.categories.updateIn([categoryId, currentModeration],\n            (moderated) => moderated.delete(moderated.findIndex((item: string) => item === commentId)));\n      }\n      switch (moderationAction) {\n        case 'reject' || 'defer':\n          newState.categories = newState.categories\n            .updateIn([categoryId, ACTION_PLURAL[moderationAction]],\n                (item) => item.push(commentId));\n          break;\n        case 'highlight':\n          if (currentModeration === 'highlighted' && moderationAction === 'highlight') {\n            break;\n          }\n\n          newState.categories = newState.categories\n            .updateIn([categoryId, ACTION_PLURAL[moderationAction]],\n                (item) => item.push(commentId))\n            .updateIn([categoryId, 'approved'],\n                (item) => item.push(commentId));\n          break;\n        case 'reset':\n          break;\n        default:\n          newState.categories = newState.categories\n            .updateIn([categoryId, ACTION_PLURAL[moderationAction]],\n                (item) => item.push(commentId));\n          break;\n      }\n    });\n\n    return newState;\n  },\n}, initialState);\n\nfunction getRecord(state: IAppState) {\n  return state.scenes.comments.moderatedComments.moderatedComments;\n}\n\nexport function getIsLoading(state: IAppState) {\n  const stateRecord = getRecord(state);\n  return stateRecord && stateRecord.isLoading;\n}\n\nexport function getModeratedComments(state: IAppState, params: IModeratedCommentsPathParams): IModeratedComments {\n  const stateRecord = getRecord(state);\n  if (isArticleContext(params)) {\n    const articles = stateRecord.articles;\n    const articleId = params.contextId;\n    if (articles && articles.has(articleId)) {\n      return articles.get(articleId);\n    }\n  }\n  else {\n    const categories = stateRecord.categories;\n    const categoryId = params.contextId;\n    if (categories && categories.has(categoryId)) {\n      return categories.get(categoryId);\n    }\n  }\n\n  return {\n    approved: [],\n    highlighted: [],\n    rejected: [],\n    deferred: [],\n    flagged: [],\n    batched: [],\n    automated: [],\n  };\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Comments/components/NewComments/NewComments.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { Collapse } from '@material-ui/core';\nimport { autobind } from 'core-decorators';\nimport FocusTrap from 'focus-trap-react';\nimport { List, Set } from 'immutable';\nimport keyboardJS from 'keyboardjs';\nimport qs from 'query-string';\nimport React from 'react';\nimport { RouteComponentProps } from 'react-router';\nimport { Link } from 'react-router-dom';\nimport {\n  convertServerAction,\n  ICommentListItem,\n  IPreselectModel,\n  IRuleModel,\n  ITagModel,\n  ModelId,\n  TagModel,\n} from '../../../../../models';\nimport { ICommentAction } from '../../../../../types';\nimport {\n  AddIcon,\n  ApproveIcon,\n  ArticleControlIcon,\n  CommentActionButton,\n  CommentList,\n  DeferIcon,\n  HighlightIcon,\n  RejectIcon,\n  Scrim,\n  ToastMessage,\n  ToolTip,\n} from '../../../../components';\nimport {\n  DEFAULT_DRAG_HANDLE_POS1,\n  DEFAULT_DRAG_HANDLE_POS2,\n} from '../../../../config';\nimport { IContextInjectorProps } from '../../../../injectors/contextInjector';\nimport { updateArticle } from '../../../../platform/dataService';\nimport {\n  approveComments,\n  confirmCommentSummaryScore,\n  deferComments,\n  highlightComments,\n  ICommentActionFunction,\n  rejectComments,\n  rejectCommentSummaryScore,\n  tagCommentSummaryScores,\n} from '../../../../stores/commentActions';\nimport {\n  ARTICLE_CATEGORY_TYPE,\n  BASE_Z_INDEX,\n  BOX_DEFAULT_SPACING,\n  DARK_COLOR,\n  GUTTER_DEFAULT_SPACING,\n  HEADER_HEIGHT,\n  LIGHT_PRIMARY_TEXT_COLOR,\n  NICE_MIDDLE_BLUE,\n  SCRIM_STYLE,\n  SCRIM_Z_INDEX,\n  SELECT_ELEMENT,\n  TOOLTIP_Z_INDEX,\n  WHITE_COLOR,\n} from '../../../../styles';\nimport {\n  clearReturnSavedCommentRow,\n  getReturnSavedCommentRow,\n  partial,\n  setReturnSavedCommentRow,\n} from '../../../../util';\nimport { getDefaultSort, putDefaultSort } from '../../../../util/savedSorts';\nimport { css, stylesheet } from '../../../../utilx';\nimport {\n  commentDetailsPageLink,\n  INewCommentsPathParams,\n  INewCommentsQueryParams,\n  newCommentsPageLink,\n  tagSelectorLink,\n} from '../../../routes';\nimport { BatchSelector } from './components/BatchSelector';\nimport { getCommentIDsInRange } from './store';\n\nconst actionMap: { [key: string]: ICommentActionFunction } = {\n  highlight: highlightComments,\n  approve: approveComments,\n  defer: deferComments,\n  reject: rejectComments,\n  tag: tagCommentSummaryScores,\n};\n\nconst ARROW_SIZE = 6;\nconst TOAST_DELAY = 6000;\n\nconst ACTION_PLURAL: {\n  [key: string]: string;\n} = {\n  highlight: 'highlighted',\n  approve: 'approved',\n  defer: 'deferred',\n  reject: 'rejected',\n  tag: 'tagged',\n};\n\nconst LOADING_COMMENTS_MESSAGING = 'Loading comments.';\nconst NO_COMMENTS_MESSAGING = 'No matching comments found.';\n\nconst STYLES = stylesheet({\n  container: {\n    height: '100%',\n  },\n\n  buttonContainer: {\n    alignItems: 'center',\n    backgroundColor: NICE_MIDDLE_BLUE,\n    boxSizing: 'border-box',\n    display: 'flex',\n    justifyContent: 'space-between',\n    padding: `0px ${GUTTER_DEFAULT_SPACING}px 0 0`,\n    width: '100%',\n  },\n\n  commentCount: {\n    ...ARTICLE_CATEGORY_TYPE,\n    color: LIGHT_PRIMARY_TEXT_COLOR,\n    textAlign: 'left',\n    marginLeft: `${GUTTER_DEFAULT_SPACING * 2}px`,\n  },\n\n  moderateButtons: {\n    display: 'flex',\n    position: 'relative',\n    right: `${-GUTTER_DEFAULT_SPACING}px`,\n  },\n\n  commentActionButton: {\n    padding: `${GUTTER_DEFAULT_SPACING}px ${GUTTER_DEFAULT_SPACING}px  ${GUTTER_DEFAULT_SPACING}px  0`,\n  },\n\n  filler: {\n    backgroundColor: NICE_MIDDLE_BLUE,\n    height: 0,\n  },\n\n  actionToastCount: {\n    display: 'flex',\n    alignItems: 'center',\n    justifyContent: 'center',\n    fontSize: 24,\n    lineHeight: 1.5,\n    textIndent: 4,\n  },\n\n  scrim: {\n    zIndex: SCRIM_Z_INDEX,\n    background: 'none',\n  },\n\n  topSelectRow: {\n    display: 'flex',\n    justifyContent: 'space-between',\n    alignItems: 'center',\n    width: '100%',\n    paddingLeft: `${GUTTER_DEFAULT_SPACING}px`,\n    paddingRight: `${GUTTER_DEFAULT_SPACING * 2}px`,\n    boxSizing: 'border-box',\n    backgroundColor: NICE_MIDDLE_BLUE,\n    height: HEADER_HEIGHT,\n  },\n\n  dropdown: {\n    position: 'relative',\n    marginRight: `${GUTTER_DEFAULT_SPACING}px`,\n  },\n\n  select: {\n    ...SELECT_ELEMENT,\n    paddingRight: `${(ARROW_SIZE * 2) + (BOX_DEFAULT_SPACING * 2)}px`,\n    position: 'relative',\n    zIndex: BASE_Z_INDEX,\n    color: LIGHT_PRIMARY_TEXT_COLOR,\n    ':focus': {\n      outline: 0,\n      borderBottom: `1px solid ${LIGHT_PRIMARY_TEXT_COLOR}`,\n    },\n  },\n\n  arrow: {\n    position: 'absolute',\n    zIndex: BASE_Z_INDEX,\n    right: '0px',\n    top: '8px',\n    borderLeft: `${ARROW_SIZE}px solid transparent`,\n    borderRight: `${ARROW_SIZE}px solid transparent`,\n    borderTop: `${ARROW_SIZE}px solid ${LIGHT_PRIMARY_TEXT_COLOR}`,\n    display: 'block',\n    height: 0,\n    width: 0,\n    marginLeft: `${BOX_DEFAULT_SPACING}px`,\n    marginRight: `${BOX_DEFAULT_SPACING}px`,\n  },\n\n  toolTipWithTagsContainer: {\n    width: 250,\n  },\n\n  toolTipWithTagsUl: {\n    listStyle: 'none',\n    margin: 0,\n    padding: `${GUTTER_DEFAULT_SPACING}px 0`,\n  },\n\n  toolTipWithTagsButton: {\n    backgroundColor: 'transparent',\n    border: 'none',\n    borderRadius: 0,\n    color: NICE_MIDDLE_BLUE,\n    cursor: 'pointer',\n    padding: '8px 20px',\n    textAlign: 'left',\n    width: '100%',\n\n    ':hover': {\n      backgroundColor: NICE_MIDDLE_BLUE,\n      color: LIGHT_PRIMARY_TEXT_COLOR,\n    },\n\n    ':focus': {\n      backgroundColor: NICE_MIDDLE_BLUE,\n      color: LIGHT_PRIMARY_TEXT_COLOR,\n      outline: 0,\n    },\n  },\n\n  commentsNotFoundMessaging: {\n    padding: `${GUTTER_DEFAULT_SPACING}px 0`,\n    marginLeft: GUTTER_DEFAULT_SPACING,\n  },\n\n  toggleLabel: {\n    ...ARTICLE_CATEGORY_TYPE,\n    display: 'flex',\n    flexDirection: 'row',\n    alignItems: 'center',\n    color: LIGHT_PRIMARY_TEXT_COLOR,\n  },\n});\n\nexport interface INewCommentsProps extends RouteComponentProps<INewCommentsPathParams>, IContextInjectorProps {\n  preselects?: List<IPreselectModel>;\n  commentScores: Array<ICommentListItem>;\n  isLoading: boolean;\n  selectedTag?: ITagModel;\n  areNoneSelected?: boolean;\n  areAllSelected: boolean;\n  isItemChecked(id: string): boolean;\n  tags: List<ITagModel>;\n  rules?: List<IRuleModel>;\n  pagingIdentifier: string;\n  textSizes?: Map<ModelId, number>;\n  removeCommentScore?(idsToDispatch: Array<string>): any;\n  toggleSelectAll?(): any;\n  toggleSingleItem({ id }: { id: string }): any;\n  loadData(params: INewCommentsPathParams, pos1: number, pos2: number, sort: string): void;\n}\n\nexport interface INewCommentsState {\n  categoryId?: ModelId;\n  articleId?: ModelId;\n  tag?: string;\n  defaultPos1?: number;\n  defaultPos2?: number;\n  defaultSort?: string;\n  pos1?: number;\n  pos2?: number;\n  sort?: string;\n  commentIds?: Array<ModelId>;\n  isConfirmationModalVisible?: boolean;\n  isRuleInfoVisible?: boolean;\n  confirmationAction?: ICommentAction;\n  actionCount?: number;\n  actionText?: string;\n  toastButtonLabel?: 'Undo';\n  toastIcon?: JSX.Element;\n  ruleToastIcon?: JSX.Element;\n  showCount?: boolean;\n  isTaggingToolTipMetaVisible?: boolean;\n  taggingToolTipMetaPosition?: {\n    top: number;\n    left: number;\n  };\n  selectedRow?: number;\n  articleControlOpen: boolean;\n  rulesInCategory?: List<IRuleModel>;\n  hideHistogram: boolean;\n}\n\nexport class NewComments extends React.Component<INewCommentsProps, INewCommentsState> {\n\n  commentActionCancelled = false;\n  listContainerRef: any = null;\n\n  state: INewCommentsState = {\n    isConfirmationModalVisible: false,\n    isRuleInfoVisible: false,\n    confirmationAction: null,\n    actionCount: 0,\n    actionText: '',\n    toastButtonLabel: null,\n    toastIcon: null,\n    ruleToastIcon: null,\n    showCount: false,\n    isTaggingToolTipMetaVisible: false,\n    taggingToolTipMetaPosition: {\n      top: 0,\n      left: 0,\n    },\n    selectedRow: null,\n    articleControlOpen: false,\n    hideHistogram: false,\n  };\n\n  static getDerivedStateFromProps(props: INewCommentsProps, state: INewCommentsState) {\n    let preselect: IPreselectModel;\n    const params = props.match.params;\n    const tag = params.tag;\n    const {categoryId, articleId} = props;\n    let defaultPos1: number;\n    let defaultPos2: number;\n    let defaultSort;\n    if (tag !== state.tag || !state.defaultSort) {\n      defaultSort = getDefaultSort(categoryId, 'new', tag);\n    }\n    else {\n      defaultSort = state.defaultSort;\n    }\n\n    if (tag !== 'DATE') {\n      if (props.preselects) {\n        if (categoryId !== 'all' && props.selectedTag) {\n          preselect = props.preselects.find((p) =>\n            (p.categoryId === categoryId && p.tagId === props.selectedTag.id));\n        }\n        if (!preselect && categoryId !== 'all') {\n          preselect = props.preselects.find((p) =>\n            (p.categoryId === categoryId && !p.tagId));\n        }\n        if (!preselect && props.selectedTag) {\n          preselect = props.preselects.find((p) =>\n            (!p.categoryId && p.tagId === props.selectedTag.id));\n        }\n        if (!preselect) {\n          preselect = props.preselects.find((p) => (!p.categoryId && !p.tagId));\n        }\n      }\n      defaultPos1 = preselect ? preselect.lowerThreshold : DEFAULT_DRAG_HANDLE_POS1;\n      defaultPos2 = preselect ? preselect.upperThreshold : DEFAULT_DRAG_HANDLE_POS2;\n    }\n    else {\n      defaultPos1 = 0;\n      defaultPos2 = 1;\n    }\n\n    const query: INewCommentsQueryParams = qs.parse(props.location.search);\n    const pos1 = query.pos1 ? Number.parseFloat(query.pos1) : defaultPos1;\n    const pos2 = query.pos2 ? Number.parseFloat(query.pos2) : defaultPos2;\n    const sort = query.sort || defaultSort;\n\n    const commentIds = getCommentIDsInRange(\n      props.commentScores,\n      pos1,\n      pos2,\n      props.match.params.tag === 'DATE',\n    );\n\n    let rulesInCategory: List<IRuleModel>;\n    if (props.rules) {\n      if (categoryId !== 'all') {\n        rulesInCategory = props.rules.filter((r) =>\n          (r.categoryId === categoryId || !r.categoryId)) as List<IRuleModel>;\n      }\n      else {\n        rulesInCategory = props.rules.filter((r) => (!r.categoryId)) as List<IRuleModel>;\n      }\n    }\n\n    if ((categoryId !== state.categoryId) || (articleId !== state.articleId) || (tag !== state.tag) ||\n        (pos1 !== state.pos1) || (pos2 !== state.pos2) || (sort !== state.sort)) {\n      props.loadData(params, pos1, pos2, sort);\n    }\n\n    return {\n      categoryId,\n      articleId,\n      tag,\n      commentIds,\n      defaultPos1,\n      defaultPos2,\n      defaultSort,\n      pos1,\n      pos2,\n      sort,\n      rulesInCategory,\n    };\n  }\n\n  async componentDidUpdate(_prevProps: INewCommentsProps) {\n    // We need to wait for commentIDsInRange to load so we can check that against the saved row\n    const commentId = getReturnSavedCommentRow();\n\n    if ((typeof commentId !== 'undefined') && !this.props.isLoading && this.state.commentIds.length > 0 ) {\n      // need to wait to make sure dom and other items are loaded before scrolling you down to the saved comment\n      // Maybe we need a better has loaded thing to see if a single row has been rendered and bubble that up to here?\n      const row = this.state.commentIds.findIndex((idInRange) => idInRange === commentId);\n      this.setState({ selectedRow: row });\n      clearReturnSavedCommentRow();\n    }\n  }\n\n  componentDidMount() {\n    keyboardJS.bind('escape', this.onPressEscape);\n  }\n\n  componentWillUnmount() {\n    keyboardJS.unbind('escape', this.onPressEscape);\n  }\n\n  @autobind\n  onPressEscape() {\n    this.setState({\n      isConfirmationModalVisible: false,\n      isRuleInfoVisible: false,\n      isTaggingToolTipMetaVisible: false,\n    });\n  }\n\n  @autobind\n  saveListContainerRef(ref: HTMLDivElement) {\n    this.listContainerRef = ref;\n  }\n\n  @autobind\n  async handleAssignTagsSubmit(commentId: ModelId, selectedTagIds: Set<ModelId>, rejectedTagIds: Set<ModelId>) {\n    selectedTagIds.forEach((tagId) => {\n      confirmCommentSummaryScore(commentId, tagId);\n    });\n\n    rejectedTagIds.forEach((tagId) => {\n      rejectCommentSummaryScore(commentId, tagId);\n    });\n\n    this.dispatchConfirmedAction('reject', [commentId]);\n  }\n\n  render() {\n    const {\n      isArticleContext,\n      article,\n      commentScores,\n      textSizes,\n      areNoneSelected,\n      areAllSelected,\n      isItemChecked,\n      tags,\n      selectedTag,\n      isLoading,\n      match: { params },\n      pagingIdentifier,\n    } = this.props;\n\n    const {\n      pos1,\n      pos2,\n      commentIds,\n      isConfirmationModalVisible,\n      isRuleInfoVisible,\n      isTaggingToolTipMetaVisible,\n      taggingToolTipMetaPosition,\n      selectedRow,\n      rulesInCategory,\n      hideHistogram,\n    } = this.state;\n\n    function getLinkTarget(commentId: ModelId): string {\n      const urlParams = {\n        context: params.context,\n        contextId: params.contextId,\n        commentId: commentId,\n      };\n      const query = pagingIdentifier && {pagingIdentifier};\n      return commentDetailsPageLink(urlParams, query);\n    }\n\n    const IS_SMALL_SCREEN = window.innerWidth < 1024;\n\n    let filterSortOptions = List([\n      TagModel({\n        id: '-1',\n        label: 'Newest',\n        key: 'newest',\n        color: '',\n      }),\n      TagModel({\n        id: '-2',\n        label: 'Oldest',\n        key: 'oldest',\n        color: '',\n      }),\n    ]);\n\n    switch (selectedTag && selectedTag.key) {\n      case undefined:\n        break;\n      case 'DATE':\n        break;\n      case 'SUMMARY_SCORE':\n        filterSortOptions = filterSortOptions\n          .push(TagModel({\n            id: '-3',\n            label: `Highest ${selectedTag && selectedTag.label}`,\n            key: 'highest',\n            color: '',\n          }))\n          .push(TagModel({\n            id: '-4',\n            label: `Lowest ${selectedTag && selectedTag.label}`,\n            key: 'lowest',\n            color: '',\n          }));\n        break;\n      default:\n        filterSortOptions = filterSortOptions\n          .push(TagModel({\n            id: '-3',\n            label: `Most ${selectedTag && selectedTag.label}`,\n            key: 'highest',\n            color: '',\n          }))\n          .push(TagModel({\n            id: '-4',\n            label: `Least ${selectedTag && selectedTag.label}`,\n            key: 'lowest',\n            color: '',\n          }));\n    }\n\n    const tagLinkURL = tagSelectorLink({context: params.context, contextId: params.contextId, tag: selectedTag && selectedTag.id});\n\n    const rules = selectedTag && selectedTag.key !== 'DATE' && rulesInCategory && List<IRuleModel>(rulesInCategory.filter( (r) => r.tagId && r.tagId === selectedTag.id));\n    const disableAllButtons = areNoneSelected || commentScores.length <= 0;\n    const groupBy = (selectedTag && selectedTag.key === 'DATE') ? 'date' : 'score';\n\n    const totalScoresInView = commentIds.length;\n    let commentsMessaging: string = null;\n\n    if (isLoading) {\n      commentsMessaging = LOADING_COMMENTS_MESSAGING;\n    } else {\n      if (totalScoresInView === 0) {\n        commentsMessaging = NO_COMMENTS_MESSAGING;\n      }\n    }\n\n    const showMessaging = !!commentsMessaging;\n    const selectedIdsCount = this.getSelectedIDs().length;\n    const boundingRect = this.listContainerRef && this.listContainerRef.getBoundingClientRect();\n    const listHeightOffset = boundingRect ? boundingRect.top : 500;\n\n    return (\n      <div {...css(STYLES.container)}>\n        <Collapse in={!hideHistogram}>\n          <div key=\"tagSelection\" {...css(STYLES.topSelectRow)}>\n            <div {...css(STYLES.dropdown)}>\n              <Link {...css(STYLES.select)} to={tagLinkURL}>\n                {selectedTag && selectedTag.label}\n              </Link>\n              <span aria-hidden=\"true\" {...css(STYLES.arrow)} />\n            </div>\n            { isArticleContext && (\n              <ArticleControlIcon\n                article={article}\n                open={this.state.articleControlOpen}\n                clearPopups={this.closePopup}\n                openControls={this.openPopup}\n                saveControls={this.applyRules}\n                whiteBackground\n              />\n            )}\n          </div>\n\n          { selectedTag && (\n            <BatchSelector\n              key=\"selector\"\n              groupBy={groupBy}\n              rules={rules}\n              areAutomatedRulesApplied={article && article.isAutoModerated}\n              defaultSelectionPosition1={pos1}\n              defaultSelectionPosition2={pos2}\n              commentScores={commentScores}\n              onSelectionChangeEnd={this.onBatchCommentsChangeEnd}\n              automatedRuleToast={this.handleRemoveAutomatedRule}\n            />\n          )}\n        </Collapse>\n\n        <div {...css( STYLES.filler )} />\n\n        <div {...css(STYLES.buttonContainer)}>\n\n          <div {...css(STYLES.commentCount)}>\n            { commentScores.length > 0 && (\n              <div>\n                <span>{selectedIdsCount} of {commentScores.length} comments selected</span>\n              </div>\n            )}\n          </div>\n\n          <div {...css(STYLES.moderateButtons)}>\n            <CommentActionButton\n              disabled={disableAllButtons}\n              style={IS_SMALL_SCREEN && STYLES.commentActionButton}\n              label=\"Approve\"\n              onClick={partial(\n                this.triggerActionToast,\n                'approve',\n                selectedIdsCount,\n                this.dispatchConfirmedAction,\n              )}\n              icon={(\n                  <ApproveIcon {...css({ fill: LIGHT_PRIMARY_TEXT_COLOR })} />\n              )}\n            />\n\n            <CommentActionButton\n              disabled={disableAllButtons}\n              style={IS_SMALL_SCREEN && STYLES.commentActionButton}\n              label=\"Reject\"\n              onClick={partial(\n                this.triggerActionToast,\n                'reject',\n                selectedIdsCount,\n                this.dispatchConfirmedAction,\n              )}\n              icon={(\n                <RejectIcon {...css({ fill: LIGHT_PRIMARY_TEXT_COLOR })} />\n              )}\n            />\n\n            <CommentActionButton\n              disabled={disableAllButtons}\n              style={IS_SMALL_SCREEN && STYLES.commentActionButton}\n              label=\"Defer\"\n              onClick={partial(\n                this.triggerActionToast,\n                'defer',\n                selectedIdsCount,\n                this.dispatchConfirmedAction,\n              )}\n              icon={(\n                <DeferIcon {...css({ fill: LIGHT_PRIMARY_TEXT_COLOR })} />\n              )}\n            />\n\n            <div {...css(STYLES.dropdown)}>\n              <CommentActionButton\n                style={IS_SMALL_SCREEN && STYLES.commentActionButton}\n                disabled={disableAllButtons}\n                buttonRef={this.calculateTaggingTriggerPosition}\n                label=\"Tag\"\n                onClick={this.toggleTaggingToolTip}\n                icon={(\n                  <AddIcon {...css({ fill: LIGHT_PRIMARY_TEXT_COLOR })} />\n                )}\n              />\n              {isTaggingToolTipMetaVisible && (\n                <ToolTip\n                  arrowPosition=\"topRight\"\n                  backgroundColor={WHITE_COLOR}\n                  hasDropShadow\n                  isVisible={isTaggingToolTipMetaVisible}\n                  onDeactivate={this.toggleTaggingToolTip}\n                  position={taggingToolTipMetaPosition}\n                  size={16}\n                  width={250}\n                  zIndex={TOOLTIP_Z_INDEX}\n                >\n                  <FocusTrap\n                    focusTrapOptions={{\n                      clickOutsideDeactivates: true,\n                    }}\n                  >\n                    <div {...css(STYLES.toolTipWithTagsContainer)}>\n                      <ul {...css(STYLES.toolTipWithTagsUl)}>\n                        {tags && tags.map((t, i) => (\n                          <li key={t.id}>\n                            <button\n                              onClick={partial(this.onTagButtonClick, t.id)}\n                              key={`tag-${i}`}\n                              {...css(STYLES.toolTipWithTagsButton)}\n                            >\n                              {t.label}\n                            </button>\n                          </li>\n                        ))}\n                      </ul>\n                    </div>\n                  </FocusTrap>\n                </ToolTip>\n              )}\n            </div>\n          </div>\n        </div>\n\n        {/* Table View */}\n        <div ref={this.saveListContainerRef}>\n          { showMessaging ? (\n            <p {...css(STYLES.commentsNotFoundMessaging)}>{commentsMessaging}</p>\n          ) : (\n            <CommentList\n              heightOffset={listHeightOffset}\n              textSizes={textSizes}\n              commentIds={commentIds}\n              selectedTag={selectedTag}\n              areAllSelected={areAllSelected}\n              getLinkTarget={getLinkTarget}\n              isItemChecked={isItemChecked}\n              onSelectAllChange={this.onSelectAllChange}\n              onSelectionChange={this.onSelectionChange}\n              handleAssignTagsSubmit={this.handleAssignTagsSubmit}\n              sortOptions={filterSortOptions}\n              currentSort={this.state.sort}\n              onSortChange={this.onSortChange}\n              onCommentClick={this.saveCommentRow}\n              scrollToRow={selectedRow}\n              totalItems={commentIds.length}\n              dispatchConfirmedAction={this.dispatchConfirmedAction}\n              displayArticleTitle={!isArticleContext}\n              onTableScroll={this.onTableScroll}\n            />\n          )}\n        </div>\n\n        <Scrim\n          key=\"confirmationScrim\"\n          scrimStyles={{...STYLES.scrim, ...SCRIM_STYLE.scrim}}\n          isVisible={isConfirmationModalVisible}\n          onBackgroundClick={this.confirmationClose}\n        >\n          <FocusTrap\n            focusTrapOptions={{\n              clickOutsideDeactivates: true,\n            }}\n          >\n            <ToastMessage\n              icon={this.state.toastIcon}\n              buttonLabel={this.state.toastButtonLabel}\n              onClick={this.handleUndoClick}\n            >\n              <div key=\"toastContent\">\n                { this.state.showCount && (\n                  <span key=\"toastCount\" {...css(STYLES.actionToastCount)}>\n                    {this.state.toastIcon}\n                    {this.state.actionCount}\n                  </span>\n                )}\n                <p key=\"actionText\">{this.state.actionText}</p>\n              </div>\n            </ToastMessage>\n          </FocusTrap>\n        </Scrim>\n\n        <Scrim\n          key=\"ruleScrim\"\n          scrimStyles={{...STYLES.scrim, ...SCRIM_STYLE.scrim}}\n          isVisible={isRuleInfoVisible}\n          onBackgroundClick={this.onRuleInfoClose}\n        >\n          <FocusTrap\n            focusTrapOptions={{\n              clickOutsideDeactivates: true,\n            }}\n          >\n            <ToastMessage icon={this.state.ruleToastIcon}>\n              <p>{this.state.actionText}</p>\n            </ToastMessage>\n          </FocusTrap>\n        </Scrim>\n      </div>\n    );\n  }\n\n  matchAction(action: ICommentAction) {\n    let showActionIcon;\n\n    if (action === 'approve') {\n      showActionIcon = <ApproveIcon {...css({ fill: DARK_COLOR })} />;\n    } else if (action === 'reject') {\n      showActionIcon = <RejectIcon {...css({ fill: DARK_COLOR })} />;\n    } else if (action === 'highlight') {\n      showActionIcon = <HighlightIcon {...css({ fill: DARK_COLOR })} />;\n    } else if (action === 'defer') {\n      showActionIcon = <DeferIcon {...css({ fill: DARK_COLOR })} />;\n    }\n\n    return showActionIcon;\n  }\n\n  @autobind\n  triggerActionToast(action: ICommentAction, count: number, callback: (action?: ICommentAction) => any) {\n    this.setState({\n      isConfirmationModalVisible: true,\n      confirmationAction: action,\n      actionCount: count,\n      actionText: `Comment${count > 1 ? 's' : ''} ` + ACTION_PLURAL[action],\n      toastButtonLabel: 'Undo',\n      toastIcon: this.matchAction(action),\n      showCount: true,\n    });\n    setTimeout(async () => {\n      if (this.commentActionCancelled) {\n        this.commentActionCancelled = false;\n        this.confirmationClose();\n\n        return false;\n      } else {\n        this.setState({\n          toastButtonLabel: null,\n        });\n        await callback(action);\n        this.confirmationClose();\n      }\n    }, TOAST_DELAY);\n  }\n\n  @autobind handleRemoveAutomatedRule(rule: IRuleModel) {\n    const icon = this.matchAction(convertServerAction(rule.action));\n\n    this.setState({\n      isRuleInfoVisible: true,\n      actionText: 'These comments are auto ' + rule.action,\n      ruleToastIcon: icon,\n    });\n  }\n\n  @autobind\n  calculateTaggingTriggerPosition(ref: any) {\n    if (!ref) {\n      return;\n    }\n\n    const buttonRect = ref.getBoundingClientRect();\n\n    this.setState({\n      taggingToolTipMetaPosition: {\n        top: buttonRect.height,\n        left: buttonRect.width - 5,\n      },\n    });\n  }\n\n  @autobind\n  onTagButtonClick(tagId: string) {\n    const ids = this.getSelectedIDs();\n    this.triggerActionToast('tag', ids.length, () => tagCommentSummaryScores(ids, tagId));\n    this.toggleTaggingToolTip();\n  }\n\n  @autobind\n  toggleTaggingToolTip() {\n    this.setState({\n      isTaggingToolTipMetaVisible: !this.state.isTaggingToolTipMetaVisible,\n    });\n  }\n\n  @autobind\n  async dispatchConfirmedAction(action: ICommentAction, ids?: Array<string>) {\n\n    const idsToDispatch = ids || this.getSelectedIDs();\n\n    // Send event\n    actionMap[action](idsToDispatch);\n\n    // remove these from the ui because they are now 'moderated'\n    this.props.removeCommentScore(idsToDispatch);\n  }\n\n  @autobind\n  getSelectedIDs(): Array<string> {\n    return this.state.commentIds\n        .filter((commentId) => this.props.isItemChecked(commentId));\n  }\n\n  @autobind\n  confirmationClose() {\n    this.setState({ isConfirmationModalVisible: false });\n  }\n\n  @autobind\n  onRuleInfoClose() {\n    this.setState({ isRuleInfoVisible: false });\n  }\n\n  @autobind\n  handleUndoClick() {\n    this.commentActionCancelled = true;\n    this.confirmationClose();\n  }\n\n  @autobind\n  setQueryStringParam(pos1: number, pos2: number, sort: string): void {\n    if ((pos1 === this.state.pos1) && (pos2 === this.state.pos2) && (sort === this.state.sort)) {\n      return;\n    }\n\n    const query: INewCommentsQueryParams = {};\n    if (pos1 !== this.state.defaultPos1) {\n      query.pos1 = pos1.toString();\n    }\n    if (pos2 !== this.state.defaultPos2) {\n      query.pos2 = pos2.toString();\n    }\n    if (sort !== this.state.defaultSort) {\n      query.sort = sort;\n    }\n    this.props.history.replace(newCommentsPageLink(this.props.match.params, query));\n  }\n\n  @autobind\n  saveCommentRow(commentId: string): void {\n    setReturnSavedCommentRow(commentId);\n  }\n\n  @autobind\n  onSortChange(event: React.FormEvent<any>) {\n    const sort: string = (event.target as any).value;\n    putDefaultSort(this.state.categoryId, 'new', this.state.tag, sort);\n    this.setQueryStringParam(this.state.pos1, this.state.pos2, sort);\n  }\n\n  @autobind\n  onBatchCommentsChangeEnd(_commentIds: Array<number>, pos1: number, pos2: number) {\n    this.setQueryStringParam(pos1, pos2, this.state.sort);\n  }\n\n  @autobind\n  async onSelectAllChange() {\n    await this.props.toggleSelectAll();\n  }\n\n  @autobind\n  async onSelectionChange(id: string) {\n    await this.props.toggleSingleItem({ id });\n  }\n\n  @autobind\n  onTableScroll(position: number) {\n    this.setState({hideHistogram: position !== 0});\n    return true;\n  }\n\n  @autobind\n  openPopup() {\n    this.setState({articleControlOpen: true});\n  }\n\n  @autobind\n  closePopup() {\n    this.setState({articleControlOpen: false});\n  }\n\n  @autobind\n  applyRules(isCommentingEnabled: boolean, isAutoModerated: boolean): void {\n    this.closePopup();\n    updateArticle(this.props.article.id, isCommentingEnabled, isAutoModerated);\n  }\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Comments/components/NewComments/components/BatchSelector/BatchSelector.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { autobind } from 'core-decorators';\nimport formatDate from 'date-fns/format';\nimport { List } from 'immutable';\nimport { clamp, isEqual } from 'lodash';\nimport React from 'react';\n\nimport { ICommentDate, ICommentListItem, ICommentScore, IRuleModel } from '../../../../../../../models';\nimport { DATE_FORMAT_LONG } from '../../../../../../config';\nimport { COLCOUNT } from '../../../../../../config';\nimport { groupByDateColumns, groupByScoreColumns, IGroupedComments } from '../../../../../../util';\nimport { css, stylesheet } from '../../../../../../utilx';\n\nimport {\n  BASE_Z_INDEX,\n  BOX_DEFAULT_SPACING,\n  GUTTER_DEFAULT_SPACING,\n  LIGHT_PRIMARY_TEXT_COLOR,\n  NICE_MIDDLE_BLUE,\n} from '../../../../../../styles';\n\nimport {\n  AspectRatio,\n  DotChart,\n  DraggableHandle,\n  RangeBar,\n  RuleBars,\n  Slider,\n} from '../../../../../../components';\n\nconst ARROW_SIZE = 6;\n\nconst STYLES = stylesheet({\n  batchControls: {\n    position: 'relative',\n    backgroundColor: NICE_MIDDLE_BLUE,\n    padding: `8px ${GUTTER_DEFAULT_SPACING * 2}px`, // The overlapping height of the batch circle\n  },\n\n  select: {\n    paddingRight: `${(ARROW_SIZE * 2) + (BOX_DEFAULT_SPACING * 2)}px`,\n    position: 'relative',\n    zIndex: BASE_Z_INDEX,\n    color: LIGHT_PRIMARY_TEXT_COLOR,\n  },\n\n  arrow: {\n    position: 'absolute',\n    zIndex: BASE_Z_INDEX,\n    right: 0,\n    top: '8px',\n    borderLeft: `${ARROW_SIZE}px solid transparent`,\n    borderRight: `${ARROW_SIZE}px solid transparent`,\n    borderTop: `${ARROW_SIZE}px solid ${LIGHT_PRIMARY_TEXT_COLOR}`,\n    display: 'block',\n    height: 0,\n    width: 0,\n    marginLeft: `${BOX_DEFAULT_SPACING}px`,\n    marginRight: `${BOX_DEFAULT_SPACING}px`,\n  },\n\n  dropdown: {\n    position: 'relative',\n    width: 150,\n    paddingLeft: GUTTER_DEFAULT_SPACING,\n  },\n\n  slider: {\n    paddingTop: `${GUTTER_DEFAULT_SPACING * 2}px`,\n  },\n});\n\nexport interface IBatchSelectorProps {\n  defaultSelectionPosition1: number;\n  defaultSelectionPosition2: number;\n  automatedRuleToast?(rule: IRuleModel): void;\n  commentScores: Array<ICommentListItem>;\n  groupBy: 'date' | 'score';\n  rules?: List<IRuleModel>;\n  onSelectionChange?(selectedComments: Array<number>, pos1: number, pos2: number): void;\n  onSelectionChangeEnd?(selectedComments: Array<number>, pos1: number, pos2: number): void;\n  areAutomatedRulesApplied?: boolean;\n}\n\nexport interface IBatchSelectorState {\n  selectionPosition1?: number;\n  selectionPosition2?: number;\n  min?: number;\n  max?: number;\n}\n\nexport class BatchSelector\n    extends React.Component<IBatchSelectorProps, IBatchSelectorState> {\n\n  groupedByColumn: IGroupedComments;\n\n  state: IBatchSelectorState = {\n    selectionPosition1: this.props.defaultSelectionPosition1,\n    selectionPosition2: this.props.defaultSelectionPosition2,\n    min: Math.min(this.props.defaultSelectionPosition1, this.props.defaultSelectionPosition2),\n    max: Math.max(this.props.defaultSelectionPosition1, this.props.defaultSelectionPosition2),\n  };\n\n  componentWillUpdate(nextProps: IBatchSelectorProps) {\n    if (!this.groupedByColumn || !isEqual(this.props.commentScores, nextProps.commentScores)) {\n      if (this.props.groupBy === 'score') {\n        this.groupedByColumn = groupByScoreColumns(nextProps.commentScores as Array<ICommentScore>, COLCOUNT);\n      } else {\n        this.groupedByColumn = groupByDateColumns(nextProps.commentScores as Array<ICommentDate>, COLCOUNT);\n      }\n\n      this.onDataChange(this.state.selectionPosition1, this.state.selectionPosition2);\n      this.onDataChangeEnd(this.state.selectionPosition1, this.state.selectionPosition2);\n    }\n\n    if (this.props.defaultSelectionPosition1 !== nextProps.defaultSelectionPosition1 ||\n        this.props.defaultSelectionPosition2 !== nextProps.defaultSelectionPosition2) {\n      this.setState({\n        selectionPosition1: nextProps.defaultSelectionPosition1,\n        selectionPosition2: nextProps.defaultSelectionPosition2,\n        min: Math.min(nextProps.defaultSelectionPosition1, nextProps.defaultSelectionPosition2),\n        max: Math.max(nextProps.defaultSelectionPosition1, nextProps.defaultSelectionPosition2),\n      });\n    }\n  }\n\n  render() {\n    const {\n      selectionPosition1,\n      selectionPosition2,\n      min,\n      max,\n    } = this.state;\n\n    const {\n      rules,\n      automatedRuleToast,\n      areAutomatedRulesApplied,\n    } = this.props;\n\n    const makeDotChart = (width: number, height: number): React.ReactNode => {\n      return (\n        <DotChart\n          height={height}\n          width={width}\n          commentsByColumn={this.groupedByColumn}\n          columnCount={COLCOUNT}\n          selectedRange={{\n            start: min,\n            end: max,\n          }}\n        />\n      );\n    };\n\n    return (\n      <div {...css(STYLES.batchControls)}>\n        <div {...css({position: 'relative'})}>\n          <AspectRatio\n            ratio={5 / 1}\n            contents={makeDotChart}\n          />\n          {rules && rules.size > 0 && areAutomatedRulesApplied && (\n            <RuleBars\n              rules={rules}\n              automatedRuleToast={automatedRuleToast}\n            />\n          )}\n        </div>\n\n        <div {...css(STYLES.slider)}>\n          <Slider>\n            <RangeBar\n              selectedRange={{\n                start: min,\n                end: max,\n              }}\n            />\n            <DraggableHandle\n              label={this.convertPositionToLabel(min)}\n              position={selectionPosition1}\n              onChange={this.onHandle1Change}\n              onChangeEnd={this.onHandle1ChangeEnd}\n            />\n            <DraggableHandle\n              label={this.convertPositionToLabel(max)}\n              position={selectionPosition2}\n              onChange={this.onHandle2Change}\n              onChangeEnd={this.onHandle2ChangeEnd}\n              positionOnRight\n            />\n          </Slider>\n        </div>\n      </div>\n    );\n  }\n\n  private getSelectedComments(selectionPosition1: number, selectionPosition2: number): Array<number> {\n    const min = Math.min(selectionPosition1, selectionPosition2);\n    const max = Math.max(selectionPosition1, selectionPosition2);\n    const selectedRange = {\n      start: min,\n      end: max,\n    };\n\n    const columnKeys = Object.keys(this.groupedByColumn).sort();\n\n    return columnKeys.reduce((sum, key, i) => {\n      const colPercent = i / columnKeys.length;\n      const isSelected = selectedRange &&\n        (colPercent >= selectedRange.start && colPercent < selectedRange.end);\n\n      if (isSelected) {\n        sum = sum.concat(this.groupedByColumn[key]);\n      }\n\n      return sum;\n    }, [] as Array<number>);\n  }\n\n  @autobind\n  onHandle1Change(num: number) {\n    const clampedNum = parseFloat(num.toFixed(2));\n\n    this.setState({\n      min: Math.min(clampedNum, this.state.selectionPosition2),\n      max: Math.max(clampedNum, this.state.selectionPosition2),\n    });\n    this.onDataChange(clampedNum, this.state.selectionPosition2);\n  }\n\n  @autobind\n  onHandle2Change(num: number) {\n    const clampedNum = parseFloat(num.toFixed(2));\n\n    this.setState({\n      min: Math.min(this.state.selectionPosition1, clampedNum),\n      max: Math.max(this.state.selectionPosition1, clampedNum),\n    });\n    this.onDataChange(this.state.selectionPosition1, clampedNum);\n  }\n\n  @autobind\n  onHandle1ChangeEnd(num: number) {\n    const clampedNum = parseFloat(num.toFixed(2));\n\n    this.onDataChangeEnd(clampedNum, this.state.selectionPosition2);\n  }\n\n  @autobind\n  onHandle2ChangeEnd(num: number) {\n    const clampedNum = parseFloat(num.toFixed(2));\n\n    this.onDataChangeEnd(this.state.selectionPosition1, clampedNum);\n  }\n\n  onDataChange(pos1: number, pos2: number): void {\n    if (this.props.onSelectionChange) {\n      this.props.onSelectionChange(\n        this.getSelectedComments(pos1, pos2),\n        pos1,\n        pos2,\n      );\n    }\n  }\n\n  onDataChangeEnd(pos1: number, pos2: number): void {\n    if (this.props.onSelectionChangeEnd) {\n      this.props.onSelectionChangeEnd(\n        this.getSelectedComments(pos1, pos2),\n        pos1,\n        pos2,\n      );\n    }\n  }\n\n  private convertPositionToLabel(x: number) {\n    if (this.props.groupBy === 'date') {\n      if (!this.groupedByColumn) { return ''; }\n\n      const columnKeys = Object.keys(this.groupedByColumn).sort();\n      const startDate = parseFloat(columnKeys[0]);\n      const endDate = parseFloat(columnKeys[columnKeys.length - 1]);\n      const date = startDate + (endDate - startDate) * x;\n\n      return formatDate(date, DATE_FORMAT_LONG);\n    } else {\n      const n = clamp(x, 0, 1);\n\n      return `${Math.round((n) * 100)}%`;\n    }\n  }\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Comments/components/NewComments/components/BatchSelector/index.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nexport { BatchSelector } from './BatchSelector';\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Comments/components/NewComments/index.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { connect } from 'react-redux';\nimport { withRouter } from 'react-router';\nimport { compose } from 'redux';\nimport { createStructuredSelector } from 'reselect';\n\nimport { IAppDispatch, IAppState } from '../../../../appstate';\nimport { contextInjector } from '../../../../injectors/contextInjector';\nimport { getPreselects } from '../../../../stores/preselects';\nimport { getRules } from '../../../../stores/rules';\nimport { getTaggableTags } from '../../../../stores/tags';\nimport { getTextSizes, getTextSizesIsLoading } from '../../../../stores/textSizes';\nimport {\n  INewCommentsPathParams,\n} from '../../../routes';\nimport {\n  INewCommentsProps,\n  NewComments as PureNewComments,\n} from './NewComments';\nimport {\n  getAreAllSelected,\n  getAreAnyCommentsSelected,\n  getCommentScores,\n  getCurrentPagingIdentifier,\n  getIsItemChecked,\n  getIsLoading,\n  getSelectedTag,\n  loadCommentList,\n  removeCommentScore,\n  toggleSelectAll,\n  toggleSingleItem,\n} from './store';\n\nfunction mapDispatchToProps(dispatch: IAppDispatch): Partial<INewCommentsProps> {\n  return {\n    removeCommentScore: (idsToDispatch: Array<string>) => dispatch(removeCommentScore(idsToDispatch)),\n\n    toggleSelectAll: () => dispatch(toggleSelectAll()),\n\n    toggleSingleItem: ({ id }: { id: string }) => dispatch(toggleSingleItem({ id })),\n\n    loadData: async (params: INewCommentsPathParams, pos1: number, pos2: number, sort: string): Promise<void> => {\n      await dispatch(loadCommentList(params, pos1, pos2, sort));\n    },\n  };\n}\n\nconst mapStateToProps = createStructuredSelector({\n  preselects: getPreselects,\n\n  commentScores: getCommentScores,\n\n  isLoading: (state: IAppState) => getIsLoading(state) || getTextSizesIsLoading(state),\n\n  areNoneSelected: getAreAnyCommentsSelected,\n\n  areAllSelected: getAreAllSelected,\n\n  isItemChecked: (state: IAppState) => (id: string) => getIsItemChecked(state, id),\n\n  textSizes: getTextSizes,\n\n  tags: getTaggableTags,\n\n  selectedTag: (state: IAppState, { match: { params }}: INewCommentsProps) => {\n    return getSelectedTag(state, params.tag);\n  },\n\n  rules: getRules,\n\n  pagingIdentifier: getCurrentPagingIdentifier,\n});\n\nexport const NewComments = compose(\n  withRouter,\n  connect(mapStateToProps, mapDispatchToProps),\n  contextInjector,\n)(PureNewComments) as any;\n\nexport * from './store';\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Comments/components/NewComments/store/checkedSelection.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { Action, Reducer } from 'redux-actions';\nimport { IAppState } from '../../../../../appstate';\nimport { ICheckedSelectionPayloads, ICheckedSelectionState, makeCheckedSelectionStore } from '../../../../../util';\n\nconst checkedSelectionStore = makeCheckedSelectionStore(\n  (state: IAppState) => {\n    return state.scenes.comments.newComments.checkedSelection;\n  },\n  { defaultSelectionState: true },\n);\n\nconst checkedSelectionReducer: Reducer<ICheckedSelectionState, ICheckedSelectionPayloads> = checkedSelectionStore.reducer;\n\nconst getAreAllSelected: (state: IAppState) => boolean = checkedSelectionStore.getAreAllSelected;\nconst getAreAnyCommentsSelected: (state: IAppState) => boolean = checkedSelectionStore.getAreAnyCommentsSelected;\nconst getIsItemChecked: (state: IAppState, id: string) => boolean = checkedSelectionStore.getIsItemChecked;\nconst toggleSelectAll: () => Action<void> = checkedSelectionStore.toggleSelectAll;\nconst toggleSingleItem: (payload: { id: string }) => Action<{ id: string }> = checkedSelectionStore.toggleSingleItem;\n\nexport {\n  checkedSelectionReducer,\n  getAreAllSelected,\n  getAreAnyCommentsSelected,\n  getIsItemChecked,\n  toggleSelectAll,\n  toggleSingleItem,\n};\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Comments/components/NewComments/store/commentListLoader.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { List } from 'immutable';\nimport { ITagModel } from '../../../../../../models';\nimport { IThunkAction } from '../../../../../appstate';\nimport {clearCommentCache} from '../../../../../stores/globalActions';\nimport { getTags } from '../../../../../stores/tags';\nimport { loadTextSizesByIds } from '../../../../../stores/textSizes';\nimport { commentSortDefinitions } from '../../../../../utilx';\nimport {\n  INewCommentsPathParams,\n  isArticleContext,\n  newCommentsPageLink,\n} from '../../../../routes';\nimport { storeCommentPagingOptions } from '../../CommentDetail/store';\nimport {\n  getCommentScores,\n  loadCommentScoresForArticle,\n  loadCommentScoresForCategory,\n} from './commentScores';\nimport { setCurrentPagingIdentifier } from './currentPagingIdentifier';\nimport { getCommentIDsInRange } from './util';\n\nexport function loadCommentList(\n  params: INewCommentsPathParams,\n  pos1: number,\n  pos2: number,\n  sort: string,\n): IThunkAction<void> {\n  return async (dispatch, getState) => {\n    dispatch(clearCommentCache());\n    const tags = getTags(getState()) as List<ITagModel>;\n\n    const tagId = (params.tag === 'DATE' || params.tag === 'SUMMARY_SCORE') ? params.tag :\n      tags.find((t) => t.key === params.tag).id;\n\n    const sortDef = commentSortDefinitions[sort]\n        ? commentSortDefinitions[sort].sortInfo\n        : commentSortDefinitions['tag'].sortInfo;\n\n    const loader = isArticleContext(params) ? loadCommentScoresForArticle : loadCommentScoresForCategory;\n    await loader(dispatch, params.contextId, tagId, sortDef);\n\n    const commentScores = getCommentScores(getState());\n    const commentIDsInRange = getCommentIDsInRange(\n      commentScores,\n      pos1,\n      pos2,\n      params.tag === 'DATE',\n    );\n    const link = newCommentsPageLink(params, {pos1: pos1.toString(), pos2: pos2.toString(), sort});\n\n    const currentPagingIdentifier = await dispatch(storeCommentPagingOptions({\n      commentIds: commentIDsInRange,\n      fromBatch: true,\n      source: `Comment %i of ${commentIDsInRange.length} from new comments with tag \"${params.tag}\"`,\n      link,\n    }));\n\n    dispatch(setCurrentPagingIdentifier({ currentPagingIdentifier }));\n\n    const bodyContentWidth = 696;\n\n    await dispatch(loadTextSizesByIds(commentIDsInRange, bodyContentWidth));\n  };\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Comments/components/NewComments/store/commentScores.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\nimport { Action, createAction, handleActions } from 'redux-actions';\n\nimport { ICommentListItem } from '../../../../../../models';\nimport { IAppDispatch, IAppState } from '../../../../../appstate';\nimport {\n  listHistogramScoresByArticle,\n  listHistogramScoresByArticleByDate,\n  listHistogramScoresByCategory,\n  listHistogramScoresByCategoryByDate,\n  listMaxHistogramScoresByCategory,\n  listMaxSummaryScoreByArticle,\n} from '../../../../../platform/dataService';\n\nconst loadCommentScoresStart = createAction(\n  'article-detail-new/LOAD_COMMENTS_SCORES_START',\n);\n\ntype ILoadCommentScoresCompletePayload = {\n  scores: Array<ICommentListItem>;\n};\nconst loadCommentScoresComplete = createAction<ILoadCommentScoresCompletePayload>(\n  'article-detail-new/LOAD_COMMENTS_SCORES_COMPLETE',\n);\n\nexport const removeCommentScore: (payload: Array<string>) => Action<Array<string>> = createAction<Array<string>>(\n  'article-detail-new/REMOVE_COMMENT_SCORES',\n);\n\nexport async function loadCommentScoresForArticle(\n  dispatch: IAppDispatch,\n  articleId: string,\n  tagId:  string | 'DATE' | 'SUMMARY_SCORE',\n  sort: Array<string>,\n) {\n  await dispatch(loadCommentScoresStart());\n\n  let scores;\n\n  switch (tagId) {\n    case undefined:\n      break;\n    case 'DATE':\n      scores = await listHistogramScoresByArticleByDate(articleId, sort);\n      break;\n    case 'SUMMARY_SCORE':\n      scores = await listMaxSummaryScoreByArticle(articleId, sort);\n      break;\n    default:\n      scores = await listHistogramScoresByArticle(articleId, tagId, sort);\n  }\n\n  await dispatch(loadCommentScoresComplete({ scores }));\n}\n\nexport async function loadCommentScoresForCategory(\n  dispatch: IAppDispatch,\n  categoryId: string | 'all',\n  tagId: string | 'DATE' | 'SUMMARY_SCORE',\n  sort: Array<string>,\n) {\n  await dispatch(loadCommentScoresStart());\n\n  let scores;\n\n  switch (tagId) {\n    case undefined:\n      break;\n    case 'DATE':\n      scores = await listHistogramScoresByCategoryByDate(categoryId, sort);\n      break;\n    case 'SUMMARY_SCORE':\n      scores = await listMaxHistogramScoresByCategory(categoryId, sort);\n      break;\n    default:\n      scores = await listHistogramScoresByCategory(categoryId, tagId, sort);\n  }\n\n  await dispatch(loadCommentScoresComplete({ scores }));\n}\n\nexport type ICommentScoresState = Readonly<{\n  isLoading: boolean;\n  hasData: boolean;\n  scores: Array<ICommentListItem>;\n}>;\n\nconst initailState: ICommentScoresState = {\n  isLoading: true,\n  hasData: false,\n  scores: [],\n};\n\nexport const commentScoresReducer = handleActions<\n  ICommentScoresState,\n  void                                       | // loadCommentScoresStart\n  ILoadCommentScoresCompletePayload          | // loadCommentScoresComplete\n  Array<string>                                // removeCommentScore\n>({\n  [loadCommentScoresStart.toString()]: (state) => ({...state, isLoading: true, hasData: false }),\n\n  [loadCommentScoresComplete.toString()]: (_state, { payload }: Action<ILoadCommentScoresCompletePayload>) => {\n    const { scores } = payload;\n    return { isLoading: false, hasData: true, scores};\n  },\n\n  [removeCommentScore.toString()]: (state, { payload }: Action<Array<string>>) => ({\n    ...state,\n    scores: state.scores.filter((score: ICommentListItem) => {\n          const index = payload.findIndex((id: string) => id === score.commentId);\n          return index === -1;\n        }),\n    }),\n}, initailState);\n\nfunction getStoreRecord(state: IAppState) {\n  return state.scenes.comments.newComments.commentScores;\n}\n\nexport function getIsLoading(state: IAppState) {\n  const storeRecord = getStoreRecord(state);\n  return storeRecord && storeRecord.isLoading;\n}\n\nexport function getCommentScores(state: IAppState) {\n  const storeRecord = getStoreRecord(state);\n  return storeRecord && storeRecord.scores;\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Comments/components/NewComments/store/currentPagingIdentifier.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { Action, Reducer } from 'redux-actions';\n\nimport { IAppState } from '../../../../../appstate';\nimport {\n  ICurrentPagingIdentifierPayload,\n  ICurrentPagingIdentifierState,\n  makeCurrentPagingIdentifierReducer,\n} from '../../../../../util';\n\nconst currentPagingIdentifier = makeCurrentPagingIdentifierReducer(\n  (state: IAppState) => {\n    return state.scenes.comments.newComments.currentPagingIdentifier;\n  },\n);\n\nconst currentPagingIdentifierReducer: Reducer<ICurrentPagingIdentifierState, ICurrentPagingIdentifierPayload> = currentPagingIdentifier.reducer;\nconst setCurrentPagingIdentifier: (payload: ICurrentPagingIdentifierPayload) => Action<ICurrentPagingIdentifierPayload> = currentPagingIdentifier.setCurrentPagingIdentifier;\nconst getCurrentPagingIdentifier: (state: IAppState) => string = currentPagingIdentifier.getCurrentPagingIdentifier;\n\nexport {\n  currentPagingIdentifierReducer,\n  setCurrentPagingIdentifier,\n  getCurrentPagingIdentifier,\n};\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Comments/components/NewComments/store/index.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { combineReducers } from 'redux';\n\nimport { ICheckedSelectionState, ICurrentPagingIdentifierState } from '../../../../../util';\nimport { checkedSelectionReducer } from './checkedSelection';\nimport { commentScoresReducer, ICommentScoresState } from './commentScores';\nimport { currentPagingIdentifierReducer } from './currentPagingIdentifier';\n\nexport type INewCommentsState = Readonly<{\n  currentPagingIdentifier: ICurrentPagingIdentifierState,\n  checkedSelection: ICheckedSelectionState,\n  commentScores: ICommentScoresState,\n}>;\n\nexport const newCommentsReducer = combineReducers<INewCommentsState>({\n  currentPagingIdentifier: currentPagingIdentifierReducer,\n  checkedSelection: checkedSelectionReducer,\n  commentScores: commentScoresReducer,\n});\n\nexport * from './commentListLoader';\nexport * from './commentScores';\nexport * from './currentPagingIdentifier';\nexport * from './checkedSelection';\nexport * from './util';\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Comments/components/NewComments/store/util.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { List } from 'immutable';\nimport { clamp } from 'lodash';\nimport {\n  ICommentDate,\n  ICommentListItem,\n  ICommentScore,\n  ITagModel,\n  ModelId,\n  TagModel,\n} from '../../../../../../models';\nimport { IAppState } from '../../../../../appstate';\nimport { COLCOUNT } from '../../../../../config';\nimport { getTags } from '../../../../../stores/tags';\nimport { groupByDateColumns } from '../../../../../util';\n\nconst dateTag = TagModel({\n  id: 'DATE',\n  label: 'All Comments by Date',\n  key: 'DATE',\n  color: '',\n});\n\nexport function getTagsWithDateAndSummary(state: IAppState): List<ITagModel> {\n  return getTags(state).push(dateTag);\n}\n\nexport function getSelectedTag(state: IAppState, tag?: string): ITagModel | null {\n  return tag && getTagsWithDateAndSummary(state).find((t) => t.key === tag);\n}\n\nexport function getCommentIDsInRange(\n  commentScores: Array<ICommentListItem>,\n  selectionPosition1: number,\n  selectionPosition2: number,\n  groupByDate: boolean,\n): Array<ModelId> {\n  const minPos = Math.min(selectionPosition1, selectionPosition2);\n  const maxPos = Math.max(selectionPosition1, selectionPosition2);\n\n  let scores: Array<ICommentListItem>;\n\n  if (groupByDate) {\n    const grouped = groupByDateColumns(commentScores as Array<ICommentDate>, COLCOUNT);\n    const columnKeys = Object.keys(grouped).sort();\n\n    const pos1Index = clamp(Math.ceil(minPos * (columnKeys.length - 1)), 0, columnKeys.length - 1);\n    const pos2Index = clamp(Math.ceil(maxPos * (columnKeys.length - 1)), 0, columnKeys.length - 1);\n\n    const pos1Key = columnKeys[pos1Index];\n    const pos2Key = columnKeys[pos2Index];\n\n    const pos1Value = Number(pos1Key);\n    const pos2Value = Number(pos2Key);\n    scores = commentScores.filter((s: ICommentDate) => (+s.date >= pos1Value) && (+s.date < pos2Value));\n  } else {\n    scores = commentScores.filter((s: ICommentScore) => (s.score >= minPos) && (s.score < maxPos));\n  }\n\n  return scores.map((score) => score.commentId);\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Comments/components/Shortcuts/Shortcuts.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport React from 'react';\nimport { ARTICLE_CATEGORY_TYPE,\n  BOX_DEFAULT_SPACING,\n  DARK_PRIMARY_TEXT_COLOR,\n  DARK_SECONDARY_TEXT_COLOR,\n  GUTTER_DEFAULT_SPACING,\n  HEADLINE_TYPE,\n  NICE_MIDDLE_BLUE,\n} from '../../../../styles';\nimport { css, stylesheet } from '../../../../utilx';\n\nimport {\n  ApproveIcon,\n  ArrowIcon,\n  DeferIcon,\n  HighlightIcon,\n  KeyDownIcon,\n  KeyUpIcon,\n  RejectIcon,\n} from '../../../../components';\n\nconst KEY = 'alt';\n\nconst STYLES = stylesheet({\n  base: {\n    color: DARK_PRIMARY_TEXT_COLOR,\n    width: '100%',\n    height: '100%',\n    background: '#fff',\n  },\n\n  header: {\n    display: 'flex',\n    justifyContent: 'space-between',\n    alignItems: 'center',\n  },\n\n  title: {\n    ...HEADLINE_TYPE,\n    marginTop: GUTTER_DEFAULT_SPACING,\n    marginBottom: GUTTER_DEFAULT_SPACING,\n  },\n\n  closeButton: {\n    border: 0,\n    padding: `${BOX_DEFAULT_SPACING}px`,\n    background: 'none',\n    cursor: 'pointer',\n  },\n\n  shorcuts: {\n    marginTop: GUTTER_DEFAULT_SPACING,\n  },\n\n  shorcut: {\n    ...ARTICLE_CATEGORY_TYPE,\n    display: 'flex',\n    color: DARK_SECONDARY_TEXT_COLOR,\n    fontSize: '16px',\n    marginBottom: GUTTER_DEFAULT_SPACING,\n  },\n\n  name: {\n    width: '200px',\n    display: 'flex',\n    alignItems: 'center',\n  },\n\n  nameText: {\n    marginLeft: GUTTER_DEFAULT_SPACING,\n  },\n\n  keys: {\n    display: 'flex',\n  },\n\n  key: {\n    display: 'flex',\n    alignItems: 'center',\n    justifyContent: 'center',\n    width: 50,\n    height: 50,\n    marginRight: `${BOX_DEFAULT_SPACING}px`,\n    borderWidth: 1,\n    borderStyle: 'solid',\n    borderColor: DARK_SECONDARY_TEXT_COLOR,\n  },\n});\n\nexport interface IShortcutProps {\n  onClose?(e: React.MouseEvent<any>): any;\n}\n\nexport class Shortcuts extends React.Component<IShortcutProps> {\n  render() {\n    const {\n      onClose,\n    } = this.props;\n\n    return (\n      <div {...css(STYLES.base)}>\n        <div {...css(STYLES.header)}>\n          <h2 {...css(STYLES.title)}>Keyboard Shortcuts</h2>\n          <button {...css(STYLES.closeButton)} aria-label=\"close modal\" onClick={onClose}>\n            <RejectIcon {...css({ color: DARK_SECONDARY_TEXT_COLOR })} />\n          </button>\n        </div>\n        <div {...css(STYLES.shorcuts)}>\n          <div {...css(STYLES.shorcut)}>\n            <div {...css(STYLES.name)}>\n              <ApproveIcon {...css({ color: NICE_MIDDLE_BLUE })} />\n              <span {...css(STYLES.nameText)}>Approve</span>\n            </div>\n            <div {...css(STYLES.keys)}>\n              <div {...css(STYLES.key)}>{KEY}</div>\n              <div {...css(STYLES.key)}>A</div>\n            </div>\n          </div>\n          <div {...css(STYLES.shorcut)}>\n            <div {...css(STYLES.name)}>\n              <HighlightIcon {...css({ color: NICE_MIDDLE_BLUE })} />\n              <span {...css(STYLES.nameText)}>Highlight</span>\n            </div>\n            <div {...css(STYLES.keys)}>\n              <div {...css(STYLES.key)}>{KEY}</div>\n              <div {...css(STYLES.key)}>H</div>\n            </div>\n          </div>\n          <div {...css(STYLES.shorcut)}>\n            <div {...css(STYLES.name)}>\n              <RejectIcon {...css({ color: NICE_MIDDLE_BLUE })} />\n              <span {...css(STYLES.nameText)}>Reject</span>\n            </div>\n            <div {...css(STYLES.keys)}>\n              <div {...css(STYLES.key)}>{KEY}</div>\n              <div {...css(STYLES.key)}>R</div>\n            </div>\n          </div>\n          <div {...css(STYLES.shorcut)}>\n            <div {...css(STYLES.name)}>\n              <DeferIcon {...css({ color: NICE_MIDDLE_BLUE })} />\n              <span {...css(STYLES.nameText)}>Defer</span>\n            </div>\n            <div {...css(STYLES.keys)}>\n              <div {...css(STYLES.key)}>{KEY}</div>\n              <div {...css(STYLES.key)}>D</div>\n            </div>\n          </div>\n          <div {...css(STYLES.shorcut)}>\n            <div {...css(STYLES.name)}>\n              <ArrowIcon\n                {...css({\n                  fill: DARK_SECONDARY_TEXT_COLOR,\n                  transform: 'rotate(90deg)',\n                }) }\n                size={24}\n              />\n              <span {...css(STYLES.nameText)}>Previous</span>\n            </div>\n            <div {...css(STYLES.keys)}>\n              <div {...css(STYLES.key)}>{KEY}</div>\n              <div aria-label=\"Up arrow\" {...css(STYLES.key)}>\n                <KeyUpIcon {...css({ fill: DARK_SECONDARY_TEXT_COLOR })} />\n              </div>\n            </div>\n          </div>\n          <div {...css(STYLES.shorcut)}>\n            <div {...css(STYLES.name)}>\n              <ArrowIcon\n                {...css({\n                  fill: DARK_SECONDARY_TEXT_COLOR,\n                  transform: 'rotate(-90deg)',\n                })}\n                size={24}\n              />\n              <span {...css(STYLES.nameText)}>Next</span>\n            </div>\n            <div {...css(STYLES.keys)}>\n              <div {...css(STYLES.key)}>{KEY}</div>\n              <div aria-label=\"Down arrow\" {...css(STYLES.key)}>\n                <KeyDownIcon {...css({ fill: DARK_SECONDARY_TEXT_COLOR })} />\n              </div>\n            </div>\n          </div>\n        </div>\n      </div>\n    );\n  }\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Comments/components/Shortcuts/ShortcutsStory.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { action } from '@storybook/addon-actions';\nimport { storiesOf } from '@storybook/react';\n\nimport { Shortcuts } from '../Shortcuts';\n\nstoriesOf('Shortcuts', module)\n  .add('base', () => {\n    return <Shortcuts onClose={action('close modal')}/>;\n  });\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Comments/components/Shortcuts/__spec__/.gitkeep",
    "content": ""
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Comments/components/Shortcuts/index.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nexport { Shortcuts } from './Shortcuts';\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Comments/components/SubheaderBar.tsx",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\nimport React from 'react';\nimport { useSelector } from 'react-redux';\nimport { Link, useLocation, useRouteMatch } from 'react-router-dom';\nimport { useRouteContext } from '../../../injectors/contextInjector';\nimport { getGlobalCounts } from '../../../stores/categories';\nimport {\n  HEADER_HEIGHT,\n  LIGHT_PRIMARY_TEXT_COLOR,\n  NICE_MIDDLE_BLUE,\n} from '../../../styles';\nimport { css, stylesheet } from '../../../utilx';\nimport {\n  IContextPathParams,\n  moderatedCommentsPageLink,\n  NEW_COMMENTS_DEFAULT_TAG,\n  newCommentsPageLink,\n  rangesLink,\n  settingsLink,\n} from '../../routes';\n\nconst STYLES = stylesheet({\n  header: {\n    background: NICE_MIDDLE_BLUE,\n    color: LIGHT_PRIMARY_TEXT_COLOR,\n    boxSizing: 'border-box',\n    display: 'flex',\n    justifyContent: 'center',\n    alignItems: 'center',\n    width: '100%',\n    height: `${HEADER_HEIGHT + 12}px`,\n  },\n\n  headerItem: {\n    color: `rgba(255,255,255,0.54)`,\n    textAlign: 'center',\n    marginTop: `${3}px`,\n    width: '13vw',\n    height: `${HEADER_HEIGHT - 10 - 3}px`,\n    borderBottom: `3px solid rgba(255,255,255,0.05)`,\n  },\n\n  headerItemBig: {\n    width: '30vw',\n  },\n\n  headerItemSelected: {\n    color: `${LIGHT_PRIMARY_TEXT_COLOR}`,\n    borderBottom: `3px solid ${LIGHT_PRIMARY_TEXT_COLOR}`,\n  },\n\n  headerLink: {\n    color: 'inherit',\n    textDecoration: 'none',\n  },\n\n  headerText: {\n    fontSize: '12px',\n    lineHeight: '20px',\n    fontWeight: 500,\n  },\n\n  headerTextBig: {\n    fontSize: '20px',\n    paddingTop: '10px',\n  },\n});\n\nconst CELLS = [\n  ['New', 'unmoderatedCount', 'new'],\n  ['Approved', 'approvedCount', 'approved'],\n  ['Rejected', 'rejectedCount', 'rejected'],\n  ['Deferred', 'deferredCount', 'deferred'],\n  ['Highlighted', 'highlightedCount', 'highlighted'],\n  ['Flagged', 'flaggedCount', 'flagged'],\n  ['Batched', 'batchedCount', 'batched'],\n];\n\nexport function SubheaderBar(_props: {}) {\n  const {\n    category,\n    article,\n  } = useRouteContext();\n\n  const {params} = useRouteMatch<IContextPathParams & {pt1: string, pt2: string}>('/:context/:contextId/:pt1/:pt2');\n\n  const global = useSelector(getGlobalCounts);\n\n  function linkFunction(disposition: string) {\n    if (disposition === 'new') {\n      return newCommentsPageLink({\n        context: params.context,\n        contextId: params.contextId,\n        tag: NEW_COMMENTS_DEFAULT_TAG,\n      });\n    }\n    return moderatedCommentsPageLink({\n      context: params.context,\n      contextId: params.contextId,\n      disposition,\n    });\n  }\n\n  const counts = article ? article :\n    category ? category :\n      global;\n\n  function renderHeaderItem(cell: Array<string>) {\n    let styles = {...css(STYLES.headerItem)};\n    if (cell[2] === params.pt1 || cell[2] === params.pt2) {\n      styles = {...css(STYLES.headerItem, STYLES.headerItemSelected)};\n    }\n    return (\n      <div key={cell[2]} {...styles}>\n        <Link to={linkFunction(cell[2])} aria-label={cell[0]} {...css(STYLES.headerLink)}>\n          <div {...css(STYLES.headerText)}>{cell[0]}</div>\n          <div {...css(STYLES.headerText)}>{(counts as any)[cell[1]]}</div>\n        </Link>\n      </div>\n    );\n  }\n\n  return (\n    <header key=\"header\" role=\"banner\" {...css(STYLES.header)}>\n      {CELLS.map(renderHeaderItem)}\n    </header>\n  );\n}\n\nexport function SettingsSubheaderBar(_props: {}) {\n  const location = useLocation();\n\n  function renderHeaderItem(route: string, label: string) {\n    let styles = {...css(STYLES.headerItem, STYLES.headerItemBig)};\n    if (route === location.pathname) {\n      styles = {...css(STYLES.headerItem, STYLES.headerItemBig, STYLES.headerItemSelected)};\n    }\n    return (\n      <div key={route} {...styles}>\n        <Link to={route} {...css(STYLES.headerLink)}>\n          <div {...css(STYLES.headerText, STYLES.headerTextBig)}>{label}</div>\n        </Link>\n      </div>\n    );\n  }\n  return (\n    <header key=\"header\" role=\"banner\" {...css(STYLES.header)}>\n      {renderHeaderItem(settingsLink(), 'Users and Services')}\n      {renderHeaderItem(rangesLink(), 'Tags and Ranges')}\n    </header>\n  );\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Comments/components/TagSelector/TagSelector.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { autobind } from 'core-decorators';\nimport React from 'react';\nimport { RouteComponentProps } from 'react-router';\n\nimport { ITagModel, TagModel } from '../../../../../models';\nimport {\n  OverflowContainer,\n  RejectIcon,\n} from '../../../../components';\nimport { TagLabelRow } from '../../../../components/TagLabelRow';\nimport { API_URL } from '../../../../config';\nimport { getToken } from '../../../../platform/localStore';\nimport {\n  ARTICLE_CATEGORY_TYPE,\n  BODY_TEXT_TYPE,\n  BUTTON_RESET,\n  DARK_COLOR,\n  GUTTER_DEFAULT_SPACING,\n  LIGHT_SECONDARY_TEXT_COLOR,\n  WHITE_COLOR,\n} from '../../../../styles';\nimport { css } from '../../../../utilx';\nimport {\n  ITagSelectorPathParams,\n} from '../../../routes';\n\nconst ACTION_STYLES = {\n  header: {\n    ...BODY_TEXT_TYPE,\n    background: DARK_COLOR,\n    padding: `${GUTTER_DEFAULT_SPACING}px ${GUTTER_DEFAULT_SPACING}px ${GUTTER_DEFAULT_SPACING}px ${GUTTER_DEFAULT_SPACING * 3}px`,\n    fontSize: 18,\n    width: '100%',\n    boxSizing: 'border-box',\n    display: 'flex',\n    flex: 1,\n    justifyContent: 'space-between',\n    borderBottom: '1px solid rgba(255, 255, 255, 0.1)',\n  },\n\n  headerTitle: {\n    ...ARTICLE_CATEGORY_TYPE,\n    flex: 1,\n    color: LIGHT_SECONDARY_TEXT_COLOR,\n    margin: 0,\n  },\n\n  closeButton: {\n    ...BUTTON_RESET,\n    cursor: 'pointer',\n    alignSelf: 'flex-end',\n    borderBottom: '2px solid transparent',\n    ':focus': {\n      outline: 0,\n      borderBottom: `2px solid ${WHITE_COLOR}`,\n    },\n  },\n};\n\nconst SNAPSHOT_WIDTH = 264;\nconst SNAPSHOT_HEIGHT = 76;\n\nconst dateTag = TagModel({\n  id: 'DATE',\n  label: 'All Comments by Date',\n  key: 'DATE',\n  color: '',\n});\n\nfunction getImagePath(base: string, id: string, tagId: string) {\n  const dp = window.devicePixelRatio || 1;\n  const startPath = `${base}/${id}`;\n\n  let tagSuffix;\n\n  if (tagId === 'DATE') {\n    tagSuffix = 'byDate';\n  } else {\n    tagSuffix = `tags/${tagId}`;\n  }\n\n  return `${API_URL}/services/histogramScores/`\n    + startPath\n    + '/'\n    + tagSuffix\n    + `/chart?width=${SNAPSHOT_WIDTH * dp}&height=${SNAPSHOT_HEIGHT * dp}&token=${getToken()}`;\n}\n\nexport interface ITagSelectorProps extends RouteComponentProps<ITagSelectorPathParams> {\n  tags: Array<ITagModel>;\n}\n\nexport class TagSelector extends React.Component<ITagSelectorProps> {\n  @autobind\n  onCloseClick(e: React.MouseEvent<any>) {\n    e.preventDefault();\n    setTimeout(() => window.history.back(), 10);\n  }\n\n  render() {\n    const { tags } = this.props;\n    const { context, contextId, tag: currentTag } = this.props.match.params;\n\n    const summaryTag = tags.filter((tag) => tag.key === 'SUMMARY_SCORE');\n    const tagsWithoutSummary = tags.filter((tag) => tag.key !== 'SUMMARY_SCORE' && tag.isInBatchView === true);\n    const tagsWithDate = [...summaryTag, ...tagsWithoutSummary, dateTag];\n\n    const tagsWithDateRows = tagsWithDate.map((tag, i) => (\n      <TagLabelRow\n        tag={tag}\n        key={tag.key}\n        context={context}\n        contextId={contextId}\n        isSelected={currentTag === tag.id}\n        imageWidth={SNAPSHOT_WIDTH}\n        imageHeight={SNAPSHOT_HEIGHT}\n        imagePath={getImagePath(context, contextId, tag.key === 'SUMMARY_SCORE' ? 'SUMMARY_SCORE' : tag.id)}\n        background={i % 2 ? '#295D86' : '#2F6793'}\n      />\n    ));\n\n    return (\n      <OverflowContainer\n        header={(\n          <div {...css(ACTION_STYLES.header)}>\n            <h1 {...css(ACTION_STYLES.headerTitle)}>Select a view</h1>\n            <button\n              {...css(ACTION_STYLES.closeButton)}\n              type=\"button\"\n              onClick={this.onCloseClick}\n              aria-label=\"Go back\"\n            >\n              <RejectIcon {...css({ fill: WHITE_COLOR })} />\n            </button>\n          </div>\n        )}\n        body={(\n          <div>\n            {tagsWithDateRows}\n          </div>\n        )}\n      />\n    );\n  }\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Comments/components/TagSelector/index.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { connect } from 'react-redux';\nimport { withRouter } from 'react-router';\nimport { compose } from 'redux';\nimport { createStructuredSelector } from 'reselect';\nimport { getTags } from '../../../../stores/tags';\nimport { TagSelector as PureTagSelector } from './TagSelector';\n\nconst mapStateToProps = createStructuredSelector({\n  tags: getTags,\n});\n\nexport const TagSelector = compose(\n  withRouter,\n  connect(mapStateToProps),\n)(PureTagSelector) as any;\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Comments/components/ThreadedCommentDetail/ThreadedCommentDetail.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { Set } from 'immutable';\nimport React from 'react';\nimport { useParams } from 'react-router';\n\nimport { ModelId } from '../../../../../models';\nimport {\n  RejectIcon,\n} from '../../../../components';\nimport { useCachedComment } from '../../../../injectors/commentInjector';\nimport {\n  rejectComments,\n  tagCommentSummaryScores,\n} from '../../../../stores/commentActions';\nimport {\n  BASE_Z_INDEX,\n  BODY_TEXT_TYPE,\n  DARK_COLOR,\n  DIVIDER_COLOR,\n  GUTTER_DEFAULT_SPACING,\n  NICE_MIDDLE_BLUE,\n  SCRIM_Z_INDEX,\n  WHITE_COLOR,\n} from '../../../../styles';\nimport { css, stylesheet } from '../../../../utilx';\nimport { ICommentDetailsPathParams } from '../../../routes';\nimport { ThreadedComment } from './components/ThreadedComment';\n\nconst HEADER_HEIGHT = 75;\n\nconst STYLES = stylesheet({\n  header: {\n    ...BODY_TEXT_TYPE,\n    padding: `${GUTTER_DEFAULT_SPACING}px`,\n    borderBottom: `1px solid ${DIVIDER_COLOR}`,\n    fontSize: 16,\n    position: 'fixed',\n    top: 0,\n    width: '100%',\n    boxSizing: 'border-box',\n    background: WHITE_COLOR,\n    zIndex: BASE_Z_INDEX,\n  },\n\n  timeStamp: {\n    paddingTop: 0,\n    paddingRight: 0,\n    paddingBottom: `${GUTTER_DEFAULT_SPACING}px`,\n    paddingLeft: `${GUTTER_DEFAULT_SPACING}px`,\n    color: NICE_MIDDLE_BLUE,\n  },\n\n  closeButton: {\n    position: 'absolute',\n    top: `${GUTTER_DEFAULT_SPACING}px`,\n    right: `${GUTTER_DEFAULT_SPACING}px`,\n    background: 'none',\n    border: 'none',\n    padding: 0,\n    margin: 0,\n    cursor: 'pointer',\n  },\n\n  body: {\n    marginTop: `${HEADER_HEIGHT}px`,\n    height: `calc(100vh - ${HEADER_HEIGHT}px)`,\n    overflowY: 'auto',\n  },\n\n  scrim: {\n    zIndex: SCRIM_Z_INDEX,\n    background: 'none',\n  },\n});\n\nexport function ThreadedCommentDetail() {\n  const {commentId} = useParams<ICommentDetailsPathParams>();\n  const {comment} = useCachedComment(commentId);\n\n  function onCloseClick() {\n    setTimeout(() => window.history.back(), 60);\n  }\n\n  async function handleAssignTagsSubmit(toUpdateId: ModelId, selectedTagIds: Set<ModelId>) {\n    selectedTagIds.forEach((tagId) => {\n      tagCommentSummaryScores([toUpdateId], tagId);\n    });\n    await rejectComments([toUpdateId]);\n  }\n\n  return (\n    <div>\n      <div key=\"buttons\" {...css(STYLES.header)}>\n        Replies to comment #{comment.sourceId} from {comment.author?.name}\n        <button\n          type=\"button\"\n          onClick={onCloseClick}\n          {...css(STYLES.closeButton)}\n          aria-label=\"Go back\"\n        >\n          <RejectIcon {...css({ fill: DARK_COLOR })} />\n        </button>\n      </div>\n      <div key=\"comments\" {...css(STYLES.body)}>\n        <ThreadedComment\n          comment={comment}\n          handleAssignTagsSubmit={handleAssignTagsSubmit}\n        />\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Comments/components/ThreadedCommentDetail/components/ThreadedComment/ThreadedComment.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { Set } from 'immutable';\nimport React, {useState} from 'react';\n\nimport {ICommentModel, ModelId} from '../../../../../../../models';\nimport { ICommentAction } from '../../../../../../../types';\nimport { BasicBody } from '../../../../../../components';\nimport { useCachedComment } from '../../../../../../injectors/commentInjector';\nimport {\n  approveComments,\n  deferComments,\n  highlightComments,\n  ICommentActionFunction,\n  rejectComments,\n  resetComments,\n  tagCommentSummaryScores,\n} from '../../../../../../stores/commentActions';\nimport {\n  ARTICLE_CATEGORY_TYPE,\n  DARK_PRIMARY_TEXT_COLOR,\n  DIVIDER_COLOR,\n  GUTTER_DEFAULT_SPACING,\n  WHITE_COLOR,\n} from '../../../../../../styles';\nimport { partial } from '../../../../../../util';\nimport { css, stylesheet } from '../../../../../../utilx';\nimport { articleBase, commentDetailsPageLink } from '../../../../../routes';\n\nconst STYLES = stylesheet({\n  base: {\n    backgroundColor: WHITE_COLOR,\n    width: '100%',\n    boxSizing: 'border-box',\n  },\n\n  row: {\n    borderBottom: `1px solid ${DIVIDER_COLOR}`,\n    boxSizing: 'border-box',\n    paddingTop: `${GUTTER_DEFAULT_SPACING}px`,\n    paddingBottom: `${GUTTER_DEFAULT_SPACING * 1.5}px`,\n  },\n\n  body: {\n    margin: '0 auto',\n    width: 690,\n  },\n\n  replyBody: {\n    paddingLeft: 50,\n    boxSizing: 'border-box',\n  },\n\n  replyIcon: {\n    width: 53,\n    marginTop: 40,\n  },\n\n  moderatedIcon: {\n    display: 'flex',\n    alignItems: 'center',\n    justifyContent: 'flex-end',\n    width: `${GUTTER_DEFAULT_SPACING * 3}px`,\n    marginLeft: `${GUTTER_DEFAULT_SPACING * 2}px`,\n  },\n\n  commentInfo: {\n    ...ARTICLE_CATEGORY_TYPE,\n    color: DARK_PRIMARY_TEXT_COLOR,\n  },\n});\n\nconst actionMap: { [key: string]: ICommentActionFunction } = {\n  highlight: highlightComments,\n  approve: approveComments,\n  defer: deferComments,\n  reject: rejectComments,\n  tag: tagCommentSummaryScores,\n  reset: resetComments,\n};\n\ninterface IReplyItemProps {\n  replyId: ModelId;\n  showActions: boolean;\n  handleRowMouseEnter(id: string): void;\n  handleRowMouseLeave(): void;\n  handleAssignTagsSubmit(commentId: ModelId, selectedTagIds: Set<ModelId>, rejectedTagIds: Set<ModelId>): Promise<void>;\n  dispatchConfirmedReply(action: ICommentAction, ids: Array<string>): void;\n}\n\nfunction ReplyItem(props: IReplyItemProps) {\n  const {\n    replyId,\n    showActions,\n    handleRowMouseEnter,\n    handleRowMouseLeave,\n    dispatchConfirmedReply,\n    handleAssignTagsSubmit,\n  } = props;\n  const {comment: reply} = useCachedComment(replyId);\n  return (\n    <div\n      {...css(STYLES.row)}\n      onMouseEnter={partial(handleRowMouseEnter, replyId)}\n      onMouseLeave={handleRowMouseLeave}\n    >\n      <div {...css(STYLES.body, STYLES.replyBody)}>\n        <BasicBody\n          comment={reply}\n          commentLinkTarget={`/articles/${reply.articleId}/comments/${reply.id}`}\n          showActions={showActions}\n          handleAssignTagsSubmit={handleAssignTagsSubmit}\n          dispatchConfirmedAction={dispatchConfirmedReply}\n        />\n      </div>\n    </div>\n  );\n}\n\nexport interface IThreadedCommentProps {\n  comment: ICommentModel;\n  handleAssignTagsSubmit(commentId: ModelId, selectedTagIds: Set<ModelId>, rejectedTagIds: Set<ModelId>): Promise<void>;\n}\n\nexport function ThreadedComment(props: IThreadedCommentProps) {\n  const [hoveredRowId, setHoveredRowId] = useState<ModelId | null>(null);\n  const [hoveredRowThresholdPassed, setHoveredRowThresholdPassed] = useState(false);\n\n  function handleRowMouseEnter(id: string): void {\n    setHoveredRowId(id);\n    setTimeout(() => {\n      if (hoveredRowId === id) {\n        setHoveredRowThresholdPassed(true);\n      }\n    }, 180);\n  }\n\n  function handleRowMouseLeave(): void {\n    setHoveredRowId(null);\n    setHoveredRowThresholdPassed(false);\n  }\n\n  async function dispatchConfirmedAction(action: ICommentAction, ids: Array<string>) {\n    await actionMap[action](ids);\n  }\n\n  async function dispatchConfirmedReply(action: ICommentAction, ids: Array<string>) {\n    await actionMap[action](ids);\n  }\n\n  const {\n    comment,\n    handleAssignTagsSubmit,\n  } = props;\n\n  return (\n    <div {...css(STYLES.base)}>\n      <div\n        {...css(STYLES.row)}\n        onMouseEnter={partial(handleRowMouseEnter, comment.id)}\n        onMouseLeave={handleRowMouseLeave}\n      >\n        <div {...css(STYLES.body)}>\n          <BasicBody\n            commentLinkTarget={commentDetailsPageLink({\n              context: articleBase,\n              contextId: comment.articleId,\n              commentId: comment.id,\n            })}\n            handleAssignTagsSubmit={handleAssignTagsSubmit}\n            comment={comment}\n            showActions={(comment.id === hoveredRowId) && hoveredRowThresholdPassed}\n            dispatchConfirmedAction={dispatchConfirmedAction}\n          />\n        </div>\n      </div>\n\n      { comment.replies && comment.replies.map((replyId) => (\n        <ReplyItem\n          key={replyId}\n          replyId={replyId}\n          showActions={(replyId === hoveredRowId) && hoveredRowThresholdPassed}\n          handleRowMouseEnter={handleRowMouseEnter}\n          handleRowMouseLeave={handleRowMouseLeave}\n          handleAssignTagsSubmit={handleAssignTagsSubmit}\n          dispatchConfirmedReply={dispatchConfirmedReply}\n        />\n      )) }\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Comments/components/ThreadedCommentDetail/components/ThreadedComment/ThreadedCommentStory.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { storiesOf } from '@storybook/react';\nimport React from 'react';\nimport { MemoryRouter } from 'react-router-dom';\n\nimport { IAuthorModel } from '../../../../../../../models';\nimport { fakeCommentModel } from '../../../../../../../models/fake';\nimport { css } from '../../../../../../utilx';\nimport { ThreadedComment } from './ThreadedComment';\n\nasync function doNothing() {/**/}\n\nconst author = {\n  email: 'name@email.com',\n  location: 'NYC',\n  name: 'Bridie Skiles IV',\n} as IAuthorModel;\n\nconst replies = [\n  fakeCommentModel({\n    isDeferred: true,\n    isAccepted: null,\n    isModerated: false,\n    isHighlighted: false,\n    sourceCreatedAt: null,\n    authorSourceId: 'author2',\n    author,\n    unresolvedFlagsCount: 2,\n    flagsSummary: new Map([['red', [1, 0, 1]], ['green', [2, 2, 0]]]),\n    text: 'First reply comment text is here. This comment is marked Deferred.',\n  }),\n  fakeCommentModel({\n    isHighlighted: true,\n    isAccepted: null,\n    isDeferred: false,\n    isModerated: true,\n    sourceCreatedAt: null,\n    authorSourceId: 'author3',\n    author,\n    unresolvedFlagsCount: 1 ,\n    flagsSummary: new Map([['red', [1, 0, 0]], ['green', [2, 1, 0]]]),\n    text: 'Second reply comment text is here. This comment is marked Highlighted.',\n  }),\n  fakeCommentModel({\n    isAccepted: false,\n    isDeferred: false,\n    isModerated: true,\n    isHighlighted: false,\n    sourceCreatedAt: null,\n    authorSourceId: 'author4',\n    author,\n    unresolvedFlagsCount: 20,\n    flagsSummary: new Map([\n      ['red', [5, 3, 5]],\n      ['green', [15, 10, 15]],\n      ['blue', [10, 7, 10]],\n    ]),\n    text: 'Third reply comment text is here. This comment is marked Rejected.',\n  }),\n  fakeCommentModel({\n    isAccepted: null,\n    isDeferred: false,\n    isModerated: false,\n    isHighlighted: false,\n    sourceCreatedAt: null,\n    authorSourceId: 'author5',\n    author,\n    unresolvedFlagsCount: 0,\n    text: 'Fourth reply comment text is here. This comment has not yet been moderated.',\n  }),\n];\n\nconst comment = fakeCommentModel({\n  isAccepted: true,\n  isDeferred: false,\n  isModerated: true,\n  isHighlighted: false,\n  sourceCreatedAt: null,\n  authorSourceId: 'author1',\n  author,\n  unresolvedFlagsCount: 1,\n  flagsSummary: new Map([['red', [1, 0, 0]], ['green', [2, 1, 2]]]),\n  text: 'Originating comment text is here',\n  replies: replies.map((r) => r.id),\n});\n\nconst STORY_STYLES = {\n  base: {\n    width: '100%',\n    background: 'rgba(0, 0, 0, 0.1)',\n    padding: '20px 0',\n  },\n\n  detail: {\n    maxWidth: '700px',\n    margin: '0 auto',\n    background: '#fff',\n  },\n};\n\nstoriesOf('ThreadedComment', module)\n  .addDecorator((story) => (\n    <MemoryRouter initialEntries={['/']}>{story()}</MemoryRouter>\n  ))\n  .add('default list', () => {\n    return (\n      <div {...css(STORY_STYLES.base)}>\n        <div {...css(STORY_STYLES.detail)}>\n          <ThreadedComment\n            comment={comment}\n            handleAssignTagsSubmit={doNothing}\n          />\n        </div>\n      </div>\n    );\n  });\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Comments/components/ThreadedCommentDetail/components/ThreadedComment/index.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nexport { ThreadedComment } from './ThreadedComment';\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Comments/components/ThreadedCommentDetail/index.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nexport { ThreadedCommentDetail } from './ThreadedCommentDetail';\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Comments/index.ts",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nexport { Comments } from './Comments';\nexport { NewComments } from './components/NewComments';\nexport { TagSelector } from './components/TagSelector';\nexport { ModeratedComments } from './components/ModeratedComments';\nexport { CommentDetail } from './components/CommentDetail';\nexport { ThreadedCommentDetail } from './components/ThreadedCommentDetail';\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Comments/scoreFilters.ts",
    "content": "/*\nCopyright 2020 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { List, Map } from 'immutable';\n\nimport {\n  ICommentScoreModel,\n  ICommentSummaryScoreModel,\n  ITaggingSensitivityModel,\n  ModelId,\n} from '../../../models';\n\nexport function getSensitivitiesForCategory(\n  categoryId: ModelId,\n  taggingSensitivities: List<ITaggingSensitivityModel>,\n) {\n  return taggingSensitivities.filter((ts: ITaggingSensitivityModel) => (\n    ts.categoryId === categoryId || ts.categoryId === null\n  )) as List<ITaggingSensitivityModel>;\n}\n\nfunction isSummaryAboveThreshold(\n  taggingSensitivities: List<ITaggingSensitivityModel>,\n  score: ICommentSummaryScoreModel,\n): boolean {\n  if (score.tagId === null) {\n    return false;\n  }\n\n  return taggingSensitivities.some((ts) => {\n    return (\n      (ts.tagId === null || ts.tagId === score.tagId) &&\n      (score.score >= ts.lowerThreshold && score.score <= ts.upperThreshold)\n    );\n  });\n}\n\nfunction isScoreAboveThreshold(\n  taggingSensitivities: List<ITaggingSensitivityModel>,\n  score: ICommentScoreModel,\n): boolean {\n  if (score.tagId === null) {\n    return false;\n  }\n\n  return taggingSensitivities.some((ts) => {\n    return (\n      (ts.tagId === null || ts.tagId === score.tagId) &&\n      (score.score >= ts.lowerThreshold && score.score <= ts.upperThreshold)\n    );\n  });\n}\n\nexport function getSummaryScoresAboveThreshold(\n  taggingSensitivities: List<ITaggingSensitivityModel>,\n  scores: Array<ICommentSummaryScoreModel>): Array<ICommentSummaryScoreModel> {\n  if (!scores) {\n    return [];\n  }\n\n  return scores.filter((s) => isSummaryAboveThreshold(taggingSensitivities, s))\n    .sort((a, b) => b.score - a.score);\n}\n\nexport function getSummaryScoresBelowThreshold(\n  taggingSensitivities: List<ITaggingSensitivityModel>,\n  scores: Array<ICommentSummaryScoreModel>): Array<ICommentSummaryScoreModel> {\n  if (!scores) {\n    return [];\n  }\n  const tagsAboveThreshold = new Set(getSummaryScoresAboveThreshold(taggingSensitivities, scores).map((s) => s.tagId));\n  const scoresBelowThreshold = scores.filter((s) => !tagsAboveThreshold.has(s.tagId));\n  return scoresBelowThreshold.sort((a, b) => b.score - a.score);\n}\n\nfunction dedupeScoreTypes(scores: Array<ICommentScoreModel>): Array<ICommentScoreModel> {\n  return scores\n    .reduce((sum, score) => {\n      const existingScore = sum.get(score.tagId);\n\n      if (!existingScore || existingScore.score < score.score) {\n        return sum.set(score.tagId, score);\n      }\n\n      return sum;\n    }, Map<string, ICommentScoreModel>())\n    .toArray();\n}\n\nexport function getScoresAboveThreshold(\n  taggingSensitivities: List<ITaggingSensitivityModel>,\n  scores: Array<ICommentScoreModel>,\n): Array<ICommentScoreModel> {\n  return scores\n    .filter((s) => isScoreAboveThreshold(taggingSensitivities, s))\n    .sort((a, b) => b.score - a.score);\n}\n\nexport function getScoresBelowThreshold(\n  taggingSensitivities: List<ITaggingSensitivityModel>,\n  scores: Array<ICommentScoreModel>,\n): Array<ICommentScoreModel> {\n  const aboveThreshold = scores.filter((s) =>\n    isScoreAboveThreshold(taggingSensitivities, s));\n  const scoresBelowThreshold = scores.filter((s) =>\n    !isScoreAboveThreshold(taggingSensitivities, s) &&\n    !aboveThreshold.find((sa) => sa.tagId === s.tagId));\n\n  return scoresBelowThreshold\n    .sort((a, b) => b.score - a.score);\n}\n\nexport function getReducedScoresAboveThreshold(\n  taggingSensitivities: List<ITaggingSensitivityModel>,\n  scores: Array<ICommentScoreModel>,\n): Array<ICommentScoreModel> {\n  return dedupeScoreTypes(getScoresAboveThreshold(taggingSensitivities, scores));\n}\n\nexport function getReducedScoresBelowThreshold(\n  taggingSensitivities: List<ITaggingSensitivityModel>,\n  scores: Array<ICommentScoreModel>,\n): Array<ICommentScoreModel> {\n  return dedupeScoreTypes(getScoresBelowThreshold(taggingSensitivities, scores));\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Comments/store.ts",
    "content": "/*\nCopyright 2020 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { combineReducers } from 'redux';\n\nimport { ICommentDetailState, reducer as commentDetailReducer } from './components/CommentDetail/store';\nimport { IModeratedCommentsGlobalState, reducer as moderatedCommentsReducer } from './components/ModeratedComments/store';\nimport { INewCommentsState, newCommentsReducer } from './components/NewComments/store';\n\nexport type ICommentsGlobalState = Readonly<{\n  newComments: INewCommentsState;\n  moderatedComments: IModeratedCommentsGlobalState;\n  commentDetail: ICommentDetailState;\n}>;\n\nexport const commentsReducer = combineReducers<ICommentsGlobalState>({\n  newComments: newCommentsReducer,\n  moderatedComments: moderatedCommentsReducer,\n  commentDetail: commentDetailReducer,\n});\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Login/ConfigureOAuth.tsx",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\nimport React, {useState} from 'react';\n\nimport { SPLASH_STYLES, SplashFrame, SplashRoot } from '../../components';\nimport { getOAuthConfig, IApiConfiguration, updateOAuthConfig } from '../../platform/dataService';\nimport { COMMON_STYLES } from '../../stylesx';\nimport { css, stylesheet } from '../../utilx';\nimport { OAuthConfig } from '../Settings/components/OAuthConfig';\n\nexport const STYLES = stylesheet({\n  frame: {\n    height: '100vh',\n    display: 'flex',\n    flexDirection: 'column',\n    justifyContent: 'center',\n    alignItems: 'center',\n  },\n\n  content: {\n    backgroundColor: 'white',\n    fontSize: '2vh',\n    margin: '7vh 15vw',\n    padding: '2vh 5vh 3vh 5vh',\n    borderRadius: '4vh',\n    maxWidth: '1000px',\n  },\n});\n\ninterface IConfigureOAuthProps {\n  restart(): void;\n}\n\nexport function ConfigureOAuth(props: IConfigureOAuthProps) {\n  const [state, setState] = useState({id: '', secret: ''});\n  const [reconnecting, setReconnecting] = useState(false);\n\n  React.useEffect(() => {\n    (async () => {\n      const oauthConfig = await getOAuthConfig();\n      setState(oauthConfig);\n    })();\n  }, []);\n\n  async function onClickDone(config: IApiConfiguration) {\n    await updateOAuthConfig(config);\n    setReconnecting(true);\n    // Stall for long enough for the server to reinitialise.\n    setTimeout(props.restart, 5000);\n  }\n\n  if (reconnecting) {\n    return (\n      <SplashRoot>\n        <div key=\"message\" {...css(SPLASH_STYLES.header2Tag, COMMON_STYLES.fadeIn)}>Reconfiguring Server</div>\n      </SplashRoot>\n    );\n  }\n\n  return (\n    <SplashFrame>\n      <div key=\"frame\" {...css(STYLES.frame, COMMON_STYLES.fadeIn)}>\n        <div key=\"content\" {...css(STYLES.content)}>\n          <OAuthConfig\n            onClickDone={onClickDone}\n            id={state.id}\n            secret={state.secret}\n          />\n        </div>\n      </div>\n    </SplashFrame>\n  );\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Login/Login.tsx",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport cryptoRandomString from 'crypto-random-string';\nimport { pick } from 'lodash';\nimport qs from 'query-string';\nimport React from 'react';\n\nimport { Bubbles, SPLASH_STYLES, SplashFrame, SplashRoot } from '../../components';\nimport { API_URL } from '../../config';\nimport { COMMON_STYLES } from '../../stylesx';\nimport { IReturnURL, setCSRF, setReturnURL } from '../../util';\nimport { css, stylesheet } from '../../utilx';\n\nexport const STYLES = stylesheet({\n  frame: {\n    height: '100vh',\n    display: 'flex',\n    flexDirection: 'column',\n    justifyContent: 'center',\n    alignItems: 'center',\n  },\n\n  content: {\n    color: 'white',\n    textAlign: 'center',\n    fontSize: '2vh',\n    padding: '2vh 5vh 3vh 5vh',\n  },\n});\n\nexport interface ILoginProps {\n  errorMessage?: string;\n  firstUser?: boolean;\n  backToOAuth?(): void;\n}\n\nexport function Login(props: ILoginProps) {\n  const query = qs.parse(window.location.search);\n\n  let errorMessage = props.errorMessage;\n  if (query.error && query.error === 'true') {\n    errorMessage = query.errorMessage as string || 'An error occured.';\n  }\n\n  function redirectToLogin() {\n    const redirectURI = window.location.origin + window.location.pathname;\n    const csrf = cryptoRandomString({length: 32, type: 'alphanumeric'});\n    setCSRF(csrf);\n    let url = `${API_URL}/auth/login/google?csrf=${csrf}`;\n    if (redirectURI) {\n      url += `&referrer=${encodeURIComponent(redirectURI)}`;\n    }\n\n    if (!errorMessage) {\n      const returnDetails = pick(window.location, ['pathname', 'search']) as IReturnURL;\n      setReturnURL(returnDetails);\n    }\n\n    window.location.href = url;\n  }\n\n  function oauthBack() {\n    if (!props.backToOAuth) {\n      return '';\n    }\n    return (\n      <p key=\"back\" style={{fontSize: '1vh'}}>\n        If you are having problems logging in,\n        check your <a key=\"backlink\" onClick={props.backToOAuth} {...css(SPLASH_STYLES.inlineLink)}>OAuth configuration.</a>\n      </p>\n    );\n  }\n\n  if (errorMessage) {\n    return (\n      <SplashRoot>\n      return (\n        <div key=\"login-errors\" {...css(SPLASH_STYLES.errors, COMMON_STYLES.fadeIn)}>\n          <p key=\"message\">{errorMessage}</p>\n          <p key=\"action\" {...css(SPLASH_STYLES.errorsTryAgain)}>\n            <a key=\"try-again\" onClick={redirectToLogin} {...css(SPLASH_STYLES.link)}>Try Again</a>\n          </p>\n          {oauthBack()}\n        </div>\n      </SplashRoot>\n    );\n  }\n\n  if (props.firstUser) {\n    return (\n      <SplashFrame>\n        <div key=\"frame\" {...css(STYLES.frame, COMMON_STYLES.fadeIn)}>\n          <Bubbles/>\n          <div key=\"first-user\" {...css(STYLES.content)}>\n            <p key=\"message\">There are no administrators registered yet.</p>\n            <p key=\"message2\">The first person to log in will become the administrator.<br/>\n              Once the first user has registered, the system will be locked down.<br/>\n              Additional users can be added on the settings pages.</p>\n            <p key=\"action\" {...css(SPLASH_STYLES.errorsTryAgain)}>\n              <a onClick={redirectToLogin} {...css(SPLASH_STYLES.link)}>Create First User</a>\n            </p>\n            <p key=\"back\">\n              This also tests out the OAuth configuration you have just set.<br/>If you are having\n              problems, you may need to revisit the <a onClick={props.backToOAuth} {...css(SPLASH_STYLES.inlineLink)}>OAuth configuration page</a>\n            </p>\n          </div>\n        </div>\n      </SplashFrame>\n    );\n  }\n\n  return (\n    <SplashRoot>\n      <div key=\"signin\" {...css(SPLASH_STYLES.signIn, COMMON_STYLES.fadeIn)}>\n        <a key=\"signin\" onClick={redirectToLogin} {...css(SPLASH_STYLES.link)}>Sign In</a>\n        {oauthBack()}\n      </div>\n    </SplashRoot>\n  );\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Login/LoginStory.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { storiesOf } from '@storybook/react';\nimport { Login } from './Login';\n\nstoriesOf('Login', module)\n  .add('Login page', () => {\n    return <Login />;\n  })\n  .add('Login failed', () => {\n    return <Login errorMessage=\"There was an error.\" />;\n  });\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Login/index.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nexport { ConfigureOAuth } from './ConfigureOAuth';\nexport { Login } from './Login';\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Search/Search.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport qs from 'query-string';\nimport React, {useEffect, useRef, useState} from 'react';\nimport { useDispatch } from 'react-redux';\nimport { Route, Switch } from 'react-router';\nimport { useHistory, useLocation } from 'react-router-dom';\n\nimport {\n  HeaderBar,\n  SearchAttribute,\n  SearchHeader,\n} from '../../components';\nimport { useCachedArticle } from '../../injectors/articleInjector';\nimport {\n  WHITE_COLOR,\n} from '../../styles';\nimport { css, stylesheet } from '../../utilx';\nimport { CommentDetail } from '../Comments/components/CommentDetail';\nimport { ISearchQueryParams, searchBase, searchLink } from '../routes';\nimport { SearchResults } from './components';\nimport { loadCommentList, resetCommentIds } from './store';\n\nconst HEADER_STYLES = stylesheet({\n  main: {\n    display: 'flex',\n    flexDirection: 'column',\n    height: '100%',\n    backgroundColor: WHITE_COLOR,\n  },\n\n  searchInput: {\n    backgroundColor: 'white',\n    height: '50px',\n    border: 'none',\n    borderRadius: '3px',\n    color: 'black',\n    padding: '0 10px',\n    fontSize: '18px',\n    flex: 1,\n    marginLeft: '16px',\n    '::placeholder': {\n      color: 'grey',\n    },\n  },\n\n  formContainer: {\n    display: 'flex',\n    alignItems: 'center',\n    flex: 1,\n    marginRight: '20px',\n    marginLeft: '20px',\n  },\n});\n\nexport function Search(_props: {}) {\n  const dispatch = useDispatch();\n  const searchInputRef = useRef(null);\n  useEffect(() => {\n    searchInputRef.current.focus();\n  }, []);\n\n  const history = useHistory();\n  const location = useLocation();\n  const query: ISearchQueryParams = qs.parse(location.search);\n\n  function updateSearchQuery(queryDelta: ISearchQueryParams) {\n    history.replace(searchLink({\n      ...query,\n      ...queryDelta,\n    }));\n  }\n\n  const {articleId, term, searchByAuthor, sort} = query;\n\n  useEffect(() => {\n    if (term) {\n      loadCommentList(dispatch, {\n        term,\n        params: {\n          articleId,\n          searchByAuthor,\n          sort: [sort] || searchByAuthor ? ['-sourceCreatedAt'] : null,\n        },\n      });\n    }\n    else {\n      dispatch(resetCommentIds());\n    }\n  }, [articleId, term, searchByAuthor, sort]);\n\n  function onCancelSearch() {\n    dispatch(resetCommentIds());\n    // This fixes a bug where hitting escape tries to both clear the currently focused item\n    // as well as run this function. This was causing weird rendering issues.\n    setTimeout(() => window.history.back(), 60);\n  }\n\n  function handleSearchArticleClose() {\n    updateSearchQuery({articleId: null});\n  }\n\n  function setSearchByAuthor(value: boolean) {\n    updateSearchQuery({searchByAuthor: value});\n  }\n\n  const [searchInputValue, setSearchInputValue] = useState(term || '');\n  function onSearchInputChange(e: React.ChangeEvent<HTMLInputElement>) {\n    setSearchInputValue(e.target.value);\n  }\n  function handleSearchFormSubmit(e: React.FormEvent<any>) {\n    e.preventDefault();\n    updateSearchQuery({term: searchInputValue});\n  }\n\n  const {article} = useCachedArticle(articleId);\n  const placeholderText = searchByAuthor ? 'Search comments by author ID or name' : 'Search';\n\n  return (\n    <div {...css({height: '100%'})}>\n      <div {...css(HEADER_STYLES.main)}>\n        <HeaderBar title=\"Search\" homeLink/>\n        <SearchHeader\n          searchByAuthor={searchByAuthor}\n          setSearchByAuthor={setSearchByAuthor}\n          cancelSearch={onCancelSearch}\n        >\n          <form key=\"search-form\" aria-label=\"Search form\" onSubmit={handleSearchFormSubmit} {...css(HEADER_STYLES.formContainer)}>\n            { article && (\n              <SearchAttribute title={`Article: ${article.title}`} onClose={handleSearchArticleClose} />\n            )}\n            <input\n              key=\"search-input\"\n              placeholder={placeholderText}\n              ref={searchInputRef}\n              type=\"text\"\n              value={searchInputValue}\n              onChange={onSearchInputChange}\n              {...css(HEADER_STYLES.searchInput)}\n            />\n          </form>\n        </SearchHeader>\n        <Switch>\n          <Route path={`/${searchBase}/comments/:commentId`}>\n            <CommentDetail/>\n          </Route>\n          <Route path={`/${searchBase}`}>\n            <SearchResults searchTerm={term} searchByAuthor={searchByAuthor} updateSearchQuery={updateSearchQuery}/>\n          </Route>\n        </Switch>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Search/components/SearchResults.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { autobind } from 'core-decorators';\nimport FocusTrap from 'focus-trap-react';\nimport { List, Set } from 'immutable';\nimport keyboardJS from 'keyboardjs';\nimport React from 'react';\nimport { RouteComponentProps } from 'react-router';\n\nimport {\n  CircularProgress,\n} from '@material-ui/core';\n\nimport {\n  ITagModel,\n  ModelId,\n  TagModel,\n} from '../../../../models';\nimport { ICommentAction } from '../../../../types';\nimport {\n  AddIcon,\n  ApproveIcon,\n  CommentActionButton,\n  CommentList,\n  DeferIcon,\n  HighlightIcon,\n  RejectIcon,\n  Scrim,\n  ToastMessage,\n  ToolTip,\n} from '../../../components';\nimport {\n  approveComments,\n  deferComments,\n  highlightComments,\n  ICommentActionFunction,\n  rejectComments,\n  resetComments,\n  tagCommentSummaryScores,\n} from '../../../stores/commentActions';\nimport {\n  DARK_COLOR,\n  GUTTER_DEFAULT_SPACING,\n  HEADER_HEIGHT,\n  LIGHT_PRIMARY_TEXT_COLOR,\n  NICE_MIDDLE_BLUE,\n  SCRIM_STYLE,\n  SCRIM_Z_INDEX,\n  TOOLTIP_Z_INDEX,\n  WHITE_COLOR,\n} from '../../../styles';\nimport { partial } from '../../../util';\nimport { css, stylesheet } from '../../../utilx';\nimport { commentSearchDetailsPageLink, ISearchQueryParams } from '../../routes';\n\nconst TOAST_DELAY = 6000;\n\nlet showActionIcon: JSX.Element = null;\nconst ACTION_PLURAL: any = {\n  highlight: 'highlighted',\n  approve: 'approved',\n  defer: 'deferred',\n  reject: 'rejected',\n  tag: 'tagged',\n};\n\nconst sortDefinitions: any = {\n  relevance: {\n    sortInfo: '',\n  },\n  newest: {\n    sortInfo: '-sourceCreatedAt',\n  },\n  oldest: {\n    sortInfo: 'sourceCreatedAt',\n  },\n};\nconst sortOptions = List.of(\n  TagModel({\n    key: 'relevance',\n    label: 'Relevance',\n    color: '',\n  }),\n  TagModel({\n    key: 'newest',\n    label: 'Newest',\n    color: '',\n  }),\n  TagModel({\n    key: 'oldest',\n    label: 'Oldest',\n    color: '',\n  }),\n);\n\nconst RESULTS_HEADER_HEIGHT = 50;\n\nconst STYLES = stylesheet({\n  children: {\n    display: 'inline-block',\n  },\n  placeholderBgContainer: {\n    display: 'flex',\n    justifyContent: 'center',\n    alignItems: 'center',\n    height: window.innerHeight - HEADER_HEIGHT - RESULTS_HEADER_HEIGHT,\n    width: '100%',\n  },\n  resultsHeader: {\n    alignItems: 'center',\n    backgroundColor: NICE_MIDDLE_BLUE,\n    color: LIGHT_PRIMARY_TEXT_COLOR,\n    display: 'flex',\n    flexWrap: 'no-wrap',\n    justifyContent: 'space-between',\n    height: RESULTS_HEADER_HEIGHT,\n  },\n  resultsHeadline: {\n    marginLeft: 29,\n  },\n  resultsActionHeadline: {\n    color: WHITE_COLOR,\n  },\n  moderateButtons: {\n    display: 'flex',\n  },\n  commentActionButton: {\n    marginRight: `${GUTTER_DEFAULT_SPACING}px`,\n  },\n  dropdown: {\n    position: 'relative',\n  },\n  scrim: {\n    zIndex: SCRIM_Z_INDEX,\n    background: 'none',\n  },\n  actionToastCount: {\n    display: 'flex',\n    alignItems: 'center',\n    justifyContent: 'center',\n    fontSize: 24,\n    lineHeight: 1.5,\n    textIndent: 4,\n  },\n  toolTipWithTags: {\n    container: {\n      width: 250,\n      marginRight: GUTTER_DEFAULT_SPACING,\n    },\n\n    ul: {\n      listStyle: 'none',\n      margin: 0,\n      padding: `${GUTTER_DEFAULT_SPACING}px 0`,\n    },\n\n    button: {\n      backgroundColor: 'transparent',\n      border: 'none',\n      borderRadius: 0,\n      color: NICE_MIDDLE_BLUE,\n      cursor: 'pointer',\n      padding: '8px 20px',\n      textAlign: 'left',\n      width: '100%',\n\n      ':hover': {\n        backgroundColor: NICE_MIDDLE_BLUE,\n        color: LIGHT_PRIMARY_TEXT_COLOR,\n      },\n\n      ':focus': {\n        backgroundColor: NICE_MIDDLE_BLUE,\n        color: LIGHT_PRIMARY_TEXT_COLOR,\n      },\n    },\n  },\n});\n\nconst actionMap: { [key: string]: ICommentActionFunction } = {\n  highlight: highlightComments,\n  approve: approveComments,\n  defer: deferComments,\n  reject: rejectComments,\n  tag: tagCommentSummaryScores,\n  reset: resetComments,\n};\n\nexport interface ISearchResultsProps extends RouteComponentProps<{}> {\n  isLoading: boolean;\n  isItemChecked(id: string): boolean;\n  areNoneSelected: boolean;\n  areAllSelected: boolean;\n  selectedCount: number;\n  allCommentIds?: Array<string>;\n  tags?: List<ITagModel>;\n  textSizes?: Map<ModelId, number>;\n  pagingIdentifier?: string;\n\n  onToggleSelectAll?(): void;\n  onToggleSingleItem(item: { id: string }): void;\n  updateSearchQuery(queryDelta: ISearchQueryParams): void;\n\n  searchTerm?: string;\n  searchByAuthor?: boolean;\n}\n\nexport interface ISearchResultsState {\n  selectedCount?: number;\n  commentSortType?: string;\n  isTaggingToolTipMetaVisible?: boolean;\n  taggingToolTipMetaPosition?: {\n    top: number;\n    left: number;\n  };\n  isConfirmationModalVisible?: boolean;\n  confirmationAction?: ICommentAction;\n  toastButtonLabel?: 'Undo' | 'Remove rule';\n  toastIcon?: JSX.Element;\n  showCount?: boolean;\n  actionCount?: number;\n  actionText?: string;\n}\n\nexport class SearchResults extends React.Component<ISearchResultsProps, ISearchResultsState> {\n\n  commentActionCancelled = false;\n\n  state: ISearchResultsState = {\n    isTaggingToolTipMetaVisible: false,\n    commentSortType: this.props.searchByAuthor ? 'newest' : 'relevance',\n    taggingToolTipMetaPosition: {\n      top: 0,\n      left: 0,\n    },\n    isConfirmationModalVisible: false,\n    confirmationAction: null,\n    actionText: '',\n    toastButtonLabel: null,\n    toastIcon: null,\n    showCount: false,\n    actionCount: 0,\n  };\n\n  componentDidMount() {\n    keyboardJS.bind('escape', this.onPressEscape);\n  }\n\n  componentWillUnmount() {\n    keyboardJS.unbind('escape', this.onPressEscape);\n  }\n\n  @autobind\n  onPressEscape() {\n    this.setState({\n      isConfirmationModalVisible: false,\n      isTaggingToolTipMetaVisible: false,\n    });\n  }\n\n  /**\n   * Tell the lazy list to re-query.\n   */\n  updateScope(sort: any) {\n    let newSort = sortDefinitions[sort] && sortDefinitions[sort].sortInfo;\n    if (!newSort) {\n      newSort = null;\n    }\n    this.props.updateSearchQuery({sort: newSort});\n  }\n\n  @autobind\n  onSortChange(event: React.FormEvent<any>) {\n    const newSort = (event.target as any).value;\n    this.updateScope(newSort);\n    this.setState({commentSortType: newSort});\n  }\n\n  @autobind\n  async onSelectAllChange() {\n    await this.props.onToggleSelectAll();\n  }\n\n  @autobind\n  async onSelectionChange(id: string) {\n    await this.props.onToggleSingleItem({ id });\n  }\n\n  matchAction(action: ICommentAction) {\n    if (action === 'approve') {\n      showActionIcon = <ApproveIcon {...css({fill: DARK_COLOR})} />;\n    } else if (action === 'reject') {\n      showActionIcon = <RejectIcon {...css({fill: DARK_COLOR})} />;\n    } else if (action === 'highlight') {\n      showActionIcon = <HighlightIcon {...css({fill: DARK_COLOR})} />;\n    } else if (action === 'defer') {\n      showActionIcon = <DeferIcon {...css({fill: DARK_COLOR})} />;\n    } else if (action === 'tag') {\n      showActionIcon = <AddIcon {...css({fill: DARK_COLOR})} />;\n    }\n\n    return showActionIcon;\n  }\n\n  @autobind\n  onConfirmationClose() {\n    this.setState({isConfirmationModalVisible: false });\n  }\n\n  @autobind\n  handleUndoClick() {\n    this.commentActionCancelled = true;\n    this.onConfirmationClose();\n  }\n\n  @autobind\n  async dispatchConfirmedAction(action: ICommentAction, ids?: Array<string>) {\n    const idsToDispatch = ids || this.getSelectedIDs();\n    actionMap[action](idsToDispatch);\n  }\n\n  @autobind\n  getSelectedIDs(): Array<string> {\n    return this.props.allCommentIds.filter((id) => (\n      this.props.isItemChecked(id)\n    ));\n  }\n\n  @autobind\n  calculateTaggingTriggerPosition(ref: HTMLElement) {\n    if (!ref) {\n      return;\n    }\n\n    const buttonRect = ref.getBoundingClientRect();\n\n    this.setState({\n      taggingToolTipMetaPosition: {\n        top: buttonRect.height / 2,\n        left: (buttonRect.width / 2) - 10,\n      },\n    });\n  }\n\n  @autobind\n  toggleTaggingToolTip() {\n    this.setState({\n      isTaggingToolTipMetaVisible: !this.state.isTaggingToolTipMetaVisible,\n    });\n  }\n\n  @autobind\n  confirmationClose() {\n    this.setState({ isConfirmationModalVisible: false });\n  }\n\n  @autobind\n  onTagButtonClick(tagId: string) {\n    const ids = this.getSelectedIDs();\n    this.triggerActionToast('tag', ids.length, () => tagCommentSummaryScores(ids, tagId));\n    this.toggleTaggingToolTip();\n  }\n\n  @autobind\n  triggerActionToast(action: ICommentAction, count: number, callback: (action?: ICommentAction) => any) {\n    this.setState({\n      isConfirmationModalVisible: true,\n      confirmationAction: action,\n      actionCount: count,\n      actionText: `Comment${count > 1 ? 's' : ''} ` + ACTION_PLURAL[action],\n      toastButtonLabel: 'Undo',\n      toastIcon: this.matchAction(action),\n      showCount: true,\n    });\n    setTimeout(async () => {\n      if (this.commentActionCancelled) {\n        this.commentActionCancelled = false;\n        this.confirmationClose();\n\n        return false;\n      } else {\n        this.setState({\n          toastButtonLabel: null,\n        });\n        await callback(action);\n        this.confirmationClose();\n      }\n    }, TOAST_DELAY);\n  }\n\n  @autobind\n  async handleAssignTagsSubmit(commentId: ModelId, selectedTagIds: Set<ModelId>) {\n    selectedTagIds.forEach((tagId) => {\n      tagCommentSummaryScores([commentId], tagId);\n    });\n    this.dispatchConfirmedAction('reject', [commentId]);\n  }\n\n  render() {\n    const {\n      textSizes,\n      isItemChecked,\n      areNoneSelected,\n      areAllSelected,\n      selectedCount,\n      tags,\n      allCommentIds,\n      isLoading,\n      pagingIdentifier,\n      searchTerm,\n    } = this.props;\n\n    const {\n      isTaggingToolTipMetaVisible,\n      taggingToolTipMetaPosition,\n      commentSortType,\n      isConfirmationModalVisible,\n      actionCount,\n      actionText,\n    } = this.state;\n\n    function getLinkTarget(commentId: ModelId) {\n      const query = pagingIdentifier && {pagingIdentifier};\n      return commentSearchDetailsPageLink(commentId, query);\n    }\n\n    const totalCommentCount = allCommentIds?.length || 0;\n\n    return (\n      <div>\n        {isLoading && (\n          <div key=\"searchIcon\" {...css(STYLES.placeholderBgContainer)}>\n            <CircularProgress color=\"primary\" size={100}/>\n          </div>\n        )}\n        <div key=\"content\" {...css({backgroundColor: 'white'}, {display: 'block'})} >\n          <div {...css(STYLES.resultsHeader)}>\n            {searchTerm && !isLoading && (\n              <p {...css(STYLES.resultsHeadline, !areNoneSelected && STYLES.resultsActionHeadline)}>\n                {selectedCount > 0 && `${selectedCount} / `}\n                {totalCommentCount} result{totalCommentCount > 1 && 's'}\n                {selectedCount > 0 || areAllSelected && ' selected'}\n              </p>\n            )}\n            {isLoading && (\n              <p {...css(STYLES.resultsHeadline, !areNoneSelected && STYLES.resultsActionHeadline)}>\n                  Loading...\n              </p>\n            )}\n            { !areNoneSelected && (\n              <div {...css(STYLES.moderateButtons)}>\n                <CommentActionButton\n                  disabled={areNoneSelected}\n                  style={STYLES.commentActionButton}\n                  label=\"Approve\"\n                  onClick={partial(\n                    this.triggerActionToast,\n                    'approve',\n                    this.getSelectedIDs().length,\n                    this.dispatchConfirmedAction,\n                  )}\n                  icon={(\n                    <ApproveIcon {...css({fill: LIGHT_PRIMARY_TEXT_COLOR})} />\n                  )}\n                />\n\n                <CommentActionButton\n                  disabled={areNoneSelected}\n                  style={STYLES.commentActionButton}\n                  label=\"Reject\"\n                  onClick={partial(\n                    this.triggerActionToast,\n                    'reject',\n                    this.getSelectedIDs().length,\n                    this.dispatchConfirmedAction,\n                  )}\n                  icon={(\n                    <RejectIcon {...css({fill: LIGHT_PRIMARY_TEXT_COLOR})} />\n                  )}\n                />\n\n                <CommentActionButton\n                  disabled={areNoneSelected}\n                  style={STYLES.commentActionButton}\n                  label=\"Defer\"\n                  onClick={partial(\n                    this.triggerActionToast,\n                    'defer',\n                    this.getSelectedIDs().length,\n                    this.dispatchConfirmedAction,\n                  )}\n                  icon={(\n                    <DeferIcon {...css({fill: LIGHT_PRIMARY_TEXT_COLOR})} />\n                  )}\n                />\n                  <div {...css(STYLES.dropdown)}>\n                    <CommentActionButton\n                      disabled={areNoneSelected}\n                      buttonRef={this.calculateTaggingTriggerPosition}\n                      label=\"Tag\"\n                      onClick={this.toggleTaggingToolTip}\n                      icon={(\n                        <AddIcon {...css({fill: LIGHT_PRIMARY_TEXT_COLOR})} />\n                      )}\n                    />\n                    {isTaggingToolTipMetaVisible && (\n                      <ToolTip\n                        arrowPosition=\"topRight\"\n                        backgroundColor={WHITE_COLOR}\n                        hasDropShadow\n                        isVisible={isTaggingToolTipMetaVisible}\n                        onDeactivate={this.toggleTaggingToolTip}\n                        position={taggingToolTipMetaPosition}\n                        size={16}\n                        width={250}\n                        zIndex={TOOLTIP_Z_INDEX}\n                      >\n                        <div {...css(STYLES.toolTipWithTags.container)}>\n                          <ul {...css(STYLES.toolTipWithTags.ul)}>\n                            {tags && tags.map((t, i) => (\n                              <li key={t.id}>\n                                <button\n                                  onClick={partial(this.onTagButtonClick, t.id)}\n                                  key={`tag-${i}`}\n                                  {...css(STYLES.toolTipWithTags.button)}\n                                >\n                                  {t.label}\n                                </button>\n                              </li>\n                            ))}\n                          </ul>\n                        </div>\n                      </ToolTip>\n                    )}\n                  </div>\n                </div>\n            )}\n          </div>\n          {!isLoading && searchTerm && totalCommentCount > 0 &&  (\n            <CommentList\n              heightOffset={HEADER_HEIGHT + RESULTS_HEADER_HEIGHT}\n              textSizes={textSizes}\n              commentIds={allCommentIds}\n              areAllSelected={areAllSelected}\n              currentSort={commentSortType}\n              getLinkTarget={getLinkTarget}\n              isItemChecked={isItemChecked}\n              onSelectAllChange={this.onSelectAllChange}\n              onSelectionChange={this.onSelectionChange}\n              onSortChange={this.onSortChange}\n              sortOptions={sortOptions}\n              totalItems={totalCommentCount}\n              searchTerm={searchTerm}\n              displayArticleTitle\n              dispatchConfirmedAction={this.dispatchConfirmedAction}\n              handleAssignTagsSubmit={this.handleAssignTagsSubmit}\n            />\n          )}\n        </div>\n\n        <Scrim\n          key=\"confirmationScrim\"\n          scrimStyles={{...STYLES.scrim, ...SCRIM_STYLE.scrim}}\n          isVisible={isConfirmationModalVisible}\n          onBackgroundClick={this.confirmationClose}\n        >\n          <FocusTrap\n            focusTrapOptions={{\n              clickOutsideDeactivates: true,\n            }}\n          >\n            <ToastMessage\n              icon={this.state.toastIcon}\n              buttonLabel={this.state.toastButtonLabel}\n              onClick={this.handleUndoClick}\n            >\n              <div key=\"toastContent\">\n                { this.state.showCount && (\n                  <span key=\"toastCount\" {...css(STYLES.actionToastCount)}>\n                    {showActionIcon}\n                    {actionCount}\n                  </span>\n                )}\n                <p key=\"actionText\">{actionText}</p>\n              </div>\n            </ToastMessage>\n          </FocusTrap>\n        </Scrim>\n      </div>\n    );\n  }\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Search/components/index.ts",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { connect } from 'react-redux';\nimport { withRouter } from 'react-router';\nimport { compose } from 'redux';\nimport { createStructuredSelector } from 'reselect';\n\nimport { IAppDispatch, IAppState } from '../../../appstate';\nimport { getTaggableTags } from '../../../stores/tags';\nimport { getTextSizes, getTextSizesIsLoading } from '../../../stores/textSizes';\nimport {\n  getAllCommentIds,\n  getAreAllSelected,\n  getAreAnyCommentsSelected,\n  getCurrentPagingIdentifier,\n  getIsItemChecked,\n  getIsLoading,\n  getSelectedCount,\n  toggleSelectAll,\n  toggleSingleItem,\n} from '../store';\nimport { ISearchResultsProps, SearchResults as PureSearchResults } from './SearchResults';\n\nconst mapStateToProps = createStructuredSelector({\n  isLoading: (state: IAppState) => (getIsLoading(state) || getTextSizesIsLoading(state)),\n  isItemChecked: (state: IAppState) => (id: string) => getIsItemChecked(state, id),\n  areNoneSelected: getAreAnyCommentsSelected,\n  areAllSelected: getAreAllSelected,\n  selectedCount: getSelectedCount,\n  allCommentIds: getAllCommentIds,\n  tags: getTaggableTags,\n  textSizes: getTextSizes,\n  pagingIdentifier: getCurrentPagingIdentifier,\n});\n\nfunction mapDispatchToProps(dispatch: IAppDispatch): Partial<ISearchResultsProps> {\n  return {\n    onToggleSelectAll: () => (\n      dispatch(toggleSelectAll())\n    ),\n\n    onToggleSingleItem: (item: { id: string }) => (\n      dispatch(toggleSingleItem(item))\n    ),\n  };\n}\n\nexport type ISearchResultPublicProps = Pick<ISearchResultsProps, 'searchTerm' | 'searchByAuthor' | 'updateSearchQuery'>;\n\nexport const SearchResults: React.ComponentClass<ISearchResultPublicProps> = compose(\n  withRouter,\n  connect(mapStateToProps, mapDispatchToProps),\n)(PureSearchResults) as any;\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Search/index.ts",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\nexport { SearchResults } from './components/SearchResults';\nexport { searchReducer } from './store';\n\nexport { Search } from './Search';\nexport * from './store';\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Search/store/checkedSelection.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { Action, Reducer } from 'redux-actions';\nimport { IAppState } from '../../../appstate';\nimport { ICheckedSelectionPayloads, ICheckedSelectionState, IOverrides, makeCheckedSelectionStore } from '../../../util';\n\nconst checkedSelectionStore = makeCheckedSelectionStore(\n  (state: IAppState) => {\n    return state.scenes.search.checkedSelection;\n  },\n  { defaultSelectionState: false },\n);\n\nconst checkedSelectionReducer: Reducer<ICheckedSelectionState, ICheckedSelectionPayloads> = checkedSelectionStore.reducer;\n\nconst getAreAllSelected: (state: IAppState) => boolean = checkedSelectionStore.getAreAllSelected;\nconst getAreAnyCommentsSelected: (state: IAppState) => boolean = checkedSelectionStore.getAreAnyCommentsSelected;\nconst getOverrides: (state: IAppState) => IOverrides = checkedSelectionStore.getOverrides;\nconst getIsItemChecked: (state: IAppState, id: string) => boolean = checkedSelectionStore.getIsItemChecked;\nconst toggleSelectAll: () => Action<void> = checkedSelectionStore.toggleSelectAll;\nconst toggleSingleItem: (payload: { id: string }) => Action<{ id: string }> = checkedSelectionStore.toggleSingleItem;\n\nexport function getSelectedCount(state: IAppState): number {\n  return getOverrides(state).size;\n}\n\nexport {\n  checkedSelectionReducer,\n  getAreAllSelected,\n  getAreAnyCommentsSelected,\n  getOverrides,\n  getIsItemChecked,\n  toggleSelectAll,\n  toggleSingleItem,\n};\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Search/store/commentListLoader.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { pick } from 'lodash';\n\nimport { IAppDispatch } from '../../../appstate';\nimport { search } from '../../../platform/dataService';\nimport { clearCommentCache } from '../../../stores/globalActions';\nimport { loadTextSizesByIds } from '../../../stores/textSizes';\nimport { storeCommentPagingOptions } from '../../Comments/components/CommentDetail/store';\nimport { searchLink } from '../../routes';\nimport { ISearchScope } from '../types';\nimport { setCurrentPagingIdentifier } from './currentPagingIdentifier';\nimport { loadAllCommentIdsComplete, loadAllCommentIdsStart } from './searchResults';\n\nexport async function loadCommentList(\n  dispatch: IAppDispatch,\n  scope: ISearchScope,\n) {\n  dispatch(clearCommentCache());\n  dispatch(loadAllCommentIdsStart);\n  const { term, params } = scope;\n  const commentIds = await search(term, params);\n  dispatch(loadAllCommentIdsComplete(commentIds));\n\n  const query = {\n    ...pick(params, ['articleId', 'searchByAuthor', 'sort']),\n    term,\n  };\n  const link = searchLink(query);\n\n  const currentPagingIdentifier = await dispatch(storeCommentPagingOptions({\n    commentIds,\n    fromBatch: true,\n    source: `Comment %i of ${commentIds.length} from search for \"${term}\"`,\n    link,\n  }));\n\n  dispatch(setCurrentPagingIdentifier({ currentPagingIdentifier }));\n\n  const bodyContentWidth = 696;\n\n  await dispatch(loadTextSizesByIds(commentIds, bodyContentWidth));\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Search/store/currentPagingIdentifier.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { Action, Reducer } from 'redux-actions';\n\nimport { IAppState } from '../../../appstate';\nimport {\n  ICurrentPagingIdentifierPayload,\n  ICurrentPagingIdentifierState,\n  makeCurrentPagingIdentifierReducer,\n} from '../../../util';\n\nconst currentPagingIdentifier = makeCurrentPagingIdentifierReducer(\n  (state: IAppState) => {\n    return state.scenes.search.currentPagingIdentifier;\n  },\n);\n\nconst currentPagingIdentifierReducer: Reducer<ICurrentPagingIdentifierState, ICurrentPagingIdentifierPayload> = currentPagingIdentifier.reducer;\nconst setCurrentPagingIdentifier: (payload: ICurrentPagingIdentifierPayload) => Action<ICurrentPagingIdentifierPayload> = currentPagingIdentifier.setCurrentPagingIdentifier;\nconst getCurrentPagingIdentifier: (state: IAppState) => string = currentPagingIdentifier.getCurrentPagingIdentifier;\n\nexport {\n  currentPagingIdentifierReducer,\n  setCurrentPagingIdentifier,\n  getCurrentPagingIdentifier,\n};\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Search/store/index.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\nimport { combineReducers } from 'redux';\n\nimport { ICheckedSelectionState, ICurrentPagingIdentifierState } from '../../../util';\nimport { checkedSelectionReducer } from './checkedSelection';\nimport { currentPagingIdentifierReducer } from './currentPagingIdentifier';\nimport { allCommentIdsReducer, IAllCommentIDsState } from './searchResults';\n\nexport type ISearchState = Readonly<{\n  currentPagingIdentifier: ICurrentPagingIdentifierState,\n  checkedSelection: ICheckedSelectionState,\n  allCommentIds: IAllCommentIDsState,\n}>;\n\nexport const searchReducer = combineReducers<ISearchState>({\n  currentPagingIdentifier: currentPagingIdentifierReducer,\n  checkedSelection: checkedSelectionReducer,\n  allCommentIds: allCommentIdsReducer,\n});\n\nexport * from './commentListLoader';\nexport * from './currentPagingIdentifier';\nexport * from './checkedSelection';\nexport * from './searchResults';\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Search/store/searchResults.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { Action, createAction, handleActions } from 'redux-actions';\nimport { ModelId } from '../../../../models';\nimport { IAppState } from '../../../appstate';\n\nexport const loadAllCommentIdsStart: () => Action<void> = createAction(\n  'search/LOAD_ALL_COMMENT_IDS',\n);\n\nexport type ILoadAllCommentIdsCompletePayload = Array<ModelId>;\nexport const loadAllCommentIdsComplete: (payload: ILoadAllCommentIdsCompletePayload) => Action<ILoadAllCommentIdsCompletePayload> =\n  createAction<ILoadAllCommentIdsCompletePayload>(\n    'search/LOAD_ALL_COMMENT_IDS_COMPLETE',\n  );\n\nexport const resetCommentIds: () => Action<void> = createAction(\n  'search/RESET_ALL_COMMENT_IDS',\n);\n\nexport type IAllCommentIDsState = Readonly<{\n  isLoading: boolean;\n  ids: Array<ModelId>;\n}>;\n\nconst initialState: IAllCommentIDsState = {\n  isLoading: false,\n  ids: [],\n};\n\nexport const allCommentIdsReducer = handleActions<\n  IAllCommentIDsState,\n  void | // resetCommentIds\n  ILoadAllCommentIdsCompletePayload // loadAllCommentIdsComplete\n>({\n    [resetCommentIds.toString()]: () => initialState,\n\n    [loadAllCommentIdsStart().toString()]: () => ({...initialState, isLoading: true}),\n\n    [loadAllCommentIdsComplete.toString()]: (_state, { payload }: Action<ILoadAllCommentIdsCompletePayload>) => (\n      {isLoading: false, ids: payload}\n    ),\n  },\n\n  initialState,\n);\n\nfunction getStateRecord(state: IAppState) {\n  return state.scenes.search.allCommentIds;\n}\n\nexport function getAllCommentIds(state: IAppState) {\n  return getStateRecord(state).ids;\n}\n\nexport function getIsLoading(state: IAppState) {\n  return getStateRecord(state).isLoading;\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Search/types.ts",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nexport interface ISearchScope {\n  term: string;\n  params?: any;\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Settings/Ranges.tsx",
    "content": "/*\nCopyright 2020 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport {autobind} from 'core-decorators';\nimport React from 'react';\n\nimport {\n  HeaderBar,\n  Scrim,\n} from '../../components';\nimport {css} from '../../utilx';\nimport {SettingsSubheaderBar} from '../Comments/components/SubheaderBar';\nimport {ManageAutomatedRules} from './components/ManageAutomatedRules';\nimport {ManagePreselects} from './components/ManagePreselects';\nimport {ManageSensitivities} from './components/ManageSensitivities';\nimport {ManageTags} from './components/ManageTags';\n\nimport {\n  SCRIM_STYLE,\n  VISUALLY_HIDDEN,\n} from '../../styles';\nimport { STYLES } from './styles';\n\nfunction StatusScrim(props: {visible: boolean, submitStatus: string}) {\n  return (\n    <Scrim\n      key=\"statusScrim\"\n      scrimStyles={SCRIM_STYLE.scrim}\n      isVisible={props.visible}\n    >\n      <div {...css(SCRIM_STYLE.popup, {position: 'relative', width: 450})} tabIndex={0}>\n        <p>{props.submitStatus}</p>\n      </div>\n    </Scrim>\n  );\n}\n\nexport interface IRangesProps  {\n}\n\nexport interface IRangesState {\n  isStatusScrimVisible?: boolean;\n  submitStatus?: string;\n}\n\nexport class Ranges extends React.Component<IRangesProps, IRangesState> {\n  state: IRangesState = {\n    isStatusScrimVisible: false,\n  };\n\n  componentWillReceiveProps(_: Readonly<IRangesProps>) {\n    if (this.state.isStatusScrimVisible) {\n      this.setState({\n        isStatusScrimVisible: false,\n      });\n    }\n  }\n\n  @autobind\n  setSaving(isSaving: boolean) {\n    if (isSaving) {\n      this.setState({\n        isStatusScrimVisible: true,\n        submitStatus: 'Saving changes...',\n      });\n    }\n    else {\n      this.setState({\n        isStatusScrimVisible: false,\n      });\n    }\n  }\n\n  @autobind\n  setError(message: string) {\n    this.setState({\n      isStatusScrimVisible: true,\n      submitStatus: `There was an error saving your changes. Please reload and try again. Error: ${message}`,\n    });\n  }\n\n  render() {\n    return (\n      <div {...css(STYLES.base)}>\n        <HeaderBar homeLink title=\"Settings\"/>\n        <SettingsSubheaderBar/>\n        <div {...css(STYLES.body)}>\n          <h1 {...css(VISUALLY_HIDDEN)}>Open Source Moderator Settings: Tags and Ranges</h1>\n          <ManageTags\n            setSaving={this.setSaving}\n            setError={this.setError}\n          />\n          <ManageAutomatedRules\n            setSaving={this.setSaving}\n            setError={this.setError}\n          />\n          <ManageSensitivities\n            setSaving={this.setSaving}\n            setError={this.setError}\n          />\n          <ManagePreselects\n            setSaving={this.setSaving}\n            setError={this.setError}\n          />\n        </div>\n        <StatusScrim\n          visible={this.state.isStatusScrimVisible}\n          submitStatus={this.state.submitStatus}\n        />\n      </div>\n    );\n  }\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Settings/Settings.tsx",
    "content": "/*\nCopyright 2020 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport cryptoRandomString from 'crypto-random-string';\nimport {List} from 'immutable';\nimport React, {useEffect, useState} from 'react';\nimport {useDispatch, useSelector} from 'react-redux';\n\nimport {\n  IconButton,\n  Tooltip,\n} from '@material-ui/core';\nimport {\n  Edit,\n} from '@material-ui/icons';\n\nimport {\n  IUserModel,\n  ModelId,\n} from '../../../models';\nimport {IAppDispatch, IAppState} from '../../appstate';\nimport {\n  HeaderBar,\n  Scrim,\n} from '../../components';\nimport {API_URL} from '../../config';\nimport {\n  getOAuthConfig,\n  IApiConfiguration,\n  kickProcessor,\n  listSystemUsers,\n  updateOAuthConfig,\n} from '../../platform/dataService';\nimport {getToken} from '../../platform/localStore';\nimport {\n  getSystemUsers,\n  getUsers,\n  systemUsersLoaded,\n  USER_GROUP_GENERAL,\n  USER_GROUP_MODERATOR,\n  USER_GROUP_SERVICE,\n  USER_GROUP_YOUTUBE,\n} from '../../stores/users';\nimport {setCSRF} from '../../util';\nimport {css} from '../../utilx';\nimport {SettingsSubheaderBar} from '../Comments/components/SubheaderBar';\nimport {EditOAuthScrim} from './components/OAuthConfig';\nimport {\n  AddUserScrim,\n  EditUserScrim,\n  EditYouTubeScrim,\n  ModeratorSettings,\n  ServiceUserSettings,\n  UserSettings,\n  YouTubeUsersSettings,\n} from './components/users';\nimport {addUser, modifyUser} from './store';\n\nimport {\n  SCRIM_STYLE,\n  VISUALLY_HIDDEN,\n} from '../../styles';\nimport { SETTINGS_STYLES } from './settingsStyles';\nimport {STYLES} from './styles';\n\nfunction StatusScrim(props: {visible: boolean, submitStatus: string}) {\n  return (\n    <Scrim\n      key=\"statusScrim\"\n      scrimStyles={SCRIM_STYLE.scrim}\n      isVisible={props.visible}\n    >\n      <div {...css(SCRIM_STYLE.popup, {position: 'relative', width: 450})} tabIndex={0}>\n        <p>{props.submitStatus}</p>\n      </div>\n    </Scrim>\n  );\n}\n\nexport async function loadSystemUsers(dispatch: IAppDispatch, type: string): Promise<List<IUserModel>> {\n  const result = await listSystemUsers(type);\n  await dispatch(systemUsersLoaded({type, users: result}));\n  return result;\n}\n\nexport function Settings(_props: {}) {\n  const [visibleScrim, setVisibleScrim] = useState<'add_user' | 'edit_user' | 'edit_youtube' | 'oauth' | 'status' | null>(null);\n  const [submitStatus, setSubmitStatus] = useState<string | null>(null);\n  const [addUserType, setAddUserType] = useState<string | null>(null);\n  const [selectedUser, setSelectedUser] = useState<IUserModel | null>(null);\n  const [oauthConfig, setOauthConfig] = useState<IApiConfiguration | null>(null);\n  const dispatch = useDispatch();\n\n  const users = useSelector(getUsers);\n  const serviceUsers = useSelector((state: IAppState) => getSystemUsers(USER_GROUP_SERVICE, state));\n  const moderatorUsers = useSelector((state: IAppState) => getSystemUsers(USER_GROUP_MODERATOR, state));\n  const youtubeUsers = useSelector((state: IAppState) => getSystemUsers(USER_GROUP_YOUTUBE, state));\n\n  useEffect(() => {\n    loadSystemUsers(dispatch, USER_GROUP_SERVICE);\n    loadSystemUsers(dispatch, USER_GROUP_MODERATOR);\n    loadSystemUsers(dispatch, USER_GROUP_YOUTUBE);\n  }, []);\n\n  useEffect(() => {\n    if (visibleScrim === 'status') {\n      setVisibleScrim(null);\n    }\n  }, [users, youtubeUsers]);\n\n  function handleAddUser(type: string, event: React.FormEvent<any>) {\n    event.preventDefault();\n    setAddUserType(type);\n    setVisibleScrim('add_user');\n  }\n\n  function handleAddUserGeneral(event: React.FormEvent<any>) {\n    handleAddUser(USER_GROUP_GENERAL, event);\n  }\n\n  function handleAddUserService(event: React.FormEvent<any>) {\n    handleAddUser(USER_GROUP_SERVICE, event);\n  }\n\n  function handleEditUser(userId: ModelId) {\n    let user = users.get(userId);\n    if (!user) {\n      user = serviceUsers.find((u) => (u.id === userId));\n    }\n\n    setSelectedUser(user);\n    setVisibleScrim('edit_user');\n  }\n\n  function handleEditYoutube(userId: ModelId) {\n    const user = youtubeUsers.find((u) => (u.id === userId));\n    setSelectedUser(user);\n    setVisibleScrim('edit_youtube');\n  }\n\n  async function handleEditOAuth() {\n    const config = await getOAuthConfig();\n    setVisibleScrim('oauth');\n    setOauthConfig(config);\n  }\n\n  function closeScrims() {\n    setVisibleScrim(null);\n  }\n\n  async function saveAddedUser(user: IUserModel) {\n    setVisibleScrim('status');\n    setSubmitStatus('Saving changes...');\n\n    try {\n      await addUser(user);\n      if (user.group === USER_GROUP_SERVICE) {\n        loadSystemUsers(dispatch, USER_GROUP_SERVICE);\n        setVisibleScrim(null);\n      }\n      else {\n        setSubmitStatus('Waiting for refresh...');\n      }\n    }\n    catch (e) {\n      setSubmitStatus(`There was an error saving your changes. Please reload and try again. Error: ${e.message}`);\n    }\n  }\n\n  async function saveEditedUser(user: IUserModel) {\n    setVisibleScrim('status');\n    setSubmitStatus('Saving changes...');\n\n    try {\n      await modifyUser(user);\n      if (user.group === USER_GROUP_SERVICE) {\n        loadSystemUsers(dispatch, USER_GROUP_SERVICE);\n        setVisibleScrim(null);\n      }\n      else {\n        setSubmitStatus('Waiting for refresh...');\n      }\n    }\n    catch (e) {\n      setSubmitStatus(`There was an error saving your changes. Please reload and try again. Error: ${e.message}`);\n    }\n  }\n\n  async function saveYouTubeSettings(user: IUserModel) {\n    try {\n      const userId = user.id;\n      await modifyUser(user);\n      const updatedUsers = await loadSystemUsers(dispatch, USER_GROUP_YOUTUBE);\n      user = updatedUsers.find((u) => (u.id === userId));\n      setSelectedUser(user);\n    }\n    catch (e) {\n      setVisibleScrim('status');\n      setSubmitStatus(`There was an error saving your changes. Please reload and try again. Error: ${e.message}`);\n    }\n  }\n\n  async function saveOAuthSettings(config: IApiConfiguration) {\n    setVisibleScrim('status');\n    setSubmitStatus('Saving changes...');\n\n    try {\n      await updateOAuthConfig(config);\n      setVisibleScrim(null);\n    }\n    catch (e) {\n      setVisibleScrim('status');\n      setSubmitStatus(`There was an error saving your changes. Please reload and try again. Error: ${e.message}`);\n    }\n  }\n\n  function connectYouTubeAccount() {\n    const csrf = cryptoRandomString({length: 32, type: 'alphanumeric'});\n    setCSRF(csrf);\n    const token = getToken();\n    window.location.href =  `${API_URL}/youtube/connect?&csrf=${csrf}&token=${token}`;\n  }\n\n  async function kickYouTubeProcessor() {\n    setVisibleScrim('status');\n    setSubmitStatus('Backend processor starting....');\n    await kickProcessor('youtube');\n    setSubmitStatus('Backend processor processing....');\n    setTimeout(() => {\n      setVisibleScrim(null);\n      this.setState({\n        isStatusScrimVisible: false,\n      });\n      loadSystemUsers(dispatch, USER_GROUP_YOUTUBE);\n    }, 3000);\n  }\n\n  return (\n    <div {...css(STYLES.base)}>\n      <HeaderBar homeLink title=\"Settings\"/>\n      <SettingsSubheaderBar/>\n      <div {...css(STYLES.body)}>\n        <h1 {...css(VISUALLY_HIDDEN)}>Open Source Moderator Settings: Users and Services</h1>\n        <UserSettings\n          users={users}\n          handleEdit={handleEditUser}\n          handleAdd={handleAddUserGeneral}\n        />\n        <div key=\"serviceUsersHeader\" {...css(SETTINGS_STYLES.heading)}>\n          <h2 {...css(SETTINGS_STYLES.headingText)}>\n            System accounts\n          </h2>\n        </div>\n        <ServiceUserSettings\n          users={serviceUsers}\n          handleEdit={handleEditUser}\n          handleAdd={handleAddUserService}\n        />\n        <ModeratorSettings users={moderatorUsers}/>\n        <div key=\"pluginsHeader\" {...css(SETTINGS_STYLES.heading)}>\n          <h2 {...css(SETTINGS_STYLES.headingText)}>\n            Plugins\n          </h2>\n        </div>\n        <YouTubeUsersSettings\n          users={youtubeUsers}\n          handleEdit={handleEditYoutube}\n          connect={connectYouTubeAccount}\n          kick={kickYouTubeProcessor}\n        />\n        <div key=\"patformSettingsHeader\" {...css(SETTINGS_STYLES.heading)}>\n          <h2 {...css(SETTINGS_STYLES.headingText)}>\n            Platform settings\n          </h2>\n        </div>\n        <div {...css(SETTINGS_STYLES.section)}>\n          <p>Configure OAuth:\n            <Tooltip title=\"Edit this user\">\n              <IconButton onClick={handleEditOAuth}>\n                <Edit color=\"primary\"/>\n              </IconButton>\n            </Tooltip>\n          </p>\n        </div>\n      </div>\n      <StatusScrim\n        visible={visibleScrim === 'status'}\n        submitStatus={submitStatus}\n      />\n      <AddUserScrim\n        type={addUserType}\n        visible={visibleScrim === 'add_user'}\n        close={closeScrims}\n        save={saveAddedUser}\n      />\n      <EditUserScrim\n        user={selectedUser}\n        visible={visibleScrim === 'edit_user'}\n        close={closeScrims}\n        save={saveEditedUser}\n      />\n      <EditYouTubeScrim\n        user={selectedUser}\n        visible={visibleScrim === 'edit_youtube'}\n        close={closeScrims}\n        save={saveYouTubeSettings}\n      />\n      <EditOAuthScrim\n        visible={visibleScrim === 'oauth'}\n        config={oauthConfig}\n        close={closeScrims}\n        save={saveOAuthSettings}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Settings/components/AddUsers.tsx",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { autobind } from 'core-decorators';\nimport React from 'react';\n\nimport { IUserModel, UserModel } from '../../../../models';\nimport {\n  ContainerFooter,\n  ContainerHeader,\n  OverflowContainer,\n} from '../../../components';\nimport {\n  USER_GROUP_ADMIN,\n  USER_GROUP_GENERAL,\n  USER_GROUP_SERVICE,\n} from '../../../stores/users';\nimport {\n  GUTTER_DEFAULT_SPACING,\n} from '../../../styles';\nimport { css } from '../../../utilx';\nimport { UserForm } from './UserForm';\n\nexport interface IAddUsersProps {\n  userType: string;\n  onClickClose(e: React.FormEvent<any>): any;\n  onClickDone(user: IUserModel): any;\n}\n\nexport interface IAddUsersState {\n  newUser?: IUserModel;\n  isDisabled?: boolean;\n}\n\nexport class AddUsers extends React.Component<IAddUsersProps, IAddUsersState> {\n  state = {\n    newUser: UserModel({name: '', group: this.props.userType, isActive: true}),\n    isDisabled: true,\n  };\n\n  @autobind\n  isNewUserValid(user: IUserModel): boolean {\n    if (!user.name || user.name.length === 0) {\n      return false;\n    }\n\n    if (!user.group) {\n      return false;\n    }\n\n    if (user.group === USER_GROUP_GENERAL || user.group === USER_GROUP_ADMIN) {\n      if (!user.email || user.email.length === 0) {\n        return false;\n      }\n    }\n    return true;\n  }\n\n  @autobind\n  onInputChange(inputType: 'name' | 'email' | 'group' | 'isActive', value: string | boolean) {\n    const newUser = {...this.state.newUser};\n    if (inputType === 'isActive') {\n      newUser[inputType] = value as boolean;\n    }\n    else {\n      newUser[inputType] = value as string;\n    }\n\n    this.setState({\n      newUser,\n      isDisabled: !this.isNewUserValid(newUser),\n    });\n  }\n\n  @autobind\n  onSubmit() {\n    this.props.onClickDone(this.state.newUser);\n  }\n\n  render() {\n    const {\n      onClickClose,\n    } = this.props;\n\n    const {\n      newUser,\n      isDisabled,\n    } = this.state;\n\n    let title = 'Add a user';\n\n    if (newUser.group === USER_GROUP_SERVICE) {\n      title = 'Add a service user';\n    }\n\n    return (\n      <OverflowContainer\n        header={<ContainerHeader onClickClose={onClickClose}>{title}</ContainerHeader>}\n        body={(\n          <div  {...css({ marginTop: `${GUTTER_DEFAULT_SPACING}px`, marginBottom: `${GUTTER_DEFAULT_SPACING}px`, })}>\n            <UserForm onInputChange={this.onInputChange} user={newUser} />\n          </div>\n        )}\n        footer={<ContainerFooter disabled={isDisabled} onClick={this.onSubmit}/>}\n      />\n    );\n  }\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Settings/components/ColorSelect/ColorSelect.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { autobind } from 'core-decorators';\nimport React from 'react';\nimport {\n  INPUT_DROP_SHADOW,\n  OFFSCREEN,\n  PALE_COLOR,\n} from '../../../../styles';\nimport { css, stylesheet } from '../../../../utilx';\n\nconst STYLES = stylesheet({\n  colorBoxContainer: {\n    position: 'relative',\n    boxShadow:  INPUT_DROP_SHADOW,\n    marginRight: '42px',\n    width: '200px',\n  },\n  selectBox: {\n    height: '42px',\n    width: '100%',\n    appearance: 'none',\n    WebkitAppearance: 'none', // Not getting prefixed either\n    paddingLeft: '42px',\n    border: 'none',\n    backgroundColor: PALE_COLOR,\n    fontSize: '16px',\n    boxSizing: 'border-box',\n  },\n  colorBox: {\n    width: '24px',\n    height: '24px',\n    position: 'absolute',\n    left: 9,\n    top: 9,\n  },\n});\n\nexport interface IColorSelectProps {\n  color: string;\n  tag: string;\n  onChange(color: string): any;\n}\n\nexport class ColorSelect extends React.Component<IColorSelectProps> {\n  @autobind\n  onChange(e: React.ChangeEvent<HTMLInputElement>) {\n    e.preventDefault();\n\n    this.props.onChange(e.target.value);\n  }\n\n  render() {\n    const {\n      color,\n      tag,\n    } = this.props;\n\n    return (\n      <div {...css(STYLES.colorBoxContainer)}>\n        <label {...css(OFFSCREEN)} htmlFor={tag}>Choose a color for {tag}</label>\n        <input\n          type=\"text\"\n          {...css(STYLES.selectBox)}\n          id={tag}\n          name={tag}\n          value={color}\n          onChange={this.onChange}\n        />\n        <span {...css(STYLES.colorBox, { backgroundColor: color })}/>\n      </div>\n    );\n  }\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Settings/components/ColorSelect/ColorSelectStory.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { action } from '@storybook/addon-actions';\nimport { storiesOf } from '@storybook/react';\n\nimport { ColorSelect } from './ColorSelect';\n\nstoriesOf('ColorSelect', module)\n  .add('base', () => {\n    return (\n      <ColorSelect\n        color={'#FC724A'}\n        tag={'inflammatory'}\n        onChange={action('changed')}\n      />\n    );\n  });\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Settings/components/ColorSelect/index.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nexport { ColorSelect } from './ColorSelect';\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Settings/components/EditUsers.tsx",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { autobind } from 'core-decorators';\nimport React from 'react';\n\nimport { IUserModel } from '../../../../models';\nimport {\n  ContainerFooter,\n  ContainerHeader,\n  OverflowContainer,\n} from '../../../components';\nimport {\n  USER_GROUP_ADMIN,\n  USER_GROUP_GENERAL,\n  USER_GROUP_SERVICE,\n} from '../../../stores/users';\nimport {\n  GUTTER_DEFAULT_SPACING,\n} from '../../../styles';\nimport { css, stylesheet } from '../../../utilx';\nimport { UserForm } from './UserForm';\n\nconst STYLES = stylesheet({\n  body: {\n    marginTop: `${GUTTER_DEFAULT_SPACING}px`,\n    marginBottom: `${GUTTER_DEFAULT_SPACING}px`,\n  },\n});\n\nexport interface IEditUsersProps {\n  onClickClose(e: React.FormEvent<any>): void;\n  onClickDone(user: IUserModel): void;\n  userToEdit?: IUserModel;\n}\n\nexport interface IEditUsersState {\n  editedUser?: IUserModel;\n  isDisabled?: boolean;\n}\n\nexport class EditUsers extends React.Component<IEditUsersProps, IEditUsersState> {\n\n  state = {\n    editedUser: this.props.userToEdit,\n    isDisabled: true,\n  };\n\n  @autobind\n  isUserValid(user: IUserModel): boolean {\n    if (!user.name || user.name.length === 0) {\n      return false;\n    }\n\n    if (!user.group) {\n      return false;\n    }\n\n    if (user.group === USER_GROUP_GENERAL || user.group === USER_GROUP_ADMIN) {\n      if (!user.email || user.email.length === 0) {\n        return false;\n      }\n    }\n    return true;\n  }\n\n  @autobind\n  onInputChange(inputType: 'name' | 'email' | 'group' | 'isActive', value: string | boolean) {\n    const editedUser = {...this.state.editedUser};\n    if (inputType === 'isActive') {\n      editedUser[inputType] = value as boolean;\n    }\n    else {\n      editedUser[inputType] = value as string;\n    }\n\n    this.setState({\n      editedUser,\n      isDisabled: !this.isUserValid(editedUser),\n    });\n  }\n\n  @autobind\n  iseEditedUserValid(user: IUserModel): boolean {\n    return !!user.name && user.name.length > 0 && !!user.email && user.email.length > 0 && !!user.group;\n  }\n\n  @autobind\n  onSubmit() {\n    this.props.onClickDone(this.state.editedUser);\n  }\n\n  render() {\n    const {\n      onClickClose,\n    } = this.props;\n\n    const {\n      editedUser,\n      isDisabled,\n    } = this.state;\n\n    let title = 'Edit a user';\n\n    if (editedUser.group === USER_GROUP_SERVICE) {\n      title = 'Edit a service user';\n    }\n\n    return (\n      <OverflowContainer\n        header={<ContainerHeader onClickClose={onClickClose}>{title}</ContainerHeader>}\n        body={(\n          <div {...css(STYLES.body)}>\n            <UserForm onInputChange={this.onInputChange} user={editedUser} />\n          </div>\n        )}\n        footer={<ContainerFooter disabled={isDisabled} onClick={this.onSubmit}/>}\n      />\n    );\n  }\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Settings/components/EditYouTubeUser.tsx",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport React from 'react';\nimport {useSelector} from 'react-redux';\n\nimport {\n  CircularProgress,\n  IconButton,\n  Switch,\n  Tooltip,\n} from '@material-ui/core';\nimport {\n  CheckCircleOutline,\n  Sync,\n} from '@material-ui/icons';\n\nimport { ICategoryModel, IUserModel } from '../../../../models';\nimport { ContainerHeader, OverflowContainer } from '../../../components/OverflowContainer';\nimport { activateCommentSource, syncCommentSource } from '../../../platform/dataService';\nimport { getCategories } from '../../../stores/categories';\nimport { flexCenter, GUTTER_DEFAULT_SPACING, PALE_COLOR, SCRIM_Z_INDEX } from '../../../styles';\nimport { css, stylesheet } from '../../../utilx';\n\nconst STYLES = stylesheet({\n  heading: {\n    fontSize: '18px',\n  },\n\n  subheading: {\n    fontSize: '16px',\n    marginTop: `${36}px`,\n  },\n\n  headerRow: {\n    display: 'flex',\n    alignItems: 'center',\n    justifyContent: 'space-between',\n  },\n\n  row: {\n    marginTop: `${12}px`,\n    marginBottom: `${12}px`,\n    overflow: 'hidden',\n    display: 'flex',\n    alignItems: 'center',\n  },\n\n  label: {\n    fontWeight: 'bold',\n    marginRight: '24px',\n    minWidth: '120px',\n    display: 'flex',\n    alignItems: 'left',\n  },\n\n  closeButton: {\n    background: 'none',\n    border: 'none',\n    position: 'absolute',\n    right: GUTTER_DEFAULT_SPACING,\n    top: GUTTER_DEFAULT_SPACING,\n    cursor: 'pointer',\n    zIndex: SCRIM_Z_INDEX,\n    ':focus': {\n      outline: 'none',\n      background: PALE_COLOR,\n    },\n  },\n\n  userTableCell: {\n    textAlign: 'left',\n    padding: '5px 20px 5px 0',\n  },\n\n  userTableCellButton: {\n    width: '49px',\n    height: '49px',\n    ...flexCenter,\n  },\n});\n\ninterface IYoutubeCategoryProps {\n  category: ICategoryModel;\n}\n\nfunction YoutubeCategory(props: IYoutubeCategoryProps) {\n  const {\n    category,\n  } = props;\n\n  const [changingActive, setChangingActive] = React.useState<boolean>(false);\n  const [syncingComments, setSyncingComments] = React.useState<boolean>(false);\n\n  async function activate() {\n    setChangingActive(true);\n    await activateCommentSource(category.id, !category.isActive);\n    setChangingActive(false);\n  }\n\n  async function sync() {\n    setSyncingComments(true);\n    await syncCommentSource(category.id);\n    setTimeout(() => setSyncingComments(false), 3000);\n  }\n\n  return (\n    <tr>\n      <td key=\"label\" {...css(STYLES.userTableCell)}>{category.label}</td>\n      <td key=\"source\" {...css(STYLES.userTableCell)}>{category.sourceId}</td>\n      <td key=\"active\" {...css(STYLES.userTableCell)}>\n        <Tooltip\n          title={category.isActive ? 'Comments will sync every 5 minute' : 'Automatic comment sync is disabled'}\n        >\n          <Switch color=\"primary\" checked={category.isActive} onChange={activate} disabled={changingActive}/>\n        </Tooltip>\n      </td>\n      <td key=\"actions\" {...css(STYLES.userTableCell)}>\n        <div {...css(STYLES.userTableCellButton)}>\n          <Tooltip title=\"Load recent articles and comments\">\n            {syncingComments ?\n              <CircularProgress color=\"primary\" size={30}/> :\n              <IconButton onClick={sync}><Sync/></IconButton>\n            }\n          </Tooltip>\n        </div>\n      </td>\n    </tr>\n  );\n}\n\nexport interface IEditYouTubeUserProps {\n  onClickClose(e: React.FormEvent<any>): any;\n  onUserUpdate(user: IUserModel): Promise<void>;\n  user?: IUserModel;\n}\n\nexport function EditYouTubeUser(props: IEditYouTubeUserProps) {\n  const [changingActive, setChangingActive] = React.useState<boolean>(false);\n\n  async function onIsActiveChange() {\n    setChangingActive(true);\n    await props.onUserUpdate({...user, isActive: !user.isActive});\n    setChangingActive(false);\n  }\n\n  const {\n    user,\n    onClickClose,\n  } = props;\n\n  const hasError = !!user.extra.lastError;\n  const categories = useSelector(getCategories);\n  const relevant = categories.filter((c) => c.ownerId === user.id);\n\n  return (\n    <OverflowContainer\n      header={<ContainerHeader onClickClose={onClickClose}>Settings for YouTube account</ContainerHeader>}\n      body={(\n        <div>\n          <div key=\"name\" {...css(STYLES.row)}>\n            <label {...css(STYLES.label)}>Youtube Name</label>\n            <div>{user.name}</div>\n          </div>\n          <div key=\"email\" {...css(STYLES.row)}>\n            <label {...css(STYLES.label)}>Youtube ID</label>\n            <div>{user.email}</div>\n          </div>\n          <div key=\"active\" {...css(STYLES.row)}>\n            <label {...css(STYLES.label)}>Is Active</label>\n            <div  style={{position: 'relative'}}>\n              <Switch\n                checked={user.isActive}\n                color=\"primary\"\n                disabled={hasError || changingActive}\n                onChange={onIsActiveChange}\n              />\n            </div>\n          </div>\n          <div key=\"error\" {...css(STYLES.row)}>\n            <label {...css(STYLES.label)}>Last Error</label>\n            <div>\n              {hasError ? user.extra.lastError.message : 'No error'}\n              {hasError && (\n                <Tooltip title=\"Reset errors and reactivate\" style={{marginLeft: '20px'}}>\n                  <IconButton color=\"primary\" onClick={onIsActiveChange}><CheckCircleOutline/></IconButton>\n                </Tooltip>\n              )}\n            </div>\n          </div>\n          <h2 key=\"channelTitle\" {...css(STYLES.subheading)}>Channels</h2>\n          <table>\n            <thead>\n            <tr>\n              <th key=\"1\" {...css(STYLES.userTableCell)}>\n                Name\n              </th>\n              <th key=\"2\" {...css(STYLES.userTableCell)}>\n                YouTube ID\n              </th>\n              <th key=\"3\" {...css(STYLES.userTableCell)}>\n                Is Active\n              </th>\n            </tr>\n            </thead>\n            <tbody>\n              {relevant.map((c) => (<YoutubeCategory key={c.id} category={c}/>))}\n            </tbody>\n          </table>\n          <p style={{marginTop: `${31}px`}}>\n            Activating moderation of a YouTube channel puts it into post-moderation mode.\n            Comments will not be visible to users until you mark them as approved.\n          </p>\n          <p>\n            Syncing history data will make sure the last few hundred comments are available\n            in Moderator, even if they've already been published.\n          </p>\n        </div>\n      )}\n    />\n  );\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Settings/components/LabelSettings/LabelSettings.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { autobind } from 'core-decorators';\nimport React from 'react';\n\nimport {\n  IconButton,\n  Radio,\n} from '@material-ui/core';\nimport {\n  Delete,\n} from '@material-ui/icons';\n\nimport { ITagModel } from '../../../../../models';\nimport {\n  ARTICLE_CATEGORY_TYPE,\n  DARK_TERTIARY_TEXT_COLOR,\n  GUTTER_DEFAULT_SPACING,\n  LIGHT_PRIMARY_TEXT_COLOR,\n  PALE_COLOR,\n} from '../../../../styles';\nimport { css, stylesheet } from '../../../../utilx';\nimport { SETTINGS_STYLES } from '../../settingsStyles';\nimport { ColorSelect } from '../ColorSelect';\n\nconst SMALLER_SCREEN = window.innerWidth < 1200;\nconst STYLES = stylesheet({\n  base: {\n    ...ARTICLE_CATEGORY_TYPE,\n    color: DARK_TERTIARY_TEXT_COLOR,\n  },\n\n  labelColor: {\n    ...SETTINGS_STYLES.input,\n    color: LIGHT_PRIMARY_TEXT_COLOR,\n    width: SMALLER_SCREEN ? 180 : 200,\n    padding: 10,\n    marginRight: `${GUTTER_DEFAULT_SPACING}px`,\n    borderColor: 'transparent',\n  },\n\n  description: {\n    ...SETTINGS_STYLES.input,\n    marginRight: `${GUTTER_DEFAULT_SPACING}px`,\n    flex: 1,\n    width: SMALLER_SCREEN ? 180 : 'auto',\n    backgroundColor: PALE_COLOR,\n    borderColor: 'transparent',\n    paddingLeft: 10,\n    height: 40,\n  },\n\n  checkboxContainer: {\n    width: '100px',\n    display: 'flex',\n    alignItems: 'center',\n    justifyContent: 'center',\n  },\n});\n\nexport interface ILabelSettingsProps {\n  tag: ITagModel;\n  onLabelChange(tag: ITagModel, value: string): any;\n  onDescriptionChange(tag: ITagModel, value: string): any;\n  onColorChange(tag: ITagModel, color: string): any;\n  onDeletePress(tag: ITagModel): any;\n  onTagChange(tag: ITagModel, key: string, value: boolean): any;\n}\n\nexport class LabelSettings extends React.Component<ILabelSettingsProps> {\n  @autobind\n  onLabelChange(e: React.ChangeEvent<HTMLInputElement>) {\n    e.preventDefault();\n    this.props.onLabelChange(this.props.tag, e.target.value);\n  }\n\n  @autobind\n  onDescriptionChange(e: React.ChangeEvent<HTMLInputElement>) {\n    e.preventDefault();\n    this.props.onDescriptionChange(this.props.tag, e.target.value);\n  }\n\n  @autobind\n  onColorChange(color: string) {\n    this.props.onColorChange(this.props.tag, color);\n  }\n\n  @autobind\n  onTagIsInBatchViewChange(e: React.MouseEvent<HTMLElement>) {\n    e.preventDefault();\n    const { tag, onTagChange } = this.props;\n    onTagChange(tag, 'isInBatchView', !tag.isInBatchView);\n  }\n\n  @autobind\n  onTagIsTaggableChange(e: React.MouseEvent<HTMLElement>) {\n    e.preventDefault();\n    const { tag, onTagChange } = this.props;\n    onTagChange(tag, 'isTaggable', !tag.isTaggable);\n  }\n\n  @autobind\n  onTagInSummaryScoreChange(e: React.MouseEvent<HTMLElement>) {\n    e.preventDefault();\n    const { tag, onTagChange } = this.props;\n    onTagChange(tag, 'inSummaryScore', !tag.inSummaryScore);\n  }\n\n  @autobind\n  onDeletePress(e: React.MouseEvent<HTMLElement>) {\n    e.preventDefault();\n    this.props.onDeletePress(this.props.tag);\n  }\n\n  render() {\n    const {\n      tag,\n      tag: {\n        color,\n        label,\n        description,\n      },\n    } = this.props;\n\n    return (\n      <div {...css(STYLES.base)}>\n        <div {...css(SETTINGS_STYLES.row)}>\n          <input\n            type=\"text\"\n            {...css(STYLES.labelColor, { backgroundColor: color })}\n            value={label ? label : ''}\n            onChange={this.onLabelChange}\n          />\n          <input\n            type=\"text\"\n            {...css(STYLES.description)}\n            value={description ? description : ''}\n            onChange={this.onDescriptionChange}\n          />\n          <ColorSelect tag={label} color={color} onChange={this.onColorChange} />\n          <div\n            {...css(STYLES.checkboxContainer)}\n            onClick={this.onTagIsInBatchViewChange}\n          >\n            <Radio color=\"primary\" checked={tag.isInBatchView}/>\n          </div>\n          <div\n            {...css(STYLES.checkboxContainer)}\n            onClick={this.onTagIsTaggableChange}\n          >\n            <Radio color=\"primary\" checked={tag.isTaggable}/>\n          </div>\n          <div\n            {...css(STYLES.checkboxContainer)}\n            onClick={this.onTagInSummaryScoreChange}\n          >\n            <Radio color=\"primary\" checked={tag.inSummaryScore}/>\n          </div>\n          <div style={{paddingLeft: '50px'}}>\n            <IconButton aria-label={`Delete Tag`} onClick={this.onDeletePress}>\n              <Delete color=\"primary\"/>\n            </IconButton>\n          </div>\n        </div>\n      </div>\n    );\n  }\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Settings/components/LabelSettings/LabelSettingsStory.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { action } from '@storybook/addon-actions';\nimport { storiesOf } from '@storybook/react';\nimport { fakeTagModel } from '../../../../../models/fake';\nimport { LabelSettings } from './LabelSettings';\n\nconst tag = fakeTagModel({\n  color: '#ff0000',\n  description: 'Hello World',\n  key: 'HELLO_WORLD',\n  label: 'Hello',\n});\n\nstoriesOf('LabelSettings', module)\n  .add('spam', () => {\n    return (\n      <LabelSettings\n        tag={tag}\n        onLabelChange={action('Label changed')}\n        onDescriptionChange={action('Description Changed')}\n        onColorChange={action('Color changed')}\n        onTagChange={action('Tag Status Changed')}\n        onDeletePress={action('deleted')}\n      />\n    );\n  });\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Settings/components/LabelSettings/index.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nexport { LabelSettings } from './LabelSettings';\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Settings/components/ManageAutomatedRules.tsx",
    "content": "/*\nCopyright 2020 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport {List} from 'immutable';\nimport React, {useEffect, useState} from 'react';\nimport {useSelector} from 'react-redux';\n\nimport {\n  CategoryModel,\n  ICategoryModel,\n  IRuleModel,\n  IServerAction,\n  RuleModel,\n  SERVER_ACTION_ACCEPT,\n} from '../../../../models';\nimport {getCategories} from '../../../stores/categories';\nimport {getRules} from '../../../stores/rules';\nimport {getTags} from '../../../stores/tags';\nimport {partial} from '../../../util/partial';\nimport {css} from '../../../utilx';\nimport {SETTINGS_STYLES} from '../settingsStyles';\nimport {updateRules} from '../store';\nimport {STYLES} from '../styles';\nimport {RuleRow} from './RuleRow';\nimport {SaveButtons} from './SaveButtons';\n\nlet placeholderId = -1;\n\nexport function ManageAutomatedRules(props: {\n  setSaving(isSaving: boolean): void,\n  setError(message: string): void,\n}) {\n  const baseRules = useSelector(getRules);\n  const [rules, setRules] = useState<List<IRuleModel>>(List());\n  useEffect(() => {\n    setRules(baseRules);\n    props.setSaving(false);\n  }, [baseRules]);\n\n  const tags = useSelector(getTags);\n  const categories = useSelector(getCategories);\n  const categoriesWithAll = List([\n    CategoryModel({\n      id: null,\n      label: 'All',\n      unprocessedCount: 0,\n      unmoderatedCount: 0,\n      moderatedCount: 0,\n    }),\n  ]).concat(categories) as List<ICategoryModel>;\n\n  function handleAddAutomatedRule(event: React.FormEvent<any>) {\n    event.preventDefault();\n    const newValue = RuleModel(\n      {\n        id: (placeholderId--).toString(),\n        createdBy: null,\n        categoryId: null,\n        tagId: '1',\n        lowerThreshold: .8,\n        upperThreshold: 1,\n        action: SERVER_ACTION_ACCEPT,\n      },\n    );\n\n    const updatedRules = rules ?\n      rules.set(rules.size, newValue) :\n      List([newValue]);\n\n    setRules(updatedRules);\n  }\n\n  function handleAutomatedRuleChange(category: string, rule: IRuleModel, value: number | string) {\n    setRules(rules.update(\n      rules.findIndex((r) => r.id === rule.id),\n      (r) => ({...r, [category]: value}),\n      ));\n  }\n\n  function handleAutomatedRuleDelete(rule: IRuleModel) {\n    setRules(rules.delete(rules.findIndex((r) => r.id === rule.id)));\n  }\n\n  function handleModerateButtonClick(rule: IRuleModel, action: IServerAction) {\n    const updatedRules = rules.update(\n      rules.findIndex(((r) => r.id === rule.id)),\n      (r) => ({...r, action}),\n    );\n    setRules(updatedRules);\n  }\n\n  function onCancelPress() {\n    setRules(baseRules);\n  }\n\n  async function handleFormSubmit() {\n    props.setSaving(true);\n\n    try {\n      await updateRules(baseRules, rules);\n    } catch (exception) {\n      props.setError(exception.message);\n    }\n  }\n\n  return (\n    <form {...css(STYLES.formContainer)}>\n      <div key=\"editRulesSection\">\n        <div key=\"heading\" {...css(SETTINGS_STYLES.heading)}>\n          <h2 {...css(SETTINGS_STYLES.headingText)}>Automated Rules <small>(The server will automatically pass/fail comments that match these filters)</small></h2>\n        </div>\n        <div key=\"body\" {...css(SETTINGS_STYLES.section)}>\n          {rules && rules.map((rule, i) => (\n            <RuleRow\n              key={i}\n              onDelete={handleAutomatedRuleDelete}\n              rule={rule}\n              onCategoryChange={partial(handleAutomatedRuleChange, 'categoryId', rule)}\n              onTagChange={partial(handleAutomatedRuleChange, 'tagId', rule)}\n              onLowerThresholdChange={partial(handleAutomatedRuleChange, 'lowerThreshold', rule)}\n              onUpperThresholdChange={partial(handleAutomatedRuleChange, 'upperThreshold', rule)}\n              rangeBottom={Math.round(rule.lowerThreshold * 100)}\n              rangeTop={Math.round(rule.upperThreshold * 100)}\n              selectedTag={rule.tagId}\n              selectedCategory={rule.categoryId}\n              selectedAction={rule.action}\n              hasTagging\n              onModerateButtonClick={handleModerateButtonClick}\n              categories={categoriesWithAll}\n              tags={tags}\n            />\n          ))}\n          <SaveButtons\n            onCancelPress={onCancelPress}\n            handleFormSubmit={handleFormSubmit}\n            handleAdd={handleAddAutomatedRule}\n            addTip=\"Add an automated rule\"\n            width=\"1114px\"\n          />\n        </div>\n      </div>\n    </form>\n  );\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Settings/components/ManagePreselects.tsx",
    "content": "/*\nCopyright 2020 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport {List} from 'immutable';\nimport React, {useEffect, useState} from 'react';\nimport {useSelector} from 'react-redux';\n\nimport {\n  CategoryModel,\n  ICategoryModel,\n  IPreselectModel,\n  ITagModel,\n  PreselectModel,\n  TagModel,\n} from '../../../../models';\nimport {getCategories} from '../../../stores/categories';\nimport {getPreselects} from '../../../stores/preselects';\nimport {getTags} from '../../../stores/tags';\nimport {partial} from '../../../util/partial';\nimport {css} from '../../../utilx';\nimport {SETTINGS_STYLES} from '../settingsStyles';\nimport {updatePreselects} from '../store';\nimport {STYLES} from '../styles';\nimport {RuleRow} from './RuleRow';\nimport {SaveButtons} from './SaveButtons';\n\nlet placeholderId = -1;\n\nexport function ManagePreselects(props: {\n  setSaving(isSaving: boolean): void,\n  setError(message: string): void,\n}) {\n  const basePreselects = useSelector(getPreselects);\n  const [preselects, setPreselects] = useState<List<IPreselectModel>>(List());\n  useEffect(() => {\n    setPreselects(basePreselects);\n    props.setSaving(false);\n  }, [basePreselects]);\n\n  function handleAddPreselect(event: React.FormEvent<any>) {\n    event.preventDefault();\n    const newValue = PreselectModel(\n      {\n        id: (placeholderId--).toString(),\n        categoryId: null,\n        tagId: null,\n        lowerThreshold: .8,\n        upperThreshold: 1,\n      },\n    );\n\n    const updatedPreselects = preselects ?\n      preselects.set(preselects.size, newValue) :\n      List([newValue]);\n\n    setPreselects(updatedPreselects);\n  }\n\n  function handlePreselectChange(category: string, preselect: IPreselectModel, value: number | string) {\n    setPreselects(preselects.update(\n      preselects.findIndex((r) => r.id === preselect.id),\n      (r) => ({...r, [category]: value}),\n    ));\n  }\n\n  function handlePreselectDelete(preselect: IPreselectModel) {\n    setPreselects(\n      preselects.delete(preselects.findIndex((r) => r.id === preselect.id)),\n    );\n  }\n\n  const tags = useSelector(getTags);\n  const tagsWithAll = List([\n    TagModel({\n      id: null,\n      key: 'ALL',\n      label: 'All',\n      color: null,\n    }),\n  ]).concat(tags) as List<ITagModel>;\n\n  const categories = useSelector(getCategories);\n  const categoriesWithAll = List([\n    CategoryModel({\n      id: null,\n      label: 'All',\n      unprocessedCount: 0,\n      unmoderatedCount: 0,\n      moderatedCount: 0,\n    }),\n  ]).concat(categories) as List<ICategoryModel>;\n\n  function onCancelPress() {\n    setPreselects(basePreselects);\n  }\n\n  async function handleFormSubmit() {\n    props.setSaving(true);\n\n    try {\n      await updatePreselects(basePreselects, preselects);\n    } catch (exception) {\n      props.setError(exception.message);\n    }\n  }\n\n  return (\n    <form {...css(STYLES.formContainer)}>\n      <div key=\"editRangesSection\">\n        <div key=\"heading\" {...css(SETTINGS_STYLES.heading)}>\n          <h2 {...css(SETTINGS_STYLES.headingText)}>\n            New Comments Page: Preselected Ranges <small>(Controls range of scores to show when first visiting the New Comments page)</small>\n          </h2>\n        </div>\n        <div key=\"body\" {...css(SETTINGS_STYLES.section)}>\n          {preselects && preselects.map((preselect, i) => (\n            <RuleRow\n              key={i}\n              onDelete={handlePreselectDelete}\n              rule={preselect}\n              onCategoryChange={partial(handlePreselectChange, 'categoryId', preselect)}\n              onTagChange={partial(handlePreselectChange, 'tagId', preselect)}\n              onLowerThresholdChange={partial(handlePreselectChange, 'lowerThreshold', preselect)}\n              onUpperThresholdChange={partial(handlePreselectChange, 'upperThreshold', preselect)}\n              rangeBottom={Math.round(preselect.lowerThreshold * 100)}\n              rangeTop={Math.round(preselect.upperThreshold * 100)}\n              selectedTag={preselect.tagId}\n              selectedCategory={preselect.categoryId}\n              categories={categoriesWithAll}\n              tags={tagsWithAll}\n            />\n          ))}\n          <SaveButtons\n            onCancelPress={onCancelPress}\n            handleFormSubmit={handleFormSubmit}\n            handleAdd={handleAddPreselect}\n            addTip=\"Add a preselect\"\n            width=\"931px\"\n          />\n        </div>\n      </div>\n    </form>\n  );\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Settings/components/ManageSensitivities.tsx",
    "content": "/*\nCopyright 2020 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport {List} from 'immutable';\nimport React, {useEffect, useState} from 'react';\nimport {useSelector} from 'react-redux';\n\nimport {\n  CategoryModel,\n  ICategoryModel,\n  ITaggingSensitivityModel,\n  ITagModel,\n  TaggingSensitivityModel,\n  TagModel,\n} from '../../../../models';\nimport {getCategories} from '../../../stores/categories';\nimport {getTaggingSensitivities} from '../../../stores/taggingSensitivities';\nimport {getTags} from '../../../stores/tags';\nimport {partial} from '../../../util/partial';\nimport {css} from '../../../utilx';\nimport {SETTINGS_STYLES} from '../settingsStyles';\nimport {updateTaggingSensitivities} from '../store';\nimport {STYLES} from '../styles';\nimport {RuleRow} from './RuleRow';\nimport {SaveButtons} from './SaveButtons';\n\nlet placeholderId = -1;\n\nexport function ManageSensitivities(props: {\n  setSaving(isSaving: boolean): void,\n  setError(message: string): void,\n}) {\n  const baseSensitivities = useSelector(getTaggingSensitivities);\n  const [sensitivities, setSensitivities] = useState<List<ITaggingSensitivityModel>>(List());\n  useEffect(() => {\n    setSensitivities(baseSensitivities);\n    props.setSaving(false);\n  }, [baseSensitivities]);\n\n  const tags = useSelector(getTags);\n  const categories = useSelector(getCategories);\n  const categoriesWithAll = List([\n    CategoryModel({\n      id: null,\n      label: 'All',\n      unprocessedCount: 0,\n      unmoderatedCount: 0,\n      moderatedCount: 0,\n    }),\n  ]).concat(categories) as List<ICategoryModel>;\n\n  const summaryScoreTag = tags.find((tag) => tag.key === 'SUMMARY_SCORE');\n  const summaryScoreTagId = summaryScoreTag && summaryScoreTag.id;\n  const tagsWithAll = List([\n    TagModel({\n      id: null,\n      key: 'ALL',\n      label: 'All',\n      color: null,\n    }),\n  ]).concat(tags) as List<ITagModel>;\n  const tagsWithAllNoSummary = tagsWithAll.filter((tag) => tag.id !== summaryScoreTagId).toList();\n\n  function handleAddTaggingSensitivity(event: React.FormEvent<any>) {\n    event.preventDefault();\n    const newValue = TaggingSensitivityModel(\n      {\n        id: (placeholderId--).toString(),\n        categoryId: null,\n        tagId: null,\n        lowerThreshold: .65,\n        upperThreshold: 1,\n      },\n    );\n\n    const updatedTS = sensitivities ?\n      sensitivities.set(sensitivities.size, newValue) :\n      List([newValue]);\n\n    setSensitivities(updatedTS);\n  }\n\n  function handleTaggingSensitivityChange(category: string, ts: ITaggingSensitivityModel, value: number | string) {\n    setSensitivities(sensitivities.update(\n      sensitivities.findIndex((r) => r.id === ts.id),\n      (r) => ({...r, [category]: value}),\n    ));\n  }\n\n  function handleTaggingSensitivityDelete(ts: ITaggingSensitivityModel) {\n    setSensitivities(sensitivities.delete(\n      sensitivities.findIndex((r) => r.id === ts.id),\n    ));\n  }\n\n  function onCancelPress() {\n    setSensitivities(baseSensitivities);\n  }\n\n  async function handleFormSubmit() {\n    props.setSaving(true);\n\n    try {\n      await updateTaggingSensitivities(baseSensitivities, sensitivities);\n    } catch (exception) {\n      props.setError(exception.message);\n    }\n  }\n\n  return (\n    <form {...css(STYLES.formContainer)}>\n      <div key=\"editSensitivitiesSection\">\n        <div key=\"heading\" {...css(SETTINGS_STYLES.heading)}>\n          <h2 {...css(SETTINGS_STYLES.headingText)}>Sensitivity <small>(The range where a score become interesting. Scores that match these ranges are highlighted in the UI)</small></h2>\n        </div>\n        <div key=\"body\" {...css(SETTINGS_STYLES.section)}>\n          {sensitivities && sensitivities.map((ts, i) => (\n            <RuleRow\n              key={i}\n              onDelete={handleTaggingSensitivityDelete}\n              rule={ts}\n              onCategoryChange={partial(handleTaggingSensitivityChange, 'categoryId', ts)}\n              onTagChange={partial(handleTaggingSensitivityChange, 'tagId', ts)}\n              onLowerThresholdChange={partial(handleTaggingSensitivityChange, 'lowerThreshold', ts)}\n              onUpperThresholdChange={partial(handleTaggingSensitivityChange, 'upperThreshold', ts)}\n              rangeBottom={Math.round(ts.lowerThreshold * 100)}\n              rangeTop={Math.round(ts.upperThreshold * 100)}\n              selectedTag={ts.tagId}\n              selectedCategory={ts.categoryId}\n              categories={categoriesWithAll}\n              tags={tagsWithAllNoSummary}\n            />\n          ))}\n          <SaveButtons\n            onCancelPress={onCancelPress}\n            handleFormSubmit={handleFormSubmit}\n            handleAdd={handleAddTaggingSensitivity}\n            addTip=\"Add a tagging sensitivity rule\"\n            width=\"931px\"\n          />\n        </div>\n      </div>\n    </form>\n  );\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Settings/components/ManageTags.tsx",
    "content": "/*\nCopyright 2020 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport {List} from 'immutable';\nimport React, {useEffect, useState} from 'react';\nimport {useSelector} from 'react-redux';\n\nimport {ITagModel, TagModel} from '../../../../models';\nimport {getTags} from '../../../stores/tags';\nimport {\n  GUTTER_DEFAULT_SPACING,\n  NICE_MIDDLE_BLUE,\n} from '../../../styles';\nimport {css, stylesheet} from '../../../utilx';\nimport {SETTINGS_STYLES} from '../settingsStyles';\nimport {updateTags} from '../store';\nimport {STYLES} from '../styles';\nimport {LabelSettings} from './LabelSettings';\nimport {SaveButtons} from './SaveButtons';\n\nconst SMALLER_SCREEN = window.innerWidth < 1200;\nconst LOCAL_STYLES: any = stylesheet({\n  labelTitle: {\n    width: 200,\n    marginRight: `${GUTTER_DEFAULT_SPACING}px`,\n  },\n  descriptionTitle: {\n    flex: 1,\n    marginRight: `${GUTTER_DEFAULT_SPACING}px`,\n  },\n  colorTitle: {\n    width: '200px',\n    marginRight: `24px`,\n  },\n  summaryTitle: {\n    width: '100px',\n    marginRight: `${GUTTER_DEFAULT_SPACING}px`,\n  },\n  pluginLink: {\n    display: 'inline-block',\n    color: NICE_MIDDLE_BLUE,\n  },\n});\n\nfunction validateColor(color: string): boolean {\n  const div = document.createElement('div') as HTMLDivElement;\n\n  div.style.backgroundColor = color;\n\n  return div.style.backgroundColor !== '';\n}\n\nlet placeholderId = -1;\n\nexport function ManageTags(props: {\n  setSaving(isSaving: boolean): void,\n  setError(message: string): void,\n}) {\n  const baseTags = useSelector(getTags);\n  const [tags, setTags] = useState<List<ITagModel>>(List());\n  useEffect(() => {\n    setTags(baseTags);\n    props.setSaving(false);\n  }, [baseTags]);\n\n  function handleAddTag(event: React.FormEvent<any>) {\n    event.preventDefault();\n    const newValue = TagModel(\n      {\n        id: (placeholderId--).toString(),\n        key: null,\n        label: 'Add Label',\n        description: 'Add Description',\n        color: '#999999',\n      },\n    );\n\n    setTags(tags.set(tags.size, newValue));\n  }\n\n  function handleLabelChange(tag: ITagModel, value: string) {\n    setTags(tags.update(\n      tags.findIndex((t) => t.id === tag.id),\n      (t) => ({...t, label: value}),\n    ));\n  }\n\n  function handleDescriptionChange(tag: ITagModel, value: string) {\n    setTags(tags.update(\n      tags.findIndex((t) =>  t.id === tag.id),\n      (t) => ({...t, description: value}),\n    ));\n  }\n\n  function handleColorChange(tag: ITagModel, color: string) {\n    if (!validateColor(color)) {\n      console.log('invalid color: ', color);\n    }\n\n    setTags(tags.update(\n      tags.findIndex((t) =>  t.id === tag.id),\n      (t) => ({...t, color}),\n    ));\n  }\n\n  function handleTagDeletePress(tag: ITagModel) {\n    setTags(tags.delete(tags.findIndex((t) =>  t.id === tag.id)));\n  }\n\n  function handleTagChange(\n    tag: ITagModel,\n    key: string,\n    value: boolean,\n  ) {\n    setTags(tags.update(\n      tags.findIndex((t) =>  t.id === tag.id),\n      (t) => ({...t, [key]: value}),\n    ));\n  }\n\n  function onCancelPress() {\n    setTags(baseTags);\n  }\n\n  async function handleFormSubmit() {\n    props.setSaving(true);\n\n    try {\n      await updateTags(baseTags, tags);\n    } catch (exception) {\n      props.setError(exception.message);\n    }\n  }\n\n  const summaryScoreTag = tags.find((tag) => tag.key === 'SUMMARY_SCORE');\n  const summaryScoreTagId = summaryScoreTag && summaryScoreTag.id;\n  const tagsNoSummary = tags.filter((tag) => tag.id !== summaryScoreTagId).toList();\n\n  return (\n    <form {...css(STYLES.formContainer)}>\n      <div key=\"heading\" {...css(SETTINGS_STYLES.heading)}>\n        <h2 {...css(SETTINGS_STYLES.headingText)}>Tags</h2>\n      </div>\n      <div key=\"body\" {...css(SETTINGS_STYLES.section)}>\n        <div {...css(SETTINGS_STYLES.row, {padding: 0})}>\n          <p {...css(LOCAL_STYLES.labelTitle, SMALLER_SCREEN && {width: '184px', marginRight: '20px'})}>Label</p>\n          <p {...css(LOCAL_STYLES.descriptionTitle)}>Description</p>\n          <p {...css(LOCAL_STYLES.colorTitle, SMALLER_SCREEN && {marginRight: '20px'})}>Color</p>\n          <p {...css(LOCAL_STYLES.summaryTitle, SMALLER_SCREEN && {width: '90px', marginRight: '20px'})}>In Batch View</p>\n          <p {...css(LOCAL_STYLES.summaryTitle, SMALLER_SCREEN && {width: '90px', marginRight: '20px'})}>Is Taggable</p>\n          <p {...css(LOCAL_STYLES.summaryTitle, SMALLER_SCREEN && {width: '90px'}, { marginRight: '68px'})}>In Summary Score</p>\n        </div>\n        {tagsNoSummary.map((tag, i) => (\n          <LabelSettings\n            tag={tag}\n            key={i}\n            onLabelChange={handleLabelChange}\n            onDescriptionChange={handleDescriptionChange}\n            onColorChange={handleColorChange}\n            onDeletePress={handleTagDeletePress}\n            onTagChange={handleTagChange}\n          />\n        ))}\n        <SaveButtons\n          onCancelPress={onCancelPress}\n          handleFormSubmit={handleFormSubmit}\n          handleAdd={handleAddTag}\n          addTip=\"Add a tag\"\n        />\n      </div>\n    </form>\n  );\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Settings/components/OAuthConfig.tsx",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport React, { useCallback, useState } from 'react';\n\nimport {\n  Button,\n  makeStyles,\n  TextField,\n} from '@material-ui/core';\n\nimport { ContainerHeader, OverflowContainer } from '../../../components/OverflowContainer';\nimport { Scrim } from '../../../components/Scrim';\nimport { API_URL } from '../../../config';\nimport { IApiConfiguration } from '../../../platform/dataService';\nimport { SCRIM_STYLE } from '../../../styles';\nimport { css } from '../../../utilx';\n\nexport interface IOAuthConfigProps extends IApiConfiguration {\n  onClickDone(config: IApiConfiguration): any;\n  showLogoutWarning?: boolean;\n}\n\nconst useStyles = makeStyles((_theme) => ({\n  textField: {\n    width: '40vw',\n    maxWidth: '600px',\n  },\n}));\n\nexport function OAuthConfig(props: IOAuthConfigProps) {\n  const [id, setId] = useState(props.id);\n  const [secret, setSecret] = useState(props.secret);\n  const classes = useStyles({});\n\n  const idHandler = useCallback((e) => setId(e.target.value), [setId]);\n  const secretHandler = useCallback((e) => setSecret(e.target.value), [setSecret]);\n  function submitHandler() {\n    props.onClickDone({id, secret});\n  }\n\n  return (\n    <div>\n      <p>Configure your connection to the Google Authentication service.</p>\n      <p>\n        To allocate and view tokens, visit <a\n          href=\"https://console.developers.google.com/apis/credentials\"\n          target=\"_blank\"\n          style={{color: 'inherit', fontWeight: 'bold', textDecoration: 'underline'}}\n        >\n          the Google API console\n        </a>.\n        If you don't yet have an <b>OAuth Client ID</b>, or want to allocate a new one,\n        you can do so by clicking the <b>Create Credentials</b> button and selecting the <b>OAuth</b> entry.\n      </p>\n      <p>\n        When asked for an application type, choose <b>Web application</b>.\n        Set the <b>Authorised redirect URI</b> to <b>{API_URL}/auth/callback/google</b>.\n        (If you want to connect to YouTube, you'll also need to add a second redirect URI\n        of <b>{API_URL}/youtube/callback</b>.)\n      </p>\n      <p>\n        Once allocated, copy the client id and secret into the fields below.\n      </p>\n      <div style={{textAlign: 'center'}}>\n        <TextField\n          className={classes.textField}\n          label=\"Client ID\"\n          value={id}\n          onChange={idHandler}\n          margin=\"normal\"\n          variant=\"outlined\"\n        />\n        <TextField\n          className={classes.textField}\n          label=\"Client Secret\"\n          value={secret}\n          onChange={secretHandler}\n          margin=\"normal\"\n          variant=\"outlined\"\n        />\n      </div>\n      {props.showLogoutWarning && (\n      <p>\n        <b>WARNING:</b> Changing OAuth configuration will log everyone out.\n      </p>\n      )}\n      <p>\n        The server will take a few seconds to reconfigure itself after the OAuth configuration has changed,\n        and during this time, it will not be available.  If you have login issues, please try again after a few seconds.\n      </p>\n      <div style={{textAlign: 'right', marginTop: '2vh', marginRight: '9vw'}}>\n        <Button\n          variant=\"contained\"\n          color=\"primary\"\n          onClick={submitHandler}\n          disabled={!id || !secret}\n        >\n          Save Configuration\n        </Button>\n      </div>\n    </div>\n  );\n}\n\nexport function EditOAuthScrim(props: {\n  visible: boolean,\n  config: IApiConfiguration,\n  save(config: IApiConfiguration): void;\n  close(): void,\n}) {\n  const id = props.config ? props.config.id : '';\n  const secret = props.config ? props.config.secret : '';\n\n  return (\n    <Scrim\n      key=\"oauthScrim\"\n      scrimStyles={SCRIM_STYLE.scrim}\n      isVisible={props.visible}\n      onBackgroundClick={props.close}\n    >\n      <div {...css(SCRIM_STYLE.popup, {position: 'relative', width: '75vw', maxWidth: '800px'})}>\n        <OverflowContainer\n          header={<ContainerHeader onClickClose={props.close}>OAuth Config</ContainerHeader>}\n          body={<OAuthConfig onClickDone={props.save} id={id} secret={secret} showLogoutWarning/>}\n        />\n      </div>\n    </Scrim>\n  );\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Settings/components/RuleRow/RuleRow.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { autobind } from 'core-decorators';\nimport { List } from 'immutable';\nimport React from 'react';\n\nimport {\n  IconButton,\n} from '@material-ui/core';\nimport {\n  Delete,\n} from '@material-ui/icons';\n\nimport {\n  convertClientAction,\n  convertServerAction,\n  ICategoryModel,\n  IPreselectModel,\n  IRuleModel,\n  IServerAction,\n  ITaggingSensitivityModel,\n  ITagModel,\n} from '../../../../../models';\nimport { IModerationAction } from '../../../../../types';\nimport { ModerateButtons } from '../../../../components';\nimport {\n  ARTICLE_CATEGORY_TYPE,\n  DARK_PRIMARY_TEXT_COLOR,\n  GUTTER_DEFAULT_SPACING,\n  INPUT_DROP_SHADOW,\n  OFFSCREEN,\n  PALE_COLOR,\n} from '../../../../styles';\nimport { maybeCallback, partial } from '../../../../util';\nimport { sortByLabel } from '../../../../util';\nimport { css, stylesheet } from '../../../../utilx';\nimport { SETTINGS_STYLES } from '../../settingsStyles';\n\nconst INPUT_HEIGHT = 36;\nconst STYLES = stylesheet({\n  base: {\n    ...ARTICLE_CATEGORY_TYPE,\n    color: DARK_PRIMARY_TEXT_COLOR,\n  },\n\n  selectContainer: {\n    position: 'relative',\n  },\n\n  select: {\n    width: 'auto',\n    height: INPUT_HEIGHT,\n    marginRight: GUTTER_DEFAULT_SPACING,\n    paddingLeft: `${GUTTER_DEFAULT_SPACING / 2}px`,\n    paddingRight: `${GUTTER_DEFAULT_SPACING}px`,\n    appearance: 'none',\n    WebkitAppearance: 'none', // Not getting prefixed either\n    border: 'none',\n    borderRadius: 2,\n    boxShadow: INPUT_DROP_SHADOW,\n    backgroundColor: PALE_COLOR,\n    fontSize: '16px',\n  },\n\n  input: {\n    ...SETTINGS_STYLES.input,\n    width: 84,\n    height: INPUT_HEIGHT,\n  },\n});\n\nexport interface IRuleRowProps {\n  categories: List<ICategoryModel>;\n  tags: List<ITagModel>;\n  rangeBottom: number;\n  rangeTop: number;\n  selectedAction?: IServerAction;\n  hasTagging?: boolean;\n  onModerateButtonClick?(\n    rule: IRuleModel,\n    action: IServerAction,\n  ): any;\n  buttons?: JSX.Element;\n  selectedCategory: string;\n  selectedTag?: string;\n  onDelete?(rule: IRuleModel | ITaggingSensitivityModel | IPreselectModel): any;\n  onCategoryChange?(value: string): any;\n  onTagChange?(value: string): any;\n  onLowerThresholdChange?(value: number): any;\n  onUpperThresholdChange?(value: number): any;\n  rule: IRuleModel | ITaggingSensitivityModel | IPreselectModel;\n}\n\nexport class RuleRow extends React.Component<IRuleRowProps> {\n\n  @autobind\n  onNumberFieldChange(callback: ((value: number) => any), e: React.ChangeEvent<HTMLInputElement>) {\n    e.preventDefault();\n    callback(parseInt(e.target.value, 10) / 100);\n  }\n\n  @autobind\n  onCategoryFieldChange(callback: ((value: string) => any), e: React.ChangeEvent<HTMLSelectElement>) {\n    e.preventDefault();\n    callback(e.target.value);\n  }\n\n  @autobind\n  notifyWrapperOfActionChange(action: IModerationAction) {\n    const {\n      onModerateButtonClick,\n      rule,\n    } = this.props;\n    const saction = convertClientAction(action);\n\n    if (onModerateButtonClick) {\n      onModerateButtonClick(rule as IRuleModel, saction);\n    }\n  }\n  render() {\n    const {\n      categories,\n      tags,\n      rangeBottom,\n      rangeTop,\n      hasTagging,\n      selectedCategory,\n      selectedTag,\n      selectedAction,\n      onCategoryChange,\n      onTagChange,\n      onLowerThresholdChange,\n      onUpperThresholdChange,\n      onDelete,\n      rule,\n    } = this.props;\n\n    const sortedCategories = sortByLabel(categories);\n    const sortedTags = sortByLabel(tags);\n\n    return (\n      <div {...css(STYLES.base, SETTINGS_STYLES.row)}>\n        <div {...css(STYLES.selectContainer)}>\n          <label {...css(OFFSCREEN)} htmlFor={`categories-${rule.id}`}>Select a section</label>\n          <select\n            {...css(STYLES.select)}\n            id={`categories-${rule.id}`}\n            name={`categories-${rule.id}`}\n            value={selectedCategory ? selectedCategory : ''}\n            onChange={partial(this.onCategoryFieldChange, maybeCallback(onCategoryChange))}\n          >\n            {sortedCategories && sortedCategories.map((category, i) => (\n              <option value={category.id ? category.id.toString() : ''} key={i}>{category.label}</option>\n            ))}\n          </select>\n          <span aria-hidden=\"true\" {...css(SETTINGS_STYLES.arrow)} />\n        </div>\n        <div {...css(STYLES.selectContainer)}>\n          <label {...css(OFFSCREEN)} htmlFor={`tags-${rule.id}`}>Select a tag</label>\n          <select\n            {...css(STYLES.select)}\n            id={`tags-${rule.id}`}\n            name={`tags-${rule.id}`}\n            value={selectedTag ? selectedTag : ''}\n            onChange={partial(this.onCategoryFieldChange, maybeCallback(onTagChange))}\n          >\n            {sortedTags && sortedTags.map((tag, i) => (\n              <option value={tag.id ? tag.id.toString() : ''} key={i}>{tag.label}</option>\n            ))}\n          </select>\n          <span aria-hidden=\"true\" {...css(SETTINGS_STYLES.arrow)} />\n        </div>\n        <label {...css(OFFSCREEN)} htmlFor={`rangeBottom-${rule.id}`}>Bottom of range</label>\n        <input\n          {...css(STYLES.input, {marginRight: 10})}\n          type=\"number\"\n          min=\"0\"\n          max=\"100\"\n          id={`rangeBottom-${rule.id}`}\n          value={rangeBottom ? rangeBottom.toString() : '0'}\n          onChange={partial(this.onNumberFieldChange, maybeCallback(onLowerThresholdChange))}\n        />\n        <label {...css(OFFSCREEN)} htmlFor={`rangeTop-${rule.id}`}>Top of range</label>\n        <span {...css({fontSize: 14})}>–</span>\n        <input\n          {...css(STYLES.input, {marginLeft: 10})}\n          type=\"number\"\n          min=\"0\"\n          max=\"100\"\n          id={`rangeTop-${rule.id}`}\n          value={rangeTop ? rangeTop.toString() : ''}\n          onChange={partial(this.onNumberFieldChange, maybeCallback(onUpperThresholdChange))}\n        />\n          { hasTagging && (\n            <ModerateButtons\n              darkOnLight\n              hideLabel\n              activeButtons={List<IModerationAction>().push(convertServerAction(selectedAction))}\n              containerSize={36}\n              onClick={this.notifyWrapperOfActionChange}\n            />\n          )}\n        <div style={{paddingLeft: '50px'}}>\n          <IconButton aria-label={`Delete Rule`} onClick={partial(maybeCallback(onDelete), rule)}>\n            <Delete color=\"primary\"/>\n          </IconButton>\n        </div>\n      </div>\n    );\n  }\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Settings/components/RuleRow/RuleRowStory.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { storiesOf } from '@storybook/react';\nimport { List } from 'immutable';\nimport { fakeCategoryModel, fakeRuleModel, fakeTagModel } from '../../../../../models/fake';\nimport { RuleRow } from './RuleRow';\n\nconst categories = List([\n  fakeCategoryModel({ id: '1', label: 'Category 1' }),\n  fakeCategoryModel({ id: '2', label: 'Category 2' }),\n  fakeCategoryModel({ id: '3', label: 'Category 3' }),\n  fakeCategoryModel({ id: '4', label: 'Category 4' }),\n  fakeCategoryModel({ id: '5', label: 'Category 5' }),\n  fakeCategoryModel({ id: '6', label: 'Category 6' }),\n]);\n\nconst tags = List([\n  fakeTagModel({ id: '1', label: 'Tag 1' }),\n  fakeTagModel({ id: '2', label: 'Tag 2' }),\n  fakeTagModel({ id: '3', label: 'Tag 3' }),\n  fakeTagModel({ id: '4', label: 'Tag 4' }),\n  fakeTagModel({ id: '5', label: 'Tag 5' }),\n  fakeTagModel({ id: '6', label: 'Tag 6' }),\n]);\n\nconst rangeBottom = 0;\nconst rangeTop = 100;\n\nstoriesOf('RuleRow', module)\n  .add('Rule Row', () => {\n    return (\n      <RuleRow\n        tags={tags}\n        rangeBottom={rangeBottom}\n        rangeTop={rangeTop}\n        categories={categories}\n        rule={fakeRuleModel({ id: '1' })}\n        selectedCategory={'2'}\n      />\n    );\n  });\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Settings/components/RuleRow/index.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nexport { RuleRow } from './RuleRow';\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Settings/components/SaveButtons.tsx",
    "content": "/*\nCopyright 2020 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport React from 'react';\n\nimport {Button, Fab, Tooltip} from '@material-ui/core';\nimport {Add} from '@material-ui/icons';\n\nimport {GUTTER_DEFAULT_SPACING} from '../../../styles';\nimport {css, stylesheet} from '../../../utilx';\n\nexport const STYLES: any = stylesheet({\n  buttonGroup: {\n    display: 'flex',\n    flexDirection: 'row',\n    paddingTop: `${GUTTER_DEFAULT_SPACING}px`,\n    paddingBottom: `${GUTTER_DEFAULT_SPACING}px`,\n  },\n});\n\nexport function SaveButtons(props: {\n  onCancelPress(): void,\n  handleFormSubmit(): void,\n  handleAdd(event: React.FormEvent<any>): void,\n  addTip: string,\n  width?: string,\n}) {\n  const {onCancelPress} = props;\n  function handleFormSubmit(e: React.MouseEvent<any>) {\n    e.preventDefault();\n    props.handleFormSubmit();\n  }\n\n  const width = props.width || '100%';\n\n  return (\n    <div key=\"submitSection\" {...css(STYLES.buttonGroup, {width: width})}>\n      <div style={{flexGrow: 0, paddingRight: `${GUTTER_DEFAULT_SPACING}px`}}>\n        <Button variant=\"outlined\" onClick={onCancelPress} style={{width: '150px'}}>\n          Cancel\n        </Button>\n      </div>\n      <div style={{flexGrow: 0}}>\n        <Button variant=\"contained\" color=\"primary\" onClick={handleFormSubmit} style={{width: '150px'}}>\n          Save\n        </Button>\n      </div>\n      <div style={{flexGrow: 1}}/>\n      <div style={{flexGrow: 0}}>\n        <Tooltip title={props.addTip}>\n          <Fab color=\"primary\" onClick={props.handleAdd}>\n            <Add/>\n          </Fab>\n        </Tooltip>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Settings/components/UserForm.tsx",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { autobind } from 'core-decorators';\nimport { List } from 'immutable';\nimport React from 'react';\n\nimport {\n  Switch,\n} from '@material-ui/core';\n\nimport { IUserModel } from '../../../../models';\nimport { USER_GROUP_ADMIN, USER_GROUP_GENERAL } from '../../../stores/users';\nimport { partial } from '../../../util';\nimport { css } from '../../../utilx';\nimport { SETTINGS_STYLES } from '../settingsStyles';\n\nexport interface IAddUsersProps {\n  onInputChange(type: 'name' | 'email' | 'group' | 'isActive', value: string | boolean): any;\n  user?: IUserModel;\n}\n\nconst GROUPS = List([\n  [USER_GROUP_GENERAL, 'Moderator'],\n  [USER_GROUP_ADMIN, 'Administrator'],\n]) as List<Array<string>>;\n\nexport class UserForm extends React.Component<IAddUsersProps> {\n\n  @autobind\n  onValueChange(property: 'name' | 'email' | 'group' , e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) {\n    e.preventDefault();\n    this.props.onInputChange(property, e.target.value);\n  }\n\n  @autobind\n  onIsActiveChange(e: React.ChangeEvent<HTMLInputElement>) {\n    this.props.onInputChange('isActive', e.target.checked);\n  }\n\n  render() {\n    const {\n      user,\n    } = this.props;\n\n    const realUser = (user.group === USER_GROUP_GENERAL || user.group === USER_GROUP_ADMIN);\n\n    return (\n      <div>\n        <div key=\"name\" {...css(SETTINGS_STYLES.row)}>\n          <label htmlFor=\"name\" {...css(SETTINGS_STYLES.label)}>Full Name</label>\n          <input\n            id=\"name\"\n            type=\"text\"\n            {...css(SETTINGS_STYLES.input, {width: '100%'})}\n            value={user.name ? user.name : ''}\n            onChange={partial(this.onValueChange, 'name')}\n          />\n        </div>\n        {realUser && (\n        <div key=\"email\" {...css(SETTINGS_STYLES.row)}>\n          <label htmlFor=\"email\" {...css(SETTINGS_STYLES.label)}>Email Address</label>\n          <input\n            id=\"email\"\n            type=\"email\"\n            {...css(SETTINGS_STYLES.input, {width: '100%'})}\n            value={user.email ? user.email : ''}\n            onChange={partial(this.onValueChange, 'email')}\n          />\n        </div>\n        )}\n        {realUser && (\n        <div key=\"group\" {...css(SETTINGS_STYLES.row, SETTINGS_STYLES.selectBoxRow)}>\n          <label htmlFor=\"name\" {...css(SETTINGS_STYLES.label)}>Group</label>\n          <select\n            {...css(SETTINGS_STYLES.selectBox)}\n            id=\"group\"\n            name=\"group\"\n            value={user.group ? user.group : ''}\n            onChange={partial(this.onValueChange, 'group')}\n          >\n            {GROUPS.map((group: Array<string>) =>\n              <option value={group[0]} key={group[0]}>{group[1]}</option>,\n            )}\n          </select>\n          <span aria-hidden=\"true\" {...css(SETTINGS_STYLES.arrow)} />\n        </div>\n        )}\n        <div key=\"active\" {...css(SETTINGS_STYLES.row)}>\n          <label htmlFor=\"isActive\" {...css(SETTINGS_STYLES.label)}>User is active</label>\n          <Switch checked={user.isActive} color=\"primary\" onChange={this.onIsActiveChange}/>\n        </div>\n      </div>\n    );\n  }\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Settings/components/rows.tsx",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as copyToClipboard from 'copy-to-clipboard';\nimport React from 'react';\n\nimport {\n  IconButton,\n  Tooltip,\n} from '@material-ui/core';\nimport {\n  Edit,\n  FileCopy,\n} from '@material-ui/icons';\n\nimport { IUserModel, ModelId } from '../../../../models';\nimport { USER_GROUP_ADMIN } from '../../../stores/users';\nimport { css } from '../../../utilx';\nimport { SETTINGS_STYLES } from '../settingsStyles';\n\nexport interface IUserProps {\n  user: IUserModel;\n  handleEditUser(userId: ModelId): void;\n}\n\nexport function UserRow({ user, handleEditUser }: IUserProps) {\n  function handleEditUserWrapper() {\n    handleEditUser(user.id);\n  }\n\n  return (\n    <tr {...css(SETTINGS_STYLES.userTableCell)}>\n      <td {...css(SETTINGS_STYLES.userTableCell)}>\n        {user.name}\n      </td>\n      <td {...css(SETTINGS_STYLES.userTableCell)}>\n        {user.email}\n      </td>\n      <td {...css(SETTINGS_STYLES.userTableCell)}>\n        {user.group === USER_GROUP_ADMIN ? 'Administrator' : 'Moderator'}\n      </td>\n      <td {...css(SETTINGS_STYLES.userTableCell)}>\n        {user.isActive ? 'Active' : ''}\n      </td>\n      <td {...css(SETTINGS_STYLES.userTableCell)}>\n        <Tooltip title=\"Edit this user\">\n          <IconButton onClick={handleEditUserWrapper}>\n            <Edit color=\"primary\"/>\n          </IconButton>\n        </Tooltip>\n      </td>\n    </tr>\n  );\n}\n\nexport function ServiceUserRow({ user, handleEditUser }: IUserProps) {\n  function copyButtonCLicked() {\n    copyToClipboard(user.extra.jwt);\n  }\n\n  function handleEditUserWrapper() {\n    handleEditUser(user.id);\n  }\n\n  return (\n    <tr {...css(SETTINGS_STYLES.userTableCell)}>\n      <td {...css(SETTINGS_STYLES.userTableCell)}>\n        {user.id}\n      </td>\n      <td {...css(SETTINGS_STYLES.userTableCell)}>\n        {user.name}\n      </td>\n      <td {...css(SETTINGS_STYLES.userTableCell, {fontSize: '10px'})}>\n        {user.extra.jwt}\n      </td>\n      <td>\n        <Tooltip title=\"Copy auth token to clipboard\">\n          <IconButton aria-label=\"Copy to clipboard\" onClick={copyButtonCLicked}>\n            <FileCopy fontSize=\"small\" />\n          </IconButton>\n        </Tooltip>\n      </td>\n      <td {...css(SETTINGS_STYLES.userTableCell)}>\n        {user.isActive ? 'Active' : ''}\n      </td>\n      <td {...css(SETTINGS_STYLES.userTableCell)}>\n        <Tooltip title=\"Edit this user\">\n          <IconButton onClick={handleEditUserWrapper}>\n            <Edit color=\"primary\"/>\n          </IconButton>\n        </Tooltip>\n      </td>\n    </tr>\n  );\n}\n\nexport function ModeratorUserRow({ user }: {user: IUserModel}) {\n  return (\n    <tr {...css(SETTINGS_STYLES.userTableCell)}>\n      <td {...css(SETTINGS_STYLES.userTableCell)}>\n        {user.name}\n      </td>\n      <td {...css(SETTINGS_STYLES.userTableCell)}>\n        {user.extra.endpointType}\n      </td>\n      <td {...css(SETTINGS_STYLES.userTableCell)}>\n        {user.extra.endpoint}\n      </td>\n      <td {...css(SETTINGS_STYLES.userTableCell)}>\n        {user.isActive ? 'Active' : ''}\n      </td>\n    </tr>\n  );\n}\n\nexport function YoutubeUserRow({ user, handleEditUser }: IUserProps) {\n  function handleEditUserWrapper() {\n    handleEditUser(user.id);\n  }\n\n  return (\n    <tr key={user.id} {...css(SETTINGS_STYLES.userTableCell)}>\n      <td {...css(SETTINGS_STYLES.userTableCell)}>\n        {user.name}\n      </td>\n      <td {...css(SETTINGS_STYLES.userTableCell)}>\n        {user.email}\n      </td>\n      <td {...css(SETTINGS_STYLES.userTableCell)}>\n        {user.isActive ? 'Active' : 'Inactive'}\n      </td>\n      <td {...css(SETTINGS_STYLES.userTableCell)}>\n        {user.extra.lastError ? user.extra.lastError.message : 'No error'}\n      </td>\n      <td {...css(SETTINGS_STYLES.userTableCell)}>\n        <Tooltip title=\"Edit YouTube connection\">\n          <IconButton onClick={handleEditUserWrapper}>\n            <Edit color=\"primary\"/>\n          </IconButton>\n        </Tooltip>\n      </td>\n    </tr>\n  );\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Settings/components/users.tsx",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport FocusTrap from 'focus-trap-react';\nimport { List, Map } from 'immutable';\nimport React from 'react';\n\nimport {\n  Fab,\n  IconButton,\n  Tooltip,\n} from '@material-ui/core';\nimport {\n  Add,\n  Input,\n  SaveAlt,\n} from '@material-ui/icons';\n\nimport { IUserModel, ModelId } from '../../../../models';\nimport { Scrim } from '../../../components/Scrim';\nimport { SCRIM_STYLE } from '../../../styles';\nimport { css } from '../../../utilx';\nimport { SETTINGS_STYLES } from '../settingsStyles';\nimport { AddUsers } from './AddUsers';\nimport { EditUsers } from './EditUsers';\nimport { EditYouTubeUser } from './EditYouTubeUser';\nimport { ModeratorUserRow, ServiceUserRow, UserRow, YoutubeUserRow } from './rows';\n\nexport function UserSettings(props: {\n  users: Map<ModelId, IUserModel>,\n  handleEdit(userId: ModelId): void,\n  handleAdd(event: React.FormEvent<any>): void,\n}) {\n  const { users } = props;\n  const sortedUsers = users.valueSeq().sort((a, b) => a.name.localeCompare(b.name));\n\n  return (\n    <div key=\"editUsersSection\">\n      <div key=\"heading\" {...css(SETTINGS_STYLES.heading)}>\n        <h2 {...css(SETTINGS_STYLES.headingText)}>Users</h2>\n      </div>\n      <div key=\"body\" {...css(SETTINGS_STYLES.section)}>\n        <div {...css(SETTINGS_STYLES.row)}>\n          <table>\n            <thead>\n            <tr>\n              <th key=\"1\" {...css(SETTINGS_STYLES.userTableCell)}>\n                Name\n              </th>\n              <th key=\"2\" {...css(SETTINGS_STYLES.userTableCell)}>\n                Email\n              </th>\n              <th key=\"3\" {...css(SETTINGS_STYLES.userTableCell)}>\n                Role\n              </th>\n              <th key=\"4\" {...css(SETTINGS_STYLES.userTableCell)}>\n                Is Active\n              </th>\n              <th key=\"5\" {...css(SETTINGS_STYLES.userTableCell)}/>\n            </tr>\n            </thead>\n            <tbody>\n            {sortedUsers.map((u) => (\n              <UserRow key={u.id} user={u} handleEditUser={props.handleEdit}/>\n            ))}\n            <tr key=\"button\">\n              <td key=\"spacer\" colSpan={4}/>\n              <td key=\"add\" {...css(SETTINGS_STYLES.userTableCell)}>\n                <Tooltip title=\"Add a user\">\n                  <IconButton  color=\"primary\" onClick={props.handleAdd}>\n                    <Add/>\n                  </IconButton>\n                </Tooltip>\n              </td>\n            </tr>\n            </tbody>\n          </table>\n        </div>\n      </div>\n    </div>\n  );\n}\n\nexport function ServiceUserSettings(props: {\n  users?: List<IUserModel>,\n  handleEdit(userId: ModelId): void,\n  handleAdd(event: React.FormEvent<any>): void,\n}) {\n  const {\n    users,\n  } = props;\n\n  if (!users || users.count() === 0) {\n    return (<p>None configured</p>);\n  }\n  return (\n    <div key=\"serviceUsers\" {...css(SETTINGS_STYLES.section)}>\n      <h3>Service accounts</h3>\n      <p>These accounts are used to access the OSMod API.</p>\n      <div key=\"serviceUsersSection\">\n        <table>\n          <thead>\n          <tr>\n            <th key=\"1\" {...css(SETTINGS_STYLES.userTableCell)}>\n              ID\n            </th>\n            <th key=\"2\" {...css(SETTINGS_STYLES.userTableCell)}>\n              Name\n            </th>\n            <th key=\"3\" {...css(SETTINGS_STYLES.userTableCell)}>\n              JWT Authentication token\n            </th>\n            <th/>\n            <th key=\"6\" {...css(SETTINGS_STYLES.userTableCell)}/>\n          </tr>\n          </thead>\n          <tbody>\n          {users.map((u) => (\n            <ServiceUserRow key={u.id} user={u} handleEditUser={props.handleEdit}/>\n          ))}\n          <tr key=\"button\">\n            <td key=\"spacer\" colSpan={5}/>\n            <td key=\"add\" {...css(SETTINGS_STYLES.userTableCell)}>\n              <Tooltip title=\"Add a service user\">\n                <IconButton  color=\"primary\" onClick={props.handleAdd}>\n                  <Add/>\n                </IconButton>\n              </Tooltip>\n            </td>\n          </tr>\n          </tbody>\n        </table>\n      </div>\n    </div>\n  );\n}\n\nexport function ModeratorSettings(props: {\n  users?: List<IUserModel>,\n}) {\n  const {\n    users,\n  } = props;\n\n  if (!users || users.count() === 0) {\n    return (<p>None configured</p>);\n  }\n  return (\n    <div key=\"moderatorUsers\" {...css(SETTINGS_STYLES.section)}>\n      <h3>Moderator accounts</h3>\n      <p>These accounts are responsible for sending comments to the Perspective API scorer.</p>\n      <div key=\"moderatorUsersSection\">\n        <table>\n          <thead>\n          <tr>\n            <th key=\"1\" {...css(SETTINGS_STYLES.userTableCell)}>\n              Name\n            </th>\n            <th key=\"3\" {...css(SETTINGS_STYLES.userTableCell)}>\n              Type\n            </th>\n            <th key=\"4\" {...css(SETTINGS_STYLES.userTableCell)}>\n              Endpoint\n            </th>\n            <th key=\"5\" {...css(SETTINGS_STYLES.userTableCell)}>\n              Is Active\n            </th>\n            <th key=\"6\" {...css(SETTINGS_STYLES.userTableCell)}/>\n          </tr>\n          </thead>\n          <tbody>\n          {users.map((u) => (\n            <ModeratorUserRow key={u.id} user={u}/>\n          ))}\n          </tbody>\n        </table>\n      </div>\n    </div>\n  );\n}\n\nexport function YouTubeUsersSettings(props: {\n  users?: List<IUserModel>,\n  handleEdit(userId: ModelId): void,\n  connect(): void,\n  kick(): void,\n}) {\n  const {\n    users,\n  } = props;\n\n  if (!users || users.count() === 0) {\n    return (<p>None configured</p>);\n  }\n\n  return (\n    <div key=\"pluginsContent\" {...css(SETTINGS_STYLES.section)}>\n      <h3>YouTube accounts</h3>\n      <div key=\"youtubeUsersSection\">\n        <table>\n          <thead>\n          <tr>\n            <th key=\"1\" {...css(SETTINGS_STYLES.userTableCell)}>\n              Name\n            </th>\n            <th key=\"2\" {...css(SETTINGS_STYLES.userTableCell)}>\n              Email\n            </th>\n            <th key=\"3\" {...css(SETTINGS_STYLES.userTableCell)}>\n              Is Active\n            </th>\n            <th key=\"4\" {...css(SETTINGS_STYLES.userTableCell)}>\n              Last Error\n            </th>\n          </tr>\n          </thead>\n          <tbody>\n          {users.map((u) => (\n            <YoutubeUserRow key={u.id} user={u} handleEditUser={props.handleEdit}/>\n          ))}\n          </tbody>\n        </table>\n      </div>\n      <div key=\"youtubeButtons\" {...css(SETTINGS_STYLES.buttonGroup)}>\n        <div style={{paddingRight: '30px'}}>\n          <Tooltip title=\"Connect an account\">\n            <Fab color=\"primary\" onClick={props.connect}>\n              <Input/>\n            </Fab>\n          </Tooltip>\n        </div>\n        <div style={{paddingRight: '30px'}}>\n          <Tooltip title=\"Check for new channels\">\n            <Fab color=\"primary\" onClick={props.kick}>\n              <SaveAlt/>\n            </Fab>\n          </Tooltip>\n        </div>\n      </div>\n      <p>To connect a YouTube account, click the button above, and select a Google user and YouTube account.</p>\n      <p>We'll then start syncing comments with the channels and videos in that account.</p>\n      <p>If you are seeing errors above, then try reconnecting to your account.</p>\n    </div>\n  );\n}\n\nexport function AddUserScrim(props: {\n  type?: string,\n  visible: boolean,\n  close(): void,\n  save(user: IUserModel): Promise<void>,\n}) {\n  return (\n    <Scrim\n      key=\"addUserScrim\"\n      scrimStyles={SCRIM_STYLE.scrim}\n      isVisible={props.visible}\n      onBackgroundClick={props.close}\n    >\n      <FocusTrap\n        focusTrapOptions={{\n          clickOutsideDeactivates: true,\n        }}\n      >\n        <div\n          key=\"addUserContainer\"\n          tabIndex={0}\n          {...css(SCRIM_STYLE.popup, {position: 'relative', width: 450})}\n        >\n          <AddUsers\n            userType={props.type}\n            onClickDone={props.save}\n            onClickClose={props.close}\n          />\n        </div>\n      </FocusTrap>\n    </Scrim>\n  );\n}\n\nexport function EditUserScrim(props: {\n  user?: IUserModel,\n  visible: boolean,\n  close(): void,\n  save(user: IUserModel): Promise<void>,\n}) {\n  return (\n    <Scrim\n      key=\"editUserScrim\"\n      scrimStyles={SCRIM_STYLE.scrim}\n      isVisible={props.visible}\n      onBackgroundClick={props.close}\n    >\n      <FocusTrap\n        focusTrapOptions={{\n          clickOutsideDeactivates: true,\n        }}\n      >\n        <div\n          key=\"editUserContainer\"\n          tabIndex={0}\n          {...css(SCRIM_STYLE.popup, {position: 'relative', width: 450})}\n        >\n          <EditUsers\n            userToEdit={props.user}\n            onClickDone={props.save}\n            onClickClose={props.close}\n          />\n        </div>\n      </FocusTrap>\n    </Scrim>\n  );\n}\n\nexport function EditYouTubeScrim(props: {\n  user?: IUserModel,\n  visible: boolean,\n  close(): void,\n  save(user: IUserModel): Promise<void>,\n}) {\n  return (\n    <Scrim\n      key=\"editYouTubeScrim\"\n      scrimStyles={SCRIM_STYLE.scrim}\n      isVisible={props.visible}\n      onBackgroundClick={props.close}\n    >\n      <FocusTrap\n        focusTrapOptions={{\n          clickOutsideDeactivates: true,\n        }}\n      >\n        <div\n          key=\"editYouTubeContainer\"\n          tabIndex={0}\n          {...css(SCRIM_STYLE.popup, {position: 'relative', width: '77vh'})}\n        >\n          <EditYouTubeUser\n            user={props.user}\n            onUserUpdate={props.save}\n            onClickClose={props.close}\n          />\n        </div>\n      </FocusTrap>\n    </Scrim>\n  );\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Settings/settingsStyles.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport {\n  ARTICLE_CATEGORY_TYPE,\n  BASE_Z_INDEX,\n  BOX_DEFAULT_SPACING,\n  DARK_SECONDARY_TEXT_COLOR,\n  GUTTER_DEFAULT_SPACING,\n  INPUT_DROP_SHADOW,\n  NICE_MIDDLE_BLUE,\n  PALE_COLOR, WHITE_COLOR,\n} from '../../styles';\n\nconst ARROW_SIZE = 6;\nconst ROW_HEIGHT = 42;\n\nexport const SETTINGS_STYLES = {\n  row: {\n    position: 'relative',\n    padding: `0 0 ${GUTTER_DEFAULT_SPACING}px 0`,\n    display: 'flex',\n    alignItems: 'center',\n    maxWidth: '100%',\n    overflow: 'hidden',\n  },\n\n  label: {\n    ...ARTICLE_CATEGORY_TYPE,\n    marginRight: '24px',\n    minWidth: '120px',\n    display: 'flex',\n    alignItems: 'center',\n    height: ROW_HEIGHT,\n  },\n\n  input: {\n    borderRadius: 2,\n    boxShadow: INPUT_DROP_SHADOW,\n    borderWidth: 0,\n    fontSize: '16px',\n    marginRight: GUTTER_DEFAULT_SPACING,\n    paddingLeft: 10,\n    backgroundColor: PALE_COLOR,\n    height: ROW_HEIGHT,\n    boxSizing: 'border-box',\n  },\n\n  selectBox: {\n    width: 280,\n    height: ROW_HEIGHT,\n    paddingLeft: 10,\n    appearance: 'none',\n    WebkitAppearance: 'none', // Not getting prefixed either\n    border: 'none',\n    borderRadius: 2,\n    boxShadow: INPUT_DROP_SHADOW,\n    backgroundColor: PALE_COLOR,\n    fontSize: '16px',\n    boxSizing: 'border-box',\n  },\n\n  selectBoxRow: {\n    position: 'relative',\n  },\n\n  button: {\n    alignSelf: 'flex-end',\n    backgroundColor: 'transparent',\n    border: 'none',\n    color: NICE_MIDDLE_BLUE,\n    cursor: 'pointer',\n    height: ROW_HEIGHT,\n    ':focus': {\n      outline: 0,\n      textDecoration: 'underline',\n    },\n  },\n\n  checkbox: {\n    marginLeft: `${GUTTER_DEFAULT_SPACING / 2}px`,\n  },\n\n  arrow: {\n    position: 'absolute',\n    zIndex: BASE_Z_INDEX,\n    right: 28,\n    top: 15,\n    borderLeft: `${ARROW_SIZE}px solid transparent`,\n    borderRight: `${ARROW_SIZE}px solid transparent`,\n    borderTop: `${ARROW_SIZE}px solid ${DARK_SECONDARY_TEXT_COLOR}`,\n    display: 'block',\n    height: 0,\n    width: 0,\n    marginLeft: `${BOX_DEFAULT_SPACING}px`,\n    marginRight: `${BOX_DEFAULT_SPACING}px`,\n    pointerEvents: 'none',\n  },\n\n  userTableCell: {\n    textAlign: 'left',\n    padding: '5px 30px',\n  },\n\n  heading: {\n    backgroundColor: PALE_COLOR,\n    color: NICE_MIDDLE_BLUE,\n    padding: `4px ${GUTTER_DEFAULT_SPACING}px`,\n  },\n  headingText: {\n    fontSize: '1.3em',\n  },\n  section: {\n    paddingTop: `${GUTTER_DEFAULT_SPACING}px`,\n    paddingLeft: `${GUTTER_DEFAULT_SPACING}px`,\n    paddingRight: `${GUTTER_DEFAULT_SPACING}px`,\n    paddingBottom: `${GUTTER_DEFAULT_SPACING * 2}px`,\n    backgroundColor: WHITE_COLOR,\n  },\n  buttonGroup: {\n    display: 'flex',\n    flexDirection: 'row',\n    padding: `${GUTTER_DEFAULT_SPACING}px`,\n  },\n};\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Settings/store.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { List } from 'immutable';\nimport { isEqual } from 'lodash';\nimport slugify from 'slugify';\n\nimport {\n  IPreselectModel,\n  IRuleModel,\n  ITaggingSensitivityModel,\n  ITagModel,\n  IUserModel,\n  ModelId,\n} from '../../../models';\nimport {\n  createPreselect,\n  createRule,\n  createSensitivity,\n  createTag,\n  createUser,\n  destroyModel,\n  updatePreselect,\n  updateRule,\n  updateSensitivity,\n  updateTag,\n  updateUser,\n} from '../../platform/dataService';\n\nfunction diff<T extends {id: ModelId}>(original: List<T>, current: List<T>): {\n  modified: Array<T>,\n  added: Array<T>,\n  removed: Array<ModelId>,\n} {\n  const originals = new Map<ModelId, T>();\n  const toAdd: Array<T> = [];\n  const toModify: Array<T> = [];\n  for (const o of original.toArray()) {\n    originals.set(o.id, o);\n  }\n  for (const c of current.toArray()) {\n    if (originals.has(c.id)) {\n      if (!isEqual(originals.get(c.id), c)) {\n        toModify.push(c);\n      }\n      originals.delete(c.id);\n    } else {\n      toAdd.push(c);\n    }\n  }\n\n  return { modified: toModify, added: toAdd, removed: Array.from(originals.keys()) };\n}\n\nexport async function addUser(user: IUserModel): Promise<void> {\n  await createUser(user);\n}\n\nexport async function modifyUser(user: IUserModel): Promise<void> {\n await updateUser(user);\n}\n\nasync function addTag(tag: ITagModel): Promise<void> {\n  await createTag({\n    ...tag,\n    key: slugify(tag.label, '_').toUpperCase(),\n  });\n}\n\nasync function modifyTag(tag: ITagModel): Promise<void> {\n  await updateTag(tag);\n}\n\nasync function deleteTag(tagId: ModelId): Promise<void> {\n  await destroyModel('tag', tagId);\n}\n\nexport async function updateTags(oldTags: List<ITagModel>, newTags: List<ITagModel>) {\n  const { modified, added, removed } = diff<ITagModel>(oldTags, newTags);\n  await Promise.all([\n    Promise.all(modified.map(modifyTag)),\n    Promise.all(added.map(addTag)),\n    Promise.all(removed.map(deleteTag)),\n  ]);\n}\n\nasync function addRule(rule: IRuleModel): Promise<void> {\n  await createRule(rule);\n}\n\nasync function modifyRule(rule: IRuleModel): Promise<void> {\n  await updateRule(rule);\n}\n\nasync function deleteRule(ruleId: ModelId): Promise<void> {\n  await destroyModel('moderation_rule', ruleId);\n}\n\nexport async function updateRules(oldRules: List<IRuleModel>, newRules: List<IRuleModel>) {\n  const { modified, added, removed } = diff<IRuleModel>(oldRules, newRules);\n  await Promise.all([\n    Promise.all(modified.map(modifyRule)),\n    Promise.all(added.map(addRule)),\n    Promise.all(removed.map(deleteRule)),\n  ]);\n}\n\nasync function addPreselect(preselect: IPreselectModel): Promise<void> {\n  await createPreselect(preselect);\n}\n\nasync function modifyPreselect(preselect: IPreselectModel): Promise<void> {\n  await updatePreselect(preselect);\n}\n\nasync function deletePreselect(preselectId: ModelId): Promise<void> {\n  await destroyModel('preselect', preselectId);\n}\n\nexport async function updatePreselects(oldPreselects: List<IPreselectModel>, newPreselects: List<IPreselectModel>) {\n  const { modified, added, removed } = diff<IPreselectModel>(oldPreselects, newPreselects);\n  await Promise.all([\n    Promise.all(modified.map(modifyPreselect)),\n    Promise.all(added.map(addPreselect)),\n    Promise.all(removed.map(deletePreselect)),\n  ]);\n}\n\nasync function addTaggingSensitivity(taggingSensitivity: ITaggingSensitivityModel): Promise<void> {\n  await createSensitivity(taggingSensitivity);\n}\n\nasync function modifyTaggingSensitivity(taggingSensitivity: ITaggingSensitivityModel): Promise<void> {\n  await updateSensitivity(taggingSensitivity);\n}\n\nasync function deleteTaggingSensitivity(taggingSensitivityId: ModelId): Promise<void> {\n  await destroyModel('tagging_sensitivity', taggingSensitivityId);\n}\n\nexport async function updateTaggingSensitivities(oldRules: List<ITaggingSensitivityModel>, newRules: List<ITaggingSensitivityModel>) {\n  const { modified, added, removed } = diff<ITaggingSensitivityModel>(oldRules, newRules);\n  await Promise.all([\n    Promise.all(modified.map(modifyTaggingSensitivity)),\n    Promise.all(added.map(addTaggingSensitivity)),\n    Promise.all(removed.map(deleteTaggingSensitivity)),\n  ]);\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Settings/styles.ts",
    "content": "/*\nCopyright 2020 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport {\n  ARTICLE_CATEGORY_TYPE,\n  DARK_PRIMARY_TEXT_COLOR,\n  GUTTER_DEFAULT_SPACING,\n  HEADER_HEIGHT,\n  WHITE_COLOR,\n} from '../../styles';\nimport {stylesheet} from '../../utilx';\n\nexport const STYLES: any = stylesheet({\n  base: {\n    ...ARTICLE_CATEGORY_TYPE,\n    color: DARK_PRIMARY_TEXT_COLOR,\n    position: 'relative',\n    height: '100%',\n    boxSizing: 'border-box',\n  },\n  body: {\n    height: `calc(100% - ${2 * HEADER_HEIGHT + 12}px)`,\n    overflowY: 'auto',\n    WebkitOverflowScrolling: 'touch',\n  },\n  formContainer: {\n    background: WHITE_COLOR,\n    paddingBottom: `${GUTTER_DEFAULT_SPACING}px`,\n  },\n});\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Tables/ArticleTable.tsx",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport FocusTrap from 'focus-trap-react';\nimport { Set } from 'immutable';\nimport React, {useMemo, useState} from 'react';\nimport { useSelector } from 'react-redux';\nimport { useHistory, useParams } from 'react-router';\nimport { Link } from 'react-router-dom';\nimport AutoSizer from 'react-virtualized-auto-sizer';\nimport { VariableSizeList } from 'react-window';\n\nimport { IArticleAttributes, IArticleModel, ICategoryModel, ModelId } from '../../../models';\nimport { ArticleControlIcon, AssignModerators, MagicTimestamp } from '../../components';\nimport * as icons from '../../components/Icons';\nimport { Scrim } from '../../components/Scrim';\nimport {CustomScrollbarsVirtualList} from '../../components/VirtualListScrollbar';\nimport {\n  updateArticle,\n  updateArticleModerators,\n  updateCategoryModerators,\n} from '../../platform/dataService';\nimport { getArticles } from '../../stores/articles';\nimport { getCategoryMap, ISummaryCounts } from '../../stores/categories';\nimport { getUsers } from '../../stores/users';\nimport {\n  flexCenter,\n  HEADER_HEIGHT,\n  NICE_LIGHTEST_BLUE,\n  NICE_MIDDLE_BLUE,\n  SCRIM_STYLE,\n} from '../../styles';\nimport { COMMON_STYLES, medium } from '../../stylesx';\nimport { css, IPossibleStyle, stylesheet, useBindEscape } from '../../utilx';\nimport {\n  articleBase,\n  categoryBase,\n  dashboardLink,\n  IDashboardPathParams,\n  moderatedCommentsPageLink,\n  NEW_COMMENTS_DEFAULT_TAG,\n  newCommentsPageLink,\n} from '../routes';\nimport { ModeratorsWidget, TITLE_CELL_STYLES, TitleCell } from './components';\nimport { FilterSidebar } from './FilterSidebar';\nimport { ARTICLE_TABLE_STYLES, CELL_HEIGHT } from './styles';\nimport {\n  NOT_SET,\n  SORT_APPROVED,\n  SORT_DEFERRED,\n  SORT_FLAGGED,\n  SORT_HIGHLIGHTED,\n  SORT_LAST_MODERATED,\n  SORT_NEW,\n  SORT_REJECTED,\n  SORT_TITLE,\n  SORT_UPDATED,\n} from './utils';\nimport {\n  executeFilter,\n  executeSort,\n  getFilterString,\n  getSortString,\n  IFilterItem,\n  isFilterActive,\n  parseFilter,\n  parseSort,\n} from './utils';\n\nconst STYLES = stylesheet({\n  scrimPopup: {\n    background: 'rgba(0, 0, 0, 0.4)',\n    ...flexCenter,\n    alignContent: 'center',\n  },\n\n  pagingBar: {\n    height: `${HEADER_HEIGHT}px`,\n    width: '100%',\n    display: 'flex',\n    justifyContent: 'center',\n    textSize: '18px',\n    color: 'white',\n  },\n\n  directionIndicator: {\n    position: 'absolute',\n    left: 0,\n    right: 0,\n    textAlign: 'center',\n    lineHeight: 'initial',\n  },\n});\n\nconst POPUP_MODERATORS = 'moderators';\nconst POPUP_CONTROLS = 'controls';\nconst POPUP_FILTERS = 'filters';\nconst POPUP_SAVING = 'saving';\n\nfunction renderTime(time: string | null) {\n  if (!time) {\n    return 'Never';\n  }\n  return <MagicTimestamp timestamp={time} inFuture={false}/>;\n}\n\ninterface ICountsInfoProps {\n  counts: ISummaryCounts;\n  cellStyle: IPossibleStyle;\n  getLink(disposition: string): string;\n}\n\nfunction CountsInfo(props: ICountsInfoProps) {\n  const {getLink, cellStyle, counts} = props;\n\n  return (\n    <>\n      <div {...css(cellStyle, ARTICLE_TABLE_STYLES.numberCell)}>\n        <Link to={getLink('new')} {...css(COMMON_STYLES.cellLink)}>\n          {counts.unmoderatedCount}\n        </Link>\n      </div>\n      <div {...css(cellStyle, ARTICLE_TABLE_STYLES.numberCell)}>\n        <Link to={getLink('approved')} {...css(COMMON_STYLES.cellLink)}>\n          {counts.approvedCount}\n        </Link>\n      </div>\n      <div {...css(cellStyle, ARTICLE_TABLE_STYLES.numberCell)}>\n        <Link to={getLink('rejected')} {...css(COMMON_STYLES.cellLink)}>\n          {counts.rejectedCount}\n        </Link>\n      </div>\n      <div {...css(cellStyle, ARTICLE_TABLE_STYLES.numberCell)}>\n        <Link to={getLink('deferred')} {...css(COMMON_STYLES.cellLink)}>\n          {counts.deferredCount}\n        </Link>\n      </div>\n      <div {...css(cellStyle, ARTICLE_TABLE_STYLES.numberCell)}>\n        <Link to={getLink('highlighted')} {...css(COMMON_STYLES.cellLink)}>\n          {counts.highlightedCount}\n        </Link>\n      </div>\n      <div {...css(cellStyle, ARTICLE_TABLE_STYLES.numberCell)}>\n        <Link to={getLink('flagged')} {...css(COMMON_STYLES.cellLink)}>\n          {counts.flaggedCount}\n        </Link>\n      </div>\n    </>\n  );\n}\n\ninterface IRowActions {\n  clearPopups(): void;\n  openControls(article: IArticleModel): void;\n  saveControls(isCommentingEnabled: boolean, isAutoModerated: boolean): void;\n  openSetModerators(\n    targetId: ModelId,\n    moderatorIds: Array<ModelId>,\n    superModeratorIds: Array<ModelId>,\n    isCategory: boolean,\n  ): void;\n}\n\ninterface IArticleRowProps extends IRowActions {\n  article: IArticleModel;\n  selectedArticle?: ModelId | null;\n}\n\nfunction ArticleRow(props: IArticleRowProps) {\n  const {article, selectedArticle} = props;\n  const categories = useSelector(getCategoryMap);\n  const users = useSelector(getUsers);\n\n  const lastModerated = renderTime(article.lastModeratedAt);\n  const category = categories.get(article.categoryId);\n  function getLink(disposition: string) {\n    if (disposition === 'new') {\n      return newCommentsPageLink({context: articleBase, contextId: article.id, tag: NEW_COMMENTS_DEFAULT_TAG});\n    }\n    return moderatedCommentsPageLink({context: articleBase, contextId: article.id, disposition});\n  }\n\n  const targetId = article.id;\n  const moderatorIds = article.assignedModerators;\n  const superModeratorIds = category?.assignedModerators;\n\n  function openSetModerators() {\n    props.openSetModerators(targetId, moderatorIds, superModeratorIds, false);\n  }\n\n  const cellStyle = ARTICLE_TABLE_STYLES.dataCell;\n\n  return (\n    <div {...css(cellStyle, ARTICLE_TABLE_STYLES.dataBody)}>\n      <div {...css(cellStyle, ARTICLE_TABLE_STYLES.textCell)}>\n        <TitleCell\n          category={categories.get(article.categoryId)}\n          article={article}\n          link={getLink('new')}\n        />\n      </div>\n      <CountsInfo counts={article} cellStyle={cellStyle} getLink={getLink}/>\n      <div {...css(cellStyle, ARTICLE_TABLE_STYLES.timeCell)}>\n        <MagicTimestamp timestamp={article.updatedAt} inFuture={false}/>\n      </div>\n      <div {...css(cellStyle, ARTICLE_TABLE_STYLES.timeCell)}>\n        {lastModerated}\n      </div>\n      <div {...css(cellStyle, ARTICLE_TABLE_STYLES.iconCell)}>\n        <div {...css({display: 'inline-block'})}>\n          <ArticleControlIcon\n            article={article}\n            open={selectedArticle && selectedArticle === article.id}\n            clearPopups={props.clearPopups}\n            openControls={props.openControls}\n            saveControls={props.saveControls}\n          />\n        </div>\n      </div>\n      <div {...css(cellStyle, ARTICLE_TABLE_STYLES.iconCell)}>\n        {targetId && (\n          <ModeratorsWidget\n            users={users}\n            moderatorIds={moderatorIds}\n            superModeratorIds={superModeratorIds}\n            openSetModerators={openSetModerators}\n          />\n        )}\n      </div>\n    </div>\n  );\n}\n\ninterface ISummaryRowProps extends IRowActions {\n  summary: IArticleModel;\n}\n\nfunction SummaryRow(props: ISummaryRowProps) {\n  const {summary} = props;\n  const categories = useSelector(getCategoryMap);\n  const users = useSelector(getUsers);\n  const category = categories.get(summary.categoryId);\n\n  function getLink(disposition: string) {\n    const categoryId = category ? category.id : 'all';\n    if (disposition === 'new') {\n      return newCommentsPageLink({context: categoryBase, contextId: categoryId, tag: NEW_COMMENTS_DEFAULT_TAG});\n    }\n    return moderatedCommentsPageLink({context: categoryBase, contextId: categoryId, disposition});\n  }\n\n  const targetId = category?.id;\n  const moderatorIds = category?.assignedModerators;\n\n  function openSetModerators() {\n    props.openSetModerators(targetId, moderatorIds, null, true);\n  }\n\n  const cellStyle = ARTICLE_TABLE_STYLES.summaryCell;\n\n  return (\n    <div {...css(cellStyle, ARTICLE_TABLE_STYLES.dataBody)}>\n      <div {...css(cellStyle, ARTICLE_TABLE_STYLES.textCell)}>\n        <Link to={getLink('new')} {...css(COMMON_STYLES.cellLink, TITLE_CELL_STYLES.mainTextText)}>\n          {summary.title}\n        </Link>\n      </div>\n      <CountsInfo counts={summary} cellStyle={cellStyle} getLink={getLink}/>\n      <div {...css(cellStyle, ARTICLE_TABLE_STYLES.timeCell)}/>\n      <div {...css(cellStyle, ARTICLE_TABLE_STYLES.timeCell)}/>\n      <div {...css(cellStyle, ARTICLE_TABLE_STYLES.iconCell)}>\n        <div {...css({display: 'inline-block'})}/>\n      </div>\n      <div {...css(cellStyle, ARTICLE_TABLE_STYLES.iconCell)}>\n        {targetId && (\n          <ModeratorsWidget\n            users={users}\n            moderatorIds={moderatorIds}\n            superModeratorIds={null}\n            openSetModerators={openSetModerators}\n          />\n        )}\n      </div>\n    </div>\n  );\n}\n\nfunction DirectionIndicatorUp() {\n  return (\n    <div {...css(STYLES.directionIndicator, {top: '-15px'})}>\n      <icons.KeyUpIcon/>\n    </div>\n  );\n}\n\nfunction DirectionIndicatorDown() {\n  return (\n    <div {...css(STYLES.directionIndicator, {bottom: '-18px'})}>\n      <icons.KeyDownIcon/>\n    </div>\n  );\n}\n\nexport interface IArticleTableProps {\n}\n\nfunction calculateSummaryCounts(articles: Array<IArticleModel>) {\n  const columns = [\n    'unmoderatedCount',\n    'approvedCount',\n    'rejectedCount',\n    'deferredCount',\n    'highlightedCount',\n    'flaggedCount',\n  ];\n  const summary: Partial<IArticleAttributes> & {[key: string]: number} =  {};\n  for (const i of columns) {\n    summary['count'] = 0;\n    summary[i] = 0;\n  }\n\n  articles.reduce((s: any, a) =>  {\n    s['count'] ++;\n    for (const i of columns) {\n      s[i] += (a as any)[i];\n    }\n    return s;\n  }, summary);\n\n  return summary;\n}\n\nfunction sortArticles(articles: Array<IArticleModel>, sort: Array<string>) {\n  const sortFn = (sort.length > 0) ? executeSort(sort) : executeSort([`+${SORT_NEW}`]);\n  return articles.sort(sortFn);\n}\n\nfunction filterArticles(\n  articles: Array<IArticleModel>,\n  filter: Array<IFilterItem>,\n  categories: Map<ModelId, ICategoryModel>,\n): Array<IArticleModel> {\n  if (filter.length === 0) {\n    return articles;\n  }\n\n  return articles.filter(executeFilter(filter, {categories}));\n}\n\nexport function ArticleTable(_props: IArticleTableProps) {\n  const history = useHistory();\n  const params = useParams<IDashboardPathParams>();\n  const articlesContainerHeight = window.innerHeight - HEADER_HEIGHT * 2;\n  const categories = useSelector(getCategoryMap);\n  const articles = useSelector(getArticles);\n  const users = useSelector(getUsers);\n\n  const filterString = params.filter || NOT_SET;\n  const sortString = params.sort || NOT_SET;\n  const filter = useMemo(() => parseFilter(filterString), [filterString]);\n  const sort = useMemo(() => parseSort(sortString), [sortString]);\n  const sortedArticles = useMemo(() => sortArticles(articles, sort), [articles, sort]);\n  const filteredArticles = useMemo(\n    () => filterArticles(sortedArticles, filter, categories),\n    [sortedArticles, filter, categories],\n  );\n  const summary = useMemo(() => calculateSummaryCounts(filteredArticles), [filteredArticles]);\n\n  // Use users map from store\n  const count = summary['count'];\n  summary['id'] = 'summary';\n  summary['title'] = ` ${count} Title` + (count !== 1 ? 's' : '');\n\n  const categoryMatch = /category=(\\d+)/.exec(filterString);\n  const selectedCategory = categoryMatch ? categories.get(categoryMatch[1]) : null;\n\n  if (selectedCategory) {\n    summary['categoryId'] = selectedCategory.id;\n    summary['title'] += ` in section ${selectedCategory.label}`;\n  }\n\n  if (filter.length > 1 || (filter.length === 1 && filter[0].key !== 'category')) {\n    summary['title'] += ' matching filter';\n  }\n\n  const [popupToShow, setPopupToShow] = useState<string>(null);\n  const [selectedArticle, setSelectedArticle] = useState<IArticleModel>(null);\n\n  const [targetIsCategory, setTargetIsCategory] = useState<boolean>(null);\n  const [targetId, setTargetId] = useState<ModelId>(null);\n  const [moderatorIds, setModeratorIds] = useState<Set<ModelId>>(null);\n  const [superModeratorIds, setSuperModeratorIds] = useState<Set<ModelId>>(null);\n\n  const currentFilter = getFilterString(filter);\n  const currentSort = getSortString(sort);\n\n  function clearPopups() {\n    setPopupToShow(null);\n    setSelectedArticle(null);\n    setTargetIsCategory(null);\n    setTargetId(null);\n    setModeratorIds(null);\n    setSuperModeratorIds(null);\n  }\n\n  useBindEscape(clearPopups);\n\n  function openSetModerators(\n    iTargetId: ModelId,\n    iModeratorIds: Array<ModelId>,\n    iSuperModeratorIds: Array<ModelId>,\n    isCategory: boolean,\n  ) {\n    clearPopups();\n    setPopupToShow(POPUP_MODERATORS);\n    setTargetIsCategory(isCategory);\n    setTargetId(iTargetId);\n    setModeratorIds(Set<ModelId>(iModeratorIds));\n    setSuperModeratorIds(Set<ModelId>(iSuperModeratorIds));\n  }\n\n  function openFilters() {\n    clearPopups();\n    setPopupToShow(POPUP_FILTERS);\n  }\n\n  function openControls(article: IArticleModel) {\n    setPopupToShow(POPUP_CONTROLS);\n    setSelectedArticle(article);\n  }\n\n  function renderFilterPopup() {\n    function setFilter(newFilter: Array<IFilterItem>) {\n      history.push(dashboardLink({filter: getFilterString(newFilter), sort: currentSort}));\n    }\n\n    return (\n      <FilterSidebar\n        key=\"filter-sidebar\"\n        open={popupToShow === POPUP_FILTERS}\n        filterString={filterString}\n        filter={filter}\n        users={users.valueSeq()}\n        setFilter={setFilter}\n        clearPopups={clearPopups}\n      />\n    );\n  }\n\n  async function saveControls(isCommentingEnabled: boolean, isAutoModerated: boolean) {\n    clearPopups();\n    setPopupToShow(POPUP_SAVING);\n    await updateArticle(selectedArticle.id, isCommentingEnabled, isAutoModerated);\n    clearPopups();\n  }\n\n  function onAddModerator(userId: string) {\n    setModeratorIds(moderatorIds.add(userId));\n  }\n\n  function onRemoveModerator(userId: string) {\n    setModeratorIds(moderatorIds.remove(userId));\n  }\n\n  async function saveModerators() {\n    clearPopups();\n    setPopupToShow(POPUP_SAVING);\n\n    if (targetIsCategory) {\n      await updateCategoryModerators(targetId, moderatorIds.toArray());\n    }\n    else {\n      await updateArticleModerators(targetId, moderatorIds.toArray());\n    }\n\n    clearPopups();\n  }\n\n  function renderSaving() {\n    if (popupToShow === POPUP_SAVING) {\n      return (\n        <Scrim key=\"saving\" isVisible onBackgroundClick={clearPopups} scrimStyles={STYLES.scrimPopup}>\n          <div tabIndex={0} {...css(SCRIM_STYLE.popup)}>\n            Saving....\n          </div>\n        </Scrim>\n      );\n    }\n\n    return null;\n  }\n\n  function renderSetModerators() {\n    if (popupToShow !== POPUP_MODERATORS) {\n      return null;\n    }\n\n    return (\n      <Scrim key=\"set-moderators\" isVisible onBackgroundClick={clearPopups} scrimStyles={STYLES.scrimPopup}>\n        <FocusTrap focusTrapOptions={{clickOutsideDeactivates: true}}>\n          <div tabIndex={0} {...css(SCRIM_STYLE.popup, {position: 'relative'})}>\n            <AssignModerators\n              label={targetIsCategory ? 'Assign a category moderator' : 'Assign a moderator'}\n              moderatorIds={moderatorIds}\n              superModeratorIds={superModeratorIds}\n              onAddModerator={onAddModerator}\n              onRemoveModerator={onRemoveModerator}\n              onClickDone={saveModerators}\n              onClickClose={clearPopups}\n            />\n          </div>\n        </FocusTrap>\n      </Scrim>\n    );\n  }\n\n  function rowHeight(index: number) {\n    if (index === 0) {\n      return HEADER_HEIGHT;\n    }\n    return CELL_HEIGHT;\n  }\n\n  function renderRow(index: number, style: any) {\n    if (index === 0) {\n      return (\n        <div key={'summary'} style={style}>\n          <SummaryRow\n            summary={summary as IArticleModel}\n            clearPopups={clearPopups}\n            openControls={openControls}\n            saveControls={saveControls}\n            openSetModerators={openSetModerators}\n          />\n        </div>\n      );\n    }\n\n    const article = filteredArticles[index - 1];\n    return (\n      <div key={index} style={style}>\n        <ArticleRow\n          article={article}\n          selectedArticle={selectedArticle?.id}\n          clearPopups={clearPopups}\n          openControls={openControls}\n          saveControls={saveControls}\n          openSetModerators={openSetModerators}\n        />\n      </div>\n    );\n  }\n\n  const HeaderItem: React.FunctionComponent<{sortField: string}> = (props) => {\n    const {sortField, children} = props;\n\n    let directionIndicator: string | JSX.Element = '';\n    let nextSortItem = `+${sortField}`;\n\n    for (const item of sort) {\n      if (item.endsWith(sortField)) {\n        if (item[0] === '+') {\n          directionIndicator = DirectionIndicatorDown();\n          nextSortItem =  `-${sortField}`;\n        }\n        else if (item[0] === '-') {\n          directionIndicator = DirectionIndicatorUp();\n          nextSortItem = '';\n        }\n        break;\n      }\n    }\n    // const newSort = sortString(updateSort(sort, nextSortItem)); implements multi sort\n    const newSort = getSortString([nextSortItem]);\n    return (\n      <Link to={dashboardLink({filter: currentFilter, sort: newSort})} {...css(COMMON_STYLES.cellLink)}>\n        <span {...css({position: 'relative'})}>\n          {children}\n          {directionIndicator}\n        </span>\n      </Link>\n    );\n  };\n\n  const filterActive = isFilterActive(filter);\n  return (\n    <div key=\"main\">\n      <div key=\"header\" {...css(ARTICLE_TABLE_STYLES.dataHeader)}>\n        <div key=\"title\" {...css(ARTICLE_TABLE_STYLES.headerCell, ARTICLE_TABLE_STYLES.textCell)}>\n          <HeaderItem sortField={SORT_TITLE}>Title</HeaderItem>\n        </div>\n        <div key=\"new\" {...css(ARTICLE_TABLE_STYLES.headerCell, ARTICLE_TABLE_STYLES.numberCell)}>\n          <HeaderItem sortField={SORT_NEW}>New</HeaderItem>\n        </div>\n        <div key=\"approved\" {...css(ARTICLE_TABLE_STYLES.headerCell, ARTICLE_TABLE_STYLES.numberCell)}>\n          <HeaderItem sortField={SORT_APPROVED}>Approved</HeaderItem>\n        </div>\n        <div key=\"rejected\" {...css(ARTICLE_TABLE_STYLES.headerCell, ARTICLE_TABLE_STYLES.numberCell)}>\n          <HeaderItem sortField={SORT_REJECTED}>Rejected</HeaderItem>\n        </div>\n        <div key=\"deferred\" {...css(ARTICLE_TABLE_STYLES.headerCell, ARTICLE_TABLE_STYLES.numberCell)}>\n          <HeaderItem sortField={SORT_DEFERRED}>Deferred</HeaderItem>\n        </div>\n        <div key=\"highlighted\" {...css(ARTICLE_TABLE_STYLES.headerCell, ARTICLE_TABLE_STYLES.numberCell)}>\n          <HeaderItem sortField={SORT_HIGHLIGHTED}>Highlighted</HeaderItem>\n        </div>\n        <div key=\"flagged\" {...css(ARTICLE_TABLE_STYLES.headerCell, ARTICLE_TABLE_STYLES.numberCell)}>\n          <HeaderItem sortField={SORT_FLAGGED}>Flagged</HeaderItem>\n        </div>\n        <div key=\"modified\" {...css(ARTICLE_TABLE_STYLES.headerCell, ARTICLE_TABLE_STYLES.timeCell)}>\n          <HeaderItem sortField={SORT_UPDATED}>Modified</HeaderItem>\n        </div>\n        <div key=\"moderated\" {...css(ARTICLE_TABLE_STYLES.headerCell, ARTICLE_TABLE_STYLES.timeCell)}>\n          <HeaderItem sortField={SORT_LAST_MODERATED}>Moderated</HeaderItem>\n        </div>\n        <div key=\"flags\" {...css(ARTICLE_TABLE_STYLES.headerCell, ARTICLE_TABLE_STYLES.iconCell)}/>\n        <div key=\"mods\" {...css(ARTICLE_TABLE_STYLES.headerCell, ARTICLE_TABLE_STYLES.iconCell)}>\n          <div {...css({width: '100%', height: '100%', ...flexCenter})}>\n            <div\n              {...css({width: '44px', height: '44px', borderRadius: '50%', ...flexCenter,\n                backgroundColor: filterActive ? NICE_LIGHTEST_BLUE : NICE_MIDDLE_BLUE,\n                color: filterActive ? NICE_MIDDLE_BLUE : NICE_LIGHTEST_BLUE})}\n            >\n              <icons.FilterIcon {...css(medium)} onClick={openFilters}/>\n            </div>\n          </div>\n        </div>\n      </div>\n      <div key=\"content\" style={{height: `${articlesContainerHeight}px`, backgroundColor: 'white'}}>\n        <AutoSizer>\n          {({width, height}) => (\n            <VariableSizeList\n              outerElementType={CustomScrollbarsVirtualList}\n              itemSize={rowHeight}\n              itemCount={filteredArticles.length + 1}\n              height={height}\n              width={width}\n              overscanCount={10}\n            >\n              {({index, style}) => (\n                renderRow(index, style)\n              )}\n            </VariableSizeList>\n          )}\n        </AutoSizer>\n      </div>\n      {renderFilterPopup()}\n      {renderSaving()}\n      {renderSetModerators()}\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Tables/CategorySidebar.tsx",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\nimport React from 'react';\nimport PerfectScrollbar from 'react-perfect-scrollbar';\nimport { Link } from 'react-router-dom';\n\nimport 'react-perfect-scrollbar/dist/css/styles.css';\n\nimport { AccountCircle, Settings } from '@material-ui/icons';\n\nimport { ICategoryModel, IUserModel } from '../../../models';\nimport {\n  ALMOST_WHITE,\n  HEADER_HEIGHT,\n} from '../../styles';\nimport { COMMON_STYLES } from '../../stylesx';\nimport { css, stylesheet } from '../../utilx';\nimport {\n  categoryBase,\n  dashboardLink,\n  NEW_COMMENTS_DEFAULT_TAG,\n  newCommentsPageLink,\n  settingsLink,\n} from '../routes';\nimport { FILTER_CATEGORY } from './utils';\n\nconst SIDEBAR_HEADER_HEIGHT = 159;\nconst SIDEBAR_ROW_HEIGHT = 55;\nconst SIDEBAR_ICON_SIZE = 36;\nconst SIDEBAR_XPAD = 17;\nexport const SIDEBAR_WIDTH = 280;\n\nconst STYLES = stylesheet({\n  sidebar: {\n    width: `${SIDEBAR_WIDTH}px`,\n    backgroundColor: ALMOST_WHITE,\n    color: 'black',\n    display: 'flex',\n    flexFlow: 'column',\n  },\n\n  sidebarFixed: {\n    height: `${window.innerHeight - HEADER_HEIGHT}px`,\n  },\n\n  sidebarFloating: {\n    height: '100%',\n  },\n\n  sidebarChunk: {\n    flex: `0 0 auto`,\n  },\n\n  sidebarHeader: {\n    flex: `0 0 ${SIDEBAR_HEADER_HEIGHT}px`,\n    fontSize: '14px',\n    display: 'flex',\n  },\n\n  sidebarFooter: {\n    flex: `0 0 ${30}px`,\n  },\n\n  sidebarBar: {\n    width: '100%',\n    height: '0px',\n    borderBottom: `1px solid black`,\n    opacity: '0.08',\n    flex: `0 0 ${1}px`,\n  },\n\n  sidebarHeaderIcon: {\n    width: `${SIDEBAR_ICON_SIZE}px`,\n    height: `${SIDEBAR_ICON_SIZE}px`,\n    borderRadius: `${(SIDEBAR_ICON_SIZE / 2)}px`,\n    margin: `${(SIDEBAR_HEADER_HEIGHT - SIDEBAR_ICON_SIZE) / 2}px 13px ${(SIDEBAR_HEADER_HEIGHT - SIDEBAR_ICON_SIZE) / 2}px 18px`,\n  },\n\n  verticalCenterText: {\n    height: '100%',\n    display: 'inline-flex',\n    flexDirection: 'column',\n    justifyContent: 'center',\n  },\n\n  sidebarRow: {\n    width: `${SIDEBAR_WIDTH - 16}px`,\n    padding: `8px`,\n  },\n\n  sidebarRowInner: {\n    width: `${SIDEBAR_WIDTH - 16}px`,\n    height: `${SIDEBAR_ROW_HEIGHT - 16}px`,\n    padding: `0 ${SIDEBAR_XPAD}px`,\n    display: 'flex',\n    flexDirection: 'row',\n    justifyContent: 'space-between',\n    boxSizing: 'border-box',\n    fontSize: '14px',\n  },\n\n  sidebarRowSelected: {\n    backgroundColor: 'rgba(46,131,237,0.12)',\n    borderRadius: '4px',\n    boxShadow: `0 2px 4px 0 rgba(0,0,0,0.25)`,\n  },\n\n  sidebarSettings: {\n    fontSize: '14px',\n    color: 'rgba(0,0,0,0.56)',\n    width: `${SIDEBAR_WIDTH - 33}px`,\n    height: `${SIDEBAR_ROW_HEIGHT}px`,\n  },\n\n  sidebarRowHeader: {\n    fontSize: '12px',\n    color: 'rgba(0,0,0,0.54)',\n  },\n\n  sidebarSection: {\n  },\n\n  sidebarCount: {\n    marginLeft: `${SIDEBAR_XPAD}px`,\n    minWidth: '50px',\n    textAlign: 'right',\n  },\n\n  sidebarLink: {\n    color: 'inherit',\n    textDecoration: 'none',\n    ':hover': {\n      textDecoration: 'underline',\n    },\n    ':focus': {\n      outline: 'none',\n    },\n  },\n});\n\nexport interface ICategorySidebarProps {\n  user: IUserModel;\n  categories: Array<ICategoryModel>;\n  selectedCategory?: ICategoryModel;\n  isAdmin?: boolean;\n  isFixed?: boolean;\n  hideSidebar?(): void;\n}\n\nexport class CategorySidebar extends React.PureComponent<ICategorySidebarProps> {\n  _scrollBarRef: PerfectScrollbar = null;\n\n  componentDidMount(): void {\n    // For some reason, we have to give the perfect scrollbar a kick once the sizes of everything is known.\n    // This is probably because we are in a flexbox.\n    setTimeout(() => {\n      (this._scrollBarRef as any).updateScroll();\n    }, 50);\n  }\n\n  render() {\n    const {\n      user,\n      categories,\n      selectedCategory,\n      hideSidebar,\n      isAdmin,\n      isFixed,\n    } = this.props;\n\n    const allLink = dashboardLink({});\n    const allUnmoderated = categories.reduce((r: number, v: ICategoryModel) => (r + v.unmoderatedCount), 0);\n    const sorted = categories.sort((a, b) => (b.unmoderatedCount - a.unmoderatedCount));\n\n    return(\n      <div key=\"sidebar\" {...css(STYLES.sidebar, isFixed ? STYLES.sidebarFixed : STYLES.sidebarFloating)}>\n        <div key=\"header\" {...css(STYLES.sidebarHeader)} onClick={hideSidebar}>\n          {user.avatarURL ?\n            <img src={user.avatarURL} {...css(STYLES.sidebarHeaderIcon)} alt=\"Your image\"/> :\n            <AccountCircle {...css(STYLES.sidebarHeaderIcon, {fontSize: SIDEBAR_ICON_SIZE})}/>\n          }\n          <span {...css(STYLES.verticalCenterText)}>{user.name}</span>\n        </div>\n        <div key=\"bar\" {...css(STYLES.sidebarBar)}/>\n        {isAdmin && (\n          <div key=\"settings\" {...css(STYLES.sidebarRow, STYLES.sidebarSettings, STYLES.sidebarChunk, {paddingLeft: `${SIDEBAR_XPAD + 8}px`})}>\n            <Link to={settingsLink()} aria-label=\"Settings\" {...css(STYLES.sidebarLink)}>\n              <div key=\"label\" {...css(STYLES.sidebarSection, STYLES.verticalCenterText)}><Settings/></div>\n              <div key=\"count\" {...css(STYLES.sidebarCount, STYLES.verticalCenterText, {marginLeft: '46px', verticalAlign: 'bottom'})}>Settings</div>\n            </Link>\n          </div>\n        )}\n        {isAdmin && <div key=\"bar2\" {...css(STYLES.sidebarBar)}/>}\n        <div key=\"labels\" {...css(STYLES.sidebarRow, STYLES.sidebarRowHeader, STYLES.sidebarChunk)}>\n          <div {...css(STYLES.sidebarRowInner)}>\n            <span key=\"label\" {...css(STYLES.sidebarSection, STYLES.verticalCenterText)}>Section</span>\n            <span key=\"count\" {...css(STYLES.sidebarCount, STYLES.verticalCenterText)}>New comments</span>\n          </div>\n        </div>\n        <PerfectScrollbar key=\"scrollbarArea\" ref={(ref) => { this._scrollBarRef = ref; }}>\n          <div key=\"all\" {...css(STYLES.sidebarRow)}>\n            <div {...css(STYLES.sidebarRowInner, selectedCategory ? {} : STYLES.sidebarRowSelected)}>\n              <div key=\"label\" {...css(STYLES.sidebarSection, STYLES.verticalCenterText)}>\n                <Link to={allLink} onClick={hideSidebar} {...css(COMMON_STYLES.cellLink)}>Home / All</Link>\n              </div>\n              <Link\n                to={newCommentsPageLink({\n                  context: categoryBase,\n                  contextId: 'all',\n                  tag: NEW_COMMENTS_DEFAULT_TAG})\n                }\n                {...css(COMMON_STYLES.cellLink)}\n              >\n                <div key=\"count\" {...css(STYLES.sidebarCount, STYLES.verticalCenterText)}>{allUnmoderated}</div>\n              </Link>\n            </div>\n          </div>\n          {sorted.map((c: ICategoryModel) => (\n            <div key={c.id} {...css(STYLES.sidebarRow)}>\n              <div {...css(STYLES.sidebarRowInner, selectedCategory && selectedCategory.id === c.id ? STYLES.sidebarRowSelected : {})}>\n                <div key=\"label\" {...css(STYLES.sidebarSection, STYLES.verticalCenterText)}>\n                  <Link\n                    to={dashboardLink({filter: `${FILTER_CATEGORY}=${c.id}`})}\n                    onClick={hideSidebar}\n                    {...css(COMMON_STYLES.cellLink)}\n                  >\n                    {c.label}\n                  </Link>\n                </div>\n                <div key=\"count\" {...css(STYLES.sidebarCount, STYLES.verticalCenterText)}>\n                  <Link\n                    to={newCommentsPageLink({\n                      context: categoryBase,\n                      contextId: c.id,\n                      tag: NEW_COMMENTS_DEFAULT_TAG})}\n                    {...css(COMMON_STYLES.cellLink)}\n                  >\n                    {c.unmoderatedCount}\n                  </Link>\n                </div>\n              </div>\n            </div>\n          ))}\n        </PerfectScrollbar>\n        {isAdmin && <div key=\"bar3\" {...css(STYLES.sidebarBar)}/>}\n        <div key=\"footer\" {...css(STYLES.sidebarFooter)}/>\n      </div>\n    );\n  }\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Tables/ComponentsStory.tsx",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { storiesOf } from '@storybook/react';\nimport faker from 'faker';\nimport React from 'react';\nimport { MemoryRouter } from 'react-router-dom';\n\nimport { fakeArticleModel, fakeCategoryModel } from '../../../models/fake';\nimport { css } from '../../utilx';\nimport { TitleCell } from './components';\nimport { ARTICLE_TABLE_STYLES } from './styles';\n\nfaker.seed(456);\n\nconst category = fakeCategoryModel({label: 'ChuChu TV Nursery Rhymes & Kids Songs', unmoderatedCount: 2});\nconst category2 = fakeCategoryModel({label: 'World', unmoderatedCount: 2});\n\nconst article1 = fakeArticleModel({\n  title: 'IMF chief Christine Lagarde warns Britain on Brexit: ‘It will never be as good as it is now’',\n  categoryId: category.id,\n});\nconst article2 = fakeArticleModel({\n  categoryId: null,\n  url: null,\n});\nconst article3 = fakeArticleModel({\n  categoryId: category2.id,\n  sourceCreatedAt: null,\n  url: null,\n});\nconst article4 = fakeArticleModel({\n  categoryId: null,\n  sourceCreatedAt: null,\n});\n\nstoriesOf('TableComponents', module)\n  .addDecorator((story) => (\n    <MemoryRouter initialEntries={['/']}>{story()}</MemoryRouter>\n  ))\n  .add('Title cell', () => {\n    const cellStyle = ARTICLE_TABLE_STYLES.dataCell;\n    return (\n      <div {...css(cellStyle, ARTICLE_TABLE_STYLES.dataBody)}>\n        <div {...css(cellStyle, ARTICLE_TABLE_STYLES.textCell)}>\n          <TitleCell category={category} article={article1} link={'#'}/>\n        </div>\n        <div {...css(cellStyle, ARTICLE_TABLE_STYLES.textCell)}>\n            <TitleCell article={article2} link={'#'}/>\n        </div>\n        <div {...css(cellStyle, ARTICLE_TABLE_STYLES.textCell)}>\n          <TitleCell category={category2} article={article3} link={'#'}/>\n        </div>\n        <div {...css(cellStyle, ARTICLE_TABLE_STYLES.textCell)}>\n          <TitleCell article={article4} link={'#'}/>\n        </div>\n      </div>\n    );\n  })\n;\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Tables/FilterSidebar.tsx",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { autobind } from 'core-decorators';\nimport { Seq, Set } from 'immutable';\nimport React, { KeyboardEvent, SyntheticEvent } from 'react';\nimport PerfectScrollbar from 'react-perfect-scrollbar';\n\nimport 'react-perfect-scrollbar/dist/css/styles.css';\n\nimport {\n  Button,\n  ExpansionPanel,\n  ExpansionPanelDetails,\n  ExpansionPanelSummary,\n  FormControl,\n  InputLabel,\n  MenuItem,\n  Radio,\n  Select,\n  Slide,\n  TextField,\n} from '@material-ui/core';\nimport {\n  ExpandMore,\n} from '@material-ui/icons';\n\nimport { IUserModel, ModelId } from '../../../models';\nimport { Avatar } from '../../components';\nimport * as icons from '../../components/Icons';\nimport { getMyUserId } from '../../stores/users';\nimport { HEADER_HEIGHT, SCRIM_STYLE } from '../../styles';\nimport { css, stylesheet } from '../../utilx';\nimport {\n  FILTER_DATE_lastModeratedAt,\n  FILTER_DATE_sourceCreatedAt,\n  FILTER_DATE_updatedAt,\n  FILTER_MODERATORS,\n  FILTER_MODERATORS_UNASSIGNED,\n  FILTER_TITLE,\n  FILTER_TO_REVIEW,\n  FILTER_TO_REVIEW_ANY,\n  FILTER_TO_REVIEW_DEFERRED,\n  FILTER_TO_REVIEW_NEW,\n  FILTER_TOGGLE_isAutoModerated,\n  FILTER_TOGGLE_isCommentingEnabled,\n  FILTER_TOGGLE_OFF,\n  FILTER_TOGGLE_ON,\n} from './utils';\nimport {\n  filterDatePrior,\n  filterDateRange,\n  filterDateRangeValues,\n  filterDateSince,\n  getFilterValue,\n  IFilterItem,\n  isFilterActive,\n  resetFilterToRoot,\n  updateFilter,\n} from './utils';\n\nconst SIDEBAR_WIDTH = 350;\n\nconst STYLES = stylesheet({\n  filter: {\n    position: 'absolute',\n    bottom: '0',\n    right: '0',\n    height: `${window.innerHeight - HEADER_HEIGHT}px`,\n    width: `${SIDEBAR_WIDTH + 40}px`,\n    color: 'black',\n    display: 'flex',\n    flexFlow: 'column',\n    justifyContent: 'flex-start',\n    textAlign: 'left',\n  },\n\n  filterSection: {\n    borderTop: '2px solid #eee',\n  },\n  filterSectionModeratorsTitle: {\n    borderTop: '2px solid #eee',\n    padding: '20px 20px 10px 20px',\n  },\n  filterSectionModerators: {\n    padding: '0 0 20px 20px',\n  },\n  filterSectionTitle: {\n    padding: '20px',\n    height: '30px',\n    fontSize: '18px',\n    margin: 0,\n    flex: '0 0 auto',\n  },\n  filterSectionFixed: {\n    flex: '0 0 auto',\n  },\n  filterSectionFlexible: {\n    flex: '0 1 auto',\n    overflowY: 'auto',\n  },\n  filterHeading: {\n    ...SCRIM_STYLE.popupTitle,\n    fontSize: '16px',\n    fontWeight: 'bold',\n    margin: 0,\n    opacity: '0.4',\n  },\n});\n\nexport interface IFilterSidebarProps {\n  open: boolean;\n  filterString: string;\n  filter: Array<IFilterItem>;\n\n  users: Seq.Indexed<IUserModel>;\n\n  setFilter(filter: Array<IFilterItem>): void;\n\n  clearPopups(): void;\n}\n\nexport interface IFilterSidebarState {\n  titleFilter: string;\n  moderatorFilterString: string;\n  moderatorFilterUsers?: Set<ModelId>;\n  dateFilterKey: string;\n  dateFilterValue: string;\n  dateFilterSelect?: string;\n  dateFilterFrom?: string;\n  dateFilterTo?: string;\n  isCommentingEnabledFilter: string;\n  isAutoModeratedFilter: string;\n  commentsToReviewFilter: string;\n  isFilterActive: boolean;\n  isDateFilterActive: boolean;\n  wasOpen: boolean;\n}\n\nconst DATE_FILTER_RANGE = 'custom';\n\nexport class FilterSidebar extends React.Component<IFilterSidebarProps, IFilterSidebarState> {\n  state: IFilterSidebarState = {\n    titleFilter: '',\n    moderatorFilterString: '',\n    dateFilterKey: '',\n    dateFilterValue: '',\n    isCommentingEnabledFilter: '',\n    isAutoModeratedFilter: '',\n    commentsToReviewFilter: '',\n    isFilterActive: false,\n    isDateFilterActive: false,\n    wasOpen: false,\n  };\n\n  static getDerivedStateFromProps(props: Readonly<IFilterSidebarProps>, state: Readonly<IFilterSidebarState>): IFilterSidebarState {\n    const filter = props.filter;\n    const moderatorFilterString = getFilterValue(filter, FILTER_MODERATORS);\n    let moderatorFilterUsers: Array<string> = [];\n\n    if (moderatorFilterString.length > 0 &&\n      moderatorFilterString !== FILTER_MODERATORS_UNASSIGNED) {\n      moderatorFilterUsers = moderatorFilterString.split(',');\n    }\n\n    // For things whose effect don't occur immediately, we only update their state when opening the sidebar\n    let titleFilter = state.titleFilter;\n    let dateFilterKey = state.dateFilterKey;\n    let dateFilterValue = state.dateFilterValue;\n    let dateFilterSelect = state.dateFilterSelect;\n    let dateFilterFrom = state.dateFilterFrom;\n    let dateFilterTo = state.dateFilterTo;\n    let isDateFilterActive = state.isDateFilterActive;\n\n    if (!state.wasOpen) {\n      titleFilter = getFilterValue(filter, FILTER_TITLE);\n\n      for (const f of filter) {\n        if (f.key === FILTER_DATE_sourceCreatedAt ||\n          f.key === FILTER_DATE_updatedAt ||\n          f.key === FILTER_DATE_lastModeratedAt) {\n          dateFilterKey = f.key;\n          dateFilterValue = f.value;\n          isDateFilterActive = true;\n          break;\n        }\n      }\n\n      const dateFilterRangeValues = filterDateRangeValues(dateFilterValue);\n      if (dateFilterRangeValues) {\n        dateFilterSelect = DATE_FILTER_RANGE;\n        dateFilterFrom = dateFilterRangeValues[0];\n        dateFilterTo = dateFilterRangeValues[1];\n      }\n      else {\n        dateFilterSelect = dateFilterValue;\n      }\n    }\n\n    return {\n      titleFilter,\n      moderatorFilterString: moderatorFilterString,\n      moderatorFilterUsers: Set<string>(moderatorFilterUsers),\n      dateFilterKey,\n      dateFilterValue,\n      dateFilterSelect,\n      dateFilterFrom,\n      dateFilterTo,\n      isDateFilterActive,\n      isCommentingEnabledFilter: getFilterValue(filter, FILTER_TOGGLE_isCommentingEnabled),\n      isAutoModeratedFilter: getFilterValue(filter, FILTER_TOGGLE_isAutoModerated),\n      commentsToReviewFilter: getFilterValue(filter, FILTER_TO_REVIEW),\n      isFilterActive: isFilterActive(filter),\n      wasOpen: props.open,\n    };\n  }\n\n  @autobind\n  setFilter(key: string) {\n    return (e: any) => {\n      this.props.setFilter(updateFilter(this.props.filter, key, e.target.value));\n    };\n  }\n\n  @autobind\n  changeTitleFilter(e: SyntheticEvent<any>) {\n    this.setState({titleFilter: e.currentTarget.value});\n  }\n\n  @autobind\n  setTitleFilter() {\n    this.props.setFilter(updateFilter(this.props.filter, FILTER_TITLE, this.state.titleFilter));\n  }\n\n  @autobind\n  checkForTitleEnter(e: KeyboardEvent<any>) {\n    if (e.key === 'Enter') {\n      this.props.setFilter(updateFilter(this.props.filter, FILTER_TITLE, this.state.titleFilter));\n    }\n  }\n\n  @autobind\n  changeDateFilter(currentFilter: Array<IFilterItem>, key: string, value: string) {\n    this.props.setFilter(updateFilter(currentFilter, key, value));\n  }\n\n  @autobind\n  changeDateFilterKey(e: React.ChangeEvent<HTMLSelectElement>) {\n    const oldValue = this.state.dateFilterKey;\n    const value = e.target.value;\n    if (value === oldValue) {\n      return;\n    }\n\n    const cleanedFilter = updateFilter(this.props.filter, oldValue);\n    if (this.state.isDateFilterActive && value === '') {\n      this.props.setFilter(cleanedFilter);\n      return;\n    }\n\n    if (value !== '' && this.state.dateFilterValue !== '') {\n      this.changeDateFilter(cleanedFilter, value, this.state.dateFilterValue);\n      return;\n    }\n\n    this.setState({\n      dateFilterKey: value,\n      dateFilterSelect: value === '' ? '' : this.state.dateFilterSelect,\n    });\n  }\n\n  @autobind\n  changeDateFilterSelect(e: React.ChangeEvent<HTMLSelectElement>) {\n    const value = e.target.value;\n    if (value === this.state.dateFilterSelect) {\n      return;\n    }\n\n    if (this.state.isDateFilterActive && value === '') {\n      const cleanedFilter = updateFilter(this.props.filter, this.state.dateFilterKey);\n      this.props.setFilter(cleanedFilter);\n      return;\n    }\n    if (this.state.dateFilterKey !== '' && value !== '') {\n      if (value !== DATE_FILTER_RANGE) {\n        this.changeDateFilter(this.props.filter, this.state.dateFilterKey, value);\n      }\n      else {\n        this.changeDateFilter(this.props.filter, this.state.dateFilterKey, filterDateRange(null, null));\n      }\n      return;\n    }\n    this.setState({\n      dateFilterSelect: value,\n      dateFilterValue: value === DATE_FILTER_RANGE ? filterDateRange(null, null) : value,\n    });\n  }\n\n  @autobind\n  changeDateFilterFrom(e: SyntheticEvent<any>) {\n    const newFilterValue = filterDateRange(e.currentTarget.value, this.state.dateFilterTo);\n    this.changeDateFilter(this.props.filter, this.state.dateFilterKey, newFilterValue);\n  }\n\n  @autobind\n  changeDateFilterTo(e: SyntheticEvent<any>) {\n    const newFilterValue = filterDateRange(this.state.dateFilterFrom, e.currentTarget.value);\n    this.changeDateFilter(this.props.filter, this.state.dateFilterKey, newFilterValue);\n  }\n\n  @autobind\n  setModerator(id: string) {\n    return () => {\n      const currentUsers = this.state.moderatorFilterUsers;\n      const newUsers = currentUsers.contains(id) ? currentUsers.delete(id) : currentUsers.add(id);\n      const moderators = newUsers.toArray().reduce<string | null>((s: string | null, v: ModelId) => (s ? `${s},${v}` : v.toString()), null);\n      this.props.setFilter(updateFilter(this.props.filter, FILTER_MODERATORS, moderators));\n    };\n  }\n\n  @autobind\n  setModeratorUnassigned() {\n    this.props.setFilter(updateFilter(this.props.filter, FILTER_MODERATORS, FILTER_MODERATORS_UNASSIGNED));\n  }\n\n  @autobind\n  clearFilters() {\n    this.props.clearPopups();\n    this.props.setFilter(resetFilterToRoot(this.props.filter));\n  }\n\n  renderModerator(u: IUserModel) {\n    return (\n      <tr key={u.id} onClick={this.setModerator(u.id)}>\n        <td key=\"icon\">\n          <Avatar target={u} size={30}/>\n        </td>\n        <td key=\"text\" {...css({textAlign: 'left'})}>\n          {u.name}\n        </td>\n        <td key=\"toggle\" {...css({textAlign: 'right', paddingRight: '20px'})}>\n          <Radio checked={this.state.moderatorFilterUsers.has(u.id)} color=\"primary\"/>\n        </td>\n      </tr>\n    );\n  }\n\n  renderCustomDateControls() {\n    if (this.state.dateFilterSelect !== DATE_FILTER_RANGE) {\n      return '';\n    }\n    return (\n      <div key=\"date range\" {...css({marginTop: '10px'})}>\n        <TextField\n          label=\"From\"\n          type=\"date\"\n          defaultValue={this.state.dateFilterFrom}\n          InputLabelProps={{\n            shrink: true,\n          }}\n          onChange={this.changeDateFilterFrom}\n          style={{width: `${SIDEBAR_WIDTH / 2 - 10}px`, margin: '0 10px 0 0'}}\n        />\n        <TextField\n          label=\"To\"\n          type=\"date\"\n          defaultValue={this.state.dateFilterTo}\n          InputLabelProps={{\n            shrink: true,\n          }}\n          onChange={this.changeDateFilterTo}\n          style={{width: `${SIDEBAR_WIDTH / 2 - 10}px`, margin: '0 0 0 10px'}}\n        />\n      </div>\n    );\n  }\n\n  render() {\n    const {\n      users,\n      open,\n      clearPopups,\n    } = this.props;\n\n    const {\n      titleFilter,\n      moderatorFilterString,\n      dateFilterKey,\n      dateFilterSelect,\n      isCommentingEnabledFilter,\n      isAutoModeratedFilter,\n      commentsToReviewFilter,\n    } = this.state;\n\n    const myUserId = getMyUserId();\n    const me = users.find((u) => u.id === myUserId);\n    const others = users.filter((u) => u.id !== myUserId).sort((u1, u2) => ('' + u1.name).localeCompare(u2.name));\n\n    return (\n      <Slide direction=\"left\" in={open} mountOnEnter unmountOnExit>\n        <div key=\"main\" tabIndex={0} {...css(SCRIM_STYLE.popupMenu, STYLES.filter)}>\n          <h4 key=\"header\" {...css(SCRIM_STYLE.popupTitle, STYLES.filterSectionTitle)}>\n            Filter titles\n            <div onClick={clearPopups} {...css({float: 'right'})}>\n              <icons.CloseIcon/>\n            </div>\n            {this.state.isFilterActive &&  (\n              <Button\n                key=\"reset\"\n                variant=\"contained\"\n                color=\"primary\"\n                onClick={this.clearFilters}\n                style={{float: 'right', marginTop: '-7px', marginRight: '30px'}}\n              >\n                Reset\n              </Button>\n            )}\n          </h4>\n          <div key=\"moderatorTitle\" {...css(STYLES.filterSectionModeratorsTitle, STYLES.filterSectionFixed)}>\n            <h5 key=\"header\" {...css(STYLES.filterHeading)}>\n              Moderators\n            </h5>\n          </div>\n          <div key=\"moderators\" {...css(STYLES.filterSectionModerators, STYLES.filterSectionFlexible)}>\n            <PerfectScrollbar>\n              <table key=\"main\" {...css({width: '100%'})}>\n                <tbody>\n                <tr key=\"unassigned\" onClick={this.setModeratorUnassigned}>\n                  <td key=\"icon\"/>\n                  <td key=\"text\" {...css({textAlign: 'left'})}>\n                    No moderator assigned.\n                  </td>\n                  <td key=\"toggle\" {...css({textAlign: 'right', paddingRight: '20px'})}>\n                    <Radio checked={moderatorFilterString === FILTER_MODERATORS_UNASSIGNED} color=\"primary\"/>\n                  </td>\n                </tr>\n                {this.renderModerator(me)}\n                {others.map((u: IUserModel) => this.renderModerator(u))}\n                </tbody>\n              </table>\n            </PerfectScrollbar>\n          </div>\n          <div key=\"title\" {...css(STYLES.filterSection, STYLES.filterSectionFixed)}>\n            <ExpansionPanel elevation={0} defaultExpanded>\n              <ExpansionPanelSummary expandIcon={<ExpandMore/>}>\n                <h5 key=\"header\" {...css(STYLES.filterHeading)}>\n                  Other filters\n                </h5>\n              </ExpansionPanelSummary>\n              <ExpansionPanelDetails>\n                <div style={{display: 'flex', flexDirection: 'column'}}>\n                  <TextField\n                    label=\"Articles with titles that contain...\"\n                    value={titleFilter}\n                    margin=\"dense\"\n                    style={{width: `${SIDEBAR_WIDTH}px`, marginTop: '0px'}}\n                    onKeyPress={this.checkForTitleEnter}\n                    onChange={this.changeTitleFilter}\n                    onBlur={this.setTitleFilter}\n                  />\n                  <div key=\"dates\" style={{marginTop: '40px'}}>\n                    <FormControl style={{minWidth: `${SIDEBAR_WIDTH}px`, margin: '0'}}>\n                      <InputLabel htmlFor=\"date-key\">Articles with date</InputLabel>\n                      <Select\n                        value={dateFilterKey}\n                        onChange={this.changeDateFilterKey}\n                        inputProps={{id: 'date-key'}}\n                      >\n                        <MenuItem value=\"\"><em>None</em></MenuItem>\n                        <MenuItem value={FILTER_DATE_sourceCreatedAt}>Published</MenuItem>\n                        <MenuItem value={FILTER_DATE_updatedAt}>Last modified</MenuItem>\n                        <MenuItem value={FILTER_DATE_lastModeratedAt}>Last moderated</MenuItem>\n                      </Select>\n                    </FormControl>\n                  </div>\n                  <div key=\"dates2\" style={{marginTop: '10px'}}>\n                    <FormControl disabled={dateFilterKey === ''} style={{minWidth: `${SIDEBAR_WIDTH}px`, margin: '0'}}>\n                      <InputLabel htmlFor=\"date-select\">matching</InputLabel>\n                      <Select\n                        value={dateFilterSelect}\n                        onChange={this.changeDateFilterSelect}\n                        inputProps={{id: 'date-select'}}\n                      >\n                        <MenuItem value=\"\"><em>None</em></MenuItem>\n                        <MenuItem value={filterDateSince(12)}>Last 12 hours</MenuItem>\n                        <MenuItem value={filterDateSince(24)}>Last 24 hours</MenuItem>\n                        <MenuItem value={filterDateSince(48)}>Last 48 hours</MenuItem>\n                        <MenuItem value={filterDateSince(168)}>Last 7 days</MenuItem>\n                        <MenuItem value={filterDatePrior(48)}>Older than 48 hours</MenuItem>\n                        <MenuItem value={filterDatePrior(168)}>Older than 7 days</MenuItem>\n                        <MenuItem value={DATE_FILTER_RANGE}>Custom Range</MenuItem>\n                      </Select>\n                    </FormControl>\n                  </div>\n                  {this.renderCustomDateControls()}\n                  <div key=\"commenting\" style={{marginTop: '40px'}}>\n                    <FormControl style={{width: `${SIDEBAR_WIDTH}px`, margin: '0'}}>\n                      <InputLabel htmlFor=\"ice-key\">Articles with commenting...</InputLabel>\n                      <Select\n                        value={isCommentingEnabledFilter}\n                        onChange={this.setFilter(FILTER_TOGGLE_isCommentingEnabled)}\n                        inputProps={{id: 'ice-key'}}\n                      >\n                        <MenuItem value=\"\"><em>Show All</em></MenuItem>\n                        <MenuItem value={FILTER_TOGGLE_ON}>Enabled</MenuItem>\n                        <MenuItem value={FILTER_TOGGLE_OFF}>Disabled</MenuItem>\n                      </Select>\n                    </FormControl>\n                  </div>\n                  <div key=\"automoderation\" style={{marginTop: '40px'}}>\n                    <FormControl style={{width: `${SIDEBAR_WIDTH}px`, margin: '0'}}>\n                      <InputLabel htmlFor=\"iam-key\">Articles with auto-moderation...</InputLabel>\n                      <Select\n                        value={isAutoModeratedFilter}\n                        onChange={this.setFilter(FILTER_TOGGLE_isAutoModerated)}\n                        inputProps={{id: 'iam-key'}}\n                      >\n                        <MenuItem value=\"\">Show All</MenuItem>\n                        <MenuItem value={FILTER_TOGGLE_ON}>Enabled</MenuItem>\n                        <MenuItem value={FILTER_TOGGLE_OFF}>Disabled</MenuItem>\n                      </Select>\n                    </FormControl>\n                  </div>\n                  <div key=\"commentsToReview\" style={{marginTop: '40px', marginBottom: '40px'}}>\n                    <FormControl style={{width: `${SIDEBAR_WIDTH}px`, margin: '0'}}>\n                      <InputLabel htmlFor=\"ctr-key\">Articles with comments to review...</InputLabel>\n                      <Select\n                        value={commentsToReviewFilter}\n                        onChange={this.setFilter('commentsToReview')}\n                        inputProps={{id: 'ctr-key'}}\n                      >\n                        <MenuItem value=\"\">Show All</MenuItem>\n                        <MenuItem value={FILTER_TO_REVIEW_ANY}>New and deferred</MenuItem>\n                        <MenuItem value={FILTER_TO_REVIEW_NEW}>New</MenuItem>\n                        <MenuItem value={FILTER_TO_REVIEW_DEFERRED}>Deferred</MenuItem>\n                      </Select>\n                    </FormControl>\n                  </div>\n                </div>\n              </ExpansionPanelDetails>\n            </ExpansionPanel>\n          </div>\n        </div>\n      </Slide>\n    );\n  }\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Tables/TableFrame.tsx",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\nimport { autobind } from 'core-decorators';\nimport React from 'react';\nimport { connect } from 'react-redux';\nimport { Route, RouteComponentProps, withRouter } from 'react-router';\nimport { compose } from 'redux';\nimport { createStructuredSelector } from 'reselect';\n\nimport {\n  Drawer,\n} from '@material-ui/core';\nimport {\n  createMuiTheme,\n  MuiThemeProvider,\n} from '@material-ui/core/styles';\n\nimport { ICategoryModel, IUserModel } from '../../../models';\nimport { HeaderBar } from '../../components';\nimport { getActiveCategories } from '../../stores/categories';\nimport { getCurrentUser, getCurrentUserIsAdmin } from '../../stores/users';\nimport { NICE_CONTROL_BLUE } from '../../styles';\nimport { IDashboardPathParams } from '../routes';\nimport { ArticleTable } from './ArticleTable';\nimport { CategorySidebar, SIDEBAR_WIDTH } from './CategorySidebar';\nimport { FILTER_CATEGORY } from './utils';\n\nconst theme = createMuiTheme({\n  palette: {\n    primary: {\n      main: NICE_CONTROL_BLUE,\n    },\n  },\n});\n\nexport interface ITableFrameProps extends RouteComponentProps<IDashboardPathParams> {\n  dispatch: Function;\n  user: IUserModel;\n  isAdmin: boolean;\n  categories: Array<ICategoryModel>;\n}\n\nexport interface ITableFrameState {\n  sidebarOpen: boolean;\n  fixedSidebar: boolean;\n}\n\nfunction fixedSidebar() {\n  return SIDEBAR_WIDTH / window.innerWidth < 0.17;\n}\n\nexport class PureTableFrame extends React.Component<ITableFrameProps, ITableFrameState> {\n  constructor(props: ITableFrameProps) {\n    super(props);\n\n    this.state = {\n      sidebarOpen: false,\n      fixedSidebar: fixedSidebar(),\n    };\n  }\n  @autobind\n  showSidebar() {\n    this.setState({sidebarOpen: true});\n  }\n\n  @autobind\n  hideSidebar() {\n    this.setState({sidebarOpen: false});\n  }\n\n  componentWillMount() {\n    window.addEventListener('resize', this.updateWindowDimensions);\n  }\n\n  componentWillUnmount() {\n    window.removeEventListener('resize', this.updateWindowDimensions);\n  }\n\n  @autobind\n  updateWindowDimensions() {\n    this.setState({fixedSidebar: fixedSidebar()});\n  }\n\n  renderSidebarPopup(category?: ICategoryModel) {\n    if (this.state.fixedSidebar) {\n      return null;\n    }\n\n    const {\n      user,\n      categories,\n      isAdmin,\n    } = this.props;\n\n    return (\n      <Drawer open={this.state.sidebarOpen} onClose={this.hideSidebar}>\n        <CategorySidebar\n          user={user}\n          categories={categories}\n          selectedCategory={category}\n          hideSidebar={this.hideSidebar}\n          isAdmin={isAdmin}\n        />\n      </Drawer>\n    );\n  }\n\n  render() {\n    const {\n      user,\n      isAdmin,\n      categories,\n      location,\n      match: {path},\n    } = this.props;\n\n    let category = null;\n    const re = new RegExp(`${FILTER_CATEGORY}=(\\\\d+)`);\n    const categoryMatch = re.exec(location.pathname);\n    if (categoryMatch) {\n      category = categories.find((c) => c.id === categoryMatch[1]);\n    }\n\n    if (this.state.fixedSidebar) {\n      return (\n        <div style={{width: '100vw', height: '100vh'}}>\n          <HeaderBar\n            category={category}\n          />\n          <div style={{float: 'left', width: `${SIDEBAR_WIDTH + 1}px`, backgroundColor: 'white'}}>\n            <div style={{width: `${SIDEBAR_WIDTH}px`, borderRight: '1px solid rgba(0,0,0,0.12)'}}>\n              <CategorySidebar\n                user={user}\n                categories={categories}\n                selectedCategory={category}\n                isAdmin={isAdmin}\n                isFixed\n              />\n            </div>\n          </div>\n          <div style={{marginLeft: `${SIDEBAR_WIDTH + 1}px`, height: '100%'}}>\n            <Route path={`${path}`} component={ArticleTable}/>\n          </div>\n        </div>\n      );\n    }\n\n    return (\n      <MuiThemeProvider theme={theme}>\n        <HeaderBar\n          category={category}\n          showSidebar={this.showSidebar}\n        />\n        {this.renderSidebarPopup(category)}\n        <div key=\"content\">\n          <Route path={`${path}`} component={ArticleTable}/>\n        </div>\n      </MuiThemeProvider>\n    );\n  }\n}\n\nexport const TableFrame: React.ComponentClass<{}> = compose(\n  withRouter,\n  connect(\n    createStructuredSelector({\n      user: getCurrentUser,\n      isAdmin: getCurrentUserIsAdmin,\n      categories: getActiveCategories,\n    }),\n  ),\n)(PureTableFrame);\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Tables/TableFrameStory.tsx",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { storiesOf } from '@storybook/react';\nimport faker from 'faker';\nimport React from 'react';\nimport { MemoryRouter } from 'react-router-dom';\n\nimport { fakeCategoryModel, fakeUserModel } from '../../../models/fake';\nimport { HeaderBar } from '../../components';\nimport { CategorySidebar } from './CategorySidebar';\n\nfaker.seed(123);\n\nconst user = fakeUserModel();\n\nconst userNoIcon = fakeUserModel({\n  name: 'Algernon Bullwinkle the Third',\n  avatarURL: null,\n});\n\nconst categories = [\n  fakeCategoryModel({unmoderatedCount: 10}),\n  fakeCategoryModel({unmoderatedCount: 2}),\n  fakeCategoryModel({label: 'N.Y.C. Events Guide', unmoderatedCount: 15}),\n  fakeCategoryModel({label: 'ChuChu TV Nursery Rhymes & Kids Songs', unmoderatedCount: 2}),\n  fakeCategoryModel({unmoderatedCount: 1000}),\n  fakeCategoryModel({unmoderatedCount: 100}),\n  fakeCategoryModel({unmoderatedCount: 10}),\n  fakeCategoryModel({unmoderatedCount: 12}),\n  fakeCategoryModel({unmoderatedCount: 13}),\n  fakeCategoryModel({unmoderatedCount: 14}),\n  fakeCategoryModel({unmoderatedCount: 15}),\n  fakeCategoryModel({unmoderatedCount: 16}),\n  fakeCategoryModel({unmoderatedCount: 17}),\n  fakeCategoryModel({unmoderatedCount: 18}),\n  fakeCategoryModel({unmoderatedCount: 19}),\n  fakeCategoryModel({unmoderatedCount: 12}),\n  fakeCategoryModel({unmoderatedCount: 22}),\n  fakeCategoryModel({unmoderatedCount: 11}),\n  fakeCategoryModel({unmoderatedCount: 15}),\n  fakeCategoryModel({unmoderatedCount: 10}),\n  ];\n\nconst categoriesShort = [\n  fakeCategoryModel({unmoderatedCount: 10}),\n  fakeCategoryModel({unmoderatedCount: 2}),\n  fakeCategoryModel({label: 'N.Y.C. Events Guide', unmoderatedCount: 15}),\n  fakeCategoryModel({label: 'ChuChu TV Nursery Rhymes & Kids Songs', unmoderatedCount: 2}),\n  fakeCategoryModel({label: 'ChuChu TV Nursery Rhymes & Kids Songs', unmoderatedCount: 2999}),\n  fakeCategoryModel({unmoderatedCount: 1000}),\n  fakeCategoryModel({unmoderatedCount: 100}),\n];\n\nconst singleCategory = fakeCategoryModel({label: 'ChuChu TV Nursery Rhymes & Kids Songs', unmoderatedCount: 2999});\n\nstoriesOf('TableFrame', module)\n  .addDecorator((story) => (\n    <MemoryRouter initialEntries={['/']}>{story()}</MemoryRouter>\n  ))\n  .add('DontTest:Category sidebar overlay', () => {\n    function hide() { console.log('hide clicked'); }\n    return (\n      <CategorySidebar\n        user={user}\n        categories={categories}\n        selectedCategory={categories[2]}\n        hideSidebar={hide}\n      />\n    );\n  })\n  .add('DontTest:Category sidebar', () => {\n    return (\n      <CategorySidebar\n        user={userNoIcon}\n        categories={categoriesShort}\n        selectedCategory={categoriesShort[3]}\n      />\n    );\n  })\n  .add('DontTest:Header bar with show sidebar', () => {\n    function show() { console.log('show clicked'); }\n    return (\n      <HeaderBar\n        showSidebar={show}\n      />\n    );\n  })\n  .add('DontTest:Header bar for admin', () => {\n    return (\n      <HeaderBar\n        category={singleCategory}\n      />\n    );\n  });\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Tables/components.tsx",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { Map, Set } from 'immutable';\nimport React from 'react';\nimport { Link } from 'react-router-dom';\n\nimport { OpenInNew, PersonAdd } from '@material-ui/icons/';\n\nimport { IArticleModel, ICategoryModel, IUserModel, ModelId } from '../../../models';\nimport { Avatar, MagicTimestamp, PseudoAvatar } from '../../components';\nimport { COMMON_STYLES, IMAGE_BASE } from '../../stylesx';\nimport { css, stylesheet } from '../../utilx';\n\ninterface IModeratorsWidgetProps {\n  users: Map<string, IUserModel>;\n  moderatorIds: Array<ModelId>;\n  superModeratorIds: Array<ModelId>;\n  openSetModerators(): void;\n}\n\nexport const MODERATOR_WIDGET_STYLES = stylesheet({\n  widget: {\n    display: 'flex',\n    flexWrap: 'wrap',\n    justifyContent: 'center',\n  },\n});\n\nexport function ModeratorsWidget(props: IModeratorsWidgetProps) {\n  const { users, moderatorIds, superModeratorIds }  = props;\n\n  let s = Set(moderatorIds);\n  if (superModeratorIds) {\n    s = s.merge(superModeratorIds);\n  }\n\n  const moderators = s.toArray().map((uid: string) => users.get(uid));\n\n  if (moderators.length === 0) {\n    return (\n      <div onClick={props.openSetModerators} {...css(MODERATOR_WIDGET_STYLES.widget)}>\n        <PseudoAvatar size={IMAGE_BASE}>\n          <PersonAdd/>\n        </PseudoAvatar>\n      </div>\n    );\n  }\n\n  if (moderators.length === 1) {\n    const u = moderators[0];\n    return (\n      <div onClick={props.openSetModerators} {...css(MODERATOR_WIDGET_STYLES.widget)}>\n        <Avatar target={u} size={IMAGE_BASE}/>\n      </div>\n    );\n  }\n\n  const ret = [];\n  let limit = moderators.length;\n  let extra = false;\n  if (limit > 4) {\n    limit = 3;\n    extra = true;\n  } else if (limit === 4) {\n    limit = 4;\n  }\n\n  for (let i = 0; i < limit; i++) {\n    ret.push(<Avatar target={moderators[i]} size={IMAGE_BASE / 2}/>);\n  }\n  if (extra) {\n    ret.push(<PseudoAvatar size={IMAGE_BASE / 2}>+{moderators.length - 3}</PseudoAvatar>);\n  }\n\n  return (\n    <div onClick={props.openSetModerators} {...css(MODERATOR_WIDGET_STYLES.widget)}>\n      {ret}\n    </div>\n  );\n}\n\nexport const TITLE_CELL_STYLES = stylesheet({\n  superText: {\n    fontSize: '10px',\n    fontWeight: '600',\n    color: 'rgba(0,0,0,0.54)',\n  },\n  categoryLabel: {\n    textTransform: 'uppercase',\n    marginRight: '12px',\n  },\n  mainText: {\n    display: 'flex',\n  },\n  mainTextText: {\n    lineHeight: '20px',\n  },\n  mainTextLink: {\n    padding: '0 10px',\n    color: 'rgba(0,0,0,0.54)',\n  },\n});\n\ninterface ITitleCellProps {\n  category?: ICategoryModel;\n  article: IArticleModel;\n  link: string;\n}\n\nexport function TitleCell(props: ITitleCellProps) {\n  const {\n    category,\n    article,\n    link,\n  } = props;\n\n  const supertext = [];\n  if (category) {\n    supertext.push(<span key=\"label\" {...css(TITLE_CELL_STYLES.categoryLabel)}>{category.label}</span>);\n  }\n  if (article.sourceCreatedAt) {\n    supertext.push((\n      <span key=\"timestamp\">\n        <MagicTimestamp timestamp={article.sourceCreatedAt} inFuture={false}/>\n      </span>\n    ));\n  }\n\n  return (\n    <>\n      {supertext.length > 0 && <div {...css(TITLE_CELL_STYLES.superText)}>{supertext}</div>}\n      <div {...css(TITLE_CELL_STYLES.mainText)}>\n        <div>\n          <Link to={link} {...css(COMMON_STYLES.cellLink, TITLE_CELL_STYLES.mainTextText)}>\n            {article.title}\n          </Link>\n        </div>\n        {article.url && (\n        <div {...css(TITLE_CELL_STYLES.mainTextLink)}>\n          <a key=\"link\" href={article.url} target=\"_blank\" {...css(COMMON_STYLES.cellLink)}>\n            <OpenInNew fontSize=\"small\" />\n          </a>\n        </div>\n        )}\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Tables/styles.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport {\n  GUTTER_DEFAULT_SPACING,\n  HEADER_HEIGHT,\n  INPUT_DROP_SHADOW,\n  NICE_MIDDLE_BLUE,\n  PALE_COLOR,\n} from '../../styles';\nimport { stylesheet } from '../../utilx';\n\nexport const CELL_HEIGHT = 96;\n\nexport const ARTICLE_TABLE_STYLES = stylesheet({\n  dataHeader: {\n    color: 'rgba(255,255,255,0.54)',\n    backgroundColor: NICE_MIDDLE_BLUE,\n    fontSize: '12px',\n    fontWeight: 500,\n    display: 'flex',\n    flexDirection: 'row',\n    alignItems: 'center',\n  },\n\n  dataBody: {\n    background: 'white',\n    display: 'flex',\n    flexDirection: 'row',\n    alignItems: 'center',\n    justifyContent: 'right',\n  },\n  headerCell: {\n    height: `${HEADER_HEIGHT}px`,\n    lineHeight: `${HEADER_HEIGHT}px`,\n  },\n  dataCell: {\n    borderBottom: '1px solid rgba(38,50,56,0.12)',\n    height: `${CELL_HEIGHT}px`,\n    lineHeight: `${CELL_HEIGHT}px`,\n    fontSize: '14px',\n    fontWeight: '500',\n  },\n  summaryCell: {\n    borderBottom: '1px solid rgba(38,50,56,0.12)',\n    height: `${HEADER_HEIGHT}px`,\n    lineHeight: `${HEADER_HEIGHT}px`,\n    fontSize: '14px',\n    fontWeight: '500',\n    backgroundColor: '#FAFAFA',\n  },\n  iconCell: {\n    width: `${HEADER_HEIGHT}px`,\n    minHeight: `${HEADER_HEIGHT}px`,\n    display: 'flex',\n    alignItems: 'center',\n    justifyContent: 'center',\n  },\n  textCell: {\n    textAlign: 'left',\n    paddingLeft: '10px',\n    paddingRight: '20px',\n    flexGrow: '1',\n    display: 'flex',\n    flexDirection: 'column',\n    justifyContent: 'space-around',\n    lineHeight: 'initial',\n    paddingTop: '20px',\n    paddingBottom: '15px',\n  },\n  numberCell: {\n    textAlign: 'right',\n    width: '90px',\n    paddingRight: '10px',\n  },\n  timeCell: {\n    textAlign: 'right',\n    width: '100px',\n    paddingRight: '20px',\n  },\n  select: {\n    width: 'auto',\n    height: '36px',\n    paddingLeft: `${GUTTER_DEFAULT_SPACING / 2}px`,\n    paddingRight: `${GUTTER_DEFAULT_SPACING}px`,\n    border: 'none',\n    borderRadius: 2,\n    boxShadow: INPUT_DROP_SHADOW,\n    backgroundColor: PALE_COLOR,\n    fontSize: '16px',\n  },\n});\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/Tables/utils.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { IArticleModel, ICategoryModel, ModelId } from '../../../models';\n\nexport interface IFilterItem {\n  key: string;\n  value: string;\n}\n\nexport const NOT_SET = '~';\n\nexport function parseFilter(filter: string | undefined): Array<IFilterItem> {\n  if (!filter || filter.length === 0 || filter === NOT_SET) {\n    return [];\n  }\n\n  const items = filter.split('+');\n  const filterList: Array<IFilterItem> = [];\n  for (const i of items) {\n    const fields = i.split('=');\n    if (fields.length !== 2) {\n      continue;\n    }\n    filterList.push({key: fields[0], value: fields[1]});\n  }\n  return filterList;\n}\n\nexport function updateFilter(filterList: Array<IFilterItem>, newKey: string, newValue?: string): Array<IFilterItem> {\n  filterList = filterList.filter((item: IFilterItem) => item.key !== newKey);\n  if (newValue) {\n    filterList.push({key: newKey, value: newValue});\n  }\n  return filterList;\n}\n\nexport function getFilterString(filterList: Array<IFilterItem>): string {\n  if (filterList.length === 0) {\n    return NOT_SET;\n  }\n\n  return filterList.reduce<string>((r: string, i: IFilterItem) => (r ? `${r}+` : '') +  `${i.key}=${i.value}`, undefined);\n}\n\nexport function getFilterValue(filterList: Array<IFilterItem>, key: string) {\n  const item = filterList.find((i) => i.key === key);\n  if (item) {\n    return item.value;\n  }\n  return '';\n}\n\nexport interface IFilterContext {\n  categories: Map<ModelId, ICategoryModel>;\n}\n\nexport const FILTER_TITLE = 'title';\nexport const FILTER_CATEGORY = 'category';\nexport const FILTER_CATEGORY_NONE = 'none';\nexport const FILTER_MODERATORS = 'moderators';\nexport const FILTER_MODERATORS_UNASSIGNED = 'unassigned';\nexport const FILTER_TOGGLE_isCommentingEnabled = 'isCommentingEnabled';\nexport const FILTER_TOGGLE_isAutoModerated = 'isAutoModerated';\nexport const FILTER_TOGGLE_ON = 'yes';\nexport const FILTER_TOGGLE_OFF = 'no';\nexport const FILTER_TO_REVIEW = 'commentsToReview';\nexport const FILTER_TO_REVIEW_ANY = 'any';\nexport const FILTER_TO_REVIEW_NEW = 'new';\nexport const FILTER_TO_REVIEW_DEFERRED = 'deferred';\nexport const FILTER_DATE_sourceCreatedAt = 'sourceCreatedAt';\nexport const FILTER_DATE_updatedAt = 'updatedAt';\nexport const FILTER_DATE_lastModeratedAt = 'lastModeratedAt';\nexport const FILTER_DATE_SINCE = 'since-';\nexport const FILTER_DATE_PRIOR = 'prior-';\n\nfunction articleMatchesModerators(context: IFilterContext, article: IArticleModel, moderatorIds: Set<string>) {\n  const category = context.categories.get(article.categoryId);\n  for (const mId of article.assignedModerators) {\n    if (moderatorIds.has(mId)) {\n      return true;\n    }\n  }\n  if (category) {\n    for (const mId of category.assignedModerators) {\n      if (moderatorIds.has(mId)) {\n        return true;\n      }\n    }\n  }\n}\n\nexport function executeFilter(filterList: Array<IFilterItem>, context: IFilterContext) {\n  return (article: IArticleModel) => {\n    for (const i of filterList) {\n      switch (i.key) {\n        case FILTER_TITLE:\n          if (article.title.toLocaleLowerCase().indexOf(i.value.toLocaleLowerCase()) < 0) {\n            return false;\n          }\n          break;\n\n        case FILTER_MODERATORS:\n          if (i.value === FILTER_MODERATORS_UNASSIGNED) {\n            if (article.assignedModerators.length !== 0) {\n              return false;\n            }\n            const category = context.categories.get(article.categoryId);\n            if (category && category.assignedModerators.length !== 0) {\n              return false;\n            }\n          }\n          else {\n            const moderatorIds = new Set<string>(i.value.split(','));\n            return articleMatchesModerators(context, article, moderatorIds);\n          }\n          break;\n\n        case FILTER_CATEGORY:\n          if (i.value === FILTER_CATEGORY_NONE) {\n            if (article.categoryId) {\n              return false;\n            }\n          }\n          else {\n            if (article.categoryId !== i.value) {\n              return false;\n            }\n          }\n          break;\n\n        case FILTER_DATE_sourceCreatedAt:\n        case FILTER_DATE_updatedAt:\n        case FILTER_DATE_lastModeratedAt:\n          const dateValue = new Date(article[i.key]);\n          if (i.value.startsWith(FILTER_DATE_SINCE)) {\n            const hours = Number(i.value.substr(FILTER_DATE_SINCE.length));\n            const cutoff = new Date(Date.now() - 1000 * 60 * 60 * hours);\n            if (dateValue < cutoff) {\n              return false;\n            }\n          }\n          else if (i.value.startsWith(FILTER_DATE_PRIOR)) {\n            const hours = Number(i.value.substr(FILTER_DATE_PRIOR.length));\n            const cutoff = new Date(Date.now() - 1000 * 60 * 60 * hours);\n            if (dateValue > cutoff) {\n              return false;\n            }\n          }\n          else {\n            const values = filterDateRangeValues(i.value);\n            if (values) {\n              if (values[0] && values[0].length > 0) {\n                const fromDate = new Date(values[0]);\n                if (dateValue < fromDate) {\n                  return false;\n                }\n              }\n              if (values[1] && values[1].length > 0) {\n                const toDate = new Date(values[1]);\n                toDate.setDate(toDate.getDate() + 1);\n                if (dateValue > toDate) {\n                  return false;\n                }\n              }\n            }\n          }\n          break;\n\n        case FILTER_TOGGLE_isCommentingEnabled:\n        case FILTER_TOGGLE_isAutoModerated:\n          if (i.value === FILTER_TOGGLE_ON) {\n            if (!article[i.key]) {\n              return false;\n            }\n          }\n          else if (i.value === FILTER_TOGGLE_OFF) {\n            if (article[i.key]) {\n              return false;\n            }\n          }\n          break;\n\n        case FILTER_TO_REVIEW:\n          if (i.value === FILTER_TO_REVIEW_ANY) {\n            if ((article.unmoderatedCount + article.deferredCount) === 0) {\n              return false;\n            }\n          }\n          else if (i.value === FILTER_TO_REVIEW_NEW) {\n            if (article.unmoderatedCount === 0) {\n              return false;\n            }\n          }\n          else if (i.value === FILTER_TO_REVIEW_DEFERRED) {\n            if (article.deferredCount === 0) {\n              return false;\n            }\n          }\n          break;\n      }\n    }\n\n    return true;\n  };\n}\n\nexport function resetFilterToRoot(filter: Array<IFilterItem>): Array<IFilterItem> {\n  return filter.filter((item: IFilterItem) => {\n    return item.key === FILTER_CATEGORY;\n  });\n}\n\nexport function isFilterActive(filter: Array<IFilterItem>): boolean {\n  for (const i of filter) {\n    if (i.key === FILTER_CATEGORY) {\n      continue;\n    }\n    return true;\n  }\n  return false;\n}\n\nexport function filterDateSince(hours: number) {\n  return `${FILTER_DATE_SINCE}${hours}`;\n}\n\nexport function filterDatePrior(hours: number) {\n  return `${FILTER_DATE_PRIOR}${hours}`;\n}\n\nexport function filterDateRangeValues(value: string) {\n  const values = value.split(':');\n  if (values.length !== 2) {\n    return null;\n  }\n  return values;\n}\n\nexport function filterDateRange(from: string, to: string) {\n  from = from || '';\n  to = to || '';\n  return `${from}:${to}`;\n}\n\nexport function parseSort(sort: string | undefined) {\n  if (!sort || sort.length === 0 || sort === NOT_SET) {\n    return [];\n  }\n  return sort.split(',');\n}\n\nexport function updateSort(sortList: Array<string>, newSort: string): Array<string> {\n  function removeItem(sl: Array<string>, item: string) {\n    return sl.filter((sortitem) => !sortitem.endsWith(item));\n  }\n\n  if (!newSort.startsWith('+') && !newSort.startsWith('-')) {\n    return removeItem(sortList, newSort);\n  }\n\n  sortList = removeItem(sortList, newSort.substr(1));\n  sortList.unshift(newSort);\n  return sortList;\n}\n\nexport function getSortString(sl: Array<string>) {\n  if (sl.length === 0) {\n    return NOT_SET;\n  }\n  return sl.join(',');\n}\n\nexport const SORT_TITLE = 'title';\nexport const SORT_NEW = 'new';\nexport const SORT_APPROVED = 'approved';\nexport const SORT_REJECTED = 'rejected';\nexport const SORT_DEFERRED = 'deferred';\nexport const SORT_HIGHLIGHTED = 'highlighted';\nexport const SORT_FLAGGED = 'flagged';\nexport const SORT_SOURCE_CREATED = 'sourceCreatedAt';\nexport const SORT_UPDATED = 'updatedAt';\nexport const SORT_LAST_MODERATED = 'lastModeratedAt';\n\nexport function executeSort(sortList: Array<string>) {\n  function compareItem(a: IArticleModel, b: IArticleModel, comparator: string) {\n    switch (comparator) {\n      case SORT_TITLE:\n        return ('' + a.title).localeCompare(b.title);\n      case SORT_NEW:\n        return b.unmoderatedCount - a.unmoderatedCount;\n      case SORT_APPROVED:\n        return b.approvedCount - a.approvedCount;\n      case SORT_REJECTED:\n        return b.rejectedCount - a.rejectedCount;\n      case SORT_DEFERRED:\n        return b.deferredCount - a.deferredCount;\n      case SORT_HIGHLIGHTED:\n        return b.flaggedCount - a.flaggedCount;\n      case SORT_FLAGGED:\n        return b.flaggedCount - a.flaggedCount;\n      case SORT_LAST_MODERATED:\n      case SORT_SOURCE_CREATED:\n      case SORT_UPDATED:\n        const lma = a[comparator];\n        const lmb = b[comparator];\n        if (!lma && !lmb) {\n          return 0;\n        }\n        if (!lma) {\n          return 1;\n        }\n        if (!lmb) {\n          return -1;\n        }\n        return (new Date(lmb)).getTime() - (new Date(lma)).getTime();\n    }\n  }\n  return (a: IArticleModel, b: IArticleModel) => {\n    for (const sortItem of sortList) {\n      const direction = sortItem[0];\n      const comparison = compareItem(a, b, sortItem.substr(1));\n      if (comparison === 0) {\n        continue;\n      }\n      if (direction === '-') {\n        return -comparison;\n      }\n      return comparison;\n    }\n    return 0;\n  };\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/appstate.ts",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { ICommentsGlobalState } from './Comments/store';\nimport { ISearchState } from './Search/store';\n\nexport type IScenesState = Readonly<{\n  comments: ICommentsGlobalState;\n  search: ISearchState;\n}>;\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/index.tsx",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { useSelector } from 'react-redux';\nimport { Redirect, Route, Switch } from 'react-router';\n\nimport { SplashRoot } from '../components';\nimport { getCurrentUserIsAdmin } from '../stores/users';\nimport {\n  Comments,\n  TagSelector,\n} from './Comments';\nimport {\n  dashboardBase,\n  rangesBase,\n  searchBase,\n  settingsBase,\n  tagSelectorBase,\n} from './routes';\nimport { Search } from './Search';\nimport { Ranges } from './Settings/Ranges';\nimport { Settings } from './Settings/Settings';\nimport { TableFrame } from './Tables/TableFrame';\n\nfunction redirect(to: string) {\n  return () => {\n    return <Redirect to={to}/>;\n  };\n}\n\nexport function AppRoot() {\n  const isAdmin = useSelector(getCurrentUserIsAdmin);\n  return (\n    <Switch>\n      <Route exact path=\"/\" render={redirect(`/${dashboardBase}`)} />\n      <Route path={`/${dashboardBase}/:filter?/:sort?`} component={TableFrame}/>\n      {isAdmin && <Route path={`/${settingsBase}`} component={Settings}/>}\n      {isAdmin && <Route path={`/${rangesBase}`} component={Ranges}/>}\n      <Route path={`/${searchBase}`} component={Search}/>\n      <Route path={`/${tagSelectorBase}/:context/:contextId/:tag`} component={TagSelector} />\n      <Route path={'/:context/:contextId'} component={Comments}/>\n      <Route path={'/'} component={SplashRoot}/>\n    </Switch>\n  );\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/routes.ts",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport qs from 'query-string';\n\nimport { ModelId } from '../../models';\n\nexport interface IDashboardPathParams {\n  filter?: string;\n  sort?: string;\n}\n\nexport const dashboardBase = 'dashboard';\nexport function dashboardLink(params: IDashboardPathParams) {\n  let ret = `/${dashboardBase}`;\n  if (params.filter) {\n    ret = `${ret}/${params.filter}`;\n  }\n  else if (params.sort) {\n    // Need to add a filter placeholder so we can add sort.\n    ret = `${ret}/~/`;\n  }\n\n  if (params.sort) {\n    ret = `${ret}/${params.sort}`;\n  }\n  return ret;\n}\n\nexport const settingsBase = 'settings';\nexport function settingsLink() {\n  return `/${settingsBase}`;\n}\n\nexport const rangesBase = 'ranges';\nexport function rangesLink() {\n  return `/${rangesBase}`;\n}\n\nexport interface ISearchQueryParams {\n  articleId?: ModelId;\n  searchByAuthor?: boolean;\n  term?: string;\n  sort?: string;\n}\n\nexport const searchBase = 'search';\nexport function searchLink(params: ISearchQueryParams) {\n  let gotQuery = false;\n  for (const key of Object.keys(params)) {\n    const value = (params as any)[key];\n    if (!value) {\n      delete (params as any)[key];\n      continue;\n    }\n    gotQuery = true;\n  }\n\n  if (gotQuery) {\n    return `/${searchBase}?${qs.stringify(params)}`;\n  }\n  return `/${searchBase}`;\n}\n\nexport interface IContextPathParams {\n  context: string;\n  contextId: ModelId;\n}\n\nexport function isArticleContext(params: IContextPathParams) {\n  return params.context === articleBase;\n}\n\nexport interface INewCommentsPathParams extends IContextPathParams {\n  tag: string;\n}\n\nexport interface INewCommentsQueryParams {\n  pos1?: string;\n  pos2?: string;\n  sort?: string;\n}\n\nexport interface IModeratedCommentsPathParams extends IContextPathParams {\n  disposition: string;\n}\n\nexport interface IModeratedCommentsQueryParams {\n  sort?: string;\n}\n\nexport const articleBase = 'articles';\nexport const categoryBase = 'categories';\nexport const NEW_COMMENTS_DEFAULT_TAG = 'SUMMARY_SCORE';\nexport function newCommentsPageLink(\n  {context, contextId, tag}: INewCommentsPathParams,\n  query?: INewCommentsQueryParams,\n) {\n  const queryString = query ? '?' + qs.stringify(query) : '';\n  return `/${context}/${contextId}/new/${tag}${queryString}`;\n}\n\nexport function moderatedCommentsPageLink(\n  {context, contextId, disposition}: IModeratedCommentsPathParams,\n  query?: IModeratedCommentsQueryParams,\n) {\n  const queryString = query ? '?' + qs.stringify(query) : '';\n  return `/${context}/${contextId}/moderated/${disposition}${queryString}`;\n}\n\nexport interface ITagSelectorPathParams extends IContextPathParams {\n  tag?: string;\n}\n\nexport const tagSelectorBase = 'tagselector';\nexport function tagSelectorLink({context, contextId, tag}: ITagSelectorPathParams) {\n  return `/${tagSelectorBase}/${context}/${contextId}/${tag}`;\n}\n\nexport interface ICommentDetailsPathParams extends IContextPathParams {\n  commentId: ModelId;\n}\n\nexport interface ICommentDetailsQueryParams {\n  pagingIdentifier: string;\n}\n\nexport function commentDetailsPageLink (\n  {context, contextId, commentId}: ICommentDetailsPathParams,\n  query?: ICommentDetailsQueryParams,\n) {\n  const queryString = query ? `?${qs.stringify(query)}` : '';\n  return `/${context}/${contextId}/comments/${commentId}${queryString}`;\n}\n\nexport function commentRepliesDetailsLink(\n  {context, contextId, commentId}: ICommentDetailsPathParams,\n) {\n  return `/${context}/${contextId}/comments/${commentId}/replies`;\n}\n\nexport function commentSearchDetailsPageLink(\n  commentId: ModelId,\n  query?: ICommentDetailsQueryParams,\n) {\n  const queryString = query ? `?${qs.stringify(query)}` : '';\n  return `/${searchBase}/comments/${commentId}${queryString}`;\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/scenes/store.ts",
    "content": "/*\nCopyright 2020 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { combineReducers } from 'redux';\n\nimport { IScenesState } from './appstate';\nimport { commentsReducer } from './Comments/store';\nimport { searchReducer } from './Search/store';\n\nexport const reducer = combineReducers<IScenesState>({\n  comments: commentsReducer,\n  search: searchReducer,\n});\n"
  },
  {
    "path": "packages/frontend-web/src/app/store.ts",
    "content": "/*\nCopyright 2020 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { applyMiddleware, combineReducers, compose, createStore, Store } from 'redux';\nimport thunk from 'redux-thunk';\n\nimport { reducer as scenesReducer } from './scenes/store';\nimport { reducer as globalReducer } from './stores';\n\nexport const reducers = combineReducers({\n  scenes: scenesReducer,\n  global: globalReducer,\n});\nexport const store = createStore(\n  reducers,\n  {},\n  compose(\n    applyMiddleware(thunk),\n    // TODO: Make this toggle based on environment\n    // (window as any)['devToolsExtension'] ? (window as any)['devToolsExtension']() : (f: any) => f,\n  ),\n) as Store;\n"
  },
  {
    "path": "packages/frontend-web/src/app/stores/appstate.ts",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { IArticlesState } from './articles';\nimport { ICategoriesState } from './categories';\nimport { ICommentsState } from './comments';\nimport { ICountsState } from './counts';\nimport { IPreselectsState } from './preselects';\nimport { IRulesState } from './rules';\nimport { ITaggingSensitivitiesState } from './taggingSensitivities';\nimport { ITagsState } from './tags';\nimport { ITextSizesState } from './textSizes';\nimport { IUsersState } from './users';\n\nexport type IGlobalState = Readonly<{\n  categories: ICategoriesState;\n  articles: IArticlesState;\n  comments: ICommentsState;\n  counts: ICountsState;\n  users: IUsersState;\n  tags: ITagsState;\n  rules: IRulesState;\n  preselects: IPreselectsState;\n  taggingSensitivities: ITaggingSensitivitiesState;\n  textSizes: ITextSizesState;\n}>;\n"
  },
  {
    "path": "packages/frontend-web/src/app/stores/articles.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { Action, createAction, handleActions } from 'redux-actions';\n\nimport { IArticleModel, ModelId } from '../../models';\nimport { IAppState } from '../appstate';\n\nexport const articlesLoaded = createAction<Array<IArticleModel>>('global/ARTICLES_LOADED');\nexport const articlesUpdated = createAction<Array<IArticleModel>>('global/ARTICLES_UPDATED');\n\nexport function getArticleMap(state: IAppState): Map<ModelId, IArticleModel> {\n  return state.global.articles.index;\n}\n\nexport function getArticles(state: IAppState): Array<IArticleModel> {\n  return state.global.articles.array;\n}\n\nexport function getArticle(state: IAppState, articleId: ModelId): IArticleModel {\n  return getArticleMap(state).get(articleId);\n}\n\nexport interface IArticlesState {\n  index: Map<ModelId, IArticleModel>;\n  array: Array<IArticleModel>;\n}\n\nconst reducer = handleActions<Readonly<IArticlesState>, Array<IArticleModel>>( {\n  [articlesLoaded.toString()]: (_state, { payload }: Action<Array<IArticleModel>>) => {\n    const index = new Map<ModelId, IArticleModel>(payload.map((v) => ([v.id, v])));\n    const array = Array.from(index.values());\n    return {index, array};\n  },\n  [articlesUpdated.toString()]: (state, { payload }: Action<Array<IArticleModel>>) => {\n    const index: Map<ModelId, IArticleModel> = state.index;\n    for (const article of payload) {\n      index.set(article.id, article);\n    }\n    const array = Array.from(index.values());\n    return {index, array};\n  },\n}, {index: new Map<ModelId, IArticleModel>(), array: []});\n\nexport { reducer };\n"
  },
  {
    "path": "packages/frontend-web/src/app/stores/categories.ts",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { Action, createAction, handleActions } from 'redux-actions';\n\nimport { ICategoryModel, ModelId } from '../../models';\nimport { IAppState } from '../appstate';\n\nexport const categoriesLoaded = createAction<Array<ICategoryModel>>('global/CATEGORIES_LOADED');\nexport const categoriesUpdated = createAction<Array<ICategoryModel>>('global/CATEGORIES_UPDATED');\n\nexport function getCategoryMap(state: IAppState): Map<ModelId, ICategoryModel> {\n  return state.global.categories.index;\n}\n\nexport function getCategories(state: IAppState): Array<ICategoryModel> {\n  return state.global.categories.array;\n}\n\nexport function getActiveCategories(state: IAppState): Array<ICategoryModel> {\n  return state.global.categories.active;\n}\n\nexport function getCategory(state: IAppState, categoryId: ModelId): ICategoryModel {\n  return getCategoryMap(state).get(categoryId);\n}\n\nexport interface ISummaryCounts {\n  unmoderatedCount: number;\n  moderatedCount: number;\n  deferredCount: number;\n  approvedCount: number;\n  highlightedCount: number;\n  rejectedCount: number;\n  flaggedCount: number;\n  batchedCount: number;\n}\n\nexport function getGlobalCounts(state: IAppState): ISummaryCounts {\n  const categories = getCategories(state);\n  const counts: ISummaryCounts = {\n    unmoderatedCount: 0,\n    moderatedCount: 0,\n    deferredCount: 0,\n    approvedCount: 0,\n    highlightedCount: 0,\n    rejectedCount: 0,\n    flaggedCount: 0,\n    batchedCount: 0,\n  };\n\n  for (const category of categories) {\n    counts.unmoderatedCount += category.unmoderatedCount;\n    counts.moderatedCount += category.moderatedCount;\n    counts.deferredCount += category.deferredCount;\n    counts.approvedCount += category.approvedCount;\n    counts.highlightedCount += category.highlightedCount;\n    counts.rejectedCount += category.rejectedCount;\n    counts.flaggedCount += category.flaggedCount;\n    counts.batchedCount += category.batchedCount;\n  }\n  return counts;\n}\n\nexport interface ICategoriesState {\n  index: Map<ModelId, ICategoryModel>;\n  array: Array<ICategoryModel>;\n  active: Array<ICategoryModel>;\n}\n\nexport const reducer = handleActions<Readonly<ICategoriesState>, Array<ICategoryModel>>( {\n  [categoriesLoaded.toString()]: (_state, { payload }: Action<Array<ICategoryModel>>) => {\n    const index = new Map<ModelId, ICategoryModel>(payload.map((v) => ([v.id, v])));\n    const array = Array.from(index.values());\n    const active = array.filter((c) => c.isActive);\n    return {index, array, active};\n  },\n  [categoriesUpdated.toString()]: (state, { payload }: Action<Array<ICategoryModel>>) => {\n    const index: Map<ModelId, ICategoryModel> = state.index;\n    for (const category of payload) {\n      index.set(category.id, category);\n    }\n    const array = Array.from(index.values());\n    const active = array.filter((c) => c.isActive);\n    return {index, array, active};\n  },\n}, {index: new Map<ModelId, ICategoryModel>(), array: [], active: []});\n"
  },
  {
    "path": "packages/frontend-web/src/app/stores/commentActions.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport {CommentScoreModel, IAuthorAttributes, ICommentScoreAttributes, ModelId} from '../../models';\nimport {\n  approveCommentsRequest,\n  approveFlagsAndCommentsRequest,\n  confirmCommentScoreRequest,\n  confirmCommentSummaryScoreRequest,\n  deferCommentsRequest,\n  deleteCommentScoreRequest,\n  editAndRescoreCommentRequest,\n  getComments,\n  highlightCommentsRequest,\n  rejectCommentScoreRequest,\n  rejectCommentsRequest,\n  rejectCommentSummaryScoreRequest,\n  rejectFlagsAndCommentsRequest,\n  resetCommentScoreRequest,\n  resetCommentsRequest,\n  resolveFlagsRequest,\n  tagCommentsAnnotationRequest,\n  tagCommentsRequest,\n  tagCommentSummaryScoresRequest,\n} from '../platform/dataService';\nimport {store} from '../store';\nimport {\n  addCommentScore,\n  ATTRIBUTES_APPROVED,\n  ATTRIBUTES_DEFERRED,\n  ATTRIBUTES_HIGHLIGHTED,\n  ATTRIBUTES_REJECTED,\n  ATTRIBUTES_RESET,\n  commentAttributesUpdated,\n  commentsUpdated,\n  removeAllCommentScores,\n  removeCommentScore,\n  updateCommentScore,\n} from './globalActions';\nimport {getMyUserId} from './users';\n\nexport async function fetchComments(commentIds: Array<ModelId>) {\n  const comments = await getComments(commentIds);\n  store.dispatch(commentsUpdated(comments));\n}\n\nexport async function highlightComments(commentIds: Array<ModelId>) {\n  await highlightCommentsRequest(commentIds);\n  store.dispatch(commentAttributesUpdated({commentIds, attributes: ATTRIBUTES_HIGHLIGHTED}));\n}\n\nexport async function resetComments(commentIds: Array<ModelId>) {\n  await resetCommentsRequest(commentIds);\n  store.dispatch(commentAttributesUpdated({commentIds, attributes: ATTRIBUTES_RESET}));\n}\n\nexport async function approveComments(commentIds: Array<ModelId>) {\n  await approveCommentsRequest(commentIds);\n  store.dispatch(commentAttributesUpdated({commentIds, attributes: ATTRIBUTES_APPROVED}));\n}\n\nexport async function approveFlagsAndComments(commentIds: Array<ModelId>) {\n  await approveFlagsAndCommentsRequest(commentIds);\n  store.dispatch(commentAttributesUpdated({commentIds, attributes: ATTRIBUTES_APPROVED, resolveFlags: true}));\n}\n\nexport async function resolveFlags(commentIds: Array<ModelId>) {\n  await resolveFlagsRequest(commentIds);\n  store.dispatch(commentAttributesUpdated({commentIds,  resolveFlags: true}));\n}\n\nexport async function deferComments(commentIds: Array<ModelId>) {\n  await deferCommentsRequest(commentIds);\n  store.dispatch(commentAttributesUpdated({commentIds, attributes: ATTRIBUTES_DEFERRED}));\n}\n\nexport async function rejectComments(commentIds: Array<ModelId>) {\n  await rejectCommentsRequest(commentIds);\n  store.dispatch(commentAttributesUpdated({commentIds, attributes: ATTRIBUTES_REJECTED}));\n}\n\nexport async function rejectFlagsAndComments(commentIds: Array<ModelId>) {\n  await rejectFlagsAndCommentsRequest(commentIds);\n  store.dispatch(commentAttributesUpdated({commentIds, attributes: ATTRIBUTES_REJECTED, resolveFlags: true}));\n}\n\nfunction sendAddScoreAction(commentId: ModelId, tagId: ModelId, start?: number, end?: number) {\n  const score: ICommentScoreAttributes = {\n    id: null,\n    commentId: commentId,\n    isConfirmed: true,\n    confirmedUserId: getMyUserId(),\n    sourceType: 'Moderator',\n    score: 1,\n    tagId,\n  };\n  if (start) {\n    score.annotationStart = start;\n  }\n  if (end) {\n    score.annotationEnd = end;\n  }\n  store.dispatch(addCommentScore(CommentScoreModel(score)));\n}\n\nexport async function tagComment(commentId: ModelId, tagId: ModelId) {\n  sendAddScoreAction(commentId, tagId);\n  await tagCommentsRequest([commentId], tagId);\n}\n\nexport async function tagCommentWithAnnotation(commentId: string, tagId: string, start: number, end: number) {\n  sendAddScoreAction(commentId, tagId, start, end);\n  await tagCommentsAnnotationRequest(commentId, tagId, start, end);\n}\n\nexport async function untagComment(commentId: ModelId, commentScoreId: string) {\n  store.dispatch(removeCommentScore(commentScoreId));\n  await deleteCommentScoreRequest(commentId, commentScoreId);\n}\n\nexport async function tagCommentSummaryScores(commentIds: Array<ModelId>, tagId: string) {\n  await tagCommentSummaryScoresRequest(commentIds, tagId);\n}\n\nexport async function confirmCommentSummaryScore(commentId: ModelId, tagId: string) {\n  await confirmCommentSummaryScoreRequest(commentId, tagId);\n}\n\nexport async function rejectCommentSummaryScore(commentId: ModelId, tagId: string) {\n  await rejectCommentSummaryScoreRequest(commentId, tagId);\n}\n\nexport async function resetCommentScore(commentId: ModelId, commentScoreId: string) {\n  store.dispatch(updateCommentScore({\n    id: commentScoreId,\n    confirmedUserId: null,\n    isConfirmed: null,\n  }));\n  await resetCommentScoreRequest(commentId, commentScoreId);\n}\n\nexport async function confirmCommentScore(commentId: ModelId, commentScoreId: string) {\n  store.dispatch(updateCommentScore({\n    id: commentScoreId,\n    isConfirmed: true,\n    confirmedUserId: getMyUserId(),\n  }));\n  await confirmCommentScoreRequest(commentId, commentScoreId);\n}\n\nexport async function rejectCommentScore(commentId: ModelId, commentScoreId: string) {\n  store.dispatch(updateCommentScore({\n    id: commentScoreId,\n    isConfirmed: false,\n    confirmedUserId: getMyUserId(),\n  }));\n  await rejectCommentScoreRequest(commentId, commentScoreId);\n}\n\nexport async function editAndRescoreComment(\n  commentId: ModelId,\n  text: string,\n  author: IAuthorAttributes,\n): Promise<void> {\n  store.dispatch(removeAllCommentScores(commentId));\n  await editAndRescoreCommentRequest(commentId, text, author.name, author.location);\n  store.dispatch(commentAttributesUpdated({\n    commentIds: [commentId],\n    attributes: {...ATTRIBUTES_RESET, text, author, summaryScores: []}}));\n}\n\nexport type ICommentActionFunction = (ids: Array<string>, tagId?: string) => Promise<void>;\n"
  },
  {
    "path": "packages/frontend-web/src/app/stores/comments.ts",
    "content": "/*\nCopyright 2020 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport {Action, handleActions} from 'redux-actions';\n\nimport {ICommentModel, ModelId, UNRESOLVED_FLAGS_COUNT} from '../../models';\nimport {IAppState} from '../appstate';\nimport {clearCommentCache, commentAttributesUpdated, commentsUpdated, ICommentAttributesUpdate} from './globalActions';\n\nconst commentPendingQueue = new Set<ModelId>();\nconst commentFetchQueue = new Set<ModelId>();\n\nlet timer: any;\n\nfunction executeFetch(commentFetcher: (commentIds: Array<ModelId>) => void) {\n  commentFetcher(Array.from(commentPendingQueue));\n  commentPendingQueue.forEach((i) => commentFetchQueue.add(i));\n  commentPendingQueue.clear();\n  timer = null;\n}\n\nexport function ensureCache(commentId: ModelId, commentFetcher: (commentIds: Array<ModelId>) => void) {\n  if (commentFetchQueue.has(commentId) || commentPendingQueue.has(commentId)) {\n    // Already fetching or pending fetch\n    return;\n  }\n\n  if (!timer) {\n    timer = setTimeout(() => executeFetch(commentFetcher), 100);\n  }\n  commentPendingQueue.add(commentId);\n}\n\nexport function clearCommentFetchQueue() {\n  commentPendingQueue.clear();\n  commentFetchQueue.clear();\n  if (timer) {\n    clearTimeout(timer);\n  }\n}\n\nfunction resolveFlags(flagsSummary?: Map<string, Array<number>>) {\n  if (!flagsSummary) {\n    return flagsSummary;\n  }\n  for (const summary of flagsSummary.values()) {\n    summary[UNRESOLVED_FLAGS_COUNT] = 0;\n  }\n\n  return flagsSummary;\n}\n\nexport interface ICommentsState {\n  index: Map<ModelId, ICommentModel>;\n}\n\nexport const reducer = handleActions<Readonly<ICommentsState>, Array<ICommentModel> | ICommentAttributesUpdate>( {\n  [clearCommentCache.toString()]: () => {\n    clearCommentFetchQueue();\n    return {index: new Map()};\n  },\n  [commentsUpdated.toString()]: (state, { payload }: Action<Array<ICommentModel>>) => {\n    const index = state.index;\n    for (const comment of payload) {\n      index.set(comment.id, comment);\n    }\n    return {index};\n  },\n  [commentAttributesUpdated.toString()]: (state, { payload }: Action<ICommentAttributesUpdate>) => {\n    const index = state.index;\n    for (const commentId of payload.commentIds) {\n      const comment = index.get(commentId);\n      if (comment) {\n        let newComment = {\n          ...comment,\n          updatedAt: new Date().toISOString(),\n        };\n        if (payload.attributes) {\n          newComment = {\n            ...newComment,\n            ...payload.attributes,\n          };\n          if (payload.attributes.isModerated === null) {\n            delete newComment.isModerated;\n          }\n          if (payload.attributes.isAccepted === null) {\n            delete newComment.isAccepted;\n          }\n        }\n        if (payload.resolveFlags) {\n          newComment.unresolvedFlagsCount = 0;\n          newComment.flagsSummary = resolveFlags(newComment.flagsSummary);\n        }\n        index.set(commentId, newComment);\n      }\n    }\n    return {index};\n  },\n}, {index: new Map()});\n\nexport function getComment(state: IAppState, commentId: ModelId) {\n  const comment: ICommentModel = state.global.comments.index.get(commentId);\n  if (comment) {\n    commentFetchQueue.delete(commentId);\n  }\n  return comment;\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/stores/counts.ts",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { Action, createAction, handleActions } from 'redux-actions';\n\nimport { IAppState } from '../appstate';\n\nexport const assignmentCountUpdated = createAction<number>('global/ASSIGNMENT_COUNT_UPDATED');\n\nexport type ICountsState = Readonly<{\n  assignments: number;\n}>;\n\nexport function getAssignments(state: IAppState) {\n  return state.global.counts.assignments;\n}\n\nexport const reducer = handleActions<\n  ICountsState,\n  number\n  >({\n  [assignmentCountUpdated.toString()]: (_state, { payload }: Action<number>) => {\n    return {assignments: payload};\n  },\n}, {\n  assignments: 0,\n});\n"
  },
  {
    "path": "packages/frontend-web/src/app/stores/globalActions.ts",
    "content": "/*\nCopyright 2020 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport {createAction} from 'redux-actions';\n\nimport {ICommentModel, ICommentScoreModel, ModelId} from '../../models';\n\nexport const commentsUpdated = createAction<Array<ICommentModel>>('global/COMMENTS_UPDATED');\n\nexport type ICommentAttributesUpdateDetails = {\n  isModerated?: boolean | null;\n  isAccepted?: boolean | null;\n} & Partial<Pick<ICommentModel,\n  'isDeferred' | 'isHighlighted' |'text' | 'author' | 'summaryScores'>>;\n\nexport interface ICommentAttributesUpdate {\n  commentIds: Array<ModelId>;\n  attributes?: ICommentAttributesUpdateDetails;\n  resolveFlags?: boolean;\n}\n\nexport const ATTRIBUTES_HIGHLIGHTED: ICommentAttributesUpdateDetails = {\n  isModerated: true,\n  isAccepted: true,\n  isHighlighted: true,\n  isDeferred: false,\n};\n\nexport const ATTRIBUTES_RESET: ICommentAttributesUpdateDetails = {\n  isModerated: null,\n  isAccepted: null,\n  isHighlighted: false,\n  isDeferred: false,\n};\n\nexport const ATTRIBUTES_APPROVED: ICommentAttributesUpdateDetails = {\n  isModerated: true,\n  isAccepted: true,\n  isHighlighted: false,\n  isDeferred: false,\n};\n\nexport const ATTRIBUTES_REJECTED: ICommentAttributesUpdateDetails = {\n  isModerated: true,\n  isAccepted: false,\n  isHighlighted: false,\n  isDeferred: false,\n};\n\nexport const ATTRIBUTES_DEFERRED: ICommentAttributesUpdateDetails = {\n  isModerated: true,\n  isAccepted: null,\n  isHighlighted: false,\n  isDeferred: true,\n};\n\nexport const commentAttributesUpdated = createAction<ICommentAttributesUpdate>('global/COMMENT_ATTRIBUTES_UPDATED');\n\nexport const addCommentScore = createAction<ICommentScoreModel>('global/ADD_COMMENT_SCORE');\nexport const removeCommentScore = createAction<ModelId>('global/REMOVE_COMMENT_SCORE');\nexport const removeAllCommentScores = createAction<ModelId>('global/REMOVE_ALL_COMMENT_SCORE');\nexport const updateCommentScore = createAction<{id: ModelId} & Partial<ICommentScoreModel>>('global/UPDATE_COMMENT_SCORE');\nexport const clearCommentCache = createAction('global/CLEAR_COMMENT_CACHE');\n"
  },
  {
    "path": "packages/frontend-web/src/app/stores/index.ts",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { combineReducers } from 'redux';\n\nimport { IGlobalState } from './appstate';\nimport { reducer as articleReducer } from './articles';\nimport { reducer as categoriesReducer } from './categories';\nimport { reducer as commentsReducer } from './comments';\nimport { reducer as countsReducer } from './counts';\nimport { reducer as preselectsReducer } from './preselects';\nimport { reducer as rulesReducer } from './rules';\nimport { reducer as taggingSensitivitiesReducer } from './taggingSensitivities';\nimport { reducer as tagsReducer } from './tags';\nimport { reducer as textSizesReducer } from './textSizes';\nimport { reducer as usersReducer } from './users';\n\nexport const reducer = combineReducers<IGlobalState>({\n  categories: categoriesReducer,\n  articles: articleReducer,\n  comments: commentsReducer,\n  counts: countsReducer,\n  users: usersReducer,\n  tags: tagsReducer,\n  rules: rulesReducer,\n  preselects: preselectsReducer,\n  taggingSensitivities: taggingSensitivitiesReducer,\n  textSizes: textSizesReducer,\n});\n"
  },
  {
    "path": "packages/frontend-web/src/app/stores/preselects.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { List } from 'immutable';\nimport { Action, createAction, handleActions } from 'redux-actions';\nimport { IPreselectModel } from '../../models';\nimport { IAppState } from '../appstate';\n\nexport const preselectsUpdated = createAction(\n  'all-preselects/UPDATED',\n);\n\nexport function getPreselects(state: IAppState): List<IPreselectModel> {\n  return state.global.preselects.items;\n}\n\nexport interface IPreselectsState {\n  items: List<IPreselectModel>;\n}\n\nconst reducer = handleActions<Readonly<IPreselectsState>, List<IPreselectModel>>( {\n  [preselectsUpdated.toString()]: (_state, { payload }: Action<List<IPreselectModel>>) => ({items:  payload}),\n}, {\n  items: List<IPreselectModel>(),\n});\n\nexport { reducer };\n"
  },
  {
    "path": "packages/frontend-web/src/app/stores/rules.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { List } from 'immutable';\nimport { Action, createAction, handleActions } from 'redux-actions';\n\nimport { IRuleModel } from '../../models';\nimport { IAppState } from '../appstate';\n\nexport const rulesUpdated = createAction(\n  'all-rules/UPDATED',\n);\n\nexport function getRules(state: IAppState): List<IRuleModel> {\n  return state.global.rules.items;\n}\n\nexport interface IRulesState {\n  items: List<IRuleModel>;\n}\n\nconst reducer = handleActions<Readonly<IRulesState>, List<IRuleModel>>( {\n  [rulesUpdated.toString()]: (_state, { payload }: Action<List<IRuleModel>>) => ({items: payload}),\n}, {\n  items: List<IRuleModel>(),\n});\n\nexport { reducer };\n"
  },
  {
    "path": "packages/frontend-web/src/app/stores/taggingSensitivities.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { List } from 'immutable';\nimport { Action, createAction, handleActions } from 'redux-actions';\n\nimport { ITaggingSensitivityModel } from '../../models';\nimport { IAppState } from '../appstate';\n\nexport const taggingSensitivitiesUpdated = createAction(\n  'all-taggingSensitivities/UPDATED',\n);\n\nexport function getTaggingSensitivities(state: IAppState): List<ITaggingSensitivityModel> {\n  return state.global.taggingSensitivities.items;\n}\n\nexport interface ITaggingSensitivitiesState {\n  items: List<ITaggingSensitivityModel>;\n}\n\nconst reducer = handleActions<Readonly<ITaggingSensitivitiesState>, List<ITaggingSensitivityModel>>( {\n  [taggingSensitivitiesUpdated.toString()]:\n    (_state, { payload }: Action<List<ITaggingSensitivityModel>>) => ({items:  payload}),\n}, {\n  items: List<ITaggingSensitivityModel>(),\n});\n\nexport { reducer };\n"
  },
  {
    "path": "packages/frontend-web/src/app/stores/tags.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { List } from 'immutable';\nimport { Action, createAction, handleActions } from 'redux-actions';\n\nimport { ITagModel } from '../../models';\nimport { IAppState } from '../appstate';\n\nexport const tagsUpdated = createAction<object>(\n  'all-tags/UPDATED',\n);\n\nexport function getTags(state: IAppState): List<ITagModel> {\n  return state.global.tags.items;\n}\n\nexport function getTaggableTags(state: IAppState) {\n  return List(getTags(state).filter((tag: ITagModel) => tag.isTaggable));\n}\n\nexport interface ITagsState {\n  items: List<ITagModel>;\n}\n\nconst reducer = handleActions<Readonly<ITagsState>, List<ITagModel>>( {\n  [tagsUpdated.toString()]: (_state, { payload }: Action<List<ITagModel>>) => ({items: payload}),\n}, {\n  items: List<ITagModel>(),\n});\n\nexport { reducer };\n"
  },
  {
    "path": "packages/frontend-web/src/app/stores/textSizes.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { Action, createAction, handleActions } from 'redux-actions';\n\nimport { ModelId } from '../../models';\nimport { IAppState, IThunkAction } from '../appstate';\nimport { listTextSizesByIds } from '../platform/dataService';\n\nconst loadTextSizesStart = createAction(\n  'text-sizes/LOAD_TEXT_SIZES_START',\n);\n\ntype ILoadTestSizesCompletePayload = {\n  textSizes: Map<ModelId, number>;\n};\nconst loadTextSizesComplete = createAction<ILoadTestSizesCompletePayload>(\n  'text-sizes/LOAD_TEXT_SIZES_COMPLETE',\n);\n\nexport type ITextSizesState = Readonly<{\n  isLoading: boolean;\n  hasData: boolean;\n  textSizes: Map<ModelId, number>;\n}>;\n\nconst textSizesReducer = handleActions<\n  ITextSizesState,\n  void                          | // loadTextSizesStart\n  ILoadTestSizesCompletePayload   // loadTextSizesComplete\n>({\n  [loadTextSizesStart.toString()]: (state) => ({...state, isLoading: true}),\n\n  [loadTextSizesComplete.toString()]: (state, { payload: { textSizes } }: Action<ILoadTestSizesCompletePayload>) => ({\n    isLoading: false,\n    hasData: true,\n    textSizes: new Map([...state.textSizes, ...textSizes]),\n  }),\n}, {\n  isLoading: false,\n  hasData: false,\n  textSizes: new Map<ModelId, number>(),\n});\n\nfunction getStateRecord(state: IAppState) {\n  return state.global.textSizes;\n}\n\nfunction getTextSizesHasData(state: IAppState) {\n  const stateRecord = getStateRecord(state);\n  return stateRecord && stateRecord.hasData;\n}\n\nfunction getTextSizes(state: IAppState) {\n  const stateRecord = getStateRecord(state);\n  return stateRecord && stateRecord.textSizes;\n}\n\nfunction getTextSizesIsLoading(state: IAppState) {\n  const stateRecord = getStateRecord(state);\n  return stateRecord && stateRecord.isLoading;\n}\n\nfunction loadTextSizesByIds(ids: Array<ModelId>, width: number): IThunkAction<Promise<void>> {\n  return async (dispatch, getState) => {\n    if (ids.length <= 0) {\n      return;\n    }\n\n    await dispatch(loadTextSizesStart());\n\n    const state = getState();\n    const hasData = getTextSizesHasData(state);\n    const loadedSizes = getTextSizes(state);\n    const unloadedIDs = !hasData ? ids : ids.filter((id) => !loadedSizes.has(id));\n\n    const textSizes = await listTextSizesByIds(unloadedIDs, width);\n\n    await dispatch(loadTextSizesComplete({ textSizes }));\n  };\n}\n\nexport {\n  textSizesReducer,\n  getTextSizesHasData,\n  getTextSizes,\n  getTextSizesIsLoading,\n  loadTextSizesByIds,\n  textSizesReducer as reducer,\n};\n"
  },
  {
    "path": "packages/frontend-web/src/app/stores/users.ts",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { List, Map } from 'immutable';\nimport { Action, createAction, handleActions } from 'redux-actions';\n\nimport { IUserModel, ModelId } from '../../models';\nimport { IAppState } from '../appstate';\n\nlet userId: string | null;\n\nexport function setMyUserId(uid: string | null) {\n  userId = uid;\n}\n\nexport function getMyUserId(): string | null {\n  return userId;\n}\n\nexport const USER_GROUP_GENERAL = 'general';\nexport const USER_GROUP_ADMIN = 'admin';\nexport const USER_GROUP_SERVICE = 'service';\nexport const USER_GROUP_MODERATOR = 'moderator';\nexport const USER_GROUP_YOUTUBE = 'youtube';\n\nexport const usersUpdated = createAction<List<IUserModel>>(\n  'all-users/USERS_UPDATED',\n);\n\nexport interface ILoadSystemUsers { type: string; users: List<IUserModel>; }\n\nexport const systemUsersLoaded = createAction<ILoadSystemUsers>(\n  'system-users/SYSTEM_USERS_LOADED',\n);\n\nexport function getUsers(state: IAppState): Map<ModelId, IUserModel> {\n  return state.global.users.humans;\n}\n\nexport function getUser(state: IAppState, id: ModelId): IUserModel | null {\n  return getUsers(state).get(id);\n}\n\nexport function getCurrentUser(state: IAppState): IUserModel | null {\n  const id = getMyUserId();\n  if (!id) {\n    return null;\n  }\n  return getUser(state, id);\n}\n\nexport function userIsAdmin(user: IUserModel | null): boolean {\n  return user && user.group === 'admin';\n}\n\nexport function getCurrentUserIsAdmin(state: IAppState): boolean {\n  return userIsAdmin(getCurrentUser(state));\n}\n\nexport function getSystemUsers(type: string, state: IAppState): List<IUserModel> {\n  if (type === USER_GROUP_SERVICE ||\n    type === USER_GROUP_MODERATOR ||\n    type === USER_GROUP_YOUTUBE) {\n    return state.global.users[type];\n  }\n  return List<IUserModel>();\n}\n\nexport interface IUsersState {\n  humans: Map<ModelId, IUserModel>;\n  [USER_GROUP_SERVICE]: List<IUserModel>;\n  [USER_GROUP_MODERATOR]: List<IUserModel>;\n  [USER_GROUP_YOUTUBE]: List<IUserModel>;\n}\n\nconst reducer = handleActions<Readonly<IUsersState>, List<IUserModel> | ILoadSystemUsers>( {\n  [usersUpdated.toString()]: (state: Readonly<IUsersState>, { payload }: Action<List<IUserModel>>) => {\n    const users = Map<ModelId, IUserModel>(payload.map((v) => ([v.id, v])));\n    return {...state, humans: users};\n  },\n  [systemUsersLoaded.toString()]: (state: Readonly<IUsersState>, { payload }: Action<ILoadSystemUsers>) => {\n    if (payload.type === USER_GROUP_SERVICE ||\n      payload.type === USER_GROUP_MODERATOR ||\n      payload.type === USER_GROUP_YOUTUBE) {\n      return {...state, [payload.type]: payload.users};\n    }\n    return state;\n  },\n}, {\n  humans: Map<ModelId, IUserModel>(),\n  [USER_GROUP_SERVICE]: List<IUserModel>(),\n  [USER_GROUP_MODERATOR]: List<IUserModel>(),\n  [USER_GROUP_YOUTUBE]: List<IUserModel>(),\n});\n\nexport { reducer };\n"
  },
  {
    "path": "packages/frontend-web/src/app/styles/breakpoints.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\n// SIZING\nexport const TABLET_WIDTH = 1023;\nexport const HEADER_HEIGHT = 64;\n\n// BREAKPOINTS\nexport const TABLET_PORTRAIT_BREAKPOINT =\n    `@media (max-width: ${TABLET_WIDTH}px)`;\n"
  },
  {
    "path": "packages/frontend-web/src/app/styles/colors.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\n// COLORS\nexport const DARK_COLOR = '#255271';\nexport const MEDIUM_COLOR = '#326891';\nexport const LIGHT_COLOR = '#46779c';\nexport const DIVIDER_COLOR = '#ededed';\nexport const PALE_COLOR = '#f4f7f9';\nexport const WHITE_COLOR = '#ffffff';\nexport const ALMOST_WHITE = '#fafafa';\nexport const GREY_COLOR = '#999999';\nexport const NICE_DARK_BLUE = '#0f4a92';\nexport const NICE_MIDDLE_BLUE = '#185bac';\nexport const NICE_LIGHT_BLUE = '#95b3d8';\nexport const NICE_LIGHTEST_BLUE = '#e1e9f4';\nexport const NICE_CONTROL_BLUE = '#2e84ed';\nexport const NICE_BLUE_GREY = '#eff0f0';\nexport const SIDEBAR_BLUE = '#0f4a92';\nexport const NICE_LIGHT_HIGHLIGHT_BLUE = '#2e83ed';\n\n// TEXT COLOR\nexport const DARK_PRIMARY_TEXT_COLOR = 'rgba(0, 0, 0, 0.87)';\nexport const DARK_SECONDARY_TEXT_COLOR = 'rgba(0, 0, 0, 0.54)';\nexport const DARK_TERTIARY_TEXT_COLOR = 'rgba(0, 0, 0, 0.38)';\nexport const DARK_LINK_TEXT_COLOR = 'rgba(50, 104, 145, 1)';\n\nconst LIGHT_COLOR_BASE = 'rgba(255, 255, 255, 1)';\nexport const LIGHT_PRIMARY_TEXT_COLOR = LIGHT_COLOR_BASE;\nexport const LIGHT_SECONDARY_TEXT_COLOR = 'rgba(255, 255, 255, 0.7)';\nexport const LIGHT_TERTIARY_TEXT_COLOR = 'rgba(255, 255, 255, 0.5)';\nexport const LIGHT_HIGHLIGHT_COLOR = 'rgba(255, 255, 255, 0.2)';\nexport const LIGHT_LINKS_TEXT_COLOR = LIGHT_COLOR_BASE;\n\n// TAGS\nexport const TAG_OBSCENE_COLOR = '#d71b60';\nexport const TAG_INCOHERENT_COLOR = '#9c28b1';\nexport const TAG_SPAM_COLOR = '#673bb8';\nexport const TAG_OFF_TOPIC_COLOR = '#3f51b5';\nexport const TAG_INFLAMMATORY_COLOR = '#1976d3';\nexport const TAG_UNSUBSTANTIAL_COLOR = '#3d5afe';\nexport const TAG_OTHER_COLOR = '#01828f';\n"
  },
  {
    "path": "packages/frontend-web/src/app/styles/forms.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport {\n  NICE_MIDDLE_BLUE,\n} from './colors';\n\n/*\n * Select element styles\n */\nexport const SELECT_ELEMENT = {\n  appearance: 'none',\n  WebkitAppearance: 'none', // Not getting prefixed either\n  background: 'transparent',\n  borderTop: 0,\n  borderRight: 0,\n  borderBottom: 0,\n  borderLeft: 0,\n  color: NICE_MIDDLE_BLUE,\n  cursor: 'pointer',\n  lineHeight: '1.4em', // Prevent descenders from getting clipped.\n  width: '100%',\n};\n\nexport const BUTTON_RESET = {\n  background: 'none',\n    borderTop: 0,\n    borderRight: 0,\n    borderBottom: 0,\n    borderLeft: 0,\n    color: 'inherit',\n    lineHeight: 'normal',\n    overflow: 'visible',\n    padding: 0,\n};\n"
  },
  {
    "path": "packages/frontend-web/src/app/styles/header.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\n// import { stylesheet } from '../util';\nimport { LIGHT_PRIMARY_TEXT_COLOR, NICE_MIDDLE_BLUE } from '../styles/colors';\nimport { ARTICLE_HEADLINE_TYPE } from '../styles/typography';\nimport { GUTTER_DEFAULT_SPACING } from '../styles/util';\nimport { ARTICLE_PREVIEW_Z_INDEX } from '../styles/zindex';\n\n/*\n * Select element styles\n */\nexport const ARTICLE_HEADER = {\n  header: {\n    display: 'flex',\n    alignItems: 'center',\n    width: '100%',\n    height: '100%',\n    justifyContent: 'space-between',\n  },\n\n  meta: {\n    display: 'flex',\n    alignItems: 'center',\n    width: '50%',\n    height: '100%',\n    paddingLeft: 0,\n    flex: 1,\n  },\n\n  title: {\n    ...ARTICLE_HEADLINE_TYPE,\n    color: LIGHT_PRIMARY_TEXT_COLOR,\n    whiteSpace: 'nowrap',\n    textOverflow: 'ellipsis',\n    overflow: 'hidden',\n    lineHeight: 1.5,\n    marginTop: 0,\n    marginRight: 0,\n    marginBottom: 0,\n  },\n\n  titleLink: {\n    cursor: 'pointer',\n    opacity: 1,\n    transition: 'opacity 0.3 ease',\n    ':hover': {\n      transition: 'opacity 0.3 ease',\n      opacity: 0.64,\n    },\n    ':focus': {\n      outline: 0,\n      opacity: 0.64,\n      textDecoration: 'underline',\n    },\n  },\n\n  articlePreviewScrim: {\n    backgroundColor: 'rgba(255, 255, 255, 0.5)',\n    zIndex: ARTICLE_PREVIEW_Z_INDEX,\n  },\n\n  articlePreviewWrapper: {\n    position: 'absolute',\n    width: 693,\n  },\n\n  tabs: {\n    display: 'flex',\n    height: '100%',\n  },\n\n  link: {\n    textDecoration: 'none',\n    transition: 'backgroundColor 0.3 ease',\n    backgroundColor: 'transparent',\n    display: 'flex',\n    ':hover': {\n      transition: 'backgroundColor 0.3 ease',\n      backgroundColor: NICE_MIDDLE_BLUE,\n    },\n    ':focus': {\n      outline: 0,\n      color: LIGHT_PRIMARY_TEXT_COLOR,\n      textDecoration: 'underline',\n    },\n  },\n\n  tab: {\n    padding: `0 ${GUTTER_DEFAULT_SPACING}px`,\n    alignItems: 'center',\n  },\n};\n"
  },
  {
    "path": "packages/frontend-web/src/app/styles/hoverstates.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\n/*\n * Hover opacity for arrow buttons and multiple elements\n */\n\nexport const OPACITY_TRANSITION = {\n  opacity: 1,\n  transition: 'opacity 0.3 ease',\n  ':hover': {\n    transition: 'opacity 0.3 ease',\n    opacity: 0.64,\n  },\n  ':focus': {\n    outline: 0,\n    opacity: 0.64,\n  },\n};\n\n/*\n * Use for link and button elements; color needs to be\n * specified in ':hover'\n */\nexport const BOTTOM_BORDER_TRANSITION = {\n  textDecoration: 'none',\n  borderBottomColor: 'transparent',\n  borderBottomStyle: 'solid',\n  borderBottomWidth: '1px',\n  transition: 'all 0.3 ease',\n};\n"
  },
  {
    "path": "packages/frontend-web/src/app/styles/index.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nexport * from './typography';\nexport * from './colors';\nexport * from './util';\nexport * from './forms';\nexport * from './breakpoints';\nexport * from './header';\nexport * from './scrim';\nexport * from './hoverstates';\nexport * from './zindex';\n"
  },
  {
    "path": "packages/frontend-web/src/app/styles/scrim.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { WHITE_COLOR } from './colors';\nimport { GUTTER_DEFAULT_SPACING, MODAL_DROP_SHADOW } from './util';\n\n/*\n * Select element styles\n */\nexport const SCRIM_STYLE = {\n  scrim: {\n    background: 'rgba(255, 255, 255, 0.5)',\n    display: 'flex',\n    alignItems: 'center',\n    justifyContent: 'center',\n    alignContent: 'center',\n  },\n\n  popup: {\n    background: WHITE_COLOR,\n    paddingTop: `${GUTTER_DEFAULT_SPACING}px`,\n    paddingRight: `${GUTTER_DEFAULT_SPACING}px`,\n    paddingBottom: `${GUTTER_DEFAULT_SPACING}px`,\n    paddingLeft: `${GUTTER_DEFAULT_SPACING}px`,\n    width: '400px',\n    boxShadow: MODAL_DROP_SHADOW,\n    outline: 0,\n    ':focus': {\n      outline: 'none',\n    },\n  },\n\n  popupMenu: {\n    background: WHITE_COLOR,\n    boxShadow: MODAL_DROP_SHADOW,\n    outline: 0,\n    ':focus': {\n      outline: 'none',\n    },\n  },\n\n  popupTitle: {\n    marginTop: '5px',\n    marginBottom: '15px',\n    fontSize: '16px',\n    textAlign: 'left',\n  },\n\n  popupFooter: {\n    marginTop: '15px',\n    marginBottom: '5px',\n    fontSize: '16px',\n    fontWeight: 'bold',\n  },\n\n  popupContent: {\n    fontSize: '16px',\n  },\n};\n"
  },
  {
    "path": "packages/frontend-web/src/app/styles/typography.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nexport type ITypeStyle = {\n  fontFamily: string;\n  fontSize: number;\n  fontWeight: number;\n  lineHeight: number;\n};\n\nfunction makeTypeStyle(\n  fontFamily: string,\n  fontSize: number,\n  fontWeight = 400,\n  lineHeight = 1.5,\n): ITypeStyle {\n  return {\n    fontFamily,\n    fontSize,\n    fontWeight,\n    lineHeight,\n  };\n}\n\nconst BODY_FONT_STACK = 'Georgia, serif';\n\n// Franklin Gothic Medium\nexport const LOGIN_TITLE_TYPE =\n  makeTypeStyle('LibreFranklin-Medium, sans-serif', 60, 400);\n\n// Franklin Gothic Medium\nexport const HEADLINE_TYPE =\n  makeTypeStyle('LibreFranklin-Medium, sans-serif', 20, 400);\n\nexport const SEMI_BOLD_TYPE =\n  makeTypeStyle(BODY_FONT_STACK, 20, 600);\n\n// Cheltenham Book\nexport const ARTICLE_HEADLINE_TYPE =\n  makeTypeStyle('LibreFranklin-Medium, sans-serif', 16, 400);\n\n// Franklin Gothic Book\nexport const BODY_TEXT_TYPE =\n  makeTypeStyle(BODY_FONT_STACK, 16, 400);\n\nexport const COMMENT_DETAIL_BODY_TEXT_TYPE =\n  makeTypeStyle(BODY_FONT_STACK, 16, 400, 1.7);\n\n// FRANKLIN GOTHIC MEDIUM\nexport const ARTICLE_CATEGORY_TYPE =\n  makeTypeStyle('LibreFranklin-Medium, sans-serif', 14, 400);\n\nexport const COMMENT_DETAIL_DATE_TYPE =\n  makeTypeStyle('LibreFranklin-Medium, sans-serif', 14, 400);\n\n// Franklin Gothic Medium\nexport const BUTTON_LINK_TYPE =\n  makeTypeStyle('LibreFranklin-Medium, sans-serif', 14, 400);\n\n// Franklin Gothic Medium\nexport const COMMENT_DETAIL_TAG_LIST_BUTTON_TYPE =\n  makeTypeStyle('LibreFranklin-Medium, sans-serif', 14, 400);\n\n// Franklin Gothic Medium\nexport const ARTICLE_CAPTION_TYPE =\n  makeTypeStyle('LibreFranklin-Medium, sans-serif', 14, 400);\n\n// Franklin Gothic Bold\nexport const CAPTION_TYPE =\n  makeTypeStyle('LibreFranklin-Medium, sans-serif', 12, 400);\n\n// Franklin Gothic Medium\nexport const HANDLE_LABEL_TYPE =\n  makeTypeStyle('LibreFranklin-Medium, sans-serif', 12, 400, 1);\n"
  },
  {
    "path": "packages/frontend-web/src/app/styles/util.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nexport const DEFAULT_OPACITY = 1;\nexport const MEDIUM_OPACITY = 0.54;\nexport const LIGHT_OPACITY = 0.12;\n\n// SPACING\nexport const TEXT_OFFSET_DEFAULT_SPACING = 72;\nexport const GUTTER_DEFAULT_SPACING = 24;\nexport const BOX_DEFAULT_SPACING = 10;\n\nexport const SHORT_SCREEN_QUERY = '@media (max-height: 769px)';\n\n// ACCESSIBILITY\nexport const OFFSCREEN = {\n  clip: 'rect(1px, 1px, 1px, 1px)',\n  display: 'block',\n  height: 1,\n  margin: 0,\n  overflow: 'hidden',\n  position: 'absolute',\n  width: 1,\n};\n\nexport const MODAL_DROP_SHADOW = [\n  '0px 0px 24px 0px rgba(0,0,0, 0.22)',\n  '0px 24px 24px 0px rgba(0,0,0, 0.3)',\n].join(', ');\n\nexport const INPUT_DROP_SHADOW = [\n  '0px 0px 2px 0px rgba(0,0,0, 0.12)',\n  '0px 2px 2px 0px rgba(0,0,0, 0.24)',\n].join(', ');\n\nexport const CENTER_CONTENT = {\n  display: 'flex',\n  justifyContent: 'center',\n  alignItems: 'center',\n};\n\nexport const VISUALLY_HIDDEN = {\n  width: 0,\n  height: 0,\n  position: 'absolute',\n  clip: 'rect(1px, 1px, 1px, 1px)',\n};\n\nexport const flexCenter = {\n  display: 'flex',\n  alignItems: 'center',\n  justifyContent: 'center',\n};\n"
  },
  {
    "path": "packages/frontend-web/src/app/styles/zindex.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nlet currentMaxIndex = 0;\nconst nextIndexLevel = () => currentMaxIndex += 5;\n\nexport const BASE_Z_INDEX = nextIndexLevel();\nexport const SELECT_Z_INDEX = nextIndexLevel();\nexport const TOOLTIP_Z_INDEX = nextIndexLevel();\nexport const STICKY_Z_INDEX = nextIndexLevel();\nexport const SCRIM_Z_INDEX = nextIndexLevel();\nexport const ARTICLE_PREVIEW_Z_INDEX = nextIndexLevel();\nexport const ACCOUNT_SETTINGS_MENU_Z_INDEX = nextIndexLevel();\n"
  },
  {
    "path": "packages/frontend-web/src/app/stylesx/index.ts",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport {flexCenter, NICE_MIDDLE_BLUE} from '../styles';\nimport { stylesheet } from '../utilx';\n\nexport const IMAGE_BASE = 40;\n\nexport const big = {\n  width: `${IMAGE_BASE}px`,\n  height: `${IMAGE_BASE}px`,\n};\n\nexport const medium = {\n  width: `${IMAGE_BASE * 3 / 4}px`,\n  height: `${IMAGE_BASE * 3 / 4}px`,\n};\n\nexport const small = {\n  width: `${IMAGE_BASE / 2}px`,\n  height: `${IMAGE_BASE / 2}px`,\n};\n\nexport const ICON_STYLES = stylesheet({\n  iconCenter: {\n    width: `100%`,\n    height: `100%`,\n    ...flexCenter,\n  },\n\n  iconBackgroundCircle: {\n    ...big,\n    borderRadius: `${IMAGE_BASE}px`,\n    backgroundColor: '#eee',\n    display: 'inline-block',\n  },\n\n  iconBackgroundCircleSmall: {\n    ...small,\n    borderRadius: `${IMAGE_BASE / 2}px`,\n    backgroundColor: '#eee',\n    display: 'inline-block',\n  },\n\n  smallIcon: {\n    width: `${IMAGE_BASE + 6}px`,\n    height: `${IMAGE_BASE + 6}px`,\n  },\n\n  smallImage: {\n    width: `${IMAGE_BASE}px`,\n    height: `${IMAGE_BASE}px`,\n    borderRadius: `${(IMAGE_BASE / 2)}px`,\n  },\n\n  textCenterSmall: {\n    ...small,\n    fontSize: '12px',\n    ...flexCenter,\n  },\n});\n\nexport const COMMON_STYLES = stylesheet({\n  articleLink: {\n    color: NICE_MIDDLE_BLUE,\n    ':focus': {\n      outline: 0,\n      textDecoration: 'underline',\n    },\n  },\n\n  cellLink: {\n    fontWeight: '500',\n    color: 'inherit',\n    ':hover': {\n      textDecoration: 'underline',\n    },\n  },\n\n  fadeIn: {\n    animationName: {\n      from: {\n        opacity: 0,\n      },\n\n      to: {\n        opacity: 1,\n      },\n    },\n    animationDuration: '0.3s',\n    animationTimingFunction: 'ease',\n    animationIterationCount: 1,\n  },\n});\n"
  },
  {
    "path": "packages/frontend-web/src/app/util/DotChartRenderer.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { maxBy, values } from 'lodash';\nimport {\n  LIGHT_PRIMARY_TEXT_COLOR,\n  LIGHT_TERTIARY_TEXT_COLOR,\n} from '../styles';\n\nconst OVERDRAW = 1.2;\n\nexport interface IRange {\n  start: number;\n  end: number;\n}\n\nexport interface ICommentsByColumn {\n  [key: string]: Array<number>;\n}\n\nconst DIAMETER = 2 / 3;\nconst MARGIN = 1 - DIAMETER;\n\nexport class DotChartRenderer {\n\n  // Technically either an HTMLCanvas Element or Canvas instance\n  private canvas: any = null;\n\n  private commentsByColumn: ICommentsByColumn;\n  private selectedRangeStart: number;\n  private selectedRangeEnd: number;\n  private width: number;\n  private height: number;\n  private isNode: boolean = typeof window === 'undefined';\n  private showAll = true;\n  private backgroundColor: string;\n  private makeCanvas: (width: number, height: number) => any;\n  private isDirty = false;\n\n  constructor(makeCanvas: (width: number, height: number) => any) {\n    this.makeCanvas = makeCanvas;\n  }\n\n  setProps(props: {\n    // Technically either an HTMLCanvas Element or Canvas instance\n    canvas?: any;\n\n    width?: number;\n    height?: number;\n    selectedRangeStart?: number;\n    selectedRangeEnd?: number;\n    commentsByColumn?: ICommentsByColumn;\n    showAll?: boolean;\n    backgroundColor?: string | null;\n\n    [key: string]: any;\n  }) {\n    this.isDirty = false;\n\n    Object.keys(props)\n        .filter((key) => ['canvas', 'width', 'height', 'selectedRangeStart', 'selectedRangeEnd', 'commentsByColumn', 'showAll', 'backgroundColor'].indexOf(key) !== -1)\n        .forEach((key) => this.setProp(key, props[key]));\n\n    if (this.isDirty) {\n      this.render();\n    }\n  }\n\n  setProp(key: string, value: any) {\n    if ((this as any)[key] === value) { return; }\n\n    this.isDirty = true;\n    (this as any)[key] = value;\n  }\n\n  render() {\n    if (!this.canvas) {\n      if (this.isNode) { throw new Error('Missing canvas instance'); }\n\n      return;\n    }\n\n    if (!this.width || !this.height) {\n      if (this.isNode) { throw new Error('Missing canvas height or width'); }\n\n      return;\n    }\n\n    if (!this.isNode) {\n      const elementWidth = (window.devicePixelRatio || 1) * this.width;\n      const elementHeight = (window.devicePixelRatio || 1) * this.height;\n\n      if (this.width !== this.canvas.width || this.height !== this.canvas.height) {\n        this.canvas.width = elementWidth;\n        this.canvas.height = elementHeight;\n        this.canvas.style.width = `${this.width}px`;\n        this.canvas.style.height = `${this.height}px`;\n      }\n    }\n\n    if (this.showAll) {\n      this.renderAll();\n    } else {\n      this.renderMax();\n    }\n  }\n\n  private renderAll(): void {\n    const columnsByIndex = Object.keys(this.commentsByColumn).sort();\n    const columnCount = columnsByIndex.length;\n\n    const items = (values(this.commentsByColumn) as Array<any>);\n    const maxColumnCount = maxBy(items, (c) => c.length);\n\n    if (!this.commentsByColumn) {\n      if (this.isNode) { throw new Error('Missing comments by column'); }\n\n      return;\n    }\n\n    const ctx = this.canvas.getContext('2d');\n\n    if (this.backgroundColor) {\n      ctx.fillStyle = this.backgroundColor;\n      ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);\n    } else {\n      ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);\n    }\n\n    ctx.save();\n\n    let dpr = 1;\n    if (!this.isNode) {\n      dpr = (window.devicePixelRatio || 1);\n      ctx.scale(dpr, dpr);\n    }\n\n    const stepX = this.width / columnCount;\n    const stepY = this.height / maxColumnCount;\n\n    const radiusX = (DIAMETER / 2) * stepX;\n    const radiusY = (DIAMETER / 2) * stepY;\n\n    const radius = Math.min(radiusX, radiusY);\n\n    const marginX = MARGIN * stepX;\n    const marginY = MARGIN * stepY;\n\n    const startY = this.height - radius - (marginY / 2);\n\n    for (let i = 0; i < columnCount; i++) {\n      const x = stepX * i;\n      const key = columnsByIndex[i];\n      const colValue = i / columnCount;\n      const comments = this.commentsByColumn[key];\n\n      const isSelected = (\n        'undefined' !== typeof this.selectedRangeStart &&\n        'undefined' !== typeof this.selectedRangeEnd\n      ) && (colValue >= this.selectedRangeStart && colValue < this.selectedRangeEnd);\n\n      ctx.fillStyle =\n        isSelected ? LIGHT_PRIMARY_TEXT_COLOR : LIGHT_TERTIARY_TEXT_COLOR;\n\n      for (let j = 0; (j < comments.length); j++) {\n        const screenX = x + radius + (marginX / 2);\n        const screenY = startY - (stepY * j);\n\n        const image = this.makeSprite(radius * dpr, isSelected);\n        ctx.drawImage(image, screenX - (radius / OVERDRAW), screenY - (radius / OVERDRAW), radius * 2, radius * 2);\n      }\n    }\n\n    ctx.restore();\n  }\n\n  private renderMax(): void {\n    if (!this.commentsByColumn) {\n      if (this.isNode) { throw new Error('Missing comments by column'); }\n\n      return;\n    }\n\n    const columnsByIndex = Object.keys(this.commentsByColumn).sort();\n    const columnCount = columnsByIndex.length;\n\n    const ctx = this.canvas.getContext('2d');\n\n    if (this.backgroundColor) {\n      ctx.fillStyle = this.backgroundColor;\n      ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);\n    } else {\n      ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);\n    }\n\n    ctx.save();\n\n    let dpr = 1;\n    if (!this.isNode) {\n      dpr = (window.devicePixelRatio || 1);\n      ctx.scale(dpr, dpr);\n    }\n\n    const step = this.width / columnCount;\n    const radius = (DIAMETER / 2) * step;\n    const margin = MARGIN * step;\n    const startY = this.height - radius - (margin / 2);\n\n    for (let i = 0; i < columnCount; i++) {\n      const x = step * i;\n      const key = columnsByIndex[i];\n      const colValue = i / columnCount;\n      const comments = this.commentsByColumn[key];\n\n      const isSelected = (\n        'undefined' !== typeof this.selectedRangeStart &&\n        'undefined' !== typeof this.selectedRangeEnd\n      ) && (colValue >= this.selectedRangeStart && colValue < this.selectedRangeEnd);\n\n      ctx.fillStyle =\n        isSelected ? LIGHT_PRIMARY_TEXT_COLOR : LIGHT_TERTIARY_TEXT_COLOR;\n\n      for (let j = 0; (j < comments.length); j++) {\n        const y = step * j;\n        const nextTopY = (startY - (step * (j + 1))) - (radius + (margin / 2));\n        const screenX = x + radius + (margin / 2);\n        const screenY =  startY - y;\n\n        if (nextTopY < 0) {\n          const w = radius * 2;\n          const h = radius / 2;\n          ctx.fillRect(screenX - radius, screenY - (h / 2), w, h);\n          ctx.fillRect(screenX - (h / 2), screenY - radius, h, w);\n          break;\n        } else {\n          const image = this.makeSprite(radius * dpr, isSelected);\n          ctx.drawImage(image, screenX - (radius / OVERDRAW), screenY - (radius / OVERDRAW), radius * 2, radius * 2);\n        }\n      }\n    }\n\n    ctx.restore();\n  }\n\n  private spriteCache: { [radius: string]: { [isSelected: string]: any } } = {};\n  private makeSprite(radius: number, isSelected: boolean): any {\n    const radiusKey = radius.toString();\n    const isSelectedKey = isSelected.toString();\n\n    if (\n      this.spriteCache[radiusKey] &&\n      this.spriteCache[radiusKey][isSelectedKey]\n    ) { return this.spriteCache[radiusKey][isSelectedKey]; }\n\n    this.spriteCache[radiusKey] = this.spriteCache[radiusKey] || {};\n\n    const c = this.makeCanvas(\n      (radius * 2) * OVERDRAW,\n      (radius * 2) * OVERDRAW,\n    );\n\n    const ctx = c.getContext('2d');\n\n    ctx.fillStyle =\n      isSelected ? LIGHT_PRIMARY_TEXT_COLOR : LIGHT_TERTIARY_TEXT_COLOR;\n\n    ctx.beginPath();\n    ctx.moveTo(c.width / 2, c.height / 2);\n    ctx.arc(c.width / 2, c.height / 2, radius, 0, Math.PI * 2);\n    ctx.fill();\n\n    this.spriteCache[radiusKey][isSelectedKey] = c;\n\n    return c;\n  }\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/util/color.ts",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n// tslint:disable:no-bitwise\n\nfunction hashCode(str: string) {\n  let hash = 0;\n  for (let i = 0; i < str.length; i++) {\n    hash = str.charCodeAt(i) + ((hash << 5) - hash);\n  }\n  return hash;\n}\n\nfunction intToRGB(i: number) {\n  return `rgb(${(i >> 16) & 0xFF}, ${(i >> 8) & 0xFF}, ${i & 0xFF})`;\n}\n\nexport function randomDarkColor(seed: string) {\n  return intToRGB(hashCode(seed) & 0x007F7F7F);\n}\n\nexport function randomLightColor(seed: string) {\n  return intToRGB(~(hashCode(seed) & 0x007F7F7F));\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/util/csrf.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { RESTRICT_TO_SESSION } from '../config';\nconst NONCE_PATH = 'moderator/csrf-nonce';\n\nconst storage = () => RESTRICT_TO_SESSION ? sessionStorage : localStorage;\n\nexport function getCSRF(): string { return storage()[NONCE_PATH]; }\nexport function setCSRF(random: string): void { storage()[NONCE_PATH] = random; }\nexport function clearCSRF(): void { delete storage()[NONCE_PATH]; }\n"
  },
  {
    "path": "packages/frontend-web/src/app/util/groupByColumn.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { maxBy, minBy } from 'lodash';\n\nexport interface IGroupedComments {\n  [column: string]: Array<number>;\n}\n\nexport interface IGroupableComment {\n  commentId: string;\n  [key: string]: any;\n}\n\nexport interface ITaggedComment extends IGroupableComment {\n  score: number;\n}\n\nexport interface IDatedComment extends IGroupableComment {\n  date: Date;\n}\n\nexport function groupByKey(comments: Array<IGroupableComment>, key: string, startVal: number, endVal: number, columnCount: number): IGroupedComments {\n  const step = (endVal - startVal) / columnCount;\n  const columns: IGroupedComments = {};\n\n  for (let i = 0; i < columnCount; i++) {\n    const range = startVal + (step * i);\n    columns[range.toFixed(2)] = [];\n  }\n\n  return comments.reduce((sum, comment) => {\n    for (let i = 0; i < columnCount; i++) {\n      const start = startVal + (step * i);\n      const end = start + step;\n\n      const val = Number(comment[key]);\n      if ((val >= start) && (val < end)) {\n        sum[start.toFixed(2)].push(parseInt(comment.commentId, 10));\n        break;\n      }\n    }\n\n    return sum;\n  }, columns);\n}\n\nexport function groupByScoreColumns<T extends ITaggedComment>(comments: Array<T>, columnCount: number): IGroupedComments {\n  return groupByKey(comments, 'score', 0.0, 1.0, columnCount);\n}\n\nexport function groupByDateColumns<T extends IDatedComment>(comments: Array<T>, columnCount: number): IGroupedComments {\n  const maxDate = maxBy<T>(comments, (c) => c.date);\n  const minDate = minBy<T>(comments, (c) => c.date);\n\n  const lowEnd = minDate ? Number(minDate.date) : 0;\n  const highEnd = maxDate ? Number(maxDate.date) : 1;\n\n  return groupByKey(comments, 'date', lowEnd, highEnd, columnCount);\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/util/index.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nexport * from './csrf';\nexport * from './DotChartRenderer';\nexport * from './groupByColumn';\nexport * from './makeCheckedSelectionStore';\nexport * from './makeCurrentPagingIdentifierReducer';\nexport * from './returnURL';\nexport * from './timeout';\nexport * from './sortByLabel';\nexport * from './returnSavedCommentRow';\nexport * from './partial';\n"
  },
  {
    "path": "packages/frontend-web/src/app/util/makeCheckedSelectionStore/__spec__/makeCheckedSelectionStore.spec.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { expect } from 'chai';\nimport { ICheckedSelectionState, makeCheckedSelectionStore } from '../makeCheckedSelectionStore';\n\nconst getStateRecord = (state: any) => {\n  return state.getIn(['test']) as ICheckedSelectionState;\n};\n\nconst testMakeCheckedSelectionStore = makeCheckedSelectionStore(getStateRecord, { defaultSelectionState: false });\nconst testMakeCheckedSelectionStoreWithDefaultSelected = makeCheckedSelectionStore(getStateRecord, { defaultSelectionState: true });\nconst initialState = testMakeCheckedSelectionStore.initialState;\nconst initialStateDefaultSelected = testMakeCheckedSelectionStoreWithDefaultSelected.initialState;\n\ndescribe('makeCheckedSelectionStore reducer with not selected by default', () => {\n  const reducer = testMakeCheckedSelectionStore.reducer;\n\n  it('should toggle all to be selected and their default selection state to be true', () => {\n    const testState = reducer(initialState, testMakeCheckedSelectionStore.toggleSelectAll());\n    expect(testState.defaultSelectionState).to.be.true;\n    expect(testState.areAllSelected).to.be.true;\n    expect(testState.overrides.size).to.equal(0);\n  });\n\n  it('should toggle all to be selected and their default selection state to be true and remove all overrides', () => {\n    const testOverrides = new Map([\n      ['1', true],\n      ['2', false],\n    ]);\n    const testInitialState = {...initialState, overrides: testOverrides};\n    const testState = reducer(testInitialState, testMakeCheckedSelectionStore.toggleSelectAll());\n\n    expect(testState.defaultSelectionState).to.be.true;\n    expect(testState.areAllSelected).to.be.true;\n    expect(testState.overrides.size).to.equal(0);\n  });\n\n  it('should toggle all to be not selected and their default selection state to be false', () => {\n    const testInitialState = {...initialState, areAllSelected: true};\n    const testState = reducer(testInitialState, testMakeCheckedSelectionStore.toggleSelectAll());\n\n    expect(testState.defaultSelectionState).to.be.false;\n    expect(testState.areAllSelected).to.be.false;\n    expect(testState.overrides.size).to.equal(0);\n  });\n\n  it('should toggle all to be not selected and their default selection state to be false and remove all overrides', () => {\n    const testOverrides = new Map([\n      ['1', true],\n      ['2', false],\n    ]);\n    const testInitialState = {...initialState, overrides: testOverrides, areAllSelected: true};\n    const testState = reducer(testInitialState, testMakeCheckedSelectionStore.toggleSelectAll());\n\n    expect(testState.defaultSelectionState).to.be.false;\n    expect(testState.areAllSelected).to.be.false;\n    expect(testState.overrides.size).to.equal(0);\n  });\n\n  it('should toggle a single item and leave all are selected set as false and create an override set to true at id 1', () => {\n    const testState = reducer(initialState, testMakeCheckedSelectionStore.toggleSingleItem({id: '1'}));\n\n    expect(testState.areAllSelected).to.be.false;\n\n    const overrides = testState.overrides;\n    expect(overrides.get('1')).to.be.true;\n  });\n\n  it('should toggle a single item and change all are selected from true to be false and create an override set to true at id 1', () => {\n    const testInitialState = {...initialState, areAllSelected: true};\n    const testState = reducer(testInitialState, testMakeCheckedSelectionStore.toggleSingleItem({id: '1'}));\n\n    expect(testState.areAllSelected).to.be.false;\n\n    const overrides = testState.overrides;\n    expect(overrides.get('1')).to.be.true;\n  });\n\n  it('should toggle a single item and change all are selected from true to be false and create an override set to true at id 1, then toggle back off again', () => {\n    const testInitialState = {...initialState, areAllSelected: true};\n    let testState = reducer(testInitialState, testMakeCheckedSelectionStore.toggleSingleItem({id: '1'}));\n\n    expect(testState.areAllSelected).to.be.false;\n\n    const overrides = testState.overrides;\n    expect(overrides.get('1')).to.be.true;\n\n    testState = reducer(testState, testMakeCheckedSelectionStore.toggleSingleItem({id: '1'}));\n\n    expect(testState.areAllSelected).to.be.false;\n    expect(testState.overrides.size).to.equal(0);\n  });\n\n  it('should toggle a single item and change all are selected to be false and remove the override at id 1', () => {\n    const testOverrides = new Map([['1', true]]);\n    const testInitialState = {...initialState, overrides: testOverrides};\n    const testState = reducer(testInitialState, testMakeCheckedSelectionStore.toggleSingleItem({id: '1'}));\n\n    expect(testState.areAllSelected).to.be.false;\n    expect(testState.overrides.size).to.equal(0);\n  });\n\n  it('should toggle a single item and change all are selected to be false and remove the override at id 1 and leave the override at 2', () => {\n    const testOverrides = new Map([\n      ['1', true],\n      ['2', false],\n    ]);\n    const testInitialState = {...initialState, overrides: testOverrides};\n    const testState = reducer(testInitialState, testMakeCheckedSelectionStore.toggleSingleItem({id: '1'}));\n\n    expect(testState.areAllSelected).to.be.false;\n    expect(testState.overrides.size).to.equal(1);\n\n    const overrides = testState.overrides;\n    expect(overrides.get('2')).to.be.false;\n  });\n\n  it('should toggle a single item and change all are selected to be false and remove the override at id 1, then toggle again and add it back', () => {\n    const testOverrides = new Map([['1', true]]);\n    const testInitialState = {...initialState, overrides: testOverrides};\n    let testState = reducer(testInitialState, testMakeCheckedSelectionStore.toggleSingleItem({id: '1'}));\n\n    expect(testState.areAllSelected).to.be.false;\n    expect(testState.overrides.size).to.equal(0);\n\n    testState = reducer(testState, testMakeCheckedSelectionStore.toggleSingleItem({id: '1'}));\n\n    expect(testState.areAllSelected).to.be.false;\n\n    const overrides = testState.overrides;\n    expect(overrides.get('1')).to.be.true;\n  });\n});\n\ndescribe('makeCheckedSelectionStore reducer with selected by default set to true', () => {\n  const reducer = testMakeCheckedSelectionStoreWithDefaultSelected.reducer;\n\n  it('should toggle all to be selected as false and their default selection state to be false', () => {\n    const testState = reducer(initialStateDefaultSelected, testMakeCheckedSelectionStoreWithDefaultSelected.toggleSelectAll());\n    expect(testState.defaultSelectionState).to.be.false;\n    expect(testState.areAllSelected).to.be.false;\n    expect(testState.overrides.size).to.equal(0);\n  });\n\n  it('should toggle all to be selected and their default selection state to be true and remove all overrides', () => {\n    const testOverrides = new Map([\n      ['1', true],\n      ['2', false],\n    ]);\n    const testInitialState = {...initialStateDefaultSelected, overrides: testOverrides};\n    const testState = reducer(testInitialState, testMakeCheckedSelectionStoreWithDefaultSelected.toggleSelectAll());\n\n    expect(testState.defaultSelectionState).to.be.false;\n    expect(testState.areAllSelected).to.be.false;\n    expect(testState.overrides.size).to.equal(0);\n  });\n\n  it('should toggle all to be not selected and their default selection state to be false', () => {\n    const testInitialState = {...initialStateDefaultSelected, areAllSelected: true};\n    const testState = reducer(testInitialState, testMakeCheckedSelectionStoreWithDefaultSelected.toggleSelectAll());\n\n    expect(testState.defaultSelectionState).to.be.false;\n    expect(testState.areAllSelected).to.be.false;\n    expect(testState.overrides.size).to.equal(0);\n  });\n\n  it('should toggle all to be not selected and their default selection state to be false and remove all overrides', () => {\n    const testOverrides = new Map([\n      ['1', true],\n      ['2', false],\n    ]);\n    const testInitialState = {...initialStateDefaultSelected, overrides: testOverrides, areAllSelected: true};\n    const testState = reducer(testInitialState, testMakeCheckedSelectionStoreWithDefaultSelected.toggleSelectAll());\n\n    expect(testState.defaultSelectionState).to.be.false;\n    expect(testState.areAllSelected).to.be.false;\n    expect(testState.overrides.size).to.equal(0);\n  });\n\n  it('should toggle a single item and leave are selected to be set as false and create an override set to false at id 1 then toggle it again and remove the override', () => {\n    let testState = reducer(initialStateDefaultSelected, testMakeCheckedSelectionStoreWithDefaultSelected.toggleSingleItem({id: '1'}));\n\n    expect(testState.defaultSelectionState).to.be.true;\n    expect(testState.areAllSelected).to.be.false;\n\n    const overrides = testState.overrides;\n    expect(\n      overrides.get('1'),\n    ).to.be.false;\n\n    testState = reducer(testState, testMakeCheckedSelectionStoreWithDefaultSelected.toggleSingleItem({id: '1'}));\n\n    expect(testState.defaultSelectionState).to.be.true;\n    expect(testState.areAllSelected).to.be.true;\n    expect(testState.overrides.size).to.equal(0);\n  });\n\n  it('should toggle a single item and change all are selected from true to be false and create an override set to false at id 1', () => {\n    const testInitialState = {...initialStateDefaultSelected, areAllSelected: true};\n    const testState = reducer(testInitialState, testMakeCheckedSelectionStoreWithDefaultSelected.toggleSingleItem({id: '1'}));\n\n    expect(testState.defaultSelectionState).to.be.true;\n    expect(testState.areAllSelected).to.be.false;\n\n    const overrides = testState.overrides;\n    expect(\n      overrides.get('1'),\n    ).to.be.false;\n  });\n\n  it('should toggle a single item and change all are selected to be true and remove the override at id 1', () => {\n    const testOverrides = new Map([['1', true]]);\n    const testInitialState = {...initialStateDefaultSelected, overrides: testOverrides};\n    const testState = reducer(testInitialState, testMakeCheckedSelectionStoreWithDefaultSelected.toggleSingleItem({id: '1'}));\n\n    expect(testState.defaultSelectionState).to.be.true;\n    expect(testState.areAllSelected).to.be.true;\n    expect(testState.overrides.size).to.equal(0);\n  });\n});\n\ndescribe('makeCheckedSelectionStore getItemCheckedState', () => {\n  it('should return any overrides of a given id found in state', () => {\n    const testOverrides = new Map([\n      ['1', true],\n      ['2', false],\n    ]);\n\n    const testItemCheckedState = testMakeCheckedSelectionStore.getItemCheckedState(testOverrides, '1', false);\n\n    expect(\n      testItemCheckedState,\n    ).to.equal(\n      true,\n    );\n  });\n\n  it('should return false if no overrides matching a given id are found and isCheckedByDefault is false', () => {\n    const testOverrides = new Map([\n      ['1', true],\n      ['2', false],\n    ]);\n\n    const testItemCheckedState = testMakeCheckedSelectionStore.getItemCheckedState(testOverrides, '3', false);\n\n    expect(\n      testItemCheckedState,\n    ).to.equal(\n      false,\n    );\n  });\n\n  it('should return true if no overrides matching a given id are found and isCheckedByDefault is true', () => {\n    const testOverrides = new Map([\n      ['1', true],\n      ['2', false],\n    ]);\n\n    const testItemCheckedState = testMakeCheckedSelectionStore.getItemCheckedState(testOverrides, '3', true);\n\n    expect(\n      testItemCheckedState,\n    ).to.equal(\n      true,\n    );\n  });\n\n  it('should return false if no overrides are provided and isCheckedByDefault is false', () => {\n    const testItemCheckedState = testMakeCheckedSelectionStore.getItemCheckedState(undefined, '1', false);\n\n    expect(\n      testItemCheckedState,\n    ).to.equal(\n      false,\n    );\n  });\n\n  it('should return true if no overrides are provided and isCheckedByDefault is true', () => {\n    const testItemCheckedState = testMakeCheckedSelectionStore.getItemCheckedState(undefined, '1', true);\n\n    expect(\n      testItemCheckedState,\n    ).to.equal(\n      true,\n    );\n  });\n});\n"
  },
  {
    "path": "packages/frontend-web/src/app/util/makeCheckedSelectionStore/index.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nexport * from './makeCheckedSelectionStore';\n"
  },
  {
    "path": "packages/frontend-web/src/app/util/makeCheckedSelectionStore/makeCheckedSelectionStore.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { Action, createAction, handleActions } from 'redux-actions';\n\nimport { IAppState } from '../../appstate';\n\nlet checkedSelectionStores = 0;\n\nexport interface ICheckedSelectionStoreOptions {\n  defaultSelectionState: boolean;\n}\n\nexport type IOverrides = Map<string, boolean>;\n\nexport type ICheckedSelectionState = Readonly<{\n  defaultSelectionState: boolean;\n  areAllSelected: boolean;\n  overrides: IOverrides;\n}>;\n\nexport type ICheckedSelectionPayloads =\n    void           | // toggleSelectAll\n    { id: string };  // toggleSingleItem\n\n// Return is infered\nexport function makeCheckedSelectionStore(\n  getStateRecord: (state: IAppState) => ICheckedSelectionState,\n  {\n    defaultSelectionState,\n  }: ICheckedSelectionStoreOptions,\n) {\n  checkedSelectionStores += 1;\n\n  const toggleSelectAll: () => Action<void> = createAction(\n    `checked-selection-${checkedSelectionStores}/TOGGLE_SELECT_ALL`,\n  );\n\n  const toggleSingleItem: (payload: { id: string }) => Action<{ id: string }> = createAction<{ id: string }>(\n    `checked-selection-${checkedSelectionStores}/TOGGLE_SINGLE_ITEM`,\n  );\n\n  const initialState = {\n    defaultSelectionState,\n    areAllSelected: defaultSelectionState,\n    overrides: new Map<string, boolean>(),\n  };\n\n  const reducer = handleActions<\n    ICheckedSelectionState,\n    ICheckedSelectionPayloads\n  >({\n    [toggleSelectAll.toString()]: (state: ICheckedSelectionState) => {\n      const defaultValue = state.defaultSelectionState;\n      const areAllSelected = state.areAllSelected;\n\n      if (defaultValue === areAllSelected) {\n        return {\n         defaultSelectionState: !defaultValue,\n         areAllSelected: !defaultValue,\n         overrides: initialState.overrides,\n        };\n      } else {\n        return { ...state, areAllSelected: defaultValue, overrides: initialState.overrides};\n      }\n    },\n\n    [toggleSingleItem.toString()]: (state: ICheckedSelectionState, { payload }: Action<ICheckedSelectionPayloads>) => {\n      const { id } = payload as { id: string };\n      const defaultValue = state.defaultSelectionState;\n      const currentValue = state.overrides.get(id);\n\n      // Not in list, therefore an override.\n      const overrides = new Map(state.overrides);\n      if ('undefined' === typeof currentValue) {\n        overrides.set(id, !defaultValue);\n      }\n      else {\n        overrides.delete(id);\n      }\n      return {\n        ...state,\n        overrides,\n        areAllSelected: overrides.size <= 0 ? defaultValue : false,\n      };\n    },\n  }, initialState);\n\n  function getDefaultSelectionState(state: IAppState) {\n    const stateRecord = getStateRecord(state);\n    return stateRecord && stateRecord.defaultSelectionState;\n  }\n\n  function getAreAllSelected(state: IAppState) {\n    const stateRecord = getStateRecord(state);\n    return stateRecord && stateRecord.areAllSelected;\n  }\n\n  function getOverrides(state: IAppState) {\n    const stateRecord = getStateRecord(state);\n    return stateRecord && stateRecord.overrides;\n  }\n\n  function getItemCheckedState(overrides: IOverrides, id: string, isCheckedByDefault: boolean): boolean {\n    const override = overrides && overrides.get(id.toString());\n\n    if ('undefined' !== typeof override) {\n      return override;\n    }\n\n    return isCheckedByDefault;\n  }\n\n  function getIsItemChecked(state: IAppState, id: string): boolean {\n    const overrides = getOverrides(state);\n\n    return getItemCheckedState(overrides, id, getDefaultSelectionState(state));\n  }\n\n  function getAreAnyCommentsSelected(state: IAppState): boolean {\n    return (\n      !getDefaultSelectionState(state) &&\n      getOverrides(state) &&\n      (getOverrides(state).size === 0)\n    );\n  }\n\n  return {\n    initialState,\n    reducer,\n    getAreAllSelected,\n    getAreAnyCommentsSelected,\n    getOverrides,\n    getIsItemChecked,\n    getItemCheckedState,\n    toggleSelectAll,\n    toggleSingleItem,\n  };\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/util/makeCurrentPagingIdentifierReducer.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { Action, createAction, handleAction } from 'redux-actions';\n\nimport { IAppState } from '../appstate';\n\nlet currentPagingIdentifierReducer = 0;\n\nexport type ICurrentPagingIdentifierState = Readonly<{\n  currentPagingIdentifier: string;\n}>;\n\nconst initialState: ICurrentPagingIdentifierState = {\n  currentPagingIdentifier: null,\n};\n\nexport type ICurrentPagingIdentifierPayload = { currentPagingIdentifier: string };\n// Return infered\nexport function makeCurrentPagingIdentifierReducer(\n  getStateRecord: (state: IAppState) => ICurrentPagingIdentifierState,\n) {\n  currentPagingIdentifierReducer += 1;\n\n  const setCurrentPagingIdentifier: (payload: ICurrentPagingIdentifierPayload) => Action<ICurrentPagingIdentifierPayload> =\n    createAction<ICurrentPagingIdentifierPayload>(\n      `new-comments-list/SET_CURRENT_PAGING_IDENTIFIER_${currentPagingIdentifierReducer}`,\n    );\n\n  const reducer = handleAction<ICurrentPagingIdentifierState, ICurrentPagingIdentifierPayload>(\n    setCurrentPagingIdentifier.toString(),\n    (_state, { payload: { currentPagingIdentifier } }) => ({currentPagingIdentifier}),\n    initialState,\n  );\n\n  function getCurrentPagingIdentifier(state: IAppState) {\n    const localState = getStateRecord(state);\n    return localState && localState.currentPagingIdentifier;\n  }\n\n  return  {\n    reducer,\n    setCurrentPagingIdentifier,\n    getCurrentPagingIdentifier,\n  };\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/util/measureText.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { ITypeStyle } from '../styles';\n\nfunction getFontStyle({ fontWeight, fontSize, fontFamily }: ITypeStyle): string {\n  return `normal normal ${fontWeight} ${fontSize}px ${fontFamily}`;\n}\n\nexport function setupContext(canvas: any, styles: ITypeStyle): void {\n  const ctx = canvas.getContext('2d');\n\n  ctx.font = getFontStyle(styles);\n  ctx.strokeStyle = 'black';\n  ctx.lineWidth = 0;\n  ctx.textBaseline = 'alphabetic';\n  ctx.lineJoin = 'miter';\n  ctx.miterLimit = 10;\n}\n\nexport function measureLine(canvas: any, text: string, styles: ITypeStyle): number {\n  const ctx = canvas.getContext('2d');\n  setupContext(canvas, styles);\n\n  return ctx.measureText(text).width;\n}\n\nexport function getTextHeight(lines: Array<string>, _wordWrapWidth: number, styles: ITypeStyle): number {\n  const lineHeight = styles.fontSize * styles.lineHeight;\n\n  return lineHeight + ((lines.length - 1) * lineHeight);\n}\n\nexport function wordWrap(canvas: any, text: string, wordWrapWidth: number, styles: ITypeStyle): Array<string> {\n  const ctx = canvas.getContext('2d');\n  setupContext(canvas, styles);\n\n  let result = '';\n  const lines = text\n      .split('\\n')\n      .filter((l) => l.length > 0);\n\n  for (let i = 0; i < lines.length; i++) {\n    let spaceLeft = wordWrapWidth;\n    const words = lines[i].split(' ');\n\n    for (let j = 0; j < words.length; j++) {\n      const wordWidth = ctx.measureText(words[j]).width;\n\n      const wordWidthWithSpace = wordWidth + ctx.measureText(' ').width;\n\n      if (j === 0 || wordWidthWithSpace > spaceLeft) {\n        // Skip printing the newline if it's the first word of the line that is\n        // greater than the word wrap width.\n        if (j > 0) {\n          result += '\\n';\n        }\n\n        result += words[j];\n        spaceLeft = wordWrapWidth - wordWidth;\n      } else {\n        spaceLeft -= wordWidthWithSpace;\n        result += ` ${words[j]}`;\n      }\n    }\n\n    if (i < lines.length - 1) {\n      result += '\\n';\n    }\n  }\n\n  return result.split(/(?:\\r\\n|\\r|\\n)/);\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/util/partial/__spec__/memoize.spec.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { expect } from 'chai';\nimport {\n  List as ImmutableList,\n  Map as ImmutableMap,\n  Record as ImmutableRecord,\n  Set as ImmutableSet,\n} from 'immutable';\nimport { spy } from 'sinon';\nimport { memoize } from '../index';\n\nfunction makeMemoized(useEqualityForMutableObjects = false) {\n  const callback = spy();\n\n  const fn = memoize((...args: Array<any>): string => {\n    callback(...args);\n\n    return `Value: Args Length = ${args.length}`;\n  }, useEqualityForMutableObjects);\n\n  return {\n    callback,\n    fn,\n  };\n}\n\ndescribe('memoize', () => {\n  it('should memoize primitives', () => {\n    const {\n      callback,\n      fn,\n    } = makeMemoized();\n\n    // First run\n    fn(1, 'two', true);\n    fn(1, 'two', true);\n\n    // Second run\n    fn('two', 1, true);\n    fn('two', 1, true);\n\n    expect(callback.callCount).to.be.equal(2);\n  });\n\n  it('should memoize objects', () => {\n    const {\n      callback,\n      fn,\n    } = makeMemoized();\n\n    const o1: any = { one: 1 };\n    const o2: any = { two: 2 };\n    const o3: any = { three: 3 };\n\n    // First run\n    fn(o1, o2, o3);\n    fn(o1, o2, o3);\n\n    o1.one = 11;\n\n    // Second run\n    fn(o1, o2, o3);\n    fn(o1, o2, o3);\n\n    o2.twoMore = 22;\n\n    // Third run\n    fn(o1, o2, o3);\n    fn(o1, o2, o3);\n\n    delete o3.three;\n\n    // Fourth run\n    fn(o1, o2, o3);\n    fn(o1, o2, o3);\n\n    expect(callback.callCount).to.be.equal(4);\n  });\n\n  it('should memoize objects by equality rather than contents', () => {\n    const {\n      callback,\n      fn,\n    } = makeMemoized(true);\n\n    const o1: any = { one: 1 };\n    const o2: any = { two: 2 };\n    const o3: any = { three: 3 };\n\n    // First run\n    fn(o1, o2, o3);\n    fn(o1, o2, o3);\n\n    o1.one = 11;\n\n    // Second run\n    fn(o1, o2, o3);\n    fn(o1, o2, o3);\n\n    o2.twoMore = 22;\n\n    // Third run\n    fn(o1, o2, o3);\n    fn(o1, o2, o3);\n\n    delete o3.three;\n\n    // Fourth run\n    fn(o1, o2, o3);\n    fn(o1, o2, o3);\n\n    expect(callback.callCount).to.be.equal(1);\n  });\n\n  it('should memoize arrays', () => {\n    const {\n      callback,\n      fn,\n    } = makeMemoized();\n\n    const a1 = [1];\n    const a2 = [2];\n    const a3 = [3];\n\n    // First run\n    fn(a1, a2, a3);\n    fn(a1, a2, a3);\n\n    a1.push(11);\n\n    // Second run\n    fn(a1, a2, a3);\n    fn(a1, a2, a3);\n\n    a2.push(22);\n\n    // Third run\n    fn(a1, a2, a3);\n    fn(a1, a2, a3);\n\n    a3.splice(0, 1);\n\n    // Fourth run\n    fn(a1, a2, a3);\n    fn(a1, a2, a3);\n\n    expect(callback.callCount).to.be.equal(4);\n  });\n\n  it('should memoize arrays by equality rather than contents', () => {\n    const {\n      callback,\n      fn,\n    } = makeMemoized(true);\n\n    const a1 = [1];\n    const a2 = [2];\n    const a3 = [3];\n\n    // First run\n    fn(a1, a2, a3);\n    fn(a1, a2, a3);\n\n    a1.push(11);\n\n    // Second run\n    fn(a1, a2, a3);\n    fn(a1, a2, a3);\n\n    a2.push(22);\n\n    // Third run\n    fn(a1, a2, a3);\n    fn(a1, a2, a3);\n\n    a3.splice(0, 1);\n\n    // Fourth run\n    fn(a1, a2, a3);\n    fn(a1, a2, a3);\n\n    expect(callback.callCount).to.be.equal(1);\n  });\n\n  it('should memoize zero arguments functions', () => {\n    const {\n      callback,\n      fn,\n    } = makeMemoized();\n\n    fn();\n    fn();\n\n    expect(callback.callCount).to.be.equal(1);\n  });\n\n  it('should memoize variadic functions', () => {\n    const {\n      callback,\n      fn,\n    } = makeMemoized();\n\n    const o1: any = { one: 1 };\n    const o2: any = { two: 2 };\n    const o3: any = { three: 3 };\n\n    expect(fn(o1, o2, o3)).to.be.equal('Value: Args Length = 3', '3 arguments');\n    expect(fn(o1, o2, o3)).to.be.equal('Value: Args Length = 3', '3 arguments');\n\n    expect(fn(o1, o2)).to.be.equal('Value: Args Length = 2', '2 arguments');\n    expect(fn(o1, o2)).to.be.equal('Value: Args Length = 2', '2 arguments');\n\n    expect(fn(o1)).to.be.equal('Value: Args Length = 1', '1 arguments');\n    expect(fn(o1)).to.be.equal('Value: Args Length = 1', '1 arguments');\n\n    expect(callback.callCount).to.be.equal(3, '3 callbacks');\n  });\n\n  it('should memoize ES6 Maps', () => {\n    const {\n      callback,\n      fn,\n    } = makeMemoized();\n\n    const o1 = new Map([['one', 1]]);\n    const o2 = new Map([['two', 2]]);\n    const o3 = new Map([['three', 3]]);\n\n    // First run\n    fn(o1, o2, o3);\n    fn(o1, o2, o3);\n\n    o1.set('one', 11);\n\n    // Second run\n    fn(o1, o2, o3);\n    fn(o1, o2, o3);\n\n    o2.set('twoMore', 22);\n\n    // Third run\n    fn(o1, o2, o3);\n    fn(o1, o2, o3);\n\n    o3.delete('three');\n\n    // Fourth run\n    fn(o1, o2, o3);\n    fn(o1, o2, o3);\n\n    expect(callback.callCount).to.be.equal(4);\n  });\n\n  it('should memoize ES6 Sets', () => {\n    const {\n      callback,\n      fn,\n    } = makeMemoized();\n\n    const s1 = new Set([1]);\n    const s2 = new Set([2]);\n    const s3 = new Set([3]);\n\n    // First run\n    fn(s1, s2, s3);\n    fn(s1, s2, s3);\n\n    s1.add(11);\n\n    // Second run\n    fn(s1, s2, s3);\n    fn(s1, s2, s3);\n\n    s2.add(22);\n\n    // Third run\n    fn(s1, s2, s3);\n    fn(s1, s2, s3);\n\n    s3.delete(3);\n\n    // Fourth run\n    fn(s1, s2, s3);\n    fn(s1, s2, s3);\n\n    expect(callback.callCount).to.be.equal(4);\n  });\n\n  it('should memoize Immutable Records', () => {\n    const {\n      callback,\n      fn,\n    } = makeMemoized();\n\n    const Test = ImmutableRecord({ num: null });\n\n    let o1 = Test({ num: 1 });\n    let o2 = Test({ num: 2 });\n    let o3 = Test({ num: 3 });\n\n    // First run\n    fn(o1, o2, o3);\n    fn(o1, o2, o3);\n\n    o1 = o1.set('num', 11);\n\n    // Second run\n    fn(o1, o2, o3);\n    fn(o1, o2, o3);\n\n    o2 = o2.set('num', 22);\n\n    // Third run\n    fn(o1, o2, o3);\n    fn(o1, o2, o3);\n\n    o3 = o3.delete('num');\n\n    // Fourth run\n    fn(o1, o2, o3);\n    fn(o1, o2, o3);\n\n    expect(callback.callCount).to.be.equal(4);\n  });\n\n  it('should memoize Immutable Maps', () => {\n    const {\n      callback,\n      fn,\n    } = makeMemoized();\n\n    let o1 = ImmutableMap({ one: 1 });\n    let o2 = ImmutableMap({ two: 2 });\n    let o3 = ImmutableMap({ three: 3 });\n\n    // First run\n    fn(o1, o2, o3);\n    fn(o1, o2, o3);\n\n    o1 = o1.set('one', 11);\n\n    // Second run\n    fn(o1, o2, o3);\n    fn(o1, o2, o3);\n\n    o2 = o2.set('twoMore', 22);\n\n    // Third run\n    fn(o1, o2, o3);\n    fn(o1, o2, o3);\n\n    o3 = o3.delete('three');\n\n    // Fourth run\n    fn(o1, o2, o3);\n    fn(o1, o2, o3);\n\n    expect(callback.callCount).to.be.equal(4);\n  });\n\n  it('should memoize Immutable Sets', () => {\n    const {\n      callback,\n      fn,\n    } = makeMemoized();\n\n    let s1 = ImmutableSet([1]);\n    let s2 = ImmutableSet([2]);\n    let s3 = ImmutableSet([3]);\n\n    // First run\n    fn(s1, s2, s3);\n    fn(s1, s2, s3);\n\n    s1 = s1.add(11);\n\n    // Second run\n    fn(s1, s2, s3);\n    fn(s1, s2, s3);\n\n    s2 = s2.add(22);\n\n    // Third run\n    fn(s1, s2, s3);\n    fn(s1, s2, s3);\n\n    s3 = s3.delete(3);\n\n    // Fourth run\n    fn(s1, s2, s3);\n    fn(s1, s2, s3);\n\n    expect(callback.callCount).to.be.equal(4);\n  });\n\n  it('should memoize Immutable Lists', () => {\n    const {\n      callback,\n      fn,\n    } = makeMemoized();\n\n    let a1 = ImmutableList([1]);\n    let a2 = ImmutableList([2]);\n    let a3 = ImmutableList([3]);\n\n    // First run\n    fn(a1, a2, a3);\n    fn(a1, a2, a3);\n\n    a1 = a1.push(11);\n\n    // Second run\n    fn(a1, a2, a3);\n    fn(a1, a2, a3);\n\n    a2 = a2.push(22);\n\n    // Third run\n    fn(a1, a2, a3);\n    fn(a1, a2, a3);\n\n    a3 = a3.splice(0, 1) as ImmutableList<number>;\n\n    // Fourth run\n    fn(a1, a2, a3);\n    fn(a1, a2, a3);\n\n    expect(callback.callCount).to.be.equal(4);\n  });\n});\n"
  },
  {
    "path": "packages/frontend-web/src/app/util/partial/__spec__/partial.spec.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { expect } from 'chai';\nimport { partial } from '../index';\n\ndescribe('partial', () => {\n  it('should return the same function every time', () => {\n    function identity(param: any): any {\n      return param;\n    }\n\n    const callback1 = partial(identity, 'Test');\n    const callback2 = partial(identity, 'Test');\n\n    expect(callback1).to.equal(callback2);\n\n    const callback3 = partial(identity, 'Another Test');\n    const callback4 = partial(identity, 'Another Test');\n\n    expect(callback3).to.not.equal(callback1);\n    expect(callback3).to.equal(callback4);\n  });\n});\n"
  },
  {
    "path": "packages/frontend-web/src/app/util/partial/index.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport {\n  identity,\n  isArray,\n  isPlainObject,\n  isUndefined,\n  partial as lodashPartial,\n} from 'lodash';\n\nimport { Map as ImmutableMap } from 'immutable';\n\ninterface INode<T> {\n  children: ImmutableMap<any, INode<T>> | null;\n  value: T | undefined;\n}\n\nfunction makeNode<T>(): INode<T> {\n  const o = Object.create(null);\n\n  o.children = ImmutableMap<any, INode<T>>();\n  o.value = undefined;\n\n  return o;\n}\n\nconst mutableObjectCache = new Map<object | Array<any>, string>();\n\n// We sometimes want to use \"thing\" o as a hashtable key.  This function converts o into something\n// stringlike for this purpose\nfunction stringifyIfNecessary(o: any, useEqualityForMutableObjects: boolean): boolean | number | string {\n  if (\n    isArray(o) ||\n    isPlainObject(o) ||\n    o instanceof Map ||\n    o instanceof Set\n  ) {\n    if (useEqualityForMutableObjects) {\n      const stringKey = mutableObjectCache.get(o);\n\n      if (stringKey) {\n        return stringKey;\n      }\n\n      const nextStringKey = mutableObjectCache.size.toString();\n      mutableObjectCache.set(o, nextStringKey);\n\n      return nextStringKey;\n    } else {\n      if (\n        o instanceof Map ||\n        o instanceof Set\n      ) {\n        return JSON.stringify(Array.from(o));\n      } else {\n        return JSON.stringify(o);\n      }\n    }\n  }\n\n  return o;\n}\n\nclass Cache<T> {\n  root = makeNode<T>();\n  useEqualityForMutableObjects = false;\n\n  constructor(useEqualityForMutableObjects: boolean) {\n    this.useEqualityForMutableObjects = useEqualityForMutableObjects;\n  }\n\n  has(args: Array<any>): boolean {\n    return !isUndefined(this.get(args));\n  }\n\n  get(args: Array<any>): T | undefined {\n    let previousNode = this.root;\n\n    for (let i = 0; i < args.length; i++) {\n      const arg = args[i];\n      const key = stringifyIfNecessary(arg, this.useEqualityForMutableObjects);\n\n      // Found in tree, continue\n      if (previousNode.children.has(key)) {\n        const node = previousNode.children.get(key);\n        previousNode = node;\n      } else {\n        return undefined;\n      }\n    }\n\n    return previousNode.value;\n  }\n\n  set(args: Array<any>, value: T): void {\n    let previousNode = this.root;\n\n    for (let i = 0; i < args.length; i++) {\n      const arg = args[i];\n      const key = stringifyIfNecessary(arg, this.useEqualityForMutableObjects);\n\n      let node;\n\n      // Found in tree, continue\n      if (previousNode.children.has(key)) {\n        node = previousNode.children.get(key);\n      } else {\n        node = makeNode<T>();\n        previousNode.children = previousNode.children.set(key, node);\n      }\n\n      previousNode = node;\n    }\n\n    previousNode.value = value;\n  }\n\n  toJS(): object {\n    function serialize(data: any): any {\n      if (ImmutableMap.isMap(data)) {\n        const d = data.reduce((sum: any, v: any, k: any) => {\n          sum[k.toString()] = serialize(v);\n\n          return sum;\n        }, {} as {\n          [key: string]: any;\n        });\n\n        return d;\n      }\n\n      if (isArray(data)) {\n        return data.map(serialize);\n      }\n\n      if (isPlainObject(data)) {\n        return Object.keys(data).reduce((sum, k: string) => {\n          sum[k] = serialize(data[k]);\n\n          return sum;\n        }, {} as {\n          [key: string]: any;\n        });\n      }\n\n      if (data && data.toJS) {\n        return data.toJS();\n      }\n\n      return data;\n    }\n\n    return serialize(this.root);\n  }\n\n  toString() {\n    return JSON.stringify(this.toJS(), undefined, 2);\n  }\n}\n\nexport function memoize<T extends Function>(fn: T, useEqualityForMutableObjects = false): T {\n  const cache = new Cache<Function>(useEqualityForMutableObjects);\n\n  function memoized(...args: Array<any>) {\n    if (cache.has(args)) {\n      return cache.get(args);\n    }\n\n    const result = fn(...args);\n\n    cache.set(args, result);\n\n    return result;\n  }\n\n  return memoized as any;\n}\n\nexport const partial: typeof lodashPartial = memoize(lodashPartial);\n\nexport function maybeCallback<T>(fn?: T | null) {\n  return fn || identity;\n}\n\nexport { identity };\n"
  },
  {
    "path": "packages/frontend-web/src/app/util/returnSavedCommentRow.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nconst RETURN_ROW_PATH = 'moderator/return-comment-row';\n\nexport function getReturnSavedCommentRow(): string {\n  const commentId = sessionStorage[RETURN_ROW_PATH];\n\n  if (commentId) {\n    return commentId;\n  }\n}\n\nexport function setReturnSavedCommentRow(commentId: string): void {\n  sessionStorage[RETURN_ROW_PATH] = commentId;\n}\n\nexport function clearReturnSavedCommentRow(): void {\n  delete sessionStorage[RETURN_ROW_PATH];\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/util/returnURL.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nconst RETURN_URL_PATH = 'moderator/return-url';\n\nexport interface IReturnURL {\n  pathname: string;\n  search: string;\n}\n\nexport function getReturnURL(): IReturnURL {\n  const data = sessionStorage[RETURN_URL_PATH];\n\n  if (data) {\n    return JSON.parse(data);\n  }\n}\n\nexport function setReturnURL(data: IReturnURL): void {\n  sessionStorage[RETURN_URL_PATH] = JSON.stringify(data);\n}\n\nexport function clearReturnURL(): void {\n  delete sessionStorage[RETURN_URL_PATH];\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/util/savedSorts.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\nimport { ModelId } from '../../models';\nimport { getStoreItem, saveStoreItem } from '../platform/localStore';\n\nconst LOCAL_STORAGE_KEY = 'comment-sorts';\n\nfunction loadSorts(): Map<string, string> {\n  const raw = getStoreItem(LOCAL_STORAGE_KEY, 1);\n  if (!raw) {\n    return new Map();\n  }\n  return new Map(JSON.parse(raw));\n}\n\nconst defaultSorts = loadSorts();\n\nexport function getDefaultSort(categoryId: ModelId, page: string, tag: string) {\n  const key = `${categoryId}:${page}:${tag}`;\n  let sort = defaultSorts.get(key);\n  if (!sort) {\n    if (page === 'new') {\n      if (tag === 'DATE') {\n        sort = 'newest';\n      }\n      else {\n        sort = 'highest';\n      }\n    }\n    else {\n      if (tag === 'flagged') {\n        sort = 'flagged';\n      }\n      else {\n        sort = 'updated';\n      }\n    }\n  }\n  return sort;\n}\n\nexport function putDefaultSort(categoryId: ModelId, page: string, tag: string, sort: string) {\n  const key = `${categoryId}:${page}:${tag}`;\n  defaultSorts.set(key, sort);\n  saveStoreItem(LOCAL_STORAGE_KEY, 1, JSON.stringify([...defaultSorts]));\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/util/sortByLabel.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { List } from 'immutable';\nimport { ICategoryModel, ITagModel } from '../../models';\n\nexport function sortByLabel(list: List<ICategoryModel | ITagModel>): List<ICategoryModel | ITagModel> {\n  return list.sort((a, b) => a.label.localeCompare(b.label)) as List<ICategoryModel | ITagModel>;\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/util/time.ts",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nfunction maybeS(val: number) {\n  return val > 1 ? 's' : '';\n}\n\nexport function getTimestring(timestamp: string, inFuture: boolean) {\n  const then = new Date(timestamp);\n  const now = Date.now();\n\n  let diff = Math.round((now - then.getTime()) / 1000);\n\n  if (inFuture) {\n    diff = -diff;\n  }\n\n  let text;\n  let redrawIn = 0;\n  let isRelative = true;\n\n  if (diff < 0 || diff > 60 * 60 * 24 * 7) {\n    // Just use the date\n    const monthNames = [\n      'Jan', 'Feb', 'March',\n      'April', 'May', 'June', 'July',\n      'Aug', 'Sept', 'Oct',\n      'Nov', 'Dec',\n    ];\n\n    const day = then.getDate();\n    const monthIndex = then.getMonth();\n    const year = then.getFullYear();\n\n    text = `${monthNames[monthIndex]} ${day}`;\n    if (year !== (new Date(now)).getFullYear()) {\n      text += `, ${year}`;\n    }\n    isRelative = false;\n  }\n  else if (diff < 60) {\n    text = `a few seconds`;\n    redrawIn = 60 - diff;\n  }\n  else if (diff < 60 * 60) {\n    const mins = Math.floor(diff / 60);\n    if (inFuture) {\n      redrawIn = diff - mins * 60 + 1;\n    }\n    else {\n      redrawIn = (mins + 1) * 60 - diff + 1;\n    }\n    text = `${mins} minute${maybeS(mins)}`;\n  }\n  else if (diff < 60 * 60 * 24) {\n    const hours = Math.floor(diff / 60 / 60);\n    if (inFuture) {\n      redrawIn = diff - hours * 60 * 60 + 1;\n    }\n    else {\n      redrawIn = (hours + 1) * 60 * 60 - diff + 1;\n    }\n    text = `${hours} hour${maybeS(hours)}`;\n  }\n  else if (diff < 60 * 60 * 24 * 7) {\n    const days = Math.floor(diff / 60 / 60 / 24);\n    if (inFuture) {\n      redrawIn = diff - days * 60 * 60 + 1;\n    }\n    else {\n      redrawIn = (days + 1) * 60 * 60 - diff + 1;\n    }\n    text = `${days} day${maybeS(days)}`;\n  }\n\n  return {text, redrawIn, isRelative};\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/util/timeout.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nexport function timeout(delay: number): Promise<void> {\n  return new Promise<void>((resolve) => setTimeout(resolve, delay));\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/utilx/cssInJs.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport {\n  css as stylesheetToClassNames,\n  StyleSheet,\n} from 'aphrodite';\nimport { Map } from 'immutable';\nimport { CSSProperties } from 'react';\n\nimport { memoize } from '../util/partial';\n\nexport interface IStyleProps {\n  className?: string;\n  style?: CSSProperties;\n}\n\nlet knownStylesheets = Map();\n\nexport function stylesheet<T>(styles: T): T {\n  const sheet = StyleSheet.create(styles as any);\n\n  for (const key in sheet) {\n    if (sheet.hasOwnProperty(key)) {\n      const originalObject = (styles as any)[key];\n      knownStylesheets = knownStylesheets.set(originalObject, (sheet as any)[key]);\n    }\n  }\n\n  return styles;\n}\n\nfunction knownStyle(obj: object) {\n  return knownStylesheets.get(obj);\n}\n\nexport interface IStyle { [key: string]: IStyle | string | number; }\nexport type IPossibleStyle = IStyle | null | undefined;\n\nfunction flattenStyleReducer(sum: object, style: object): object {\n  if (!style) { return sum; }\n\n  return { ...sum, ...style };\n}\n\nfunction flattenStyles(styles: Array<object>): object {\n  return styles.reduce(flattenStyleReducer, {});\n}\n\nexport function originalCSS(...styles: Array<IPossibleStyle>): IStyleProps {\n  const fromStylesheet: Array<object> = styles.filter((style: IPossibleStyle) => style && knownStyle(style));\n  const notFromStylesheet = styles.filter((style: IPossibleStyle) => style && !knownStyle(style));\n\n  const classNames = stylesheetToClassNames(fromStylesheet.map(knownStyle));\n  const output: IStyleProps = {};\n\n  if (notFromStylesheet.length > 0) {\n    output.style = flattenStyles(notFromStylesheet);\n  }\n\n  if (classNames.length > 0) {\n    output.className = classNames;\n  }\n\n  return output;\n}\n\nexport const css = memoize(originalCSS, true);\n"
  },
  {
    "path": "packages/frontend-web/src/app/utilx/highlightText.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport {\n  HEADLINE_TYPE,\n} from '../styles';\nimport { css } from './cssInJs';\n\nexport interface ITextNode {\n  start: number;\n  end: number;\n  targetString: string;\n}\n\nexport function getTextNodes(searchStr: string, str: string) {\n  let startIndex = 0;\n  const searchStrLen = searchStr.length;\n  let index = 0;\n  const nodes: Array<ITextNode> = [];\n  str = str.toLowerCase();\n  searchStr = searchStr.toLowerCase();\n\n  do {\n    index = str.indexOf(searchStr, startIndex);\n    nodes.push({\n      start: index,\n      end: index + searchStrLen,\n      targetString: searchStr,\n    });\n    startIndex = index + searchStrLen;\n  } while (str.indexOf(searchStr, startIndex) > -1);\n\n  return nodes;\n}\n\nfunction addRange(arr: Array<JSX.Element>, originalString: string, start: number, end: number, shouldHighlight?: boolean) {\n  const str = originalString.slice(start, end);\n\n  if (str.length > 0) {\n    if (shouldHighlight) {\n      arr.push(<span {...css(HEADLINE_TYPE, { fontSize: 15 })}>{str}</span>);\n    } else {\n      arr.push(<span>{str}</span>);\n    }\n  }\n\n  return arr;\n}\n\nexport function highlightText(searchTerm: string, originalString: string) {\n  const textNodes = getTextNodes(searchTerm, originalString);\n  const sortedNodes = textNodes.sort((a, b) => a.start - b.start);\n\n  let currentIndex = 0;\n\n  let output: Array<JSX.Element> = [];\n\n  sortedNodes.forEach((n) => {\n    output = addRange(output, originalString, currentIndex, n.start);\n    output = addRange(output, originalString, n.start, n.end, true);\n    currentIndex = n.end;\n  });\n\n  output = addRange(output, originalString, currentIndex, originalString.length);\n\n  return <div>{output}</div>;\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/utilx/hooks.ts",
    "content": "/*\nCopyright 2020 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport keyboardJS from 'keyboardjs';\nimport {useEffect} from 'react';\n\nexport function useBindEscape(action: () => void) {\n  useEffect(() => {\n    keyboardJS.bind('escape', action);\n    return () => {\n      keyboardJS.unbind('escape', action);\n    };\n  }, []);\n}\n"
  },
  {
    "path": "packages/frontend-web/src/app/utilx/index.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nexport * from './cssInJs';\nexport * from './sortDefinitions';\nexport * from './highlightText';\nexport * from './keyCodes';\nexport * from './hooks';\n"
  },
  {
    "path": "packages/frontend-web/src/app/utilx/keyCodes.tsx",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nexport const ESCAPE_KEY = 27;\nexport const LEFT_ARROW_KEY = 37;\nexport const UP_ARROW_KEY = 38;\nexport const RIGHT_ARROW_KEY = 39;\nexport const DOWN_ARROW_KEY = 40;\n"
  },
  {
    "path": "packages/frontend-web/src/app/utilx/sortDefinitions.tsx",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nexport interface IColumnSortDefinition {\n  label: string;\n  sortInfo: Array<string>;\n  sortName?: string;\n}\n\nexport const commentSortDefinitions: {\n  [key: string]: IColumnSortDefinition;\n}  = {\n  approved: {\n    label: 'Approved',\n    sortInfo: ['-updatedAt'],\n    sortName: 'updatedAt',\n  },\n  highlighted: {\n    label: 'Highlighted Count',\n    sortInfo: ['-updatedAt'],\n    sortName: 'updatedAt',\n  },\n  rejected: {\n    label: 'Rejected Count',\n    sortInfo: ['-updatedAt'],\n    sortName: 'updatedAt',\n  },\n  deferred: {\n    label: 'Deferred Count',\n    sortInfo: ['-updatedAt'],\n    sortName: 'updatedAt',\n  },\n  flagged: {\n    label: 'Flagged Count',\n    sortInfo: ['-unresolvedFlagsCount'],\n    sortName: 'unresolvedFlagsCount',\n  },\n  batched: {\n    label: 'Batched',\n    sortInfo: ['-updatedAt'],\n    sortName: 'updatedAt',\n  },\n  automated: {\n    label: 'Automated',\n    sortInfo: ['-updatedAt'],\n    sortName: 'updatedAt',\n  },\n  updated: {\n    label: 'Last Updated',\n    sortInfo: ['-updatedAt'],\n    sortName: 'updatedAt',\n  },\n  oldest: {\n    label: 'Oldest',\n    sortInfo: ['sourceCreatedAt'],\n    sortName: 'sourceCreatedAt',\n  },\n  newest: {\n    label: 'Newest',\n    sortInfo: ['-sourceCreatedAt'],\n    sortName: 'sourceCreatedAt',\n  },\n  highest: {\n    label: 'Highest',\n    sortInfo: ['-score'],\n    sortName: 'highest',\n  },\n  lowest: {\n    label: 'Lowest',\n    sortInfo: ['score'],\n    sortName: 'lowest',\n  },\n  tag: {\n    label: 'Tag',\n    sortInfo: ['-score'],\n    sortName: 'score',\n  },\n};\n"
  },
  {
    "path": "packages/frontend-web/src/index.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nexport * from './server';\nexport * from './app/util/groupByColumn';\nexport * from './app/util/DotChartRenderer';\nexport * from './app/util/measureText';\nexport * from './app/util/color';\n"
  },
  {
    "path": "packages/frontend-web/src/models/article.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { ModelId } from './common';\n\nexport interface IArticleAttributes {\n  id: ModelId;\n  sourceCreatedAt: string;\n  updatedAt: string;\n  title: string;\n  text: string;\n  url?: string;\n  categoryId: ModelId;\n  allCount: number;\n  unprocessedCount: number;\n  unmoderatedCount: number;\n  moderatedCount: number;\n  deferredCount: number;\n  approvedCount: number;\n  highlightedCount: number;\n  rejectedCount: number;\n  flaggedCount: number;\n  batchedCount: number;\n  automatedCount: number;\n  lastModeratedAt: string;\n  assignedModerators: Array<ModelId>;\n  isCommentingEnabled: boolean;\n  isAutoModerated: boolean;\n}\n\nexport type IArticleModel = Readonly<IArticleAttributes>;\n\nexport function ArticleModel(articleData: IArticleAttributes): IArticleModel {\n  // Sanitize URLs for security.\n  if (articleData.url) {\n    const url = articleData.url;\n    if (!url.startsWith('http://') && !url.startsWith('https://')) {\n      delete articleData.url;\n    }\n  }\n  return articleData as IArticleModel;\n}\n"
  },
  {
    "path": "packages/frontend-web/src/models/category.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { ModelId } from './common';\n\nexport interface ICategoryAttributes {\n  id: ModelId;\n  label: string;\n  ownerId?: ModelId;\n  sourceId?: string;\n  isActive: boolean;\n  updatedAt: string;\n  allCount: number;\n  unprocessedCount: number;\n  unmoderatedCount: number;\n  moderatedCount: number;\n  deferredCount: number;\n  approvedCount: number;\n  highlightedCount: number;\n  rejectedCount: number;\n  flaggedCount: number;\n  batchedCount: number;\n  assignedModerators: Array<ModelId>;\n}\n\nexport type ICategoryModel = Readonly<ICategoryAttributes>;\n\nexport function CategoryModel(categoryData?: Partial<ICategoryAttributes>): ICategoryModel {\n  return categoryData as ICategoryModel;\n}\n"
  },
  {
    "path": "packages/frontend-web/src/models/comment.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { ModelId } from './common';\n\nexport type ICommentListItem = Readonly<{\n  commentId: ModelId;\n}>;\n\nexport type ICommentDate = Readonly<{\n  date: Date;\n}> & ICommentListItem;\n\nexport type ICommentScore = Readonly<{\n  score: number;\n}> & ICommentListItem;\n\nexport interface IAuthorAttributes {\n  email: string;\n  location: string;\n  avatar: string;\n  name: string;\n  approvalRating?: string;\n  isSubscriber: boolean;\n}\n\nexport type IAuthorModel = Readonly<IAuthorAttributes>;\n\nexport interface IAuthorCountsAttributes {\n  approvedCount: number;\n  rejectedCount: number;\n}\n\nexport type IAuthorCountsModel = Readonly<IAuthorCountsAttributes>;\n\nexport interface ITopScore {\n  score: number;\n  start: number;\n  end: number;\n}\n\nexport interface ICommentSummaryScoreAttributes {\n  tagId: ModelId;\n  score: number;\n  topScore: ITopScore;\n}\n\nexport type ICommentSummaryScoreModel = Readonly<ICommentSummaryScoreAttributes>;\n\nexport const FLAGS_COUNT = 0;\nexport const UNRESOLVED_FLAGS_COUNT = 1;\nexport const RECOMMENDATIONS_COUNT = 2;\n\nexport interface ICommentAttributes {\n  id: ModelId;\n  sourceId: string;\n  authorSourceId?: string;\n  replyToSourceId?: string;\n  author: IAuthorModel;\n  text: string;\n\n  isScored: boolean;\n  isModerated: boolean;\n  isAccepted?: boolean;\n  isDeferred: boolean;\n  isHighlighted: boolean;\n  isBatchResolved: boolean;\n  isAutoResolved: boolean;\n\n  sourceCreatedAt: string;\n  updatedAt: string;\n  sentForScoring: string;\n\n  unresolvedFlagsCount: number;\n  flagsSummary?: Map<string, Array<number>>;\n\n  categoryId?: ModelId;\n  articleId: ModelId;\n  replyId?: ModelId;\n  replies?: Array<ModelId>;\n\n  summaryScores?: Array<ICommentSummaryScoreModel>;\n  maxSummaryScore?: number;\n  maxSummaryScoreTagId?: ModelId;\n}\n\nexport type ICommentModel = Readonly<ICommentAttributes>;\n\nexport function CommentModel(commentData: ICommentAttributes): ICommentModel {\n  const author: any = commentData.author;\n\n  if (author) {\n    if (author.user_name) {\n      author.name = author.user_name;\n    }\n\n    if (author.image_uri) {\n      author.avatar = author.image_uri;\n    }\n  }\n\n  const fsd = commentData.flagsSummary;\n  const flagsSummary = fsd ? new Map(Object.entries(fsd)) : new Map();\n\n  return {...commentData, author, flagsSummary} as ICommentModel;\n}\n\nexport function getTopScore(comment: ICommentModel) {\n  return getTopScoreForTag(comment, comment.maxSummaryScoreTagId);\n}\n\nexport function getSummaryForTag(comment: ICommentModel, tagId: ModelId): ICommentSummaryScoreModel | undefined {\n  if (!comment.summaryScores) {\n    return undefined;\n  }\n  return comment.summaryScores.find((s) => s.tagId === tagId);\n}\n\nexport function getTopScoreForTag(comment: ICommentModel, tagId?: ModelId) {\n  if (!comment.summaryScores || !tagId) {\n    return null;\n  }\n  for (const summary of comment.summaryScores) {\n    if (summary.tagId === tagId) {\n      return summary.topScore;\n    }\n  }\n  return null;\n}\n"
  },
  {
    "path": "packages/frontend-web/src/models/commentFlag.ts",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nexport interface ICommentFlagAttributes {\n  id: string;\n  label: string;\n  detail?: string;\n  isRecommendation: boolean;\n  commentId: string;\n  sourceId?: string;\n  authorSourceId?: string;\n  isResolved: boolean;\n  resolvedById?: string;\n  resolvedAt?: string;\n}\n\nexport type ICommentFlagModel = Readonly<ICommentFlagAttributes>;\n\nexport function CommentFlagModel(flagData?: ICommentFlagAttributes): ICommentFlagModel {\n  return flagData as ICommentFlagModel;\n}\n"
  },
  {
    "path": "packages/frontend-web/src/models/commentScore.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport {ModelId} from './common';\n\nexport interface ICommentScoreAttributes {\n  id: ModelId;\n  commentId: ModelId;\n  confirmedUserId?: ModelId;\n  tagId?: ModelId;\n  score: number;\n  annotationStart?: number;\n  annotationEnd?: number;\n  sourceType?: string;\n  isConfirmed: boolean;\n}\n\nexport type ICommentScoreModel = Readonly<ICommentScoreAttributes>;\n\nexport function CommentScoreModel(scoreData?: ICommentScoreAttributes): ICommentScoreModel {\n  return scoreData as ICommentScoreModel;\n}\n"
  },
  {
    "path": "packages/frontend-web/src/models/common.ts",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { IModerationAction } from '../types';\n\nexport type ModelId = string;\n\nexport const SERVER_ACTION_ACCEPT = 'Accept';\nexport const SERVER_ACTION_REJECT = 'Reject';\nexport const SERVER_ACTION_DEFER = 'Defer';\nexport const SERVER_ACTION_HIGHLIGHT = 'Highlight';\n\nexport type IServerAction = 'Accept' | 'Reject' | 'Defer' | 'Highlight';\n\nexport function convertServerAction(saction: IServerAction): IModerationAction {\n  switch (saction) {\n    case SERVER_ACTION_ACCEPT:\n      return 'approve';\n    case SERVER_ACTION_REJECT:\n      return 'reject';\n    case SERVER_ACTION_DEFER:\n      return 'defer';\n    case SERVER_ACTION_HIGHLIGHT:\n      return 'highlight';\n  }\n}\n\nexport function convertClientAction(action: IModerationAction): IServerAction {\n  switch (action) {\n    case 'approve':\n      return SERVER_ACTION_ACCEPT;\n    case 'reject':\n      return SERVER_ACTION_REJECT;\n    case 'defer':\n      return SERVER_ACTION_DEFER;\n    case 'highlight':\n      return SERVER_ACTION_HIGHLIGHT;\n  }\n}\n"
  },
  {
    "path": "packages/frontend-web/src/models/fake/article.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport faker from 'faker';\nimport { ArticleModel, IArticleAttributes, IArticleModel } from '../article';\nimport { fakeCategoryModel } from './category';\n\nexport function fakeArticleModel(overrides: Partial<IArticleAttributes> = {}): IArticleModel {\n  return ArticleModel({\n    id: faker.random.number().toString(),\n    sourceCreatedAt: faker.date.recent().toString(),\n    title: faker.lorem.sentence(),\n    text: faker.lorem.paragraph(),\n    url: faker.internet.url(),\n    category: fakeCategoryModel(),\n    allCount: 0,\n    unprocessedCount: 0,\n    unmoderatedCount: 0,\n    moderatedCount: 0,\n    highlightedCount: faker.random.number(),\n    approvedCount: faker.random.number(),\n    rejectedCount: faker.random.number(),\n    deferredCount: faker.random.number(),\n    flaggedCount: faker.random.number(),\n    batchedCount: faker.random.number(),\n    lastModeratedAt: faker.date.recent().toString(),\n    isAutoModerated: faker.random.boolean(),\n    isCommentingEnabled: faker.random.boolean(),\n    ...overrides,\n  } as IArticleAttributes);\n}\n"
  },
  {
    "path": "packages/frontend-web/src/models/fake/category.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport faker from 'faker';\nimport { CategoryModel, ICategoryAttributes, ICategoryModel } from '../category';\n\nexport function fakeCategoryModel(overrides: Partial<ICategoryAttributes> = {}): ICategoryModel {\n  const label = (overrides && overrides['label']) || faker.lorem.words(2);\n\n  return CategoryModel({\n    id: faker.random.number().toString(),\n    label,\n    ...overrides,\n  } as ICategoryAttributes);\n}\n"
  },
  {
    "path": "packages/frontend-web/src/models/fake/comment.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport faker from 'faker';\nimport { CommentModel, IAuthorModel, ICommentAttributes, ICommentModel } from '../comment';\nimport { CommentFlagModel, ICommentFlagAttributes, ICommentFlagModel } from '../commentFlag';\n\nexport function fakeCommentModel(overrides: Partial<ICommentAttributes> = {}): ICommentModel {\n  const author = {\n    email: faker.internet.email(),\n    location: faker.address.city(),\n    name: faker.name.findName(),\n    avatar: faker.internet.avatar(),\n  } as IAuthorModel;\n\n  return CommentModel({\n    id: faker.random.number().toString(),\n    sourceId: faker.random.number().toString(),\n    replyToSourceId: faker.random.number().toString(),\n    replyId: faker.random.number().toString(),\n    authorSourceId: faker.random.number().toString(),\n    text: faker.lorem.paragraphs(3),\n    author,\n    isScored: faker.random.boolean(),\n    isModerated: faker.random.boolean(),\n    isAccepted: faker.random.boolean(),\n    isDeferred: faker.random.boolean(),\n    isHighlighted: faker.random.boolean(),\n    isBatchResolved: faker.random.boolean(),\n    isAutoResolved: faker.random.boolean(),\n    unresolvedFlagsCount: faker.random.number(),\n    sourceCreatedAt: faker.date.recent().toISOString(),\n    sentForScoring: faker.date.recent().toISOString(),\n    articleId: undefined,\n    updatedAt: faker.date.recent().toISOString(),\n    ...overrides,\n  });\n}\n\nexport function fakeCommentFlagModel(overrides: Partial<ICommentFlagAttributes> = {}): ICommentFlagModel {\n  return CommentFlagModel({\n    id: faker.random.number().toString(),\n    commentId: '1',\n    label: faker.lorem.words(3),\n    detail: faker.lorem.paragraph(1),\n    isRecommendation: faker.random.boolean(),\n    isResolved: faker.random.boolean(),\n    resolvedAt: faker.date.recent(10).toISOString(),\n    ...overrides,\n  });\n}\n"
  },
  {
    "path": "packages/frontend-web/src/models/fake/commentScore.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport faker from 'faker';\nimport { CommentScoreModel, ICommentScoreAttributes, ICommentScoreModel } from '../commentScore';\n\nexport function fakeCommentScoreModel(overrides: Partial<ICommentScoreAttributes> = {}): ICommentScoreModel {\n  return CommentScoreModel({\n    id: faker.random.number().toString(),\n    score: faker.random.number({min: 0, max: 1, precision: 0.01}),\n    ...overrides,\n  } as ICommentScoreAttributes);\n}\n"
  },
  {
    "path": "packages/frontend-web/src/models/fake/index.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nexport * from './article';\nexport * from './category';\nexport * from './comment';\nexport * from './commentScore';\nexport * from './rule';\nexport * from './tag';\nexport * from './user';\n"
  },
  {
    "path": "packages/frontend-web/src/models/fake/rule.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport faker from 'faker';\n\nimport {\n  SERVER_ACTION_ACCEPT,\n  SERVER_ACTION_DEFER,\n  SERVER_ACTION_HIGHLIGHT,\n  SERVER_ACTION_REJECT,\n} from '../common';\nimport {\n  IRuleAttributes,\n  IRuleModel,\n  RuleModel,\n} from '../rule';\n\nexport function fakeRuleModel(overrides: Partial<IRuleAttributes> = {}): IRuleModel {\n  return RuleModel({\n    id: faker.random.number().toString(),\n    action: faker.random.arrayElement([\n      SERVER_ACTION_ACCEPT,\n      SERVER_ACTION_REJECT,\n      SERVER_ACTION_DEFER,\n      SERVER_ACTION_HIGHLIGHT,\n    ]),\n    lowerThreshold: faker.random.number({ min: 0, max: 1, precision: 0.01 }),\n    upperThreshold: faker.random.number({ min: 0, max: 1, precision: 0.01 }),\n    ...overrides,\n  } as IRuleAttributes);\n}\n"
  },
  {
    "path": "packages/frontend-web/src/models/fake/tag.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport faker from 'faker';\nimport { ITagAttributes, ITagModel, TagModel } from '../tag';\n\nexport function fakeTagModel(overrides: Partial<ITagAttributes> = {}): ITagModel {\n  const label = faker.lorem.words(2);\n  const key = label.replace(/\\s/, '_').toUpperCase();\n\n  return TagModel({\n    id: faker.random.number().toString(),\n    color: faker.commerce.color(),\n    description: faker.lorem.paragraphs(1),\n    key,\n    label,\n    ...overrides,\n  } as ITagAttributes);\n}\n"
  },
  {
    "path": "packages/frontend-web/src/models/fake/user.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport faker from 'faker';\nimport { IUserAttributes, IUserModel, UserModel } from '../user';\n\nexport function fakeUserModel(overrides: Partial<IUserAttributes> = {}): IUserModel {\n  const name = (overrides && overrides['name']) || faker.name.findName();\n\n  return UserModel({\n    id: faker.random.number().toString(),\n    name,\n    email: faker.internet.email(),\n    avatarURL: faker.internet.avatar(),\n    group: 'general',\n    isActive: true,\n    ...overrides,\n  });\n}\n"
  },
  {
    "path": "packages/frontend-web/src/models/index.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nexport * from './article';\nexport * from './category';\nexport * from './comment';\nexport * from './commentFlag';\nexport * from './commentScore';\nexport * from './common';\nexport * from './preselect';\nexport * from './rule';\nexport * from './tag';\nexport * from './taggingSensitivity';\nexport * from './user';\n"
  },
  {
    "path": "packages/frontend-web/src/models/preselect.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { ModelId } from './common';\n\nexport interface IPreselectAttributes {\n  id: ModelId;\n  categoryId?: ModelId;\n  lowerThreshold: number;\n  upperThreshold: number;\n  tagId?: ModelId;\n}\n\nexport type IPreselectModel = Readonly<IPreselectAttributes>;\n\nexport function PreselectModel(keyValuePairs: IPreselectAttributes): IPreselectModel {\n  return keyValuePairs as IPreselectModel;\n}\n"
  },
  {
    "path": "packages/frontend-web/src/models/rule.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { IServerAction, ModelId } from './common';\n\nexport interface IRuleAttributes {\n  id: ModelId;\n  action: IServerAction | null;\n  categoryId?: ModelId;\n  createdBy: string | null;\n  lowerThreshold: number;\n  upperThreshold: number;\n  tagId?: ModelId;\n}\n\nexport type IRuleModel = Readonly<IRuleAttributes>;\n\nexport function RuleModel(keyValuePairs: IRuleAttributes): IRuleModel {\n  return keyValuePairs as IRuleModel;\n}\n"
  },
  {
    "path": "packages/frontend-web/src/models/tag.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport {ModelId} from './common';\n\nexport interface ITagAttributes {\n  id: ModelId;\n  color: string;\n  description: string;\n  key: string;\n  label: string;\n  isInBatchView: boolean;\n  inSummaryScore: boolean;\n  isTaggable: boolean;\n}\n\nexport type ITagModel = Readonly<ITagAttributes>;\n\nexport function TagModel(keyValuePairs: Partial<ITagAttributes>): ITagModel {\n  return keyValuePairs as ITagModel;\n}\n"
  },
  {
    "path": "packages/frontend-web/src/models/taggingSensitivity.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { ModelId } from './common';\n\nexport interface ITaggingSensitivityAttributes {\n  id: ModelId;\n  categoryId?: ModelId;\n  lowerThreshold: number;\n  upperThreshold: number;\n  tagId?: ModelId;\n}\n\nexport type ITaggingSensitivityModel = Readonly<ITaggingSensitivityAttributes>;\n\nexport function TaggingSensitivityModel(keyValuePairs: ITaggingSensitivityAttributes): ITaggingSensitivityModel {\n  return keyValuePairs as ITaggingSensitivityModel;\n}\n"
  },
  {
    "path": "packages/frontend-web/src/models/user.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { ModelId } from './common';\n\nexport interface IUserAttributes {\n  id?: ModelId;\n  name: string;\n  email?: string;\n  avatarURL?: string;\n  group: string;\n  isActive: boolean;\n  extra?: any;\n}\n\nexport type IUserModel = Readonly<IUserAttributes>;\n\nexport function UserModel(userAttributes: IUserAttributes): IUserModel {\n  return userAttributes as IUserModel;\n}\n"
  },
  {
    "path": "packages/frontend-web/src/server.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as express from 'express';\nimport { Application, Request, Response } from 'express';\nimport {readFileSync} from 'fs';\n\nexport function mountWebFrontend(modifyOutput?: (output: string) => string): Application {\n  const app = express();\n\n  app.disable('etag');\n  app.set('trust proxy', true);\n\n  const files = __dirname + '/../public';\n  const css = files + '/css';\n  const images = files + '/images';\n\n  const builds = __dirname + '/../build';\n  const js = builds + '/js';\n\n  function renderRoot(_req: Request, res: Response): void {\n    const html = readFileSync(files + '/index.html', 'utf8');\n\n    let path = '';\n\n    if (process.env.API_URL) {\n      path = process.env.API_URL;\n    }\n\n    let name = '';\n\n    if (process.env.APP_NAME) {\n      name = process.env.APP_NAME;\n    }\n\n    let reasonToReject = 'true';\n\n    if (process.env.REQUIRE_REASON_TO_REJECT) {\n      reasonToReject = (process.env.REQUIRE_REASON_TO_REJECT);\n    }\n\n    let restrictToSession = 'true';\n\n    if (process.env.RESTRICT_TO_SESSION) {\n      restrictToSession = process.env.RESTRICT_TO_SESSION;\n    }\n\n    let moderatorGuidelinesUrl = '';\n\n    if (process.env.MODERATOR_GUIDELINES_URL) {\n      moderatorGuidelinesUrl = process.env.MODERATOR_GUIDELINES_URL;\n    }\n\n    let submitFeedbackUrl = '';\n\n    if (process.env.SUBMIT_FEEDBACK_URL) {\n      submitFeedbackUrl = process.env.SUBMIT_FEEDBACK_URL;\n    }\n\n    let commentsEditableFlag = 'true';\n    if (process.env.COMMENTS_EDITABLE_FLAG) {\n      commentsEditableFlag = process.env.COMMENTS_EDITABLE_FLAG;\n    }\n\n    let output = html\n        .replace('{{API_URL}}', path)\n        .replace('{{APP_NAME}}', name)\n        .replace('{{REQUIRE_REASON_TO_REJECT}}', reasonToReject)\n        .replace('{{RESTRICT_TO_SESSION}}', restrictToSession)\n        .replace('{{MODERATOR_GUIDELINES_URL}}', moderatorGuidelinesUrl)\n        .replace('{{SUBMIT_FEEDBACK_URL}}', submitFeedbackUrl)\n        .replace('{{COMMENTS_EDITABLE_FLAG}}', commentsEditableFlag);\n\n    if (modifyOutput) {\n      output = modifyOutput(output);\n    }\n\n    res.send(output);\n  }\n\n  app.use('/css', express.static(css));\n  app.use('/images', express.static(images));\n  app.use('/js', express.static(js));\n  app.get('/*', renderRoot);\n\n  return app;\n}\n"
  },
  {
    "path": "packages/frontend-web/src/test/actions.ts",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport {\n  approveCommentsRequest,\n  approveFlagsAndCommentsRequest,\n  rejectCommentsRequest,\n  rejectFlagsAndCommentsRequest,\n  updateArticle,\n  updateArticleModerators,\n} from '../app/platform/dataService';\nimport { ModelId } from '../models';\nimport { articleData } from './notificationChecks';\n\nexport async function listenForMessages(\n  action: () => Promise<void>,\n  resultCheck: (type: string, message: any) => void,\n): Promise<void> {\n  let id: NodeJS.Timer;\n\n  const timeout = new Promise<void>((resolve) => {\n    id = setTimeout(() => {\n      console.log('Timed out while waiting for notification');\n      resolve();\n    }, 1000);\n  });\n\n  const readyPromise = new Promise<void>((resolve) => {\n    articleData.updateHappened = (type: string , message: any) => {\n      resultCheck(type, message);\n      resolve();\n    };\n  });\n\n  action();\n\n  await Promise.race([\n    timeout,\n    readyPromise,\n  ]);\n\n  clearTimeout(id!);\n  delete articleData.updateHappened;\n}\n\nfunction checkTypeIsUpdate(type: string) {\n  if (type !== 'article-update') {\n    console.log(`ERROR: returned message not an article update: ${type}`);\n  }\n}\n\nfunction checkExpectations(\n  message: any,\n  categoryExpectations: {[key: string]: number},\n  articleExpectations: {[key: string]: number},\n) {\n  for (const k of Object.keys(categoryExpectations)) {\n    if (message.categories[0][k] !== categoryExpectations[k]) {\n      console.log(`ERROR: category ${k} not updated correctly: ${message.categories[0][k]} should be ${categoryExpectations[k]}`);\n    }\n  }\n  for (const k of Object.keys(articleExpectations)) {\n    if (message.articles[0][k] !== articleExpectations[k]) {\n      console.log(`ERROR: article ${k} not updated correctly: ${message.articles[0][k]} should be ${articleExpectations[k]}`);\n    }\n  }\n}\n\nexport async function approveComment(\n  commentId: ModelId,\n  resolveFlags: boolean,\n  categoryExpectations: {[key: string]: number},\n  articleExpectations: {[key: string]: number},\n) {\n  await listenForMessages(\n    () => (resolveFlags ? approveFlagsAndCommentsRequest : approveCommentsRequest)([commentId]),\n    (type, message) => {\n      checkTypeIsUpdate(type);\n      checkExpectations(message, categoryExpectations, articleExpectations);\n    });\n}\n\nexport async function rejectComment(\n  commentId: ModelId,\n  resolveFlags: boolean,\n  categoryExpectations: {[key: string]: number},\n  articleExpectations: {[key: string]: number},\n) {\n  await listenForMessages(\n    () => (resolveFlags ? rejectFlagsAndCommentsRequest : rejectCommentsRequest)([commentId]),\n    (type, message) => {\n      checkTypeIsUpdate(type);\n      checkExpectations(message, categoryExpectations, articleExpectations);\n    });\n}\n\nexport async function setArticleState(\n  articleId: ModelId,\n  isCommentingEnabled: boolean,\n  isAutoModerated: boolean,\n) {\n  console.log(`  setting article ${articleId} to ${isCommentingEnabled} / ${isAutoModerated}`);\n  await listenForMessages(\n    () => updateArticle(articleId, isCommentingEnabled, isAutoModerated),\n    (type, message) => {\n      checkTypeIsUpdate(type);\n      if (message.articles[0].isCommentingEnabled !== isCommentingEnabled) {\n        console.log(`ERROR: article.isCommentingEnabled is not in correct state after op: new state: ${message.article.isCommentingEnabled}`);\n      }\n      if (message.articles[0].isAutoModerated !== isAutoModerated) {\n        console.log(`ERROR: article.isAutoModerated is not in correct state after op: new state: ${message.article.isAutoModerated}`);\n      }\n    });\n}\n\nexport async function setArticleModerators(\n  articleId: ModelId,\n  moderators: Array<ModelId>,\n) {\n  console.log(`  setting moderators for article ${articleId}:`, moderators);\n  await listenForMessages(\n    () => updateArticleModerators(articleId, moderators),\n    (type, message) => {\n      checkTypeIsUpdate(type);\n      if (moderators.length !== message.articles[0].assignedModerators.length) {\n        console.log(`ERROR: Article moderators doesn't have expected number of entries`);\n        return;\n      }\n\n      const testSet = new Set(moderators);\n      for (const m of message.articles[0].assignedModerators) {\n        if (!testSet.has(m)) {\n          console.log(`ERROR: Unexpected article moderator ${m}`);\n        }\n      }\n    });\n}\n"
  },
  {
    "path": "packages/frontend-web/src/test/apitest.ts",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nfunction usage() {\n  console.log(`usage: node ${process.argv[1]} <api_url> <token>`);\n  console.log(`   where api_url is the URL for the API backend (e.g., http://localhost:8080)`);\n  console.log(`   and <token> is an access token for an OSMod user.  (As allocated by bin/osmod user:get-token)`);\n}\n\nconst api_url = process.argv[2];\nif (!api_url) {\n  console.log('You need to specify an API URL.');\n  usage();\n  process.exit(1);\n}\n\nconst token = process.argv[3];\nif (!token) {\n  console.log('You need to specify a token.');\n  usage();\n  process.exit(1);\n}\n\n// Set up config before importing as config variables are set during import\n(global as any)['osmod_config'] = {\n  API_URL: api_url,\n};\n\nimport { decodeToken, setAxiosToken } from '../app/auth';\nimport { getArticles } from '../app/platform/dataService';\nimport { saveToken } from '../app/platform/localStore';\nimport { connectNotifier } from '../app/platform/websocketService';\nimport { approveComment, rejectComment, setArticleModerators, setArticleState } from './actions';\nimport { articleData, systemData, userData } from './notificationChecks';\nimport { checkArrayOf, checkArticle } from './objectChecks';\nimport {\n  commentDetailsPage,\n  fetchArticleText,\n  listModeratedCommentsPage,\n  listNewCommentsPage_SUMMARY_SCORE,\n} from './pageTests';\n\ntry {\n  const data = decodeToken(token);\n  console.log(`Accessing osmod backend as user ${data.user}`);\n}\ncatch (e) {\n  console.log(`Couldn't parse token ${token}.`);\n  process.exit(1);\n}\n\nsaveToken(token);\nsetAxiosToken(token);\n\n(async () => {\n  const readyPromise = new Promise<void>((resolve) => {\n    function websocketStateHandler(status: string): void {\n      console.log(`WebSocket state change.  New status: ${status}`);\n      resolve();\n    }\n\n    connectNotifier(\n      websocketStateHandler,\n      systemData.notificationHandler,\n      articleData.notificationHandler,\n      articleData.updateHandler,\n      userData.notificationHandler,\n    );\n  });\n\n  await readyPromise;\n\n  systemData.usersCheck();\n  articleData.dataCheck();\n  systemData.tagsCheck();\n\n  console.log('* WebSocket State');\n  systemData.stateCheck();\n  articleData.stateCheck();\n  userData.stateCheck();\n\n  if (articleData.articlesWithNew.length > 0) {\n    console.log('Trying out getArticles API');\n    const articles = await getArticles(articleData.articlesWithNew.map((a) => (a.id)));\n    checkArrayOf(checkArticle, articles);\n  }\n\n  if (articleData.articleFullyEnabled) {\n    console.log('\\n* Checking set article state');\n    await setArticleState(articleData.articleFullyEnabled.id, true, false);\n    await setArticleState(articleData.articleFullyEnabled.id, false, false);\n    await setArticleState(articleData.articleFullyEnabled.id, true, true);\n  }\n\n  if (articleData.articleWithNoModerators) {\n    console.log('\\n* Checking set article moderators');\n    await setArticleModerators(articleData.articleWithNoModerators.id, [systemData.users[0].id]);\n    await setArticleModerators(articleData.articleWithNoModerators.id, systemData.users.map((u) => u.id));\n    await setArticleModerators(articleData.articleWithNoModerators.id, []);\n  }\n\n  if (articleData.articlesWithFlags.length > 0 ) {\n    const articles = articleData.articlesWithFlags;\n    console.log('\\n* Doing a flagged comment fetch');\n    await listModeratedCommentsPage('flagged', 'all');\n    console.log('  Checked all');\n    const articlesWithCategory = articles.filter((a) => (!!a.categoryId));\n    if (articlesWithCategory.length > 0) {\n      await listModeratedCommentsPage('flagged', 'category', articlesWithCategory[0].categoryId);\n      console.log(`  Checked category ${articlesWithCategory[0].categoryId}`);\n    }\n    const article = articles[0];\n    const comments = await listModeratedCommentsPage('flagged', 'article', article.id);\n    console.log(`  Checked article ${article.id}`);\n    console.log(`  Found ${comments.length} flagged comments`);\n\n    if (comments.length > 0) {\n      const comment = comments[0];\n      console.log(`  Doing a fetch of comment ${comment}`);\n      await commentDetailsPage(comment);\n      console.log(`  Doing comment action tests. `);\n      const category = articleData.categories.get(article.categoryId);\n\n      await rejectComment(comment, false, {\n        rejectedCount: category.rejectedCount + 1,\n        flaggedCount: category.flaggedCount - 1,\n      }, {\n        rejectedCount: article.rejectedCount + 1,\n        flaggedCount: article.flaggedCount - 1,\n      });\n\n      await approveComment(comment, false,\n        {\n          rejectedCount: category.rejectedCount,\n          flaggedCount: category.flaggedCount,\n        }, {\n          rejectedCount: article.rejectedCount,\n          flaggedCount: article.flaggedCount,\n        });\n\n      await approveComment(comment, true,\n        {\n          flaggedCount: category.flaggedCount - 1,\n        }, {\n          flaggedCount: article.flaggedCount - 1,\n        });\n\n      await rejectComment(comment, true,\n        {\n          flaggedCount: category.flaggedCount - 1,\n        }, {\n          flaggedCount: article.flaggedCount - 1,\n        });\n    }\n  }\n\n  if (articleData.articlesWithNew.length > 0) {\n    const articles = articleData.articlesWithNew;\n    console.log('\\n* Doing a new comment fetch');\n    await listNewCommentsPage_SUMMARY_SCORE('all');\n    console.log('  Checked all');\n    const articlesWithCategory = articles.filter((a) => (!!a.categoryId));\n    if (articlesWithCategory.length > 0) {\n      await listNewCommentsPage_SUMMARY_SCORE('category', articlesWithCategory[0].categoryId);\n      console.log(`  Checked category ${articlesWithCategory[0].categoryId}`);\n    }\n\n    const article = articles[0];\n    const comments = await listNewCommentsPage_SUMMARY_SCORE('article', article.id);\n    console.log(`  Checked article ${article.id}`);\n    console.log('  Doing an article text fetch');\n    await fetchArticleText(article.id);\n    console.log(`\\n* Approving comment ${comments.slice(-1)[0]} and waiting for notification.`);\n\n    const category = articleData.categories.get(article.categoryId);\n    await approveComment(comments.slice(-1)[0], false,\n      {\n        approvedCount: category.approvedCount + 1,\n      }, {\n        approvedCount: article.approvedCount + 1,\n      });\n  }\n\n  console.log('shutting down.');\n  process.exit(0);\n})();\n"
  },
  {
    "path": "packages/frontend-web/src/test/notificationChecks.ts",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\nimport check from 'check-types';\nimport { autobind } from 'core-decorators';\n\nimport {\n  IAllArticlesData,\n  IArticleUpdate,\n  IPerUserData,\n  ISystemData,\n} from '../app/platform/websocketService';\nimport { IArticleModel, ICategoryModel, IUserModel, ModelId } from '../models';\nimport {\n  checkArticle,\n  checkCategory,\n  checkPreselect,\n  checkRule,\n  checkTag,\n  checkTaggingSensitivity,\n  checkUser,\n} from './objectChecks';\n\nclass ArticleMessages {\n  data: any = null;\n  gotUpdate = false;\n\n  countCategories = 0;\n  categoriesOk = true;\n\n  countArticles = 0;\n  articlesOk = true;\n\n  categories: Map<ModelId, ICategoryModel> = new Map();\n  articlesWithNew: Array<IArticleModel> = [];\n  articlesWithFlags: Array<IArticleModel> = [];\n  articleFullyEnabled?: IArticleModel;\n  articleWithNoModerators?: IArticleModel;\n\n  updateHappened?(type: string, message: any): void;\n\n  @autobind\n  notificationHandler(data: IAllArticlesData) {\n    console.log('+ Received all articles message');\n    this.gotUpdate = true;\n\n    this.countCategories = data.categories.length;\n    this.countArticles = data.articles.length;\n    this.data = data;\n    if (this.updateHappened) {\n      this.updateHappened('global', data);\n    }\n  }\n\n  @autobind\n  updateHandler(data: IArticleUpdate) {\n    console.log('+ Received singe article update message');\n    data.categories.forEach((c) => checkCategory(c));\n    data.articles.forEach((a) => checkArticle(a));\n\n    if (this.updateHappened) {\n      this.updateHappened('article-update', data);\n    }\n  }\n\n  dataCheck() {\n    console.log('* check categories');\n    for (const c of this.data.categories) {\n      this.categoriesOk = this.categoriesOk && checkCategory(c);\n    }\n    console.log('* check articles');\n    for (const c of this.data.categories) {\n      this.categories.set(c.id, c);\n    }\n    for (const a of this.data.articles) {\n      this.articlesOk = this.articlesOk && checkArticle(a);\n      if (a.unmoderatedCount > 0) {\n        this.articlesWithNew.push(a);\n      }\n      if (a.flaggedCount > 0) {\n        this.articlesWithFlags.push(a);\n      }\n      if (a.isCommentingEnabled && a.isAutoModerated) {\n        this.articleFullyEnabled = a;\n      }\n      if (a.assignedModerators.length === 0) {\n        this.articleWithNoModerators = a;\n      }\n    }\n  }\n\n  stateCheck() {\n    if (!this.gotUpdate) {\n      console.log('ERROR: Didn\\'t get article update message');\n      return;\n    }\n\n    console.log(`  Received ${this.countCategories} categories`);\n    if (!this.categoriesOk) {\n      console.log('ERROR: Issue with categories');\n    }\n\n    console.log(`  Received ${this.countArticles} articles: ${this.articlesWithFlags.length} with flagged comments`);\n    if (!this.articlesOk) {\n      console.log('ERROR: Issue with articles');\n    }\n  }\n}\n\nexport const articleData = new ArticleMessages();\n\nclass SystemData {\n  data: any = null;\n  gotUpdate = false;\n\n  usersOk = false;\n  countUsers = 0;\n\n  gotTags = false;\n  gotTaggingSensitivities = false;\n  gotRules = false;\n  gotPreselects = false;\n\n  users: Array<IUserModel> = [];\n\n  @autobind\n  notificationHandler(data: ISystemData) {\n    console.log('+ Received system update message');\n    this.gotUpdate = true;\n    this.countUsers = data.users.size;\n    this.usersOk =  this.countUsers > 0; // We assume there must be some users\n    this.gotTags = data.tags.toArray().length > 0;\n    this.gotTaggingSensitivities = true;\n    this.gotRules = true;\n    this.gotPreselects = true;\n    this.data = data;\n  }\n\n  usersCheck() {\n    console.log('* check users');\n    for (const u of this.data.users.toArray()) {\n      this.usersOk = this.usersOk && checkUser(u);\n      this.users.push(u);\n    }\n  }\n\n  tagsCheck() {\n    console.log('* check tags');\n    for (const t of this.data.tags.toArray()) {\n      this.gotTags = this.gotTags && checkTag(t);\n    }\n    for (const t of this.data.taggingSensitivities.toArray()) {\n      this.gotTaggingSensitivities = this.gotTaggingSensitivities && checkTaggingSensitivity(t);\n    }\n    for (const r of this.data.rules.toArray()) {\n      this.gotRules = this.gotRules && checkRule(r);\n    }\n    for (const s of this.data.preselects.toArray()) {\n      this.gotPreselects = this.gotPreselects && checkPreselect(s);\n    }\n  }\n\n  stateCheck() {\n    if (!this.gotUpdate) {\n      console.log('ERROR: Didn\\'t get system update message');\n      return;\n    }\n\n    console.log(`  Received ${this.countUsers} users`);\n    if (!this.usersOk) {\n      console.log('ERROR: Issue with users or no users fetched');\n    }\n\n    if (!this.gotTags) {\n      console.log('ERROR: Issue with tags or no tags fetched');\n    }\n    if (!this.gotTaggingSensitivities) {\n      console.log('ERROR: Issue with tagging sensitivities');\n    }\n    if (!this.gotRules) {\n      console.log('ERROR: Issue with rules');\n    }\n    if (!this.gotPreselects) {\n      console.log('ERROR: Issue with preselects');\n    }\n  }\n}\n\nexport const systemData = new SystemData();\n\nclass UserData {\n  gotUpdate = false;\n  gotAssigned = false;\n\n  @autobind\n  notificationHandler(data: IPerUserData) {\n    console.log('+ Received user update message');\n    this.gotUpdate = true;\n    this.gotAssigned = check.number(data.assignments);\n  }\n\n  stateCheck() {\n    if (!this.gotUpdate) {\n      console.log('ERROR: Didn\\'t get system update message');\n      return;\n    }\n    if (!this.gotAssigned) {\n      console.log('ERROR: Didn\\'t get assigned count');\n    }\n  }\n}\n\nexport const userData = new UserData();\n"
  },
  {
    "path": "packages/frontend-web/src/test/objectChecks.ts",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport check from 'check-types';\nimport { List, Map } from 'immutable';\n\nimport {\n  SERVER_ACTION_ACCEPT,\n  SERVER_ACTION_DEFER,\n  SERVER_ACTION_HIGHLIGHT,\n  SERVER_ACTION_REJECT,\n} from '../models';\n\nconst loggedBad: any = {};\n\nfunction date_string(val: any) {\n  if (!check.string(val)) {\n    return false;\n  }\n\n  const t = Date.parse(val);\n  return !Number.isNaN(t);\n}\n\nfunction date_string_or_null(val: any) {\n  if (!val) {\n    return true;\n  }\n  return date_string(val);\n}\n\nconst email_re = /^(([^<>()\\[\\]\\\\.,;:\\s@\"]+(\\.[^<>()\\[\\]\\\\.,;:\\s@\"]+)*)|(\".+\"))@((\\[[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\])|(([a-zA-Z\\-0-9]+\\.)+[a-zA-Z]{2,}))$/;\nfunction email(val: any) {\n  if (!check.string(val)) {\n    return false;\n  }\n  return email_re.test(val);\n}\n\nfunction url(val: any) {\n  if (!check.string(val)) {\n    return false;\n  }\n\n  // TODO: URL RE\n  return true;\n}\n\nfunction url_or_null(val: any) {\n  if (val === null) {\n    return true;\n  }\n  return url(val);\n}\n\nfunction moderator_group(val: any) {\n  return val === 'admin' || val === 'general';\n}\n\nconst categoryIds = new Set();\nconst tagIds = new Set();\nconst userIds = new Set();\n\nfunction category_id_or_null(val: any) {\n  if (val === null) {\n    return true;\n  }\n\n  // TODO: Don't like this inconsistency between categoryIds.  We really need to pin down ids into a single type.\n  return categoryIds.has(val.toString());\n}\n\nfunction tag_id_or_null(val: any) {\n  if (val === null) {\n    return true;\n  }\n\n  // TODO: Don't like this inconsistency between tagids.  We really need to pin down ids into a single type.\n  return tagIds.has(val.toString());\n}\n\nfunction is_user(u: any) {\n  if (!check.string(u)) {\n    console.log('Bad user ID', u);\n    return false;\n  }\n  if (!userIds.has(u)) {\n    console.log('User check: no user with ID', u);\n    console.log(' Known IDs', userIds);\n    return false;\n  }\n  return true;\n}\n\nfunction array_of_users(val: any) {\n  if (!check.array(val)) {\n    return false;\n  }\n\n  let ret = true;\n  for (const u of val) {\n    ret = ret && is_user(u);\n  }\n  return ret;\n}\n\nfunction action(val: any) {\n  if (!check.string(val)) {\n    return false;\n  }\n\n  return [\n    SERVER_ACTION_ACCEPT,\n    SERVER_ACTION_REJECT,\n    SERVER_ACTION_HIGHLIGHT,\n    SERVER_ACTION_DEFER,\n  ].indexOf(val) >= 0;\n}\n\nexport function checkArrayOf(itemChecker: (i: any) => boolean, o: any) {\n  if (!Array.isArray(o)) {\n    console.log(`Thing is not an Array`);\n    return false;\n  }\n\n  let valuesOk = true;\n  for (const i of o) {\n    valuesOk = itemChecker(i) && valuesOk;\n  }\n  return valuesOk;\n}\n\nfunction checkListNumber(o: any) {\n  if (!List.isList(o)) {\n    console.log(`Number list is not a list`);\n    return false;\n  }\n\n  for (const n of o.toArray()) {\n    if (!check.number(n)) {\n      console.log(`Number list contains non number ${n}`);\n      return false;\n    }\n  }\n  return true;\n}\n\nfunction checkMap(o: any, type: string, keyType: (o: any) => boolean, valueType: (o: any) => boolean) {\n  if (!Map.isMap(o)) {\n    console.log(`Got a bad ${type}: Not a map`);\n    return false;\n  }\n\n  for (const k of o.keys()) {\n    if (!keyType(k)) {\n      console.log(`Got a bad ${type}: key ${k} not a ${keyType.name}`);\n      return false;\n    }\n    if (!valueType(o.get(k))) {\n      console.log(`Got a bad ${type}: key ${k} has bad value: ${o.get(k)}: not a ${keyType.name}`);\n      return false;\n    }\n  }\n}\n\n// These attribute lists should duplicate those in updateNotifications.ts.\nconst commonFields = {\n  id: check.string,\n  updatedAt: date_string,\n  allCount: check.number,\n  unprocessedCount: check.number,\n  unmoderatedCount: check.number,\n  moderatedCount: check.number,\n  approvedCount: check.number,\n  highlightedCount: check.number,\n  rejectedCount: check.number,\n  deferredCount: check.number,\n  flaggedCount: check.number,\n  batchedCount: check.number,\n  assignedModerators: array_of_users,\n};\n\nconst categoryFields = {\n  ...commonFields,\n  label: check.string,\n};\n\nconst articleFields = {\n  ...commonFields,\n  title: check.string,\n  url: check.string,\n  categoryId: category_id_or_null,\n  sourceCreatedAt: date_string,\n  lastModeratedAt: date_string_or_null,\n  isCommentingEnabled: check.boolean,\n  isAutoModerated: check.boolean,\n};\n\nconst userFields = {\n  id: check.string,\n  name: check.string,\n  email: email,\n  avatarURL: url_or_null,\n  group: moderator_group,\n  isActive: check.boolean,\n};\n\nconst tagFields = {\n  id: check.string,\n  color: check.string,\n  description: check.maybe.string,\n  key: check.string,\n  label: check.string,\n  isInBatchView: check.boolean,\n  inSummaryScore: check.boolean,\n  isTaggable: check.boolean,\n};\n\nconst rangeFields = {\n  id: check.string,\n  categoryId: category_id_or_null,\n  lowerThreshold: check.number,\n  upperThreshold: check.number,\n  tagId: tag_id_or_null,\n};\n\nconst ruleFields = {\n  ...rangeFields,\n  action: action,\n  createdBy: check.maybe.string,\n};\n\n// TODO: not all fields here\nconst commentFields = {\n  id: check.string,\n  sourceId: check.string,\n  authorSourceId: check.string,\n  text: check.string,\n  isScored: check.boolean,\n  isModerated: check.boolean,\n  isAccepted: check.maybe.boolean,\n  isDeferred: check.boolean,\n  isHighlighted: check.boolean,\n  isBatchResolved: check.boolean,\n  isAutoResolved: check.boolean,\n  unresolvedFlagsCount: check.number,\n  flagsSummary: (o: any) => (!o || checkMap(o, 'comment:flagsSummary', check.string, checkListNumber)),\n  };\n\nconst commentScoreFields = {\n  id: check.string,\n  commentId: check.string,\n  tagId: check.string,\n  score: check.number,\n  sourceType: check.string,\n};\n\nconst commentFlagFields = {\n  id: check.string,\n  commentId: check.string,\n  label: check.string,\n  detail: check.maybe.string,\n  isRecommendation: check.boolean,\n  sourceId: check.maybe.string,\n  authorSourceId: check.maybe.string,\n  isResolved: check.boolean,\n  resolvedById: check.maybe.string,\n  resolvedAt: date_string_or_null,\n};\n\nconst moderatedCommentsFields = {\n  approved: check.array.of.string,\n  highlighted: check.array.of.string,\n  rejected: check.array.of.string,\n  deferred: check.array.of.string,\n  flagged: check.array.of.string,\n  batched: check.array.of.string,\n  automated: check.array.of.string,\n};\n\nconst histogramScoreFields = {\n  score: check.number,\n  commentId: check.string,\n};\n\nfunction checkObject(o: any, type: string, fields: any): boolean {\n  const res = check.map(o, fields);\n  if (check.all(res)) {\n    return true;\n  }\n\n  console.log(`Got a bad ${type}: ${o.id}`);\n\n  if (loggedBad[type]) {\n    return false;\n  }\n\n  for (const k in res) {\n    if (!res.hasOwnProperty(k)) {\n      continue;\n    }\n    if (!res[k]) {\n      console.log(` ${k}: ${o[k]} (expecting ${fields[k].name} got ${typeof(o[k])})`);\n    }\n  }\n  loggedBad[type] = true;\n  return false;\n}\n\nexport function checkCategory(o: any) {\n  if (!checkObject(o, 'category', categoryFields)) {\n    return false;\n  }\n\n  categoryIds.add(o.id);\n  return true;\n}\n\nexport function checkArticle(o: any) {\n  return checkObject(o, 'article', articleFields);\n}\n\nexport function checkUser(o: any) {\n  if (!checkObject(o, 'user', userFields)) {\n    return false;\n  }\n\n  userIds.add(o.id);\n  return true;\n}\n\nexport function checkTag(o: any) {\n  if (!checkObject(o, 'tag', tagFields)) {\n    return false;\n  }\n  tagIds.add(o.id);\n  return true;\n}\n\nexport function checkTaggingSensitivity(o: any) {\n  return checkObject(o, 'taggingSensitivity', rangeFields);\n}\n\nexport function checkRule(o: any) {\n  return checkObject(o, 'rule', ruleFields);\n}\n\nexport function checkPreselect(o: any) {\n  return checkObject(o, 'preselect', rangeFields);\n}\n\nexport function checkModeratedComments(o: any) {\n  return checkObject(o, 'ModeratedComments', moderatedCommentsFields);\n}\n\nexport function checkTextSizes(o: any) {\n  return checkMap(o, 'textSizes', check.string, check.number);\n}\n\nfunction checkComment(o: any) {\n  return checkObject(o, 'comment', commentFields);\n}\n\nexport function checkListComments(o: any) {\n  if (!List.isList(o)) {\n    console.log(`Got a bad listComments: Not a list`);\n    return false;\n  }\n  for (const c of o.toArray()) {\n    if (!checkComment(c)) {\n      return false;\n    }\n  }\n  return true;\n}\n\nexport function checkSingleComment(o: any) {\n  checkComment(o.model);\n}\n\nexport function checkCommentFlag(o: any) {\n  return checkObject(o, 'commentFlag', commentFlagFields);\n}\n\nexport function checkCommentScore(o: any) {\n  return checkObject(o, 'commentScore', commentScoreFields);\n}\n\nexport function checkHistogramScores(o: any) {\n  if (!List.isList(o)) {\n    console.log(`Got a bad histogram scores: Not a list`);\n    return false;\n  }\n  for (const s of o.toArray()) {\n    if (!checkObject(s, 'commentScore', histogramScoreFields)) {\n      return false;\n    }\n  }\n  return true;\n}\n"
  },
  {
    "path": "packages/frontend-web/src/test/pageTests.ts",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport check from 'check-types';\n\nimport {\n  getArticleText,\n  getCommentFlags,\n  getComments,\n  getCommentScores,\n  getModeratedCommentIdsForArticle,\n  getModeratedCommentIdsForCategory,\n  IModeratedComments,\n  listMaxHistogramScoresByCategory,\n  listMaxSummaryScoreByArticle,\n  listTextSizesByIds,\n} from '../app/platform/dataService';\nimport { ICommentScore, ModelId } from '../models';\nimport {\n  checkArrayOf,\n  checkCommentFlag,\n  checkCommentScore,\n  checkHistogramScores,\n  checkListComments,\n  checkModeratedComments,\n  checkSingleComment,\n  checkTextSizes,\n} from './objectChecks';\n\nexport async function fetchArticleText(articleId: ModelId) {\n  const text = await getArticleText(articleId);\n  check.string(text);\n}\n\nexport async function listCommentsPage(comments: Array<ModelId>) {\n  const sizes =  await listTextSizesByIds(comments, 696);\n  checkTextSizes(sizes);\n  const data = await getComments(comments);\n  checkListComments(data);\n}\n\nexport async function listNewCommentsPage_SUMMARY_SCORE(\n  type: 'all' | 'category' | 'article',\n  id?: ModelId,\n) {\n  const sort = ['-score'];\n\n  if (type === 'all') {\n    id = 'all';\n  }\n\n  let scores: Array<ICommentScore>;\n  if (type === 'article') {\n    scores = await listMaxSummaryScoreByArticle(id, sort);\n  }\n  else {\n    scores = await listMaxHistogramScoresByCategory(id, sort);\n  }\n\n  checkHistogramScores(scores);\n\n  // not working yet.  Data in wrong format?\n  // await loadTopScoresForSummaryScores(scores.toArray().slice(0, 5));\n  return scores.map((s) => s.commentId);\n}\n\nexport async function listModeratedCommentsPage(\n  tab: 'approved' | 'highlighted' | 'flagged',\n  type: 'all' | 'category' | 'article',\n  id?: ModelId,\n) {\n  let sort: Array<string>;\n  if (tab === 'flagged') {\n    sort = ['-unresolvedFlagsCount'];\n  } else {\n    sort = ['-updatedAt'];\n  }\n\n  let mc: IModeratedComments;\n  if (type === 'article') {\n    mc = await getModeratedCommentIdsForArticle(id, sort);\n  } else {\n    mc = await getModeratedCommentIdsForCategory(type === 'all' ? 'all' : id, sort);\n  }\n\n  checkModeratedComments(mc);\n  await listCommentsPage(mc[tab]);\n  return mc[tab];\n}\n\nexport async function commentDetailsPage(commentId: string)  {\n  const c = await getComments([commentId]);\n  checkSingleComment(c);\n  const flags = await getCommentFlags(commentId);\n  checkArrayOf(checkCommentFlag, flags);\n  const scores = await getCommentScores(commentId);\n  checkArrayOf(checkCommentScore, scores);\n  // dataService.js:1289 listAuthorCounts [\"c44181ec-1957-936e-4fb0-fbc21828d365\"]\n}\n"
  },
  {
    "path": "packages/frontend-web/src/types.ts",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nexport type ServerStates =\n  's_connecting' |\n  's_unavailable' |\n  's_init_oauth' |\n  's_init_first_user' |\n  's_init_perspective' |\n  's_init_check_oauth' |\n  's_gtg';\nexport type AuthenticationStates = 'initialising' | 'check_token' | 'unauthenticated' | 'gtg';\nexport type WebsocketStates = 'ws_connecting' | 'ws_gtg';\nexport type SystemStates = ServerStates | AuthenticationStates | WebsocketStates;\n\n// These are our representation of the core moderator actions,\n// i.e., the things a moderator can do to a comment via one of the action buttons\n// They map onto the IRu\nexport type IModerationAction = 'approve' | 'defer' | 'highlight' | 'reject';\n\n// These are the broader set of actions that can be applied to a comment\nexport type ICommentAction = IModerationAction | 'tag';\n\n// These are the actions we can do to a comment within the interface\nexport type IConfirmationAction = ICommentAction | 'reset';\n"
  },
  {
    "path": "packages/frontend-web/tooling/storybook/Storyshots.test.js",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport initStoryshots from '@storybook/addon-storyshots';\nimport distanceInWordsToNow from 'date-fns/distance_in_words_to_now';\n\njest.mock('date-fns/distance_in_words_to_now');\n\ndistanceInWordsToNow.mockReturnValue('over X years');\n\ninitStoryshots({\n  configPath: __dirname,\n  storyNameRegex: /^((?!.*?DontTest).)*$/,\n});\n"
  },
  {
    "path": "packages/frontend-web/tooling/storybook/__snapshots__/Storyshots.test.js.snap",
    "content": "// Jest Snapshot v1, https://goo.gl/fbAQLP\n\nexports[`Storyshots Arrow active 1`] = `\n<div>\n  <div\n    className=\"container_ft1i2l\"\n  >\n    <span\n      className=\"button_1ex8dih\"\n      style={\n        Object {\n          \"height\": 64,\n          \"width\": 64,\n        }\n      }\n    >\n      <div\n        className=\"arrow_666b00-o_O-upArrow_j26h42\"\n        style={\n          Object {\n            \"borderRightColor\": \"#255271\",\n          }\n        }\n      >\n        <svg\n          fill=\"currentColor\"\n          height={24}\n          preserveAspectRatio=\"xMidYMid meet\"\n          style={\n            Object {\n              \"fill\": \"#255271\",\n              \"verticalAlign\": \"middle\",\n            }\n          }\n          viewBox=\"0 0 24 24\"\n          width={24}\n        >\n          <g>\n            <path\n              d=\"M16,7H3.83L9.42,1.41,8,0,0,8l8,8,1.41-1.41L3.83,9H16Z\"\n              transform=\"translate(4 4)\"\n            />\n          </g>\n        </svg>\n      </div>\n    </span>\n  </div>\n  <div\n    className=\"container_ft1i2l\"\n  >\n    <span\n      className=\"button_1ex8dih\"\n      style={\n        Object {\n          \"height\": 64,\n          \"width\": 64,\n        }\n      }\n    >\n      <div\n        className=\"arrow_666b00-o_O-downArrow_akvxtb\"\n        style={\n          Object {\n            \"borderLeftColor\": \"#255271\",\n          }\n        }\n      >\n        <svg\n          fill=\"currentColor\"\n          height={24}\n          preserveAspectRatio=\"xMidYMid meet\"\n          style={\n            Object {\n              \"fill\": \"#255271\",\n              \"verticalAlign\": \"middle\",\n            }\n          }\n          viewBox=\"0 0 24 24\"\n          width={24}\n        >\n          <g>\n            <path\n              d=\"M16,7H3.83L9.42,1.41,8,0,0,8l8,8,1.41-1.41L3.83,9H16Z\"\n              transform=\"translate(4 4)\"\n            />\n          </g>\n        </svg>\n      </div>\n    </span>\n  </div>\n  <div\n    className=\"container_ft1i2l\"\n  >\n    <span\n      className=\"button_1ex8dih\"\n      style={\n        Object {\n          \"height\": 64,\n          \"width\": 64,\n        }\n      }\n    >\n      <div\n        className=\"arrow_666b00-o_O-rightArrow_tjvsvm\"\n        style={\n          Object {\n            \"borderTopColor\": \"#255271\",\n          }\n        }\n      >\n        <svg\n          fill=\"currentColor\"\n          height={24}\n          preserveAspectRatio=\"xMidYMid meet\"\n          style={\n            Object {\n              \"fill\": \"#255271\",\n              \"verticalAlign\": \"middle\",\n            }\n          }\n          viewBox=\"0 0 24 24\"\n          width={24}\n        >\n          <g>\n            <path\n              d=\"M16,7H3.83L9.42,1.41,8,0,0,8l8,8,1.41-1.41L3.83,9H16Z\"\n              transform=\"translate(4 4)\"\n            />\n          </g>\n        </svg>\n      </div>\n    </span>\n  </div>\n  <div\n    className=\"container_ft1i2l\"\n  >\n    <span\n      className=\"button_1ex8dih\"\n      style={\n        Object {\n          \"height\": 64,\n          \"width\": 64,\n        }\n      }\n    >\n      <div\n        className=\"arrow_666b00-o_O-leftArrow_2vsdcr\"\n        style={\n          Object {\n            \"borderBottomColor\": \"#255271\",\n          }\n        }\n      >\n        <svg\n          fill=\"currentColor\"\n          height={24}\n          preserveAspectRatio=\"xMidYMid meet\"\n          style={\n            Object {\n              \"fill\": \"#255271\",\n              \"verticalAlign\": \"middle\",\n            }\n          }\n          viewBox=\"0 0 24 24\"\n          width={24}\n        >\n          <g>\n            <path\n              d=\"M16,7H3.83L9.42,1.41,8,0,0,8l8,8,1.41-1.41L3.83,9H16Z\"\n              transform=\"translate(4 4)\"\n            />\n          </g>\n        </svg>\n      </div>\n    </span>\n  </div>\n</div>\n`;\n\nexports[`Storyshots Arrow base 1`] = `\n<div>\n  <div\n    className=\"container_ft1i2l\"\n  >\n    <span\n      className=\"button_1ex8dih\"\n      style={\n        Object {\n          \"height\": 64,\n          \"width\": 64,\n        }\n      }\n    >\n      <div\n        className=\"arrow_666b00-o_O-upArrow_j26h42\"\n        style={Object {}}\n      >\n        <svg\n          fill=\"currentColor\"\n          height={24}\n          preserveAspectRatio=\"xMidYMid meet\"\n          style={\n            Object {\n              \"fill\": \"#255271\",\n              \"verticalAlign\": \"middle\",\n            }\n          }\n          viewBox=\"0 0 24 24\"\n          width={24}\n        >\n          <g>\n            <path\n              d=\"M16,7H3.83L9.42,1.41,8,0,0,8l8,8,1.41-1.41L3.83,9H16Z\"\n              transform=\"translate(4 4)\"\n            />\n          </g>\n        </svg>\n      </div>\n    </span>\n  </div>\n  <div\n    className=\"container_ft1i2l\"\n  >\n    <span\n      className=\"button_1ex8dih\"\n      style={\n        Object {\n          \"height\": 64,\n          \"width\": 64,\n        }\n      }\n    >\n      <div\n        className=\"arrow_666b00-o_O-downArrow_akvxtb\"\n        style={Object {}}\n      >\n        <svg\n          fill=\"currentColor\"\n          height={24}\n          preserveAspectRatio=\"xMidYMid meet\"\n          style={\n            Object {\n              \"fill\": \"#255271\",\n              \"verticalAlign\": \"middle\",\n            }\n          }\n          viewBox=\"0 0 24 24\"\n          width={24}\n        >\n          <g>\n            <path\n              d=\"M16,7H3.83L9.42,1.41,8,0,0,8l8,8,1.41-1.41L3.83,9H16Z\"\n              transform=\"translate(4 4)\"\n            />\n          </g>\n        </svg>\n      </div>\n    </span>\n  </div>\n  <div\n    className=\"container_ft1i2l\"\n  >\n    <span\n      className=\"button_1ex8dih\"\n      style={\n        Object {\n          \"height\": 64,\n          \"width\": 64,\n        }\n      }\n    >\n      <div\n        className=\"arrow_666b00-o_O-rightArrow_tjvsvm\"\n        style={Object {}}\n      >\n        <svg\n          fill=\"currentColor\"\n          height={24}\n          preserveAspectRatio=\"xMidYMid meet\"\n          style={\n            Object {\n              \"fill\": \"#255271\",\n              \"verticalAlign\": \"middle\",\n            }\n          }\n          viewBox=\"0 0 24 24\"\n          width={24}\n        >\n          <g>\n            <path\n              d=\"M16,7H3.83L9.42,1.41,8,0,0,8l8,8,1.41-1.41L3.83,9H16Z\"\n              transform=\"translate(4 4)\"\n            />\n          </g>\n        </svg>\n      </div>\n    </span>\n  </div>\n  <div\n    className=\"container_ft1i2l\"\n  >\n    <span\n      className=\"button_1ex8dih\"\n      style={\n        Object {\n          \"height\": 64,\n          \"width\": 64,\n        }\n      }\n    >\n      <div\n        className=\"arrow_666b00-o_O-leftArrow_2vsdcr\"\n        style={Object {}}\n      >\n        <svg\n          fill=\"currentColor\"\n          height={24}\n          preserveAspectRatio=\"xMidYMid meet\"\n          style={\n            Object {\n              \"fill\": \"#255271\",\n              \"verticalAlign\": \"middle\",\n            }\n          }\n          viewBox=\"0 0 24 24\"\n          width={24}\n        >\n          <g>\n            <path\n              d=\"M16,7H3.83L9.42,1.41,8,0,0,8l8,8,1.41-1.41L3.83,9H16Z\"\n              transform=\"translate(4 4)\"\n            />\n          </g>\n        </svg>\n      </div>\n    </span>\n  </div>\n</div>\n`;\n\nexports[`Storyshots Arrow disabled 1`] = `\n<div>\n  <div\n    className=\"container_ft1i2l-o_O-disabled_1nusemj\"\n  >\n    <span\n      className=\"button_1ex8dih\"\n      style={\n        Object {\n          \"height\": 64,\n          \"width\": 64,\n        }\n      }\n    >\n      <div\n        className=\"arrow_666b00-o_O-upArrow_j26h42\"\n        style={Object {}}\n      >\n        <svg\n          fill=\"currentColor\"\n          height={24}\n          preserveAspectRatio=\"xMidYMid meet\"\n          style={\n            Object {\n              \"fill\": \"#255271\",\n              \"verticalAlign\": \"middle\",\n            }\n          }\n          viewBox=\"0 0 24 24\"\n          width={24}\n        >\n          <g>\n            <path\n              d=\"M16,7H3.83L9.42,1.41,8,0,0,8l8,8,1.41-1.41L3.83,9H16Z\"\n              transform=\"translate(4 4)\"\n            />\n          </g>\n        </svg>\n      </div>\n    </span>\n  </div>\n  <div\n    className=\"container_ft1i2l-o_O-disabled_1nusemj\"\n  >\n    <span\n      className=\"button_1ex8dih\"\n      style={\n        Object {\n          \"height\": 64,\n          \"width\": 64,\n        }\n      }\n    >\n      <div\n        className=\"arrow_666b00-o_O-downArrow_akvxtb\"\n        style={Object {}}\n      >\n        <svg\n          fill=\"currentColor\"\n          height={24}\n          preserveAspectRatio=\"xMidYMid meet\"\n          style={\n            Object {\n              \"fill\": \"#255271\",\n              \"verticalAlign\": \"middle\",\n            }\n          }\n          viewBox=\"0 0 24 24\"\n          width={24}\n        >\n          <g>\n            <path\n              d=\"M16,7H3.83L9.42,1.41,8,0,0,8l8,8,1.41-1.41L3.83,9H16Z\"\n              transform=\"translate(4 4)\"\n            />\n          </g>\n        </svg>\n      </div>\n    </span>\n  </div>\n  <div\n    className=\"container_ft1i2l-o_O-disabled_1nusemj\"\n  >\n    <span\n      className=\"button_1ex8dih\"\n      style={\n        Object {\n          \"height\": 64,\n          \"width\": 64,\n        }\n      }\n    >\n      <div\n        className=\"arrow_666b00-o_O-rightArrow_tjvsvm\"\n        style={Object {}}\n      >\n        <svg\n          fill=\"currentColor\"\n          height={24}\n          preserveAspectRatio=\"xMidYMid meet\"\n          style={\n            Object {\n              \"fill\": \"#255271\",\n              \"verticalAlign\": \"middle\",\n            }\n          }\n          viewBox=\"0 0 24 24\"\n          width={24}\n        >\n          <g>\n            <path\n              d=\"M16,7H3.83L9.42,1.41,8,0,0,8l8,8,1.41-1.41L3.83,9H16Z\"\n              transform=\"translate(4 4)\"\n            />\n          </g>\n        </svg>\n      </div>\n    </span>\n  </div>\n  <div\n    className=\"container_ft1i2l-o_O-disabled_1nusemj\"\n  >\n    <span\n      className=\"button_1ex8dih\"\n      style={\n        Object {\n          \"height\": 64,\n          \"width\": 64,\n        }\n      }\n    >\n      <div\n        className=\"arrow_666b00-o_O-leftArrow_2vsdcr\"\n        style={Object {}}\n      >\n        <svg\n          fill=\"currentColor\"\n          height={24}\n          preserveAspectRatio=\"xMidYMid meet\"\n          style={\n            Object {\n              \"fill\": \"#255271\",\n              \"verticalAlign\": \"middle\",\n            }\n          }\n          viewBox=\"0 0 24 24\"\n          width={24}\n        >\n          <g>\n            <path\n              d=\"M16,7H3.83L9.42,1.41,8,0,0,8l8,8,1.41-1.41L3.83,9H16Z\"\n              transform=\"translate(4 4)\"\n            />\n          </g>\n        </svg>\n      </div>\n    </span>\n  </div>\n</div>\n`;\n\nexports[`Storyshots Avatar 30x30 no avatar 1`] = `\n<div\n  style={\n    Object {\n      \"margin\": \"50px\",\n    }\n  }\n>\n  <div\n    style={\n      Object {\n        \"display\": \"inline-block\",\n        \"margin\": \"1px\",\n      }\n    }\n  >\n    <div\n      aria-describedby={null}\n      className=\"MuiAvatar-root MuiAvatar-circle MuiAvatar-colorDefault\"\n      onBlur={[Function]}\n      onFocus={[Function]}\n      onMouseLeave={[Function]}\n      onMouseOver={[Function]}\n      onTouchEnd={[Function]}\n      onTouchStart={[Function]}\n      style={\n        Object {\n          \"backgroundColor\": \"rgb(86, 117, 24)\",\n          \"color\": \"white\",\n          \"fontSize\": \"15px\",\n          \"height\": \"30px\",\n          \"width\": \"30px\",\n        }\n      }\n      title=\"Bridie Skiles V\"\n    >\n      BV\n    </div>\n  </div>\n</div>\n`;\n\nexports[`Storyshots Avatar no avatar 1`] = `\n<div\n  style={\n    Object {\n      \"margin\": \"50px\",\n    }\n  }\n>\n  <div\n    style={\n      Object {\n        \"display\": \"inline-block\",\n        \"margin\": \"1px\",\n      }\n    }\n  >\n    <div\n      aria-describedby={null}\n      className=\"MuiAvatar-root MuiAvatar-circle MuiAvatar-colorDefault\"\n      onBlur={[Function]}\n      onFocus={[Function]}\n      onMouseLeave={[Function]}\n      onMouseOver={[Function]}\n      onTouchEnd={[Function]}\n      onTouchStart={[Function]}\n      style={\n        Object {\n          \"backgroundColor\": \"rgb(86, 117, 24)\",\n          \"color\": \"white\",\n          \"fontSize\": \"27px\",\n          \"height\": \"54px\",\n          \"width\": \"54px\",\n        }\n      }\n      title=\"Bridie Skiles V\"\n    >\n      BV\n    </div>\n  </div>\n</div>\n`;\n\nexports[`Storyshots Button Done 1`] = `\n<button\n  className=\"button_2ihe17\"\n  onClick={[Function]}\n>\n  Done\n</button>\n`;\n\nexports[`Storyshots ColorSelect base 1`] = `\n<div\n  className=\"colorBoxContainer_h69i6v\"\n>\n  <label\n    className=\"offscreen_vluvpa\"\n    htmlFor=\"inflammatory\"\n  >\n    Choose a color for \n    inflammatory\n  </label>\n  <input\n    className=\"selectBox_pwrbze\"\n    id=\"inflammatory\"\n    name=\"inflammatory\"\n    onChange={[Function]}\n    type=\"text\"\n    value=\"#FC724A\"\n  />\n  <span\n    className=\"colorBox_1vbhbby\"\n    style={\n      Object {\n        \"backgroundColor\": \"#FC724A\",\n      }\n    }\n  />\n</div>\n`;\n\nexports[`Storyshots CommentActionButton Disabled 1`] = `\n<div\n  style={\n    Object {\n      \"background\": \"#326891\",\n      \"display\": \"inline-block\",\n    }\n  }\n>\n  <button\n    aria-label=\"Approve\"\n    className=\"base_1ghdph2-o_O-disabledButton_9m1qa0\"\n    disabled={true}\n    onBlur={[Function]}\n    onFocus={[Function]}\n    onMouseEnter={[Function]}\n    onMouseLeave={[Function]}\n    type=\"button\"\n  >\n    <div\n      aria-hidden={true}\n      className=\"content_n3cct7\"\n    >\n      <div>\n        <svg\n          fill=\"currentColor\"\n          height=\"24\"\n          preserveAspectRatio=\"xMidYMid meet\"\n          style={\n            Object {\n              \"fill\": \"rgba(255, 255, 255, 1)\",\n              \"verticalAlign\": \"middle\",\n            }\n          }\n          viewBox=\"0 0 24 24\"\n          width=\"24\"\n        >\n          <g>\n            <path\n              d=\"M0,0H24V24H0V0Z\"\n              fill=\"none\"\n            />\n            <path\n              d=\"M9,16.17L4.83,12,3.41,13.41,9,19,21,7,19.59,5.59Z\"\n            />\n          </g>\n        </svg>\n      </div>\n      <div\n        className=\"baseLabel_1rd9h3c\"\n      >\n        Approve\n      </div>\n    </div>\n  </button>\n</div>\n`;\n\nexports[`Storyshots CommentActionButton Full Width 1`] = `\n<div\n  style={\n    Object {\n      \"background\": \"#326891\",\n      \"display\": \"inline-block\",\n    }\n  }\n>\n  <button\n    aria-label=\"Approve\"\n    className=\"base_1ghdph2\"\n    onBlur={[Function]}\n    onFocus={[Function]}\n    onMouseEnter={[Function]}\n    onMouseLeave={[Function]}\n    type=\"button\"\n  >\n    <div\n      aria-hidden={true}\n      className=\"content_n3cct7\"\n    >\n      <div>\n        <svg\n          fill=\"currentColor\"\n          height=\"24\"\n          preserveAspectRatio=\"xMidYMid meet\"\n          style={\n            Object {\n              \"fill\": \"rgba(255, 255, 255, 1)\",\n              \"verticalAlign\": \"middle\",\n            }\n          }\n          viewBox=\"0 0 24 24\"\n          width=\"24\"\n        >\n          <g>\n            <path\n              d=\"M0,0H24V24H0V0Z\"\n              fill=\"none\"\n            />\n            <path\n              d=\"M9,16.17L4.83,12,3.41,13.41,9,19,21,7,19.59,5.59Z\"\n            />\n          </g>\n        </svg>\n      </div>\n      <div\n        className=\"baseLabel_1rd9h3c\"\n      >\n        Approve\n      </div>\n    </div>\n  </button>\n</div>\n`;\n\nexports[`Storyshots CommentActionButton Hide Label 1`] = `\n<div\n  style={\n    Object {\n      \"background\": \"#326891\",\n      \"display\": \"inline-block\",\n    }\n  }\n>\n  <button\n    aria-label=\"Approve\"\n    className=\"base_1ghdph2\"\n    onBlur={[Function]}\n    onFocus={[Function]}\n    onMouseEnter={[Function]}\n    onMouseLeave={[Function]}\n    type=\"button\"\n  >\n    <div\n      aria-hidden={true}\n      className=\"content_n3cct7\"\n    >\n      <div>\n        <svg\n          fill=\"currentColor\"\n          height=\"24\"\n          preserveAspectRatio=\"xMidYMid meet\"\n          style={\n            Object {\n              \"fill\": \"rgba(255, 255, 255, 1)\",\n              \"verticalAlign\": \"middle\",\n            }\n          }\n          viewBox=\"0 0 24 24\"\n          width=\"24\"\n        >\n          <g>\n            <path\n              d=\"M0,0H24V24H0V0Z\"\n              fill=\"none\"\n            />\n            <path\n              d=\"M9,16.17L4.83,12,3.41,13.41,9,19,21,7,19.59,5.59Z\"\n            />\n          </g>\n        </svg>\n      </div>\n      <div\n        className=\"baseLabel_1rd9h3c-o_O-noLabel_1u9fru1\"\n      >\n        Approve\n      </div>\n    </div>\n  </button>\n</div>\n`;\n\nexports[`Storyshots CommentBody Linked 1`] = `\n<div>\n  <div>\n    <div\n      onMouseEnter={[Function]}\n      onMouseLeave={[Function]}\n    >\n      <div\n        className=\"meta_19ulwq8\"\n      >\n        <div\n          className=\"authorRow_jgupte\"\n        >\n          <span\n            style={\n              Object {\n                \"textDecoration\": \"none\",\n              }\n            }\n          >\n             • \n            over X years\n             ago \n          </span>\n        </div>\n        <div\n          className=\"actionContainer_11iv8a4\"\n        >\n          <button\n            className=\"actionToggle_71yhch\"\n            type=\"button\"\n          >\n            <svg\n              fill=\"currentColor\"\n              height={20}\n              preserveAspectRatio=\"xMidYMid meet\"\n              style={\n                Object {\n                  \"verticalAlign\": \"middle\",\n                }\n              }\n              viewBox=\"0 0 24 24\"\n              width={20}\n            >\n              <g>\n                <path\n                  d=\"M0,0H24V24H0V0Z\"\n                  fill=\"none\"\n                />\n                <path\n                  d=\"M12,8a2,2,0,1,0-2-2A2,2,0,0,0,12,8Zm0,2a2,2,0,1,0,2,2A2,2,0,0,0,12,10Zm0,6a2,2,0,1,0,2,2A2,2,0,0,0,12,16Z\"\n                />\n              </g>\n            </svg>\n          </button>\n        </div>\n      </div>\n      <div\n        className=\"commentContainer_1s1qcet\"\n      >\n        <div\n          className=\"comment_1b4bkso\"\n        >\n          <span>\n            \n          </span>\n        </div>\n      </div>\n    </div>\n  </div>\n</div>\n`;\n\nexports[`Storyshots ConfirmationCircle approve large 1`] = `\n<div\n  className=\"circle_42xzgj\"\n  id=\"approve\"\n  style={\n    Object {\n      \"backgroundColor\": \"#27d073\",\n      \"height\": 120,\n      \"width\": 120,\n    }\n  }\n>\n  <svg\n    fill=\"currentColor\"\n    height={40}\n    label=\"Approve\"\n    preserveAspectRatio=\"xMidYMid meet\"\n    role=\"alert\"\n    style={\n      Object {\n        \"fill\": \"rgba(255, 255, 255, 1)\",\n        \"verticalAlign\": \"middle\",\n      }\n    }\n    viewBox=\"0 0 24 24\"\n    width={40}\n  >\n    <g>\n      <path\n        d=\"M0,0H24V24H0V0Z\"\n        fill=\"none\"\n      />\n      <path\n        d=\"M9,16.17L4.83,12,3.41,13.41,9,19,21,7,19.59,5.59Z\"\n      />\n    </g>\n  </svg>\n</div>\n`;\n\nexports[`Storyshots ConfirmationCircle approve small 1`] = `\n<div\n  className=\"circle_42xzgj\"\n  id=\"approve\"\n  style={\n    Object {\n      \"backgroundColor\": \"#27d073\",\n      \"height\": 40,\n      \"width\": 40,\n    }\n  }\n>\n  <svg\n    fill=\"currentColor\"\n    height={40}\n    label=\"Approve\"\n    preserveAspectRatio=\"xMidYMid meet\"\n    role=\"alert\"\n    style={\n      Object {\n        \"fill\": \"rgba(255, 255, 255, 1)\",\n        \"verticalAlign\": \"middle\",\n      }\n    }\n    viewBox=\"0 0 24 24\"\n    width={40}\n  >\n    <g>\n      <path\n        d=\"M0,0H24V24H0V0Z\"\n        fill=\"none\"\n      />\n      <path\n        d=\"M9,16.17L4.83,12,3.41,13.41,9,19,21,7,19.59,5.59Z\"\n      />\n    </g>\n  </svg>\n</div>\n`;\n\nexports[`Storyshots ConfirmationCircle defer large 1`] = `\n<div\n  className=\"circle_42xzgj\"\n  id=\"defer\"\n  style={\n    Object {\n      \"backgroundColor\": \"#999999\",\n      \"height\": 120,\n      \"width\": 120,\n    }\n  }\n>\n  <svg\n    fill=\"currentColor\"\n    height={40}\n    label=\"Defer\"\n    preserveAspectRatio=\"xMidYMid meet\"\n    role=\"alert\"\n    style={\n      Object {\n        \"fill\": \"rgba(255, 255, 255, 1)\",\n        \"verticalAlign\": \"middle\",\n      }\n    }\n    viewBox=\"0 0 24 24\"\n    width={40}\n  >\n    <g>\n      <path\n        d=\"M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z\"\n      />\n      <path\n        d=\"M0 0h24v24H0z\"\n        fill=\"none\"\n      />\n      <path\n        d=\"M12.5 7H11v6l5.25 3.15.75-1.23-4.5-2.67z\"\n      />\n    </g>\n  </svg>\n</div>\n`;\n\nexports[`Storyshots ConfirmationCircle defer small 1`] = `\n<div\n  className=\"circle_42xzgj\"\n  id=\"defer\"\n  style={\n    Object {\n      \"backgroundColor\": \"#999999\",\n      \"height\": 40,\n      \"width\": 40,\n    }\n  }\n>\n  <svg\n    fill=\"currentColor\"\n    height={40}\n    label=\"Defer\"\n    preserveAspectRatio=\"xMidYMid meet\"\n    role=\"alert\"\n    style={\n      Object {\n        \"fill\": \"rgba(255, 255, 255, 1)\",\n        \"verticalAlign\": \"middle\",\n      }\n    }\n    viewBox=\"0 0 24 24\"\n    width={40}\n  >\n    <g>\n      <path\n        d=\"M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z\"\n      />\n      <path\n        d=\"M0 0h24v24H0z\"\n        fill=\"none\"\n      />\n      <path\n        d=\"M12.5 7H11v6l5.25 3.15.75-1.23-4.5-2.67z\"\n      />\n    </g>\n  </svg>\n</div>\n`;\n\nexports[`Storyshots ConfirmationCircle highlight large 1`] = `\n<div\n  className=\"circle_42xzgj\"\n  id=\"highlight\"\n  style={\n    Object {\n      \"backgroundColor\": \"#f9b453\",\n      \"height\": 120,\n      \"width\": 120,\n    }\n  }\n>\n  <svg\n    fill=\"currentColor\"\n    height={40}\n    label=\"Highlight\"\n    preserveAspectRatio=\"xMidYMid meet\"\n    role=\"alert\"\n    style={\n      Object {\n        \"fill\": \"rgba(255, 255, 255, 1)\",\n        \"verticalAlign\": \"middle\",\n      }\n    }\n    viewBox=\"0 0 24 24\"\n    width={40}\n  >\n    <g>\n      <path\n        d=\"M17,3H7A2,2,0,0,0,5,5V21l7-3,7,3V5A2,2,0,0,0,17,3Z\"\n      />\n      <path\n        d=\"M0,0H24V24H0V0Z\"\n        fill=\"none\"\n      />\n    </g>\n  </svg>\n</div>\n`;\n\nexports[`Storyshots ConfirmationCircle highlight small 1`] = `\n<div\n  className=\"circle_42xzgj\"\n  id=\"highlight\"\n  style={\n    Object {\n      \"backgroundColor\": \"#f9b453\",\n      \"height\": 40,\n      \"width\": 40,\n    }\n  }\n>\n  <svg\n    fill=\"currentColor\"\n    height={40}\n    label=\"Highlight\"\n    preserveAspectRatio=\"xMidYMid meet\"\n    role=\"alert\"\n    style={\n      Object {\n        \"fill\": \"rgba(255, 255, 255, 1)\",\n        \"verticalAlign\": \"middle\",\n      }\n    }\n    viewBox=\"0 0 24 24\"\n    width={40}\n  >\n    <g>\n      <path\n        d=\"M17,3H7A2,2,0,0,0,5,5V21l7-3,7,3V5A2,2,0,0,0,17,3Z\"\n      />\n      <path\n        d=\"M0,0H24V24H0V0Z\"\n        fill=\"none\"\n      />\n    </g>\n  </svg>\n</div>\n`;\n\nexports[`Storyshots ConfirmationCircle reject large 1`] = `\n<div\n  className=\"circle_42xzgj\"\n  id=\"reject\"\n  style={\n    Object {\n      \"backgroundColor\": \"#fc4a79\",\n      \"height\": 120,\n      \"width\": 120,\n    }\n  }\n>\n  <svg\n    fill=\"currentColor\"\n    height={40}\n    label=\"Reject\"\n    preserveAspectRatio=\"xMidYMid meet\"\n    role=\"alert\"\n    style={\n      Object {\n        \"fill\": \"rgba(255, 255, 255, 1)\",\n        \"verticalAlign\": \"middle\",\n      }\n    }\n    viewBox=\"0 0 24 24\"\n    width={40}\n  >\n    <g>\n      <path\n        d=\"M19,6.41L17.59,5,12,10.59,6.41,5,5,6.41,10.59,12,5,17.59,6.41,19,12,13.41,17.59,19,19,17.59,13.41,12Z\"\n      />\n      <path\n        d=\"M0,0H24V24H0V0Z\"\n        fill=\"none\"\n      />\n    </g>\n  </svg>\n</div>\n`;\n\nexports[`Storyshots ConfirmationCircle reject small 1`] = `\n<div\n  className=\"circle_42xzgj\"\n  id=\"reject\"\n  style={\n    Object {\n      \"backgroundColor\": \"#fc4a79\",\n      \"height\": 40,\n      \"width\": 40,\n    }\n  }\n>\n  <svg\n    fill=\"currentColor\"\n    height={40}\n    label=\"Reject\"\n    preserveAspectRatio=\"xMidYMid meet\"\n    role=\"alert\"\n    style={\n      Object {\n        \"fill\": \"rgba(255, 255, 255, 1)\",\n        \"verticalAlign\": \"middle\",\n      }\n    }\n    viewBox=\"0 0 24 24\"\n    width={40}\n  >\n    <g>\n      <path\n        d=\"M19,6.41L17.59,5,12,10.59,6.41,5,5,6.41,10.59,12,5,17.59,6.41,19,12,13.41,17.59,19,19,17.59,13.41,12Z\"\n      />\n      <path\n        d=\"M0,0H24V24H0V0Z\"\n        fill=\"none\"\n      />\n    </g>\n  </svg>\n</div>\n`;\n\nexports[`Storyshots DotChart Applied rules 1`] = `\n<div\n  style={\n    Object {\n      \"backgroundColor\": \"#326891\",\n      \"padding\": \"50px\",\n    }\n  }\n>\n  <div\n    className=\"base_17y0hv9\"\n    onDoubleClick={[Function]}\n  >\n    <canvas />\n    <div\n      className=\"ruleBlock_1byp03c\"\n      style={\n        Object {\n          \"left\": 0,\n          \"right\": 865.92,\n        }\n      }\n    >\n      <span\n        className=\"icon_13hdfvb\"\n      >\n        <svg\n          fill=\"currentColor\"\n          height=\"24\"\n          preserveAspectRatio=\"xMidYMid meet\"\n          style={\n            Object {\n              \"fill\": \"rgba(255, 255, 255, 1)\",\n              \"verticalAlign\": \"middle\",\n            }\n          }\n          viewBox=\"0 0 24 24\"\n          width=\"24\"\n        >\n          <g>\n            <path\n              d=\"M0,0H24V24H0V0Z\"\n              fill=\"none\"\n            />\n            <path\n              d=\"M9,16.17L4.83,12,3.41,13.41,9,19,21,7,19.59,5.59Z\"\n            />\n          </g>\n        </svg>\n      </span>\n    </div>\n    <div\n      className=\"ruleBlock_1byp03c\"\n      style={\n        Object {\n          \"left\": 36.08,\n          \"right\": 856.9,\n        }\n      }\n    >\n      <span\n        className=\"icon_13hdfvb\"\n      >\n        <svg\n          fill=\"currentColor\"\n          height=\"24\"\n          preserveAspectRatio=\"xMidYMid meet\"\n          style={\n            Object {\n              \"fill\": \"rgba(255, 255, 255, 1)\",\n              \"verticalAlign\": \"middle\",\n            }\n          }\n          viewBox=\"0 0 24 24\"\n          width=\"24\"\n        >\n          <g>\n            <path\n              d=\"M17,3H7A2,2,0,0,0,5,5V21l7-3,7,3V5A2,2,0,0,0,17,3Z\"\n            />\n            <path\n              d=\"M0,0H24V24H0V0Z\"\n              fill=\"none\"\n            />\n          </g>\n        </svg>\n      </span>\n    </div>\n    <div\n      className=\"ruleBlock_1byp03c\"\n      style={\n        Object {\n          \"left\": 225.5,\n          \"right\": 613.36,\n        }\n      }\n    >\n      <span\n        className=\"icon_13hdfvb\"\n      >\n        <svg\n          fill=\"currentColor\"\n          height=\"24\"\n          preserveAspectRatio=\"xMidYMid meet\"\n          style={\n            Object {\n              \"fill\": \"rgba(255, 255, 255, 1)\",\n              \"verticalAlign\": \"middle\",\n            }\n          }\n          viewBox=\"0 0 24 24\"\n          width=\"24\"\n        >\n          <g>\n            <path\n              d=\"M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z\"\n            />\n            <path\n              d=\"M0 0h24v24H0z\"\n              fill=\"none\"\n            />\n            <path\n              d=\"M12.5 7H11v6l5.25 3.15.75-1.23-4.5-2.67z\"\n            />\n          </g>\n        </svg>\n      </span>\n    </div>\n    <div\n      className=\"ruleBlock_1byp03c\"\n      style={\n        Object {\n          \"left\": 811.8000000000001,\n          \"right\": 0,\n        }\n      }\n    >\n      <span\n        className=\"icon_13hdfvb\"\n      >\n        <svg\n          fill=\"currentColor\"\n          height=\"24\"\n          preserveAspectRatio=\"xMidYMid meet\"\n          style={\n            Object {\n              \"fill\": \"rgba(255, 255, 255, 1)\",\n              \"verticalAlign\": \"middle\",\n            }\n          }\n          viewBox=\"0 0 24 24\"\n          width=\"24\"\n        >\n          <g>\n            <path\n              d=\"M19,6.41L17.59,5,12,10.59,6.41,5,5,6.41,10.59,12,5,17.59,6.41,19,12,13.41,17.59,19,19,17.59,13.41,12Z\"\n            />\n            <path\n              d=\"M0,0H24V24H0V0Z\"\n              fill=\"none\"\n            />\n          </g>\n        </svg>\n      </span>\n    </div>\n  </div>\n</div>\n`;\n\nexports[`Storyshots DotChart By Date (24 hours) 1`] = `\n<div\n  style={\n    Object {\n      \"backgroundColor\": \"#326891\",\n      \"padding\": \"50px\",\n    }\n  }\n>\n  <div\n    className=\"base_17y0hv9\"\n    onDoubleClick={[Function]}\n  >\n    <canvas />\n  </div>\n</div>\n`;\n\nexports[`Storyshots DotChart By Date (30 days) 1`] = `\n<div\n  style={\n    Object {\n      \"backgroundColor\": \"#326891\",\n      \"padding\": \"50px\",\n    }\n  }\n>\n  <div\n    className=\"base_17y0hv9\"\n    onDoubleClick={[Function]}\n  >\n    <canvas />\n  </div>\n</div>\n`;\n\nexports[`Storyshots DotChart Default 1`] = `\n<div\n  style={\n    Object {\n      \"backgroundColor\": \"#326891\",\n      \"padding\": \"50px\",\n    }\n  }\n>\n  <div\n    className=\"base_17y0hv9\"\n    onDoubleClick={[Function]}\n  >\n    <canvas />\n  </div>\n</div>\n`;\n\nexports[`Storyshots DotChart Mobile 1`] = `\n<div\n  style={\n    Object {\n      \"backgroundColor\": \"#326891\",\n      \"padding\": \"50px\",\n    }\n  }\n>\n  <div\n    className=\"base_17y0hv9\"\n    onDoubleClick={[Function]}\n  >\n    <canvas />\n  </div>\n</div>\n`;\n\nexports[`Storyshots DotChart Narrow 1`] = `\n<div\n  style={\n    Object {\n      \"backgroundColor\": \"#326891\",\n      \"padding\": \"50px\",\n    }\n  }\n>\n  <div\n    className=\"base_17y0hv9\"\n    onDoubleClick={[Function]}\n  >\n    <canvas />\n  </div>\n</div>\n`;\n\nexports[`Storyshots Errors Error page 1`] = `\n<div>\n  <div\n    className=\"landing_headerTag\"\n  >\n    Moderator\n  </div>\n  <div\n    className=\"landing_footerTag\"\n  >\n    <a\n      className=\"landing_link\"\n      href=\"https://conversationai.github.io/\"\n      target=\"_blank\"\n    >\n      Learn more\n    </a>\n     \n    <span\n      className=\"landing_extratext\"\n    >\n      about Modereator.\n    </span>\n  </div>\n  <div\n    className=\"landing_centerOnPage\"\n  >\n    <div\n      className=\"landing_bubbleSet\"\n    >\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div />\n      <div />\n      <div />\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div />\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n    </div>\n  </div>\n  <div\n    className=\"errors_9h093d-o_O-fadeIn_167dsx7\"\n  >\n    <p>\n      Hello there\n    </p>\n    <p\n      className=\"errorsTryAgain_qf6k2g\"\n    >\n      <a\n        className=\"link_vdmqoc\"\n        onClick={[Function]}\n      >\n        Try Again\n      </a>\n    </p>\n  </div>\n</div>\n`;\n\nexports[`Storyshots LabelSettings spam 1`] = `\n<div\n  className=\"base_px57ce\"\n>\n  <div\n    style={\n      Object {\n        \"alignItems\": \"center\",\n        \"display\": \"flex\",\n        \"maxWidth\": \"100%\",\n        \"overflow\": \"hidden\",\n        \"padding\": \"0 0 24px 0\",\n        \"position\": \"relative\",\n      }\n    }\n  >\n    <input\n      className=\"labelColor_1egr02e\"\n      onChange={[Function]}\n      style={\n        Object {\n          \"backgroundColor\": \"#ff0000\",\n        }\n      }\n      type=\"text\"\n      value=\"Hello\"\n    />\n    <input\n      className=\"description_hsjanl\"\n      onChange={[Function]}\n      type=\"text\"\n      value=\"Hello World\"\n    />\n    <div\n      className=\"colorBoxContainer_h69i6v\"\n    >\n      <label\n        className=\"offscreen_vluvpa\"\n        htmlFor=\"Hello\"\n      >\n        Choose a color for \n        Hello\n      </label>\n      <input\n        className=\"selectBox_pwrbze\"\n        id=\"Hello\"\n        name=\"Hello\"\n        onChange={[Function]}\n        type=\"text\"\n        value=\"#FF0000\"\n      />\n      <span\n        className=\"colorBox_1vbhbby\"\n        style={\n          Object {\n            \"backgroundColor\": \"#ff0000\",\n          }\n        }\n      />\n    </div>\n    <div\n      className=\"checkboxContainer_14jtxwx\"\n      onClick={[Function]}\n    >\n      <span\n        aria-disabled={false}\n        className=\"MuiButtonBase-root MuiIconButton-root PrivateSwitchBase-root-122 MuiRadio-root MuiRadio-colorPrimary MuiIconButton-colorPrimary\"\n        onBlur={[Function]}\n        onDragLeave={[Function]}\n        onFocus={[Function]}\n        onKeyDown={[Function]}\n        onKeyUp={[Function]}\n        onMouseDown={[Function]}\n        onMouseLeave={[Function]}\n        onMouseUp={[Function]}\n        onTouchEnd={[Function]}\n        onTouchMove={[Function]}\n        onTouchStart={[Function]}\n        tabIndex={null}\n      >\n        <span\n          className=\"MuiIconButton-label\"\n        >\n          <input\n            className=\"PrivateSwitchBase-input-125\"\n            onChange={[Function]}\n            type=\"radio\"\n          />\n          <div\n            className=\"PrivateRadioButtonIcon-root-138\"\n          >\n            <svg\n              aria-hidden=\"true\"\n              className=\"MuiSvgIcon-root\"\n              focusable=\"false\"\n              viewBox=\"0 0 24 24\"\n            >\n              <path\n                d=\"M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z\"\n              />\n            </svg>\n            <svg\n              aria-hidden=\"true\"\n              className=\"MuiSvgIcon-root PrivateRadioButtonIcon-layer-139\"\n              focusable=\"false\"\n              viewBox=\"0 0 24 24\"\n            >\n              <path\n                d=\"M8.465 8.465C9.37 7.56 10.62 7 12 7C14.76 7 17 9.24 17 12C17 13.38 16.44 14.63 15.535 15.535C14.63 16.44 13.38 17 12 17C9.24 17 7 14.76 7 12C7 10.62 7.56 9.37 8.465 8.465Z\"\n              />\n            </svg>\n          </div>\n        </span>\n      </span>\n    </div>\n    <div\n      className=\"checkboxContainer_14jtxwx\"\n      onClick={[Function]}\n    >\n      <span\n        aria-disabled={false}\n        className=\"MuiButtonBase-root MuiIconButton-root PrivateSwitchBase-root-122 MuiRadio-root MuiRadio-colorPrimary MuiIconButton-colorPrimary\"\n        onBlur={[Function]}\n        onDragLeave={[Function]}\n        onFocus={[Function]}\n        onKeyDown={[Function]}\n        onKeyUp={[Function]}\n        onMouseDown={[Function]}\n        onMouseLeave={[Function]}\n        onMouseUp={[Function]}\n        onTouchEnd={[Function]}\n        onTouchMove={[Function]}\n        onTouchStart={[Function]}\n        tabIndex={null}\n      >\n        <span\n          className=\"MuiIconButton-label\"\n        >\n          <input\n            className=\"PrivateSwitchBase-input-125\"\n            onChange={[Function]}\n            type=\"radio\"\n          />\n          <div\n            className=\"PrivateRadioButtonIcon-root-138\"\n          >\n            <svg\n              aria-hidden=\"true\"\n              className=\"MuiSvgIcon-root\"\n              focusable=\"false\"\n              viewBox=\"0 0 24 24\"\n            >\n              <path\n                d=\"M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z\"\n              />\n            </svg>\n            <svg\n              aria-hidden=\"true\"\n              className=\"MuiSvgIcon-root PrivateRadioButtonIcon-layer-139\"\n              focusable=\"false\"\n              viewBox=\"0 0 24 24\"\n            >\n              <path\n                d=\"M8.465 8.465C9.37 7.56 10.62 7 12 7C14.76 7 17 9.24 17 12C17 13.38 16.44 14.63 15.535 15.535C14.63 16.44 13.38 17 12 17C9.24 17 7 14.76 7 12C7 10.62 7.56 9.37 8.465 8.465Z\"\n              />\n            </svg>\n          </div>\n        </span>\n      </span>\n    </div>\n    <div\n      className=\"checkboxContainer_14jtxwx\"\n      onClick={[Function]}\n    >\n      <span\n        aria-disabled={false}\n        className=\"MuiButtonBase-root MuiIconButton-root PrivateSwitchBase-root-122 MuiRadio-root MuiRadio-colorPrimary MuiIconButton-colorPrimary\"\n        onBlur={[Function]}\n        onDragLeave={[Function]}\n        onFocus={[Function]}\n        onKeyDown={[Function]}\n        onKeyUp={[Function]}\n        onMouseDown={[Function]}\n        onMouseLeave={[Function]}\n        onMouseUp={[Function]}\n        onTouchEnd={[Function]}\n        onTouchMove={[Function]}\n        onTouchStart={[Function]}\n        tabIndex={null}\n      >\n        <span\n          className=\"MuiIconButton-label\"\n        >\n          <input\n            className=\"PrivateSwitchBase-input-125\"\n            onChange={[Function]}\n            type=\"radio\"\n          />\n          <div\n            className=\"PrivateRadioButtonIcon-root-138\"\n          >\n            <svg\n              aria-hidden=\"true\"\n              className=\"MuiSvgIcon-root\"\n              focusable=\"false\"\n              viewBox=\"0 0 24 24\"\n            >\n              <path\n                d=\"M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z\"\n              />\n            </svg>\n            <svg\n              aria-hidden=\"true\"\n              className=\"MuiSvgIcon-root PrivateRadioButtonIcon-layer-139\"\n              focusable=\"false\"\n              viewBox=\"0 0 24 24\"\n            >\n              <path\n                d=\"M8.465 8.465C9.37 7.56 10.62 7 12 7C14.76 7 17 9.24 17 12C17 13.38 16.44 14.63 15.535 15.535C14.63 16.44 13.38 17 12 17C9.24 17 7 14.76 7 12C7 10.62 7.56 9.37 8.465 8.465Z\"\n              />\n            </svg>\n          </div>\n        </span>\n      </span>\n    </div>\n    <div\n      style={\n        Object {\n          \"paddingLeft\": \"50px\",\n        }\n      }\n    >\n      <button\n        aria-label=\"Delete Tag\"\n        className=\"MuiButtonBase-root MuiIconButton-root\"\n        disabled={false}\n        onBlur={[Function]}\n        onClick={[Function]}\n        onDragLeave={[Function]}\n        onFocus={[Function]}\n        onKeyDown={[Function]}\n        onKeyUp={[Function]}\n        onMouseDown={[Function]}\n        onMouseLeave={[Function]}\n        onMouseUp={[Function]}\n        onTouchEnd={[Function]}\n        onTouchMove={[Function]}\n        onTouchStart={[Function]}\n        tabIndex={0}\n        type=\"button\"\n      >\n        <span\n          className=\"MuiIconButton-label\"\n        >\n          <svg\n            aria-hidden=\"true\"\n            className=\"MuiSvgIcon-root MuiSvgIcon-colorPrimary\"\n            focusable=\"false\"\n            viewBox=\"0 0 24 24\"\n          >\n            <path\n              d=\"M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z\"\n            />\n          </svg>\n        </span>\n      </button>\n    </div>\n  </div>\n</div>\n`;\n\nexports[`Storyshots Login Login failed 1`] = `\n<div>\n  <div\n    className=\"landing_headerTag\"\n  >\n    Moderator\n  </div>\n  <div\n    className=\"landing_footerTag\"\n  >\n    <a\n      className=\"landing_link\"\n      href=\"https://conversationai.github.io/\"\n      target=\"_blank\"\n    >\n      Learn more\n    </a>\n     \n    <span\n      className=\"landing_extratext\"\n    >\n      about Modereator.\n    </span>\n  </div>\n  <div\n    className=\"landing_centerOnPage\"\n  >\n    <div\n      className=\"landing_bubbleSet\"\n    >\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div />\n      <div />\n      <div />\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div />\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n    </div>\n  </div>\n  return (\n  <div\n    className=\"errors_9h093d-o_O-fadeIn_167dsx7\"\n  >\n    <p>\n      There was an error.\n    </p>\n    <p\n      className=\"errorsTryAgain_qf6k2g\"\n    >\n      <a\n        className=\"link_vdmqoc\"\n        onClick={[Function]}\n      >\n        Try Again\n      </a>\n    </p>\n    \n  </div>\n</div>\n`;\n\nexports[`Storyshots Login Login page 1`] = `\n<div>\n  <div\n    className=\"landing_headerTag\"\n  >\n    Moderator\n  </div>\n  <div\n    className=\"landing_footerTag\"\n  >\n    <a\n      className=\"landing_link\"\n      href=\"https://conversationai.github.io/\"\n      target=\"_blank\"\n    >\n      Learn more\n    </a>\n     \n    <span\n      className=\"landing_extratext\"\n    >\n      about Modereator.\n    </span>\n  </div>\n  <div\n    className=\"landing_centerOnPage\"\n  >\n    <div\n      className=\"landing_bubbleSet\"\n    >\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div />\n      <div />\n      <div />\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div />\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n    </div>\n  </div>\n  <div\n    className=\"signIn_d2epan-o_O-fadeIn_167dsx7\"\n  >\n    <a\n      className=\"link_vdmqoc\"\n      onClick={[Function]}\n    >\n      Sign In\n    </a>\n    \n  </div>\n</div>\n`;\n\nexports[`Storyshots MagicTimestamps MagicTimestamps 1`] = `\n<div>\n  <span>\n    a few seconds ago\n  </span>\n  <br />\n  <span>\n    1 minute ago\n  </span>\n  <br />\n  <span>\n    30 minutes ago\n  </span>\n  <br />\n  <span>\n    1 hour ago\n  </span>\n  <br />\n  <span>\n    12 hours ago\n  </span>\n  <br />\n  <span>\n    1 day ago\n  </span>\n  <br />\n  <span>\n    2 days ago\n  </span>\n  <br />\n  <span>\n    Oct 24\n  </span>\n  <br />\n  <span>\n    in 2 days\n  </span>\n  <br />\n  <span>\n    Jan 1\n  </span>\n  <br />\n  <span>\n    June 24, 2017\n  </span>\n</div>\n`;\n\nexports[`Storyshots ModerateButtons Horizontal 1`] = `\n<div>\n  <div\n    className=\"container_rrjeex\"\n  >\n    <button\n      aria-label=\"Approve\"\n      className=\"base_1ghdph2\"\n      onBlur={[Function]}\n      onClick={[Function]}\n      onFocus={[Function]}\n      onMouseEnter={[Function]}\n      onMouseLeave={[Function]}\n      style={\n        Object {\n          \"height\": 46,\n          \"padding\": \"5px 0px\",\n          \"width\": 46,\n        }\n      }\n      type=\"button\"\n    >\n      <div\n        aria-hidden={true}\n        className=\"content_n3cct7\"\n      >\n        <div>\n          <svg\n            fill=\"currentColor\"\n            height=\"24\"\n            preserveAspectRatio=\"xMidYMid meet\"\n            style={\n              Object {\n                \"fill\": \"#185bac\",\n                \"height\": \"18px\",\n                \"verticalAlign\": \"middle\",\n                \"width\": \"18px\",\n              }\n            }\n            viewBox=\"0 0 24 24\"\n            width=\"24\"\n          >\n            <g>\n              <path\n                d=\"M0,0H24V24H0V0Z\"\n                fill=\"none\"\n              />\n              <path\n                d=\"M9,16.17L4.83,12,3.41,13.41,9,19,21,7,19.59,5.59Z\"\n              />\n            </g>\n          </svg>\n        </div>\n        <div\n          className=\"baseLabel_1rd9h3c-o_O-noLabel_1u9fru1\"\n        >\n          Approve\n        </div>\n      </div>\n    </button>\n    <div>\n      <button\n        aria-label=\"Reject\"\n        className=\"base_1ghdph2\"\n        onBlur={[Function]}\n        onClick={[Function]}\n        onFocus={[Function]}\n        onMouseEnter={[Function]}\n        onMouseLeave={[Function]}\n        style={\n          Object {\n            \"height\": 46,\n            \"padding\": \"5px 0px\",\n            \"width\": 46,\n          }\n        }\n        type=\"button\"\n      >\n        <div\n          aria-hidden={true}\n          className=\"content_n3cct7\"\n        >\n          <div>\n            <svg\n              fill=\"currentColor\"\n              height=\"24\"\n              preserveAspectRatio=\"xMidYMid meet\"\n              style={\n                Object {\n                  \"fill\": \"#185bac\",\n                  \"height\": \"18px\",\n                  \"verticalAlign\": \"middle\",\n                  \"width\": \"18px\",\n                }\n              }\n              viewBox=\"0 0 24 24\"\n              width=\"24\"\n            >\n              <g>\n                <path\n                  d=\"M19,6.41L17.59,5,12,10.59,6.41,5,5,6.41,10.59,12,5,17.59,6.41,19,12,13.41,17.59,19,19,17.59,13.41,12Z\"\n                />\n                <path\n                  d=\"M0,0H24V24H0V0Z\"\n                  fill=\"none\"\n                />\n              </g>\n            </svg>\n          </div>\n          <div\n            className=\"baseLabel_1rd9h3c-o_O-noLabel_1u9fru1\"\n          >\n            Reject\n          </div>\n        </div>\n      </button>\n    </div>\n    <button\n      aria-label=\"Highlight\"\n      className=\"base_1ghdph2\"\n      onBlur={[Function]}\n      onClick={[Function]}\n      onFocus={[Function]}\n      onMouseEnter={[Function]}\n      onMouseLeave={[Function]}\n      style={\n        Object {\n          \"height\": 46,\n          \"padding\": \"5px 0px\",\n          \"width\": 46,\n        }\n      }\n      type=\"button\"\n    >\n      <div\n        aria-hidden={true}\n        className=\"content_n3cct7\"\n      >\n        <div>\n          <svg\n            fill=\"currentColor\"\n            height=\"24\"\n            preserveAspectRatio=\"xMidYMid meet\"\n            style={\n              Object {\n                \"fill\": \"#185bac\",\n                \"height\": \"18px\",\n                \"verticalAlign\": \"middle\",\n                \"width\": \"18px\",\n              }\n            }\n            viewBox=\"0 0 24 24\"\n            width=\"24\"\n          >\n            <g>\n              <path\n                d=\"M17,3H7A2,2,0,0,0,5,5V21l7-3,7,3V5A2,2,0,0,0,17,3Z\"\n              />\n              <path\n                d=\"M0,0H24V24H0V0Z\"\n                fill=\"none\"\n              />\n            </g>\n          </svg>\n        </div>\n        <div\n          className=\"baseLabel_1rd9h3c-o_O-noLabel_1u9fru1\"\n        >\n          Highlight\n        </div>\n      </div>\n    </button>\n    <button\n      aria-label=\"Defer\"\n      className=\"base_1ghdph2\"\n      onBlur={[Function]}\n      onClick={[Function]}\n      onFocus={[Function]}\n      onMouseEnter={[Function]}\n      onMouseLeave={[Function]}\n      style={\n        Object {\n          \"height\": 46,\n          \"padding\": \"5px 0px\",\n          \"width\": 46,\n        }\n      }\n      type=\"button\"\n    >\n      <div\n        aria-hidden={true}\n        className=\"content_n3cct7\"\n      >\n        <div>\n          <svg\n            fill=\"currentColor\"\n            height=\"24\"\n            preserveAspectRatio=\"xMidYMid meet\"\n            style={\n              Object {\n                \"fill\": \"#185bac\",\n                \"height\": \"18px\",\n                \"verticalAlign\": \"middle\",\n                \"width\": \"18px\",\n              }\n            }\n            viewBox=\"0 0 24 24\"\n            width=\"24\"\n          >\n            <g>\n              <path\n                d=\"M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z\"\n              />\n              <path\n                d=\"M0 0h24v24H0z\"\n                fill=\"none\"\n              />\n              <path\n                d=\"M12.5 7H11v6l5.25 3.15.75-1.23-4.5-2.67z\"\n              />\n            </g>\n          </svg>\n        </div>\n        <div\n          className=\"baseLabel_1rd9h3c-o_O-noLabel_1u9fru1\"\n        >\n          Defer\n        </div>\n      </div>\n    </button>\n  </div>\n</div>\n`;\n\nexports[`Storyshots ModerateButtons Vertical 1`] = `\n<div>\n  <div\n    className=\"container_rrjeex-o_O-isVertical_1xtfh22\"\n  >\n    <button\n      aria-label=\"Approve\"\n      className=\"base_1ghdph2\"\n      onBlur={[Function]}\n      onClick={[Function]}\n      onFocus={[Function]}\n      onMouseEnter={[Function]}\n      onMouseLeave={[Function]}\n      style={\n        Object {\n          \"height\": 58,\n          \"padding\": \"5px 0px\",\n          \"width\": 58,\n        }\n      }\n      type=\"button\"\n    >\n      <div\n        aria-hidden={true}\n        className=\"content_n3cct7\"\n      >\n        <div>\n          <svg\n            fill=\"currentColor\"\n            height=\"24\"\n            preserveAspectRatio=\"xMidYMid meet\"\n            style={\n              Object {\n                \"fill\": \"#185bac\",\n                \"height\": \"24px\",\n                \"verticalAlign\": \"middle\",\n                \"width\": \"24px\",\n              }\n            }\n            viewBox=\"0 0 24 24\"\n            width=\"24\"\n          >\n            <g>\n              <path\n                d=\"M0,0H24V24H0V0Z\"\n                fill=\"none\"\n              />\n              <path\n                d=\"M9,16.17L4.83,12,3.41,13.41,9,19,21,7,19.59,5.59Z\"\n              />\n            </g>\n          </svg>\n        </div>\n        <div\n          className=\"baseLabel_1rd9h3c-o_O-noLabel_1u9fru1\"\n        >\n          Approve\n        </div>\n      </div>\n    </button>\n    <div>\n      <button\n        aria-label=\"Reject\"\n        className=\"base_1ghdph2\"\n        onBlur={[Function]}\n        onClick={[Function]}\n        onFocus={[Function]}\n        onMouseEnter={[Function]}\n        onMouseLeave={[Function]}\n        style={\n          Object {\n            \"height\": 58,\n            \"padding\": \"5px 0px\",\n            \"width\": 58,\n          }\n        }\n        type=\"button\"\n      >\n        <div\n          aria-hidden={true}\n          className=\"content_n3cct7\"\n        >\n          <div>\n            <svg\n              fill=\"currentColor\"\n              height=\"24\"\n              preserveAspectRatio=\"xMidYMid meet\"\n              style={\n                Object {\n                  \"fill\": \"#185bac\",\n                  \"height\": \"24px\",\n                  \"verticalAlign\": \"middle\",\n                  \"width\": \"24px\",\n                }\n              }\n              viewBox=\"0 0 24 24\"\n              width=\"24\"\n            >\n              <g>\n                <path\n                  d=\"M19,6.41L17.59,5,12,10.59,6.41,5,5,6.41,10.59,12,5,17.59,6.41,19,12,13.41,17.59,19,19,17.59,13.41,12Z\"\n                />\n                <path\n                  d=\"M0,0H24V24H0V0Z\"\n                  fill=\"none\"\n                />\n              </g>\n            </svg>\n          </div>\n          <div\n            className=\"baseLabel_1rd9h3c-o_O-noLabel_1u9fru1\"\n          >\n            Reject\n          </div>\n        </div>\n      </button>\n    </div>\n    <button\n      aria-label=\"Highlight\"\n      className=\"base_1ghdph2\"\n      onBlur={[Function]}\n      onClick={[Function]}\n      onFocus={[Function]}\n      onMouseEnter={[Function]}\n      onMouseLeave={[Function]}\n      style={\n        Object {\n          \"height\": 58,\n          \"padding\": \"5px 0px\",\n          \"width\": 58,\n        }\n      }\n      type=\"button\"\n    >\n      <div\n        aria-hidden={true}\n        className=\"content_n3cct7\"\n      >\n        <div>\n          <svg\n            fill=\"currentColor\"\n            height=\"24\"\n            preserveAspectRatio=\"xMidYMid meet\"\n            style={\n              Object {\n                \"fill\": \"#185bac\",\n                \"height\": \"24px\",\n                \"verticalAlign\": \"middle\",\n                \"width\": \"24px\",\n              }\n            }\n            viewBox=\"0 0 24 24\"\n            width=\"24\"\n          >\n            <g>\n              <path\n                d=\"M17,3H7A2,2,0,0,0,5,5V21l7-3,7,3V5A2,2,0,0,0,17,3Z\"\n              />\n              <path\n                d=\"M0,0H24V24H0V0Z\"\n                fill=\"none\"\n              />\n            </g>\n          </svg>\n        </div>\n        <div\n          className=\"baseLabel_1rd9h3c-o_O-noLabel_1u9fru1\"\n        >\n          Highlight\n        </div>\n      </div>\n    </button>\n    <button\n      aria-label=\"Defer\"\n      className=\"base_1ghdph2\"\n      onBlur={[Function]}\n      onClick={[Function]}\n      onFocus={[Function]}\n      onMouseEnter={[Function]}\n      onMouseLeave={[Function]}\n      style={\n        Object {\n          \"height\": 58,\n          \"padding\": \"5px 0px\",\n          \"width\": 58,\n        }\n      }\n      type=\"button\"\n    >\n      <div\n        aria-hidden={true}\n        className=\"content_n3cct7\"\n      >\n        <div>\n          <svg\n            fill=\"currentColor\"\n            height=\"24\"\n            preserveAspectRatio=\"xMidYMid meet\"\n            style={\n              Object {\n                \"fill\": \"#185bac\",\n                \"height\": \"24px\",\n                \"verticalAlign\": \"middle\",\n                \"width\": \"24px\",\n              }\n            }\n            viewBox=\"0 0 24 24\"\n            width=\"24\"\n          >\n            <g>\n              <path\n                d=\"M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z\"\n              />\n              <path\n                d=\"M0 0h24v24H0z\"\n                fill=\"none\"\n              />\n              <path\n                d=\"M12.5 7H11v6l5.25 3.15.75-1.23-4.5-2.67z\"\n              />\n            </g>\n          </svg>\n        </div>\n        <div\n          className=\"baseLabel_1rd9h3c-o_O-noLabel_1u9fru1\"\n        >\n          Defer\n        </div>\n      </div>\n    </button>\n  </div>\n</div>\n`;\n\nexports[`Storyshots ModerateButtons Vertical Approve 1`] = `\n<div>\n  <div\n    className=\"container_rrjeex-o_O-isVertical_1xtfh22\"\n  >\n    <button\n      aria-label=\"Approve\"\n      className=\"base_1ghdph2\"\n      onBlur={[Function]}\n      onClick={[Function]}\n      onFocus={[Function]}\n      onMouseEnter={[Function]}\n      onMouseLeave={[Function]}\n      style={\n        Object {\n          \"height\": 58,\n          \"padding\": \"5px 0px\",\n          \"width\": 58,\n        }\n      }\n      type=\"button\"\n    >\n      <div\n        aria-hidden={true}\n        className=\"content_n3cct7\"\n      >\n        <div>\n          <div\n            className=\"circle_42xzgj\"\n            id=\"approve\"\n            style={\n              Object {\n                \"backgroundColor\": \"#185bac\",\n                \"height\": 48,\n                \"width\": 48,\n              }\n            }\n          >\n            <svg\n              fill=\"currentColor\"\n              height={24}\n              label=\"Approve\"\n              preserveAspectRatio=\"xMidYMid meet\"\n              role=\"alert\"\n              style={\n                Object {\n                  \"fill\": \"rgba(255, 255, 255, 1)\",\n                  \"verticalAlign\": \"middle\",\n                }\n              }\n              viewBox=\"0 0 24 24\"\n              width={24}\n            >\n              <g>\n                <path\n                  d=\"M0,0H24V24H0V0Z\"\n                  fill=\"none\"\n                />\n                <path\n                  d=\"M9,16.17L4.83,12,3.41,13.41,9,19,21,7,19.59,5.59Z\"\n                />\n              </g>\n            </svg>\n          </div>\n        </div>\n        <div\n          className=\"baseLabel_1rd9h3c-o_O-noLabel_1u9fru1\"\n        >\n          Approve\n        </div>\n      </div>\n    </button>\n    <div>\n      <button\n        aria-label=\"Reject\"\n        className=\"base_1ghdph2\"\n        onBlur={[Function]}\n        onClick={[Function]}\n        onFocus={[Function]}\n        onMouseEnter={[Function]}\n        onMouseLeave={[Function]}\n        style={\n          Object {\n            \"height\": 58,\n            \"padding\": \"5px 0px\",\n            \"width\": 58,\n          }\n        }\n        type=\"button\"\n      >\n        <div\n          aria-hidden={true}\n          className=\"content_n3cct7\"\n        >\n          <div>\n            <svg\n              fill=\"currentColor\"\n              height=\"24\"\n              preserveAspectRatio=\"xMidYMid meet\"\n              style={\n                Object {\n                  \"fill\": \"#185bac\",\n                  \"height\": \"24px\",\n                  \"verticalAlign\": \"middle\",\n                  \"width\": \"24px\",\n                }\n              }\n              viewBox=\"0 0 24 24\"\n              width=\"24\"\n            >\n              <g>\n                <path\n                  d=\"M19,6.41L17.59,5,12,10.59,6.41,5,5,6.41,10.59,12,5,17.59,6.41,19,12,13.41,17.59,19,19,17.59,13.41,12Z\"\n                />\n                <path\n                  d=\"M0,0H24V24H0V0Z\"\n                  fill=\"none\"\n                />\n              </g>\n            </svg>\n          </div>\n          <div\n            className=\"baseLabel_1rd9h3c-o_O-noLabel_1u9fru1\"\n          >\n            Reject\n          </div>\n        </div>\n      </button>\n    </div>\n    <button\n      aria-label=\"Highlight\"\n      className=\"base_1ghdph2\"\n      onBlur={[Function]}\n      onClick={[Function]}\n      onFocus={[Function]}\n      onMouseEnter={[Function]}\n      onMouseLeave={[Function]}\n      style={\n        Object {\n          \"height\": 58,\n          \"padding\": \"5px 0px\",\n          \"width\": 58,\n        }\n      }\n      type=\"button\"\n    >\n      <div\n        aria-hidden={true}\n        className=\"content_n3cct7\"\n      >\n        <div>\n          <svg\n            fill=\"currentColor\"\n            height=\"24\"\n            preserveAspectRatio=\"xMidYMid meet\"\n            style={\n              Object {\n                \"fill\": \"#185bac\",\n                \"height\": \"24px\",\n                \"verticalAlign\": \"middle\",\n                \"width\": \"24px\",\n              }\n            }\n            viewBox=\"0 0 24 24\"\n            width=\"24\"\n          >\n            <g>\n              <path\n                d=\"M17,3H7A2,2,0,0,0,5,5V21l7-3,7,3V5A2,2,0,0,0,17,3Z\"\n              />\n              <path\n                d=\"M0,0H24V24H0V0Z\"\n                fill=\"none\"\n              />\n            </g>\n          </svg>\n        </div>\n        <div\n          className=\"baseLabel_1rd9h3c-o_O-noLabel_1u9fru1\"\n        >\n          Highlight\n        </div>\n      </div>\n    </button>\n    <button\n      aria-label=\"Defer\"\n      className=\"base_1ghdph2\"\n      onBlur={[Function]}\n      onClick={[Function]}\n      onFocus={[Function]}\n      onMouseEnter={[Function]}\n      onMouseLeave={[Function]}\n      style={\n        Object {\n          \"height\": 58,\n          \"padding\": \"5px 0px\",\n          \"width\": 58,\n        }\n      }\n      type=\"button\"\n    >\n      <div\n        aria-hidden={true}\n        className=\"content_n3cct7\"\n      >\n        <div>\n          <svg\n            fill=\"currentColor\"\n            height=\"24\"\n            preserveAspectRatio=\"xMidYMid meet\"\n            style={\n              Object {\n                \"fill\": \"#185bac\",\n                \"height\": \"24px\",\n                \"verticalAlign\": \"middle\",\n                \"width\": \"24px\",\n              }\n            }\n            viewBox=\"0 0 24 24\"\n            width=\"24\"\n          >\n            <g>\n              <path\n                d=\"M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z\"\n              />\n              <path\n                d=\"M0 0h24v24H0z\"\n                fill=\"none\"\n              />\n              <path\n                d=\"M12.5 7H11v6l5.25 3.15.75-1.23-4.5-2.67z\"\n              />\n            </g>\n          </svg>\n        </div>\n        <div\n          className=\"baseLabel_1rd9h3c-o_O-noLabel_1u9fru1\"\n        >\n          Defer\n        </div>\n      </div>\n    </button>\n  </div>\n</div>\n`;\n\nexports[`Storyshots ModerateButtons Vertical Defer 1`] = `\n<div>\n  <div\n    className=\"container_rrjeex-o_O-isVertical_1xtfh22\"\n  >\n    <button\n      aria-label=\"Approve\"\n      className=\"base_1ghdph2\"\n      onBlur={[Function]}\n      onClick={[Function]}\n      onFocus={[Function]}\n      onMouseEnter={[Function]}\n      onMouseLeave={[Function]}\n      style={\n        Object {\n          \"height\": 58,\n          \"padding\": \"5px 0px\",\n          \"width\": 58,\n        }\n      }\n      type=\"button\"\n    >\n      <div\n        aria-hidden={true}\n        className=\"content_n3cct7\"\n      >\n        <div>\n          <svg\n            fill=\"currentColor\"\n            height=\"24\"\n            preserveAspectRatio=\"xMidYMid meet\"\n            style={\n              Object {\n                \"fill\": \"#185bac\",\n                \"height\": \"24px\",\n                \"verticalAlign\": \"middle\",\n                \"width\": \"24px\",\n              }\n            }\n            viewBox=\"0 0 24 24\"\n            width=\"24\"\n          >\n            <g>\n              <path\n                d=\"M0,0H24V24H0V0Z\"\n                fill=\"none\"\n              />\n              <path\n                d=\"M9,16.17L4.83,12,3.41,13.41,9,19,21,7,19.59,5.59Z\"\n              />\n            </g>\n          </svg>\n        </div>\n        <div\n          className=\"baseLabel_1rd9h3c-o_O-noLabel_1u9fru1\"\n        >\n          Approve\n        </div>\n      </div>\n    </button>\n    <div>\n      <button\n        aria-label=\"Reject\"\n        className=\"base_1ghdph2\"\n        onBlur={[Function]}\n        onClick={[Function]}\n        onFocus={[Function]}\n        onMouseEnter={[Function]}\n        onMouseLeave={[Function]}\n        style={\n          Object {\n            \"height\": 58,\n            \"padding\": \"5px 0px\",\n            \"width\": 58,\n          }\n        }\n        type=\"button\"\n      >\n        <div\n          aria-hidden={true}\n          className=\"content_n3cct7\"\n        >\n          <div>\n            <svg\n              fill=\"currentColor\"\n              height=\"24\"\n              preserveAspectRatio=\"xMidYMid meet\"\n              style={\n                Object {\n                  \"fill\": \"#185bac\",\n                  \"height\": \"24px\",\n                  \"verticalAlign\": \"middle\",\n                  \"width\": \"24px\",\n                }\n              }\n              viewBox=\"0 0 24 24\"\n              width=\"24\"\n            >\n              <g>\n                <path\n                  d=\"M19,6.41L17.59,5,12,10.59,6.41,5,5,6.41,10.59,12,5,17.59,6.41,19,12,13.41,17.59,19,19,17.59,13.41,12Z\"\n                />\n                <path\n                  d=\"M0,0H24V24H0V0Z\"\n                  fill=\"none\"\n                />\n              </g>\n            </svg>\n          </div>\n          <div\n            className=\"baseLabel_1rd9h3c-o_O-noLabel_1u9fru1\"\n          >\n            Reject\n          </div>\n        </div>\n      </button>\n    </div>\n    <button\n      aria-label=\"Highlight\"\n      className=\"base_1ghdph2\"\n      onBlur={[Function]}\n      onClick={[Function]}\n      onFocus={[Function]}\n      onMouseEnter={[Function]}\n      onMouseLeave={[Function]}\n      style={\n        Object {\n          \"height\": 58,\n          \"padding\": \"5px 0px\",\n          \"width\": 58,\n        }\n      }\n      type=\"button\"\n    >\n      <div\n        aria-hidden={true}\n        className=\"content_n3cct7\"\n      >\n        <div>\n          <svg\n            fill=\"currentColor\"\n            height=\"24\"\n            preserveAspectRatio=\"xMidYMid meet\"\n            style={\n              Object {\n                \"fill\": \"#185bac\",\n                \"height\": \"24px\",\n                \"verticalAlign\": \"middle\",\n                \"width\": \"24px\",\n              }\n            }\n            viewBox=\"0 0 24 24\"\n            width=\"24\"\n          >\n            <g>\n              <path\n                d=\"M17,3H7A2,2,0,0,0,5,5V21l7-3,7,3V5A2,2,0,0,0,17,3Z\"\n              />\n              <path\n                d=\"M0,0H24V24H0V0Z\"\n                fill=\"none\"\n              />\n            </g>\n          </svg>\n        </div>\n        <div\n          className=\"baseLabel_1rd9h3c-o_O-noLabel_1u9fru1\"\n        >\n          Highlight\n        </div>\n      </div>\n    </button>\n    <button\n      aria-label=\"Defer\"\n      className=\"base_1ghdph2\"\n      onBlur={[Function]}\n      onClick={[Function]}\n      onFocus={[Function]}\n      onMouseEnter={[Function]}\n      onMouseLeave={[Function]}\n      style={\n        Object {\n          \"height\": 58,\n          \"padding\": \"5px 0px\",\n          \"width\": 58,\n        }\n      }\n      type=\"button\"\n    >\n      <div\n        aria-hidden={true}\n        className=\"content_n3cct7\"\n      >\n        <div>\n          <div\n            className=\"circle_42xzgj\"\n            id=\"defer\"\n            style={\n              Object {\n                \"backgroundColor\": \"#185bac\",\n                \"height\": 48,\n                \"width\": 48,\n              }\n            }\n          >\n            <svg\n              fill=\"currentColor\"\n              height={24}\n              label=\"Defer\"\n              preserveAspectRatio=\"xMidYMid meet\"\n              role=\"alert\"\n              style={\n                Object {\n                  \"fill\": \"rgba(255, 255, 255, 1)\",\n                  \"verticalAlign\": \"middle\",\n                }\n              }\n              viewBox=\"0 0 24 24\"\n              width={24}\n            >\n              <g>\n                <path\n                  d=\"M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z\"\n                />\n                <path\n                  d=\"M0 0h24v24H0z\"\n                  fill=\"none\"\n                />\n                <path\n                  d=\"M12.5 7H11v6l5.25 3.15.75-1.23-4.5-2.67z\"\n                />\n              </g>\n            </svg>\n          </div>\n        </div>\n        <div\n          className=\"baseLabel_1rd9h3c-o_O-noLabel_1u9fru1\"\n        >\n          Defer\n        </div>\n      </div>\n    </button>\n  </div>\n</div>\n`;\n\nexports[`Storyshots ModerateButtons Vertical Highlight 1`] = `\n<div>\n  <div\n    className=\"container_rrjeex-o_O-isVertical_1xtfh22\"\n  >\n    <button\n      aria-label=\"Approve\"\n      className=\"base_1ghdph2\"\n      onBlur={[Function]}\n      onClick={[Function]}\n      onFocus={[Function]}\n      onMouseEnter={[Function]}\n      onMouseLeave={[Function]}\n      style={\n        Object {\n          \"height\": 58,\n          \"padding\": \"5px 0px\",\n          \"width\": 58,\n        }\n      }\n      type=\"button\"\n    >\n      <div\n        aria-hidden={true}\n        className=\"content_n3cct7\"\n      >\n        <div>\n          <div\n            className=\"circle_42xzgj\"\n            id=\"approve\"\n            style={\n              Object {\n                \"backgroundColor\": \"#185bac\",\n                \"height\": 48,\n                \"width\": 48,\n              }\n            }\n          >\n            <svg\n              fill=\"currentColor\"\n              height={24}\n              label=\"Approve\"\n              preserveAspectRatio=\"xMidYMid meet\"\n              role=\"alert\"\n              style={\n                Object {\n                  \"fill\": \"rgba(255, 255, 255, 1)\",\n                  \"verticalAlign\": \"middle\",\n                }\n              }\n              viewBox=\"0 0 24 24\"\n              width={24}\n            >\n              <g>\n                <path\n                  d=\"M0,0H24V24H0V0Z\"\n                  fill=\"none\"\n                />\n                <path\n                  d=\"M9,16.17L4.83,12,3.41,13.41,9,19,21,7,19.59,5.59Z\"\n                />\n              </g>\n            </svg>\n          </div>\n        </div>\n        <div\n          className=\"baseLabel_1rd9h3c-o_O-noLabel_1u9fru1\"\n        >\n          Approve\n        </div>\n      </div>\n    </button>\n    <div>\n      <button\n        aria-label=\"Reject\"\n        className=\"base_1ghdph2\"\n        onBlur={[Function]}\n        onClick={[Function]}\n        onFocus={[Function]}\n        onMouseEnter={[Function]}\n        onMouseLeave={[Function]}\n        style={\n          Object {\n            \"height\": 58,\n            \"padding\": \"5px 0px\",\n            \"width\": 58,\n          }\n        }\n        type=\"button\"\n      >\n        <div\n          aria-hidden={true}\n          className=\"content_n3cct7\"\n        >\n          <div>\n            <svg\n              fill=\"currentColor\"\n              height=\"24\"\n              preserveAspectRatio=\"xMidYMid meet\"\n              style={\n                Object {\n                  \"fill\": \"#185bac\",\n                  \"height\": \"24px\",\n                  \"verticalAlign\": \"middle\",\n                  \"width\": \"24px\",\n                }\n              }\n              viewBox=\"0 0 24 24\"\n              width=\"24\"\n            >\n              <g>\n                <path\n                  d=\"M19,6.41L17.59,5,12,10.59,6.41,5,5,6.41,10.59,12,5,17.59,6.41,19,12,13.41,17.59,19,19,17.59,13.41,12Z\"\n                />\n                <path\n                  d=\"M0,0H24V24H0V0Z\"\n                  fill=\"none\"\n                />\n              </g>\n            </svg>\n          </div>\n          <div\n            className=\"baseLabel_1rd9h3c-o_O-noLabel_1u9fru1\"\n          >\n            Reject\n          </div>\n        </div>\n      </button>\n    </div>\n    <button\n      aria-label=\"Highlight\"\n      className=\"base_1ghdph2\"\n      onBlur={[Function]}\n      onClick={[Function]}\n      onFocus={[Function]}\n      onMouseEnter={[Function]}\n      onMouseLeave={[Function]}\n      style={\n        Object {\n          \"height\": 58,\n          \"padding\": \"5px 0px\",\n          \"width\": 58,\n        }\n      }\n      type=\"button\"\n    >\n      <div\n        aria-hidden={true}\n        className=\"content_n3cct7\"\n      >\n        <div>\n          <div\n            className=\"circle_42xzgj\"\n            id=\"highlight\"\n            style={\n              Object {\n                \"backgroundColor\": \"#185bac\",\n                \"height\": 48,\n                \"width\": 48,\n              }\n            }\n          >\n            <svg\n              fill=\"currentColor\"\n              height={24}\n              label=\"Highlight\"\n              preserveAspectRatio=\"xMidYMid meet\"\n              role=\"alert\"\n              style={\n                Object {\n                  \"fill\": \"rgba(255, 255, 255, 1)\",\n                  \"verticalAlign\": \"middle\",\n                }\n              }\n              viewBox=\"0 0 24 24\"\n              width={24}\n            >\n              <g>\n                <path\n                  d=\"M17,3H7A2,2,0,0,0,5,5V21l7-3,7,3V5A2,2,0,0,0,17,3Z\"\n                />\n                <path\n                  d=\"M0,0H24V24H0V0Z\"\n                  fill=\"none\"\n                />\n              </g>\n            </svg>\n          </div>\n        </div>\n        <div\n          className=\"baseLabel_1rd9h3c-o_O-noLabel_1u9fru1\"\n        >\n          Highlight\n        </div>\n      </div>\n    </button>\n    <button\n      aria-label=\"Defer\"\n      className=\"base_1ghdph2\"\n      onBlur={[Function]}\n      onClick={[Function]}\n      onFocus={[Function]}\n      onMouseEnter={[Function]}\n      onMouseLeave={[Function]}\n      style={\n        Object {\n          \"height\": 58,\n          \"padding\": \"5px 0px\",\n          \"width\": 58,\n        }\n      }\n      type=\"button\"\n    >\n      <div\n        aria-hidden={true}\n        className=\"content_n3cct7\"\n      >\n        <div>\n          <svg\n            fill=\"currentColor\"\n            height=\"24\"\n            preserveAspectRatio=\"xMidYMid meet\"\n            style={\n              Object {\n                \"fill\": \"#185bac\",\n                \"height\": \"24px\",\n                \"verticalAlign\": \"middle\",\n                \"width\": \"24px\",\n              }\n            }\n            viewBox=\"0 0 24 24\"\n            width=\"24\"\n          >\n            <g>\n              <path\n                d=\"M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z\"\n              />\n              <path\n                d=\"M0 0h24v24H0z\"\n                fill=\"none\"\n              />\n              <path\n                d=\"M12.5 7H11v6l5.25 3.15.75-1.23-4.5-2.67z\"\n              />\n            </g>\n          </svg>\n        </div>\n        <div\n          className=\"baseLabel_1rd9h3c-o_O-noLabel_1u9fru1\"\n        >\n          Defer\n        </div>\n      </div>\n    </button>\n  </div>\n</div>\n`;\n\nexports[`Storyshots ModerateButtons Vertical Reject 1`] = `\n<div>\n  <div\n    className=\"container_rrjeex-o_O-isVertical_1xtfh22\"\n  >\n    <button\n      aria-label=\"Approve\"\n      className=\"base_1ghdph2\"\n      onBlur={[Function]}\n      onClick={[Function]}\n      onFocus={[Function]}\n      onMouseEnter={[Function]}\n      onMouseLeave={[Function]}\n      style={\n        Object {\n          \"height\": 58,\n          \"padding\": \"5px 0px\",\n          \"width\": 58,\n        }\n      }\n      type=\"button\"\n    >\n      <div\n        aria-hidden={true}\n        className=\"content_n3cct7\"\n      >\n        <div>\n          <svg\n            fill=\"currentColor\"\n            height=\"24\"\n            preserveAspectRatio=\"xMidYMid meet\"\n            style={\n              Object {\n                \"fill\": \"#185bac\",\n                \"height\": \"24px\",\n                \"verticalAlign\": \"middle\",\n                \"width\": \"24px\",\n              }\n            }\n            viewBox=\"0 0 24 24\"\n            width=\"24\"\n          >\n            <g>\n              <path\n                d=\"M0,0H24V24H0V0Z\"\n                fill=\"none\"\n              />\n              <path\n                d=\"M9,16.17L4.83,12,3.41,13.41,9,19,21,7,19.59,5.59Z\"\n              />\n            </g>\n          </svg>\n        </div>\n        <div\n          className=\"baseLabel_1rd9h3c-o_O-noLabel_1u9fru1\"\n        >\n          Approve\n        </div>\n      </div>\n    </button>\n    <div>\n      <button\n        aria-label=\"Reject\"\n        className=\"base_1ghdph2\"\n        onBlur={[Function]}\n        onClick={[Function]}\n        onFocus={[Function]}\n        onMouseEnter={[Function]}\n        onMouseLeave={[Function]}\n        style={\n          Object {\n            \"height\": 58,\n            \"padding\": \"5px 0px\",\n            \"width\": 58,\n          }\n        }\n        type=\"button\"\n      >\n        <div\n          aria-hidden={true}\n          className=\"content_n3cct7\"\n        >\n          <div>\n            <div\n              className=\"circle_42xzgj\"\n              id=\"reject\"\n              style={\n                Object {\n                  \"backgroundColor\": \"#185bac\",\n                  \"height\": 48,\n                  \"width\": 48,\n                }\n              }\n            >\n              <svg\n                fill=\"currentColor\"\n                height={24}\n                label=\"Reject\"\n                preserveAspectRatio=\"xMidYMid meet\"\n                role=\"alert\"\n                style={\n                  Object {\n                    \"fill\": \"rgba(255, 255, 255, 1)\",\n                    \"verticalAlign\": \"middle\",\n                  }\n                }\n                viewBox=\"0 0 24 24\"\n                width={24}\n              >\n                <g>\n                  <path\n                    d=\"M19,6.41L17.59,5,12,10.59,6.41,5,5,6.41,10.59,12,5,17.59,6.41,19,12,13.41,17.59,19,19,17.59,13.41,12Z\"\n                  />\n                  <path\n                    d=\"M0,0H24V24H0V0Z\"\n                    fill=\"none\"\n                  />\n                </g>\n              </svg>\n            </div>\n          </div>\n          <div\n            className=\"baseLabel_1rd9h3c-o_O-noLabel_1u9fru1\"\n          >\n            Reject\n          </div>\n        </div>\n      </button>\n    </div>\n    <button\n      aria-label=\"Highlight\"\n      className=\"base_1ghdph2\"\n      onBlur={[Function]}\n      onClick={[Function]}\n      onFocus={[Function]}\n      onMouseEnter={[Function]}\n      onMouseLeave={[Function]}\n      style={\n        Object {\n          \"height\": 58,\n          \"padding\": \"5px 0px\",\n          \"width\": 58,\n        }\n      }\n      type=\"button\"\n    >\n      <div\n        aria-hidden={true}\n        className=\"content_n3cct7\"\n      >\n        <div>\n          <svg\n            fill=\"currentColor\"\n            height=\"24\"\n            preserveAspectRatio=\"xMidYMid meet\"\n            style={\n              Object {\n                \"fill\": \"#185bac\",\n                \"height\": \"24px\",\n                \"verticalAlign\": \"middle\",\n                \"width\": \"24px\",\n              }\n            }\n            viewBox=\"0 0 24 24\"\n            width=\"24\"\n          >\n            <g>\n              <path\n                d=\"M17,3H7A2,2,0,0,0,5,5V21l7-3,7,3V5A2,2,0,0,0,17,3Z\"\n              />\n              <path\n                d=\"M0,0H24V24H0V0Z\"\n                fill=\"none\"\n              />\n            </g>\n          </svg>\n        </div>\n        <div\n          className=\"baseLabel_1rd9h3c-o_O-noLabel_1u9fru1\"\n        >\n          Highlight\n        </div>\n      </div>\n    </button>\n    <button\n      aria-label=\"Defer\"\n      className=\"base_1ghdph2\"\n      onBlur={[Function]}\n      onClick={[Function]}\n      onFocus={[Function]}\n      onMouseEnter={[Function]}\n      onMouseLeave={[Function]}\n      style={\n        Object {\n          \"height\": 58,\n          \"padding\": \"5px 0px\",\n          \"width\": 58,\n        }\n      }\n      type=\"button\"\n    >\n      <div\n        aria-hidden={true}\n        className=\"content_n3cct7\"\n      >\n        <div>\n          <svg\n            fill=\"currentColor\"\n            height=\"24\"\n            preserveAspectRatio=\"xMidYMid meet\"\n            style={\n              Object {\n                \"fill\": \"#185bac\",\n                \"height\": \"24px\",\n                \"verticalAlign\": \"middle\",\n                \"width\": \"24px\",\n              }\n            }\n            viewBox=\"0 0 24 24\"\n            width=\"24\"\n          >\n            <g>\n              <path\n                d=\"M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z\"\n              />\n              <path\n                d=\"M0 0h24v24H0z\"\n                fill=\"none\"\n              />\n              <path\n                d=\"M12.5 7H11v6l5.25 3.15.75-1.23-4.5-2.67z\"\n              />\n            </g>\n          </svg>\n        </div>\n        <div\n          className=\"baseLabel_1rd9h3c-o_O-noLabel_1u9fru1\"\n        >\n          Defer\n        </div>\n      </div>\n    </button>\n  </div>\n</div>\n`;\n\nexports[`Storyshots NavigationTab Default 1`] = `\n<div\n  style={\n    Object {\n      \"background\": \"#326891\",\n      \"display\": \"inline-block\",\n    }\n  }\n>\n  <button\n    aria-label=\"New\"\n    onClick={[Function]}\n    style={\n      Object {\n        \"background\": \"transparent\",\n        \"border\": 0,\n        \"cursor\": \"pointer\",\n        \"height\": \"64px\",\n        \"margin\": 0,\n        \"padding\": 0,\n      }\n    }\n  >\n    <div\n      className=\"base_18zbz09\"\n      style={\n        Object {\n          \"padding\": \"0 24px\",\n        }\n      }\n    >\n      <div\n        className=\"row_jro6t0\"\n      >\n        <div\n          className=\"label_1nrwvc0\"\n        >\n          New\n        </div>\n        <div\n          className=\"count_1ooccua\"\n        >\n          542\n        </div>\n      </div>\n    </div>\n  </button>\n  <button\n    aria-label=\"Moderated\"\n    onClick={[Function]}\n    style={\n      Object {\n        \"background\": \"transparent\",\n        \"border\": 0,\n        \"cursor\": \"pointer\",\n        \"height\": \"64px\",\n        \"margin\": 0,\n        \"padding\": 0,\n      }\n    }\n  >\n    <div\n      className=\"base_18zbz09\"\n      style={\n        Object {\n          \"padding\": \"0 24px\",\n        }\n      }\n    >\n      <div\n        className=\"row_jro6t0\"\n      >\n        <div\n          className=\"label_1nrwvc0\"\n        >\n          Moderated\n        </div>\n        <div\n          className=\"count_1ooccua\"\n        >\n          923\n        </div>\n      </div>\n    </div>\n  </button>\n</div>\n`;\n\nexports[`Storyshots NavigationTab With Icons Dark 1`] = `\n<div\n  style={\n    Object {\n      \"background\": \"#326891\",\n      \"display\": \"inline-block\",\n    }\n  }\n>\n  <button\n    aria-label=\"Approved\"\n    onClick={[Function]}\n    style={\n      Object {\n        \"background\": \"transparent\",\n        \"border\": 0,\n        \"cursor\": \"pointer\",\n        \"margin\": 0,\n        \"padding\": 0,\n      }\n    }\n  >\n    <div\n      className=\"base_18zbz09-o_O-hasIcon_1ssuw1o\"\n    >\n      <div\n        className=\"icon_amv5wk\"\n      >\n        <svg\n          fill=\"currentColor\"\n          height=\"24\"\n          preserveAspectRatio=\"xMidYMid meet\"\n          style={\n            Object {\n              \"fill\": \"rgba(255, 255, 255, 1)\",\n              \"verticalAlign\": \"middle\",\n            }\n          }\n          viewBox=\"0 0 24 24\"\n          width=\"24\"\n        >\n          <g>\n            <path\n              d=\"M0,0H24V24H0V0Z\"\n              fill=\"none\"\n            />\n            <path\n              d=\"M9,16.17L4.83,12,3.41,13.41,9,19,21,7,19.59,5.59Z\"\n            />\n          </g>\n        </svg>\n      </div>\n      <div\n        className=\"row_jro6t0\"\n      >\n        <div\n          className=\"label_1nrwvc0-o_O-smallLabel_1cy02f1\"\n        >\n          Approved\n        </div>\n        <div\n          className=\"count_1ooccua-o_O-smallLabel_1cy02f1\"\n        >\n          25\n        </div>\n      </div>\n    </div>\n  </button>\n  <button\n    aria-label=\"Highlight\"\n    onClick={[Function]}\n    style={\n      Object {\n        \"background\": \"transparent\",\n        \"border\": 0,\n        \"cursor\": \"pointer\",\n        \"margin\": 0,\n        \"padding\": 0,\n      }\n    }\n  >\n    <div\n      className=\"base_18zbz09-o_O-hasIcon_1ssuw1o\"\n    >\n      <div\n        className=\"icon_amv5wk\"\n      >\n        <svg\n          fill=\"currentColor\"\n          height=\"24\"\n          preserveAspectRatio=\"xMidYMid meet\"\n          style={\n            Object {\n              \"fill\": \"rgba(255, 255, 255, 1)\",\n              \"verticalAlign\": \"middle\",\n            }\n          }\n          viewBox=\"0 0 24 24\"\n          width=\"24\"\n        >\n          <g>\n            <path\n              d=\"M17,3H7A2,2,0,0,0,5,5V21l7-3,7,3V5A2,2,0,0,0,17,3Z\"\n            />\n            <path\n              d=\"M0,0H24V24H0V0Z\"\n              fill=\"none\"\n            />\n          </g>\n        </svg>\n      </div>\n      <div\n        className=\"row_jro6t0\"\n      >\n        <div\n          className=\"label_1nrwvc0-o_O-smallLabel_1cy02f1\"\n        >\n          Highlight\n        </div>\n        <div\n          className=\"count_1ooccua-o_O-smallLabel_1cy02f1\"\n        >\n          25\n        </div>\n      </div>\n    </div>\n  </button>\n  <button\n    aria-label=\"Rejected\"\n    onClick={[Function]}\n    style={\n      Object {\n        \"background\": \"transparent\",\n        \"border\": 0,\n        \"cursor\": \"pointer\",\n        \"margin\": 0,\n        \"padding\": 0,\n      }\n    }\n  >\n    <div\n      className=\"base_18zbz09-o_O-hasIcon_1ssuw1o\"\n    >\n      <div\n        className=\"icon_amv5wk\"\n      >\n        <svg\n          fill=\"currentColor\"\n          height=\"24\"\n          preserveAspectRatio=\"xMidYMid meet\"\n          style={\n            Object {\n              \"fill\": \"rgba(255, 255, 255, 1)\",\n              \"verticalAlign\": \"middle\",\n            }\n          }\n          viewBox=\"0 0 24 24\"\n          width=\"24\"\n        >\n          <g>\n            <path\n              d=\"M19,6.41L17.59,5,12,10.59,6.41,5,5,6.41,10.59,12,5,17.59,6.41,19,12,13.41,17.59,19,19,17.59,13.41,12Z\"\n            />\n            <path\n              d=\"M0,0H24V24H0V0Z\"\n              fill=\"none\"\n            />\n          </g>\n        </svg>\n      </div>\n      <div\n        className=\"row_jro6t0\"\n      >\n        <div\n          className=\"label_1nrwvc0-o_O-smallLabel_1cy02f1\"\n        >\n          Rejected\n        </div>\n        <div\n          className=\"count_1ooccua-o_O-smallLabel_1cy02f1\"\n        >\n          25\n        </div>\n      </div>\n    </div>\n  </button>\n  <button\n    aria-label=\"Deferred\"\n    onClick={[Function]}\n    style={\n      Object {\n        \"background\": \"transparent\",\n        \"border\": 0,\n        \"cursor\": \"pointer\",\n        \"margin\": 0,\n        \"padding\": 0,\n      }\n    }\n  >\n    <div\n      className=\"base_18zbz09-o_O-hasIcon_1ssuw1o\"\n    >\n      <div\n        className=\"icon_amv5wk\"\n      >\n        <svg\n          fill=\"currentColor\"\n          height=\"24\"\n          preserveAspectRatio=\"xMidYMid meet\"\n          style={\n            Object {\n              \"fill\": \"rgba(255, 255, 255, 1)\",\n              \"verticalAlign\": \"middle\",\n            }\n          }\n          viewBox=\"0 0 24 24\"\n          width=\"24\"\n        >\n          <g>\n            <path\n              d=\"M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z\"\n            />\n            <path\n              d=\"M0 0h24v24H0z\"\n              fill=\"none\"\n            />\n            <path\n              d=\"M12.5 7H11v6l5.25 3.15.75-1.23-4.5-2.67z\"\n            />\n          </g>\n        </svg>\n      </div>\n      <div\n        className=\"row_jro6t0\"\n      >\n        <div\n          className=\"label_1nrwvc0-o_O-smallLabel_1cy02f1\"\n        >\n          Deferred\n        </div>\n        <div\n          className=\"count_1ooccua-o_O-smallLabel_1cy02f1\"\n        >\n          25\n        </div>\n      </div>\n    </div>\n  </button>\n  <button\n    aria-label=\"Flagged\"\n    onClick={[Function]}\n    style={\n      Object {\n        \"background\": \"transparent\",\n        \"border\": 0,\n        \"cursor\": \"pointer\",\n        \"margin\": 0,\n        \"padding\": 0,\n      }\n    }\n  >\n    <div\n      className=\"base_18zbz09-o_O-hasIcon_1ssuw1o\"\n    >\n      <div\n        className=\"icon_amv5wk\"\n      >\n        <svg\n          fill=\"currentColor\"\n          height=\"24\"\n          preserveAspectRatio=\"xMidYMid meet\"\n          style={\n            Object {\n              \"fill\": \"rgba(255, 255, 255, 1)\",\n              \"verticalAlign\": \"middle\",\n            }\n          }\n          viewBox=\"0 0 24 24\"\n          width=\"24\"\n        >\n          <g>\n            <path\n              d=\"M-5-4H19V20H-5Z\"\n              fill=\"none\"\n              transform=\"translate(5 4)\"\n            />\n            <path\n              d=\"M9.4,2,9,0H0V17H2V10H7.6L8,12h7V2Z\"\n              transform=\"translate(5 4)\"\n            />\n          </g>\n        </svg>\n      </div>\n      <div\n        className=\"row_jro6t0\"\n      >\n        <div\n          className=\"label_1nrwvc0-o_O-smallLabel_1cy02f1\"\n        >\n          Flagged\n        </div>\n        <div\n          className=\"count_1ooccua-o_O-smallLabel_1cy02f1\"\n        >\n          25\n        </div>\n      </div>\n    </div>\n  </button>\n</div>\n`;\n\nexports[`Storyshots NavigationTab With Icons Light 1`] = `\n<div\n  style={\n    Object {\n      \"display\": \"inline-block\",\n    }\n  }\n>\n  <button\n    aria-label=\"Approved\"\n    onClick={[Function]}\n    style={\n      Object {\n        \"background\": \"transparent\",\n        \"border\": 0,\n        \"cursor\": \"pointer\",\n        \"margin\": 0,\n        \"padding\": 0,\n      }\n    }\n  >\n    <div\n      className=\"base_18zbz09-o_O-hasIcon_1ssuw1o\"\n    >\n      <div\n        className=\"icon_amv5wk\"\n      >\n        <svg\n          fill=\"currentColor\"\n          height=\"24\"\n          preserveAspectRatio=\"xMidYMid meet\"\n          style={\n            Object {\n              \"fill\": \"rgba(0, 0, 0, 0.87)\",\n              \"verticalAlign\": \"middle\",\n            }\n          }\n          viewBox=\"0 0 24 24\"\n          width=\"24\"\n        >\n          <g>\n            <path\n              d=\"M0,0H24V24H0V0Z\"\n              fill=\"none\"\n            />\n            <path\n              d=\"M9,16.17L4.83,12,3.41,13.41,9,19,21,7,19.59,5.59Z\"\n            />\n          </g>\n        </svg>\n      </div>\n      <div\n        className=\"row_jro6t0\"\n      >\n        <div\n          className=\"label_1nrwvc0-o_O-darkLabel_17alx32-o_O-smallLabel_1cy02f1\"\n        >\n          Approved\n        </div>\n        <div\n          className=\"count_1ooccua-o_O-darkCount_ikq2xc-o_O-smallLabel_1cy02f1\"\n        >\n          25\n        </div>\n      </div>\n    </div>\n  </button>\n  <button\n    aria-label=\"Highlight\"\n    onClick={[Function]}\n    style={\n      Object {\n        \"background\": \"transparent\",\n        \"border\": 0,\n        \"cursor\": \"pointer\",\n        \"margin\": 0,\n        \"padding\": 0,\n      }\n    }\n  >\n    <div\n      className=\"base_18zbz09-o_O-hasIcon_1ssuw1o\"\n    >\n      <div\n        className=\"icon_amv5wk\"\n      >\n        <svg\n          fill=\"currentColor\"\n          height=\"24\"\n          preserveAspectRatio=\"xMidYMid meet\"\n          style={\n            Object {\n              \"fill\": \"rgba(0, 0, 0, 0.87)\",\n              \"verticalAlign\": \"middle\",\n            }\n          }\n          viewBox=\"0 0 24 24\"\n          width=\"24\"\n        >\n          <g>\n            <path\n              d=\"M17,3H7A2,2,0,0,0,5,5V21l7-3,7,3V5A2,2,0,0,0,17,3Z\"\n            />\n            <path\n              d=\"M0,0H24V24H0V0Z\"\n              fill=\"none\"\n            />\n          </g>\n        </svg>\n      </div>\n      <div\n        className=\"row_jro6t0\"\n      >\n        <div\n          className=\"label_1nrwvc0-o_O-darkLabel_17alx32-o_O-smallLabel_1cy02f1\"\n        >\n          Highlight\n        </div>\n        <div\n          className=\"count_1ooccua-o_O-darkCount_ikq2xc-o_O-smallLabel_1cy02f1\"\n        >\n          25\n        </div>\n      </div>\n    </div>\n  </button>\n  <button\n    aria-label=\"Rejected\"\n    onClick={[Function]}\n    style={\n      Object {\n        \"background\": \"transparent\",\n        \"border\": 0,\n        \"cursor\": \"pointer\",\n        \"margin\": 0,\n        \"padding\": 0,\n      }\n    }\n  >\n    <div\n      className=\"base_18zbz09-o_O-hasIcon_1ssuw1o\"\n    >\n      <div\n        className=\"icon_amv5wk\"\n      >\n        <svg\n          fill=\"currentColor\"\n          height=\"24\"\n          preserveAspectRatio=\"xMidYMid meet\"\n          style={\n            Object {\n              \"fill\": \"rgba(0, 0, 0, 0.87)\",\n              \"verticalAlign\": \"middle\",\n            }\n          }\n          viewBox=\"0 0 24 24\"\n          width=\"24\"\n        >\n          <g>\n            <path\n              d=\"M19,6.41L17.59,5,12,10.59,6.41,5,5,6.41,10.59,12,5,17.59,6.41,19,12,13.41,17.59,19,19,17.59,13.41,12Z\"\n            />\n            <path\n              d=\"M0,0H24V24H0V0Z\"\n              fill=\"none\"\n            />\n          </g>\n        </svg>\n      </div>\n      <div\n        className=\"row_jro6t0\"\n      >\n        <div\n          className=\"label_1nrwvc0-o_O-darkLabel_17alx32-o_O-smallLabel_1cy02f1\"\n        >\n          Rejected\n        </div>\n        <div\n          className=\"count_1ooccua-o_O-darkCount_ikq2xc-o_O-smallLabel_1cy02f1\"\n        >\n          25\n        </div>\n      </div>\n    </div>\n  </button>\n  <button\n    aria-label=\"Deferred\"\n    onClick={[Function]}\n    style={\n      Object {\n        \"background\": \"transparent\",\n        \"border\": 0,\n        \"cursor\": \"pointer\",\n        \"margin\": 0,\n        \"padding\": 0,\n      }\n    }\n  >\n    <div\n      className=\"base_18zbz09-o_O-hasIcon_1ssuw1o\"\n    >\n      <div\n        className=\"icon_amv5wk\"\n      >\n        <svg\n          fill=\"currentColor\"\n          height=\"24\"\n          preserveAspectRatio=\"xMidYMid meet\"\n          style={\n            Object {\n              \"fill\": \"rgba(0, 0, 0, 0.87)\",\n              \"verticalAlign\": \"middle\",\n            }\n          }\n          viewBox=\"0 0 24 24\"\n          width=\"24\"\n        >\n          <g>\n            <path\n              d=\"M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z\"\n            />\n            <path\n              d=\"M0 0h24v24H0z\"\n              fill=\"none\"\n            />\n            <path\n              d=\"M12.5 7H11v6l5.25 3.15.75-1.23-4.5-2.67z\"\n            />\n          </g>\n        </svg>\n      </div>\n      <div\n        className=\"row_jro6t0\"\n      >\n        <div\n          className=\"label_1nrwvc0-o_O-darkLabel_17alx32-o_O-smallLabel_1cy02f1\"\n        >\n          Deferred\n        </div>\n        <div\n          className=\"count_1ooccua-o_O-darkCount_ikq2xc-o_O-smallLabel_1cy02f1\"\n        >\n          25\n        </div>\n      </div>\n    </div>\n  </button>\n  <button\n    aria-label=\"Flagged\"\n    onClick={[Function]}\n    style={\n      Object {\n        \"background\": \"transparent\",\n        \"border\": 0,\n        \"cursor\": \"pointer\",\n        \"margin\": 0,\n        \"padding\": 0,\n      }\n    }\n  >\n    <div\n      className=\"base_18zbz09-o_O-hasIcon_1ssuw1o\"\n    >\n      <div\n        className=\"icon_amv5wk\"\n      >\n        <svg\n          fill=\"currentColor\"\n          height=\"24\"\n          preserveAspectRatio=\"xMidYMid meet\"\n          style={\n            Object {\n              \"fill\": \"rgba(0, 0, 0, 0.87)\",\n              \"verticalAlign\": \"middle\",\n            }\n          }\n          viewBox=\"0 0 24 24\"\n          width=\"24\"\n        >\n          <g>\n            <path\n              d=\"M-5-4H19V20H-5Z\"\n              fill=\"none\"\n              transform=\"translate(5 4)\"\n            />\n            <path\n              d=\"M9.4,2,9,0H0V17H2V10H7.6L8,12h7V2Z\"\n              transform=\"translate(5 4)\"\n            />\n          </g>\n        </svg>\n      </div>\n      <div\n        className=\"row_jro6t0\"\n      >\n        <div\n          className=\"label_1nrwvc0-o_O-darkLabel_17alx32-o_O-smallLabel_1cy02f1\"\n        >\n          Flagged\n        </div>\n        <div\n          className=\"count_1ooccua-o_O-darkCount_ikq2xc-o_O-smallLabel_1cy02f1\"\n        >\n          25\n        </div>\n      </div>\n    </div>\n  </button>\n</div>\n`;\n\nexports[`Storyshots OverflowContainer Default 1`] = `\n<div\n  className=\"container_7gv3ej\"\n>\n  <div\n    className=\"header_km4hhp\"\n  >\n    <h1>\n      OverflowContainer header\n    </h1>\n  </div>\n  <div\n    className=\"body_1r2c92o\"\n  >\n    <p>\n      OverflowContainer body Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n    </p>\n  </div>\n  <div\n    className=\"footer_1cas3pk\"\n  >\n    <h2>\n      OverflowContainer footer\n    </h2>\n  </div>\n</div>\n`;\n\nexports[`Storyshots OverflowContainer No Footer 1`] = `\n<div\n  className=\"container_7gv3ej\"\n>\n  <div\n    className=\"header_km4hhp\"\n  >\n    <h1>\n      OverflowContainer header\n    </h1>\n  </div>\n  <div\n    className=\"body_1r2c92o\"\n  >\n    <p>\n      OverflowContainer body Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n    </p>\n  </div>\n  <div\n    className=\"footer_1cas3pk\"\n  />\n</div>\n`;\n\nexports[`Storyshots OverflowContainer No SearchHeader 1`] = `\n<div\n  className=\"container_7gv3ej\"\n>\n  <div\n    className=\"header_km4hhp\"\n  />\n  <div\n    className=\"body_1r2c92o\"\n  >\n    <p>\n      OverflowContainer body Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n    </p>\n  </div>\n  <div\n    className=\"footer_1cas3pk\"\n  >\n    <h2>\n      OverflowContainer footer\n    </h2>\n  </div>\n</div>\n`;\n\nexports[`Storyshots Root Splash page 1`] = `\n<div>\n  <div\n    className=\"landing_headerTag\"\n  >\n    Moderator\n  </div>\n  <div\n    className=\"landing_footerTag\"\n  >\n    <a\n      className=\"landing_link\"\n      href=\"https://conversationai.github.io/\"\n      target=\"_blank\"\n    >\n      Learn more\n    </a>\n     \n    <span\n      className=\"landing_extratext\"\n    >\n      about Modereator.\n    </span>\n  </div>\n  <div\n    className=\"landing_centerOnPage\"\n  >\n    <div\n      className=\"landing_bubbleSet\"\n    >\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div />\n      <div />\n      <div />\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div />\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n      <div>\n        <div\n          className=\"landing_bubble\"\n        />\n      </div>\n    </div>\n  </div>\n</div>\n`;\n\nexports[`Storyshots RuleRow Rule Row 1`] = `\n<div\n  className=\"base_165z0ne\"\n  style={\n    Object {\n      \"alignItems\": \"center\",\n      \"display\": \"flex\",\n      \"maxWidth\": \"100%\",\n      \"overflow\": \"hidden\",\n      \"padding\": \"0 0 24px 0\",\n      \"position\": \"relative\",\n    }\n  }\n>\n  <div\n    className=\"selectContainer_e296pg\"\n  >\n    <label\n      className=\"offscreen_vluvpa\"\n      htmlFor=\"categories-1\"\n    >\n      Select a section\n    </label>\n    <select\n      className=\"select_1jkfuz5\"\n      id=\"categories-1\"\n      name=\"categories-1\"\n      onChange={[Function]}\n      value=\"2\"\n    >\n      <option\n        value=\"1\"\n      >\n        Category 1\n      </option>\n      <option\n        value=\"2\"\n      >\n        Category 2\n      </option>\n      <option\n        value=\"3\"\n      >\n        Category 3\n      </option>\n      <option\n        value=\"4\"\n      >\n        Category 4\n      </option>\n      <option\n        value=\"5\"\n      >\n        Category 5\n      </option>\n      <option\n        value=\"6\"\n      >\n        Category 6\n      </option>\n    </select>\n    <span\n      aria-hidden=\"true\"\n      style={\n        Object {\n          \"borderLeft\": \"6px solid transparent\",\n          \"borderRight\": \"6px solid transparent\",\n          \"borderTop\": \"6px solid rgba(0, 0, 0, 0.54)\",\n          \"display\": \"block\",\n          \"height\": 0,\n          \"marginLeft\": \"10px\",\n          \"marginRight\": \"10px\",\n          \"pointerEvents\": \"none\",\n          \"position\": \"absolute\",\n          \"right\": 28,\n          \"top\": 15,\n          \"width\": 0,\n          \"zIndex\": 5,\n        }\n      }\n    />\n  </div>\n  <div\n    className=\"selectContainer_e296pg\"\n  >\n    <label\n      className=\"offscreen_vluvpa\"\n      htmlFor=\"tags-1\"\n    >\n      Select a tag\n    </label>\n    <select\n      className=\"select_1jkfuz5\"\n      id=\"tags-1\"\n      name=\"tags-1\"\n      onChange={[Function]}\n      value=\"\"\n    >\n      <option\n        value=\"1\"\n      >\n        Tag 1\n      </option>\n      <option\n        value=\"2\"\n      >\n        Tag 2\n      </option>\n      <option\n        value=\"3\"\n      >\n        Tag 3\n      </option>\n      <option\n        value=\"4\"\n      >\n        Tag 4\n      </option>\n      <option\n        value=\"5\"\n      >\n        Tag 5\n      </option>\n      <option\n        value=\"6\"\n      >\n        Tag 6\n      </option>\n    </select>\n    <span\n      aria-hidden=\"true\"\n      style={\n        Object {\n          \"borderLeft\": \"6px solid transparent\",\n          \"borderRight\": \"6px solid transparent\",\n          \"borderTop\": \"6px solid rgba(0, 0, 0, 0.54)\",\n          \"display\": \"block\",\n          \"height\": 0,\n          \"marginLeft\": \"10px\",\n          \"marginRight\": \"10px\",\n          \"pointerEvents\": \"none\",\n          \"position\": \"absolute\",\n          \"right\": 28,\n          \"top\": 15,\n          \"width\": 0,\n          \"zIndex\": 5,\n        }\n      }\n    />\n  </div>\n  <label\n    className=\"offscreen_vluvpa\"\n    htmlFor=\"rangeBottom-1\"\n  >\n    Bottom of range\n  </label>\n  <input\n    className=\"input_1m4pna4\"\n    id=\"rangeBottom-1\"\n    max=\"100\"\n    min=\"0\"\n    onChange={[Function]}\n    style={\n      Object {\n        \"marginRight\": 10,\n      }\n    }\n    type=\"number\"\n    value=\"0\"\n  />\n  <label\n    className=\"offscreen_vluvpa\"\n    htmlFor=\"rangeTop-1\"\n  >\n    Top of range\n  </label>\n  <span\n    style={\n      Object {\n        \"fontSize\": 14,\n      }\n    }\n  >\n    –\n  </span>\n  <input\n    className=\"input_1m4pna4\"\n    id=\"rangeTop-1\"\n    max=\"100\"\n    min=\"0\"\n    onChange={[Function]}\n    style={\n      Object {\n        \"marginLeft\": 10,\n      }\n    }\n    type=\"number\"\n    value=\"100\"\n  />\n  <div\n    style={\n      Object {\n        \"paddingLeft\": \"50px\",\n      }\n    }\n  >\n    <button\n      aria-label=\"Delete Rule\"\n      className=\"MuiButtonBase-root MuiIconButton-root\"\n      disabled={false}\n      onBlur={[Function]}\n      onClick={[Function]}\n      onDragLeave={[Function]}\n      onFocus={[Function]}\n      onKeyDown={[Function]}\n      onKeyUp={[Function]}\n      onMouseDown={[Function]}\n      onMouseLeave={[Function]}\n      onMouseUp={[Function]}\n      onTouchEnd={[Function]}\n      onTouchMove={[Function]}\n      onTouchStart={[Function]}\n      tabIndex={0}\n      type=\"button\"\n    >\n      <span\n        className=\"MuiIconButton-label\"\n      >\n        <svg\n          aria-hidden=\"true\"\n          className=\"MuiSvgIcon-root MuiSvgIcon-colorPrimary\"\n          focusable=\"false\"\n          viewBox=\"0 0 24 24\"\n        >\n          <path\n            d=\"M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z\"\n          />\n        </svg>\n      </span>\n    </button>\n  </div>\n</div>\n`;\n\nexports[`Storyshots Scrim dark 1`] = `\n<div\n  className=\"background_m0abwc\"\n  style={\n    Object {\n      \"backgroundColor\": \"rgba(0, 0, 0, 0.54)\",\n    }\n  }\n>\n  <div\n    className=\"background_m0abwc\"\n    onClick={[Function]}\n  />\n  <div\n    className=\"children_mos86w\"\n  >\n    <div\n      style={\n        Object {\n          \"color\": \"#ffffff\",\n          \"fontFamily\": \"LibreFranklin-Medium, sans-serif\",\n          \"fontSize\": 16,\n          \"fontWeight\": 400,\n          \"lineHeight\": 1.5,\n        }\n      }\n    >\n      Hi!\n    </div>\n  </div>\n</div>\n`;\n\nexports[`Storyshots Scrim red 1`] = `\n<div\n  className=\"background_m0abwc\"\n  style={\n    Object {\n      \"backgroundColor\": \"rgba(255,0,0,0.5)\",\n    }\n  }\n>\n  <div\n    className=\"background_m0abwc\"\n    onClick={[Function]}\n  />\n  <div\n    className=\"children_mos86w\"\n  >\n    <div\n      style={\n        Object {\n          \"color\": \"#ffffff\",\n          \"fontFamily\": \"LibreFranklin-Medium, sans-serif\",\n          \"fontSize\": 16,\n          \"fontWeight\": 400,\n          \"lineHeight\": 1.5,\n        }\n      }\n    >\n      Hi!\n    </div>\n  </div>\n</div>\n`;\n\nexports[`Storyshots SearchHeader article header 1`] = `\n<header\n  role=\"banner\"\n>\n  <div\n    className=\"bar_gxbbl0\"\n  >\n    <div\n      className=\"childrenContainer_11b7ka7\"\n    >\n      <div\n        style={\n          Object {\n            \"alignItems\": \"center\",\n            \"display\": \"flex\",\n            \"width\": \"50%\",\n          }\n        }\n      >\n        <a\n          href=\"/\"\n          onClick={[Function]}\n        >\n          <span\n            style={\n              Object {\n                \"clip\": \"rect(1px, 1px, 1px, 1px)\",\n                \"height\": 0,\n                \"position\": \"absolute\",\n                \"width\": 0,\n              }\n            }\n          >\n            Home\n          </span>\n          <svg\n            fill=\"currentColor\"\n            height={24}\n            preserveAspectRatio=\"xMidYMid meet\"\n            style={\n              Object {\n                \"verticalAlign\": \"middle\",\n              }\n            }\n            viewBox=\"0 0 24 24\"\n            width={24}\n          >\n            <g>\n              <path\n                d=\"M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z\"\n              />\n              <path\n                d=\"M0 0h24v24H0z\"\n                fill=\"none\"\n              />\n            </g>\n          </svg>\n        </a>\n        <h1\n          style={\n            Object {\n              \"color\": \"rgba(255, 255, 255, 1)\",\n              \"fontFamily\": \"LibreFranklin-Medium, sans-serif\",\n              \"fontSize\": 16,\n              \"fontWeight\": 400,\n              \"lineHeight\": 1.5,\n              \"marginBottom\": 0,\n              \"marginLeft\": \"24px\",\n              \"marginRight\": 0,\n              \"marginTop\": 0,\n              \"overflow\": \"hidden\",\n              \"textOverflow\": \"ellipsis\",\n              \"whiteSpace\": \"nowrap\",\n              \"width\": \"100%\",\n            }\n          }\n        >\n          At Hiroshima Memorial, Obama Says Nuclear Arms Require Moral Revolution\n        </h1>\n      </div>\n    </div>\n    <button\n      aria-label=\"Author search\"\n      className=\"button_pqy0q6\"\n      onClick={[Function]}\n    >\n      <svg\n        className=\"iconStyle_33fl4q\"\n        fill=\"currentColor\"\n        height=\"24\"\n        preserveAspectRatio=\"xMidYMid meet\"\n        style={\n          Object {\n            \"verticalAlign\": \"middle\",\n          }\n        }\n        viewBox=\"0 0 24 24\"\n        width=\"24\"\n      >\n        <g>\n          <path\n            d=\"M12,12A4,4,0,1,0,8,8,4,4,0,0,0,12,12Zm0,2c-2.67,0-8,1.34-8,4v2H20V18C20,15.34,14.67,14,12,14Z\"\n          />\n          <path\n            d=\"M0,0H24V24H0V0Z\"\n            fill=\"none\"\n          />\n        </g>\n      </svg>\n      <div\n        className=\"buttonText_15rdwd1\"\n      >\n        Author\n      </div>\n    </button>\n    <button\n      aria-label=\"Open comment search\"\n      className=\"button_pqy0q6-o_O-buttonSelected_1f2a2hn\"\n      onClick={[Function]}\n    >\n      <svg\n        className=\"iconStyle_33fl4q\"\n        fill=\"currentColor\"\n        height=\"24\"\n        preserveAspectRatio=\"xMidYMid meet\"\n        style={\n          Object {\n            \"verticalAlign\": \"middle\",\n          }\n        }\n        viewBox=\"0 0 24 24\"\n        width=\"24\"\n      >\n        <g>\n          <path\n            d=\"M12.5,11h-.79l-.28-.27a6.51,6.51,0,1,0-.7.7l.27.28v.79l5,5L17.49,16Zm-6,0A4.5,4.5,0,1,1,11,6.5,4.49,4.49,0,0,1,6.5,11Z\"\n            transform=\"translate(3 3)\"\n          />\n          <path\n            d=\"M-3-3H21V21H-3Z\"\n            fill=\"none\"\n            transform=\"translate(3 3)\"\n          />\n        </g>\n      </svg>\n      <div\n        className=\"buttonText_15rdwd1\"\n      >\n        Content\n      </div>\n    </button>\n    <button\n      aria-label=\"Close search\"\n      className=\"button_pqy0q6\"\n      onClick={[Function]}\n    >\n      <svg\n        fill=\"currentColor\"\n        height=\"24\"\n        preserveAspectRatio=\"xMidYMid meet\"\n        style={\n          Object {\n            \"fill\": \"rgba(255, 255, 255, 1)\",\n            \"verticalAlign\": \"middle\",\n          }\n        }\n        viewBox=\"0 0 24 24\"\n        width=\"24\"\n      >\n        <g>\n          <path\n            d=\"M19,6.41L17.59,5,12,10.59,6.41,5,5,6.41,10.59,12,5,17.59,6.41,19,12,13.41,17.59,19,19,17.59,13.41,12Z\"\n          />\n          <path\n            d=\"M0,0H24V24H0V0Z\"\n            fill=\"none\"\n          />\n        </g>\n      </svg>\n      <div\n        className=\"buttonText_15rdwd1\"\n      >\n        Close\n      </div>\n    </button>\n  </div>\n</header>\n`;\n\nexports[`Storyshots SearchHeader main header 1`] = `\n<header\n  role=\"banner\"\n>\n  <div\n    className=\"bar_gxbbl0\"\n  >\n    <div\n      className=\"childrenContainer_11b7ka7\"\n    >\n      <a\n        href=\"/\"\n        onClick={[Function]}\n        style={\n          Object {\n            \"color\": \"rgba(255, 255, 255, 1)\",\n            \"fontFamily\": \"LibreFranklin-Medium, sans-serif\",\n            \"fontSize\": 20,\n            \"fontWeight\": 400,\n            \"lineHeight\": 1.5,\n          }\n        }\n      >\n        Moderator\n      </a>\n    </div>\n    <button\n      aria-label=\"Author search\"\n      className=\"button_pqy0q6\"\n      onClick={[Function]}\n    >\n      <svg\n        className=\"iconStyle_33fl4q\"\n        fill=\"currentColor\"\n        height=\"24\"\n        preserveAspectRatio=\"xMidYMid meet\"\n        style={\n          Object {\n            \"verticalAlign\": \"middle\",\n          }\n        }\n        viewBox=\"0 0 24 24\"\n        width=\"24\"\n      >\n        <g>\n          <path\n            d=\"M12,12A4,4,0,1,0,8,8,4,4,0,0,0,12,12Zm0,2c-2.67,0-8,1.34-8,4v2H20V18C20,15.34,14.67,14,12,14Z\"\n          />\n          <path\n            d=\"M0,0H24V24H0V0Z\"\n            fill=\"none\"\n          />\n        </g>\n      </svg>\n      <div\n        className=\"buttonText_15rdwd1\"\n      >\n        Author\n      </div>\n    </button>\n    <button\n      aria-label=\"Open comment search\"\n      className=\"button_pqy0q6-o_O-buttonSelected_1f2a2hn\"\n      onClick={[Function]}\n    >\n      <svg\n        className=\"iconStyle_33fl4q\"\n        fill=\"currentColor\"\n        height=\"24\"\n        preserveAspectRatio=\"xMidYMid meet\"\n        style={\n          Object {\n            \"verticalAlign\": \"middle\",\n          }\n        }\n        viewBox=\"0 0 24 24\"\n        width=\"24\"\n      >\n        <g>\n          <path\n            d=\"M12.5,11h-.79l-.28-.27a6.51,6.51,0,1,0-.7.7l.27.28v.79l5,5L17.49,16Zm-6,0A4.5,4.5,0,1,1,11,6.5,4.49,4.49,0,0,1,6.5,11Z\"\n            transform=\"translate(3 3)\"\n          />\n          <path\n            d=\"M-3-3H21V21H-3Z\"\n            fill=\"none\"\n            transform=\"translate(3 3)\"\n          />\n        </g>\n      </svg>\n      <div\n        className=\"buttonText_15rdwd1\"\n      >\n        Content\n      </div>\n    </button>\n    <button\n      aria-label=\"Close search\"\n      className=\"button_pqy0q6\"\n      onClick={[Function]}\n    >\n      <svg\n        fill=\"currentColor\"\n        height=\"24\"\n        preserveAspectRatio=\"xMidYMid meet\"\n        style={\n          Object {\n            \"fill\": \"rgba(255, 255, 255, 1)\",\n            \"verticalAlign\": \"middle\",\n          }\n        }\n        viewBox=\"0 0 24 24\"\n        width=\"24\"\n      >\n        <g>\n          <path\n            d=\"M19,6.41L17.59,5,12,10.59,6.41,5,5,6.41,10.59,12,5,17.59,6.41,19,12,13.41,17.59,19,19,17.59,13.41,12Z\"\n          />\n          <path\n            d=\"M0,0H24V24H0V0Z\"\n            fill=\"none\"\n          />\n        </g>\n      </svg>\n      <div\n        className=\"buttonText_15rdwd1\"\n      >\n        Close\n      </div>\n    </button>\n  </div>\n</header>\n`;\n\nexports[`Storyshots Shortcuts base 1`] = `\n<div\n  className=\"base_164y8je\"\n>\n  <div\n    className=\"header_dmn8hc\"\n  >\n    <h2\n      className=\"title_1egzpug\"\n    >\n      Keyboard Shortcuts\n    </h2>\n    <button\n      aria-label=\"close modal\"\n      className=\"closeButton_1a18mta\"\n      onClick={[Function]}\n    >\n      <svg\n        fill=\"currentColor\"\n        height=\"24\"\n        preserveAspectRatio=\"xMidYMid meet\"\n        style={\n          Object {\n            \"color\": \"rgba(0, 0, 0, 0.54)\",\n            \"verticalAlign\": \"middle\",\n          }\n        }\n        viewBox=\"0 0 24 24\"\n        width=\"24\"\n      >\n        <g>\n          <path\n            d=\"M19,6.41L17.59,5,12,10.59,6.41,5,5,6.41,10.59,12,5,17.59,6.41,19,12,13.41,17.59,19,19,17.59,13.41,12Z\"\n          />\n          <path\n            d=\"M0,0H24V24H0V0Z\"\n            fill=\"none\"\n          />\n        </g>\n      </svg>\n    </button>\n  </div>\n  <div\n    className=\"shorcuts_1cvivhm\"\n  >\n    <div\n      className=\"shorcut_1g422hg\"\n    >\n      <div\n        className=\"name_3xvkq0\"\n      >\n        <svg\n          fill=\"currentColor\"\n          height=\"24\"\n          preserveAspectRatio=\"xMidYMid meet\"\n          style={\n            Object {\n              \"color\": \"#185bac\",\n              \"verticalAlign\": \"middle\",\n            }\n          }\n          viewBox=\"0 0 24 24\"\n          width=\"24\"\n        >\n          <g>\n            <path\n              d=\"M0,0H24V24H0V0Z\"\n              fill=\"none\"\n            />\n            <path\n              d=\"M9,16.17L4.83,12,3.41,13.41,9,19,21,7,19.59,5.59Z\"\n            />\n          </g>\n        </svg>\n        <span\n          className=\"nameText_g3nx7e\"\n        >\n          Approve\n        </span>\n      </div>\n      <div\n        className=\"keys_jro6t0\"\n      >\n        <div\n          className=\"key_1h9xu26\"\n        >\n          alt\n        </div>\n        <div\n          className=\"key_1h9xu26\"\n        >\n          A\n        </div>\n      </div>\n    </div>\n    <div\n      className=\"shorcut_1g422hg\"\n    >\n      <div\n        className=\"name_3xvkq0\"\n      >\n        <svg\n          fill=\"currentColor\"\n          height=\"24\"\n          preserveAspectRatio=\"xMidYMid meet\"\n          style={\n            Object {\n              \"color\": \"#185bac\",\n              \"verticalAlign\": \"middle\",\n            }\n          }\n          viewBox=\"0 0 24 24\"\n          width=\"24\"\n        >\n          <g>\n            <path\n              d=\"M17,3H7A2,2,0,0,0,5,5V21l7-3,7,3V5A2,2,0,0,0,17,3Z\"\n            />\n            <path\n              d=\"M0,0H24V24H0V0Z\"\n              fill=\"none\"\n            />\n          </g>\n        </svg>\n        <span\n          className=\"nameText_g3nx7e\"\n        >\n          Highlight\n        </span>\n      </div>\n      <div\n        className=\"keys_jro6t0\"\n      >\n        <div\n          className=\"key_1h9xu26\"\n        >\n          alt\n        </div>\n        <div\n          className=\"key_1h9xu26\"\n        >\n          H\n        </div>\n      </div>\n    </div>\n    <div\n      className=\"shorcut_1g422hg\"\n    >\n      <div\n        className=\"name_3xvkq0\"\n      >\n        <svg\n          fill=\"currentColor\"\n          height=\"24\"\n          preserveAspectRatio=\"xMidYMid meet\"\n          style={\n            Object {\n              \"color\": \"#185bac\",\n              \"verticalAlign\": \"middle\",\n            }\n          }\n          viewBox=\"0 0 24 24\"\n          width=\"24\"\n        >\n          <g>\n            <path\n              d=\"M19,6.41L17.59,5,12,10.59,6.41,5,5,6.41,10.59,12,5,17.59,6.41,19,12,13.41,17.59,19,19,17.59,13.41,12Z\"\n            />\n            <path\n              d=\"M0,0H24V24H0V0Z\"\n              fill=\"none\"\n            />\n          </g>\n        </svg>\n        <span\n          className=\"nameText_g3nx7e\"\n        >\n          Reject\n        </span>\n      </div>\n      <div\n        className=\"keys_jro6t0\"\n      >\n        <div\n          className=\"key_1h9xu26\"\n        >\n          alt\n        </div>\n        <div\n          className=\"key_1h9xu26\"\n        >\n          R\n        </div>\n      </div>\n    </div>\n    <div\n      className=\"shorcut_1g422hg\"\n    >\n      <div\n        className=\"name_3xvkq0\"\n      >\n        <svg\n          fill=\"currentColor\"\n          height=\"24\"\n          preserveAspectRatio=\"xMidYMid meet\"\n          style={\n            Object {\n              \"color\": \"#185bac\",\n              \"verticalAlign\": \"middle\",\n            }\n          }\n          viewBox=\"0 0 24 24\"\n          width=\"24\"\n        >\n          <g>\n            <path\n              d=\"M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z\"\n            />\n            <path\n              d=\"M0 0h24v24H0z\"\n              fill=\"none\"\n            />\n            <path\n              d=\"M12.5 7H11v6l5.25 3.15.75-1.23-4.5-2.67z\"\n            />\n          </g>\n        </svg>\n        <span\n          className=\"nameText_g3nx7e\"\n        >\n          Defer\n        </span>\n      </div>\n      <div\n        className=\"keys_jro6t0\"\n      >\n        <div\n          className=\"key_1h9xu26\"\n        >\n          alt\n        </div>\n        <div\n          className=\"key_1h9xu26\"\n        >\n          D\n        </div>\n      </div>\n    </div>\n    <div\n      className=\"shorcut_1g422hg\"\n    >\n      <div\n        className=\"name_3xvkq0\"\n      >\n        <svg\n          fill=\"currentColor\"\n          height={24}\n          preserveAspectRatio=\"xMidYMid meet\"\n          style={\n            Object {\n              \"fill\": \"rgba(0, 0, 0, 0.54)\",\n              \"transform\": \"rotate(90deg)\",\n              \"verticalAlign\": \"middle\",\n            }\n          }\n          viewBox=\"0 0 24 24\"\n          width={24}\n        >\n          <g>\n            <path\n              d=\"M16,7H3.83L9.42,1.41,8,0,0,8l8,8,1.41-1.41L3.83,9H16Z\"\n              transform=\"translate(4 4)\"\n            />\n          </g>\n        </svg>\n        <span\n          className=\"nameText_g3nx7e\"\n        >\n          Previous\n        </span>\n      </div>\n      <div\n        className=\"keys_jro6t0\"\n      >\n        <div\n          className=\"key_1h9xu26\"\n        >\n          alt\n        </div>\n        <div\n          aria-label=\"Up arrow\"\n          className=\"key_1h9xu26\"\n        >\n          <svg\n            fill=\"currentColor\"\n            height=\"24\"\n            preserveAspectRatio=\"xMidYMid meet\"\n            style={\n              Object {\n                \"fill\": \"rgba(0, 0, 0, 0.54)\",\n                \"verticalAlign\": \"middle\",\n              }\n            }\n            viewBox=\"0 0 24 24\"\n            width=\"24\"\n          >\n            <g>\n              <path\n                d=\"M7 14l5-5 5 5z\"\n              />\n              <path\n                d=\"M0 0h24v24H0z\"\n                fill=\"none\"\n              />\n            </g>\n          </svg>\n        </div>\n      </div>\n    </div>\n    <div\n      className=\"shorcut_1g422hg\"\n    >\n      <div\n        className=\"name_3xvkq0\"\n      >\n        <svg\n          fill=\"currentColor\"\n          height={24}\n          preserveAspectRatio=\"xMidYMid meet\"\n          style={\n            Object {\n              \"fill\": \"rgba(0, 0, 0, 0.54)\",\n              \"transform\": \"rotate(-90deg)\",\n              \"verticalAlign\": \"middle\",\n            }\n          }\n          viewBox=\"0 0 24 24\"\n          width={24}\n        >\n          <g>\n            <path\n              d=\"M16,7H3.83L9.42,1.41,8,0,0,8l8,8,1.41-1.41L3.83,9H16Z\"\n              transform=\"translate(4 4)\"\n            />\n          </g>\n        </svg>\n        <span\n          className=\"nameText_g3nx7e\"\n        >\n          Next\n        </span>\n      </div>\n      <div\n        className=\"keys_jro6t0\"\n      >\n        <div\n          className=\"key_1h9xu26\"\n        >\n          alt\n        </div>\n        <div\n          aria-label=\"Down arrow\"\n          className=\"key_1h9xu26\"\n        >\n          <svg\n            fill=\"currentColor\"\n            height=\"24\"\n            preserveAspectRatio=\"xMidYMid meet\"\n            style={\n              Object {\n                \"fill\": \"rgba(0, 0, 0, 0.54)\",\n                \"verticalAlign\": \"middle\",\n              }\n            }\n            viewBox=\"0 0 24 24\"\n            width=\"24\"\n          >\n            <g>\n              <path\n                d=\"M7 10l5 5 5-5z\"\n              />\n              <path\n                d=\"M0 0h24v24H0z\"\n                fill=\"none\"\n              />\n            </g>\n          </svg>\n        </div>\n      </div>\n    </div>\n  </div>\n</div>\n`;\n\nexports[`Storyshots SingleComment base 1`] = `\n<div\n  style={\n    Object {\n      \"background\": \"rgba(0, 0, 0, 0.1)\",\n      \"padding\": \"20px 0\",\n      \"width\": \"100%\",\n    }\n  }\n>\n  <div\n    style={\n      Object {\n        \"background\": \"#fff\",\n        \"margin\": \"0 auto\",\n        \"maxWidth\": \"700px\",\n      }\n    }\n  >\n    <div>\n      <div\n        className=\"base_1ekfd5e\"\n      >\n        <div\n          className=\"header_rv4s4m\"\n        >\n          <div\n            style={\n              Object {\n                \"display\": \"inline-block\",\n                \"margin\": \"1px\",\n              }\n            }\n          >\n            <div\n              aria-describedby={null}\n              className=\"MuiAvatar-root MuiAvatar-circle\"\n              onBlur={[Function]}\n              onFocus={[Function]}\n              onMouseLeave={[Function]}\n              onMouseOver={[Function]}\n              onTouchEnd={[Function]}\n              onTouchStart={[Function]}\n              style={\n                Object {\n                  \"backgroundColor\": \"rgb(7, 60, 43)\",\n                  \"color\": \"white\",\n                  \"fontSize\": \"30px\",\n                  \"height\": \"60px\",\n                  \"width\": \"60px\",\n                }\n              }\n              title=\"Bridie Skiles IV\"\n            >\n              <img\n                alt=\"Bridie Skiles IV\"\n                className=\"MuiAvatar-img\"\n                src=\"https://s3.amazonaws.com/uifaces/faces/twitter/devinhalladay/128.jpg\"\n              />\n            </div>\n          </div>\n          <div\n            className=\"nameColumn_14w2fwv\"\n          >\n            <div\n              className=\"name_1mvjcjb\"\n            >\n              <a\n                className=\"authorName_hhj2q7\"\n                href=\"/search?searchByAuthor=true&term=Bridie%20Skiles%20IV\"\n                onClick={[Function]}\n              >\n                Bridie Skiles IV\n              </a>\n            </div>\n            <div\n              className=\"meta_1chol2p\"\n            >\n              <div>\n                <div\n                  className=\"location_l8m5sh\"\n                >\n                  <span>\n                    NYC\n                  </span>\n                </div>\n              </div>\n              <div\n                className=\"details_pzfj3t\"\n              >\n                <div\n                  className=\"row_wo3e5q\"\n                >\n                  <div\n                    className=\"icon_1x4du4h\"\n                  >\n                    <svg\n                      fill=\"currentColor\"\n                      height={20}\n                      preserveAspectRatio=\"xMidYMid meet\"\n                      style={\n                        Object {\n                          \"fill\": \"rgba(0, 0, 0, 0.54)\",\n                          \"verticalAlign\": \"middle\",\n                        }\n                      }\n                      viewBox=\"0 0 24 24\"\n                      width={20}\n                    >\n                      <g>\n                        <path\n                          d=\"M0,0H24V24H0V0Z\"\n                          fill=\"none\"\n                        />\n                        <path\n                          d=\"M20,4H4A2,2,0,0,0,2,6V18a2,2,0,0,0,2,2H20a2,2,0,0,0,2-2V6A2,2,0,0,0,20,4Zm0,14H4V8l8,5,8-5V18Zm-8-7L4,6H20Z\"\n                        />\n                      </g>\n                    </svg>\n                  </div>\n                  <div\n                    className=\"label_d8otiy\"\n                  >\n                    <a\n                      href=\"mailto:name@email.com\"\n                      style={\n                        Object {\n                          \":focus\": Object {\n                            \"outline\": 0,\n                            \"textDecoration\": \"underline\",\n                          },\n                          \"color\": \"rgba(0, 0, 0, 0.54)\",\n                          \"textDecoration\": \"none\",\n                        }\n                      }\n                    >\n                      name@email.com\n                    </a>\n                  </div>\n                </div>\n                <a\n                  className=\"linkFocus_euyru4\"\n                  href=\"/search?searchByAuthor=true&term=test\"\n                  onClick={[Function]}\n                >\n                  <div\n                    className=\"row_wo3e5q\"\n                  >\n                    <div\n                      className=\"icon_1x4du4h\"\n                    >\n                      <svg\n                        fill=\"currentColor\"\n                        height={20}\n                        preserveAspectRatio=\"xMidYMid meet\"\n                        style={\n                          Object {\n                            \"fill\": \"rgba(0, 0, 0, 0.54)\",\n                            \"verticalAlign\": \"middle\",\n                          }\n                        }\n                        viewBox=\"0 0 24 24\"\n                        width={20}\n                      >\n                        <g>\n                          <path\n                            d=\"M20 5H4c-1.1 0-1.99.9-1.99 2L2 17c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm-9 3h2v2h-2V8zm0 3h2v2h-2v-2zM8 8h2v2H8V8zm0 3h2v2H8v-2zm-1 2H5v-2h2v2zm0-3H5V8h2v2zm9 7H8v-2h8v2zm0-4h-2v-2h2v2zm0-3h-2V8h2v2zm3 3h-2v-2h2v2zm0-3h-2V8h2v2z\"\n                          />\n                          <path\n                            d=\"M0 0h24v24H0zm0 0h24v24H0z\"\n                            fill=\"none\"\n                          />\n                        </g>\n                      </svg>\n                    </div>\n                    <div\n                      className=\"label_d8otiy\"\n                    >\n                      test\n                    </div>\n                  </div>\n                </a>\n              </div>\n            </div>\n          </div>\n        </div>\n      </div>\n      <div\n        className=\"base_wgmchy\"\n      >\n        <div\n          className=\"commentTaggingContainer_10fbbuu\"\n        >\n          <div\n            className=\"base_1lhmnx1\"\n          >\n            <div\n              className=\"tagsContainer_5qtx03\"\n            >\n              <h4\n                className=\"offscreen_vluvpa\"\n              >\n                Assigned tags\n              </h4>\n              <p\n                className=\"offscreen_vluvpa\"\n              >\n                Click to remove.\n              </p>\n            </div>\n          </div>\n        </div>\n        <div\n          className=\"meta_ynnic0\"\n        >\n          <div\n            className=\"metaType_kvauw4\"\n          >\n            <span>\n              Nov. 30, 2016 12:00 AM\n               \n            </span>\n          </div>\n        </div>\n        <style>\n          \n  .comment-body a {\n    text-decoration: underline;\n  }\n\n  .comment-body b {\n    color: #f00;\n  }\n\n  .comment-body::selection,\n  .comment-body *::selection {\n    background: #185bac;\n    borderColor: rgba(255, 255, 255, 1);\n    color: rgba(255, 255, 255, 1);\n  }\n\n  .tag {\n    border-bottom-width: 1px;\n    border-bottom-style: solid;\n  }\n\n  .tag-obscene {\n    border-bottom-color: #d71b60;\n    color: #d71b60;\n  }\n\n  .tag-incoherent {\n    border-bottom-color: #9c28b1;\n    color: #9c28b1;\n  }\n\n  .tag-spam {\n    border-bottom-color: #673bb8;\n    color: #673bb8;\n  }\n\n  .tag-off-topic {\n    border-bottom-color: #3f51b5;\n    color: #3f51b5;\n  }\n\n  .tag-inflammatory {\n    border-bottom-color: #1976d3;\n    color: #1976d3;\n  }\n\n  .tag-unsubstantial {\n    border-bottom-color: #3d5afe;\n    color: #3d5afe;\n  }\n\n  .tag-other {\n    border-bottom-color: #01828f;\n    color: #01828f;\n  }\n\n        </style>\n        <div\n          className=\"body_14k49ou comment-body\"\n        >\n          <div>\n            <div\n              onMouseDown={[Function]}\n              onTouchStart={[Function]}\n            >\n              <div>\n                Omnis quo qui deserunt. Magnam minus mollitia rerum et. Quidem consequuntur voluptate voluptas.\n \nMaxime eos repudiandae deserunt quia quis. Mollitia dolor repellendus velit nulla est et. Voluptates voluptatem quos dolorem at. Qui necessitatibus numquam consectetur adipisci enim.\n \nEt blanditiis assumenda repellendus. Itaque omnis corporis quod. Eos fugiat blanditiis blanditiis est necessitatibus voluptas qui deserunt sint.\n              </div>\n            </div>\n          </div>\n        </div>\n        <div\n          className=\"tags_1joc06t\"\n        />\n        <button\n          aria-label=\"View all tags\"\n          className=\"scoresLink_186dkgy\"\n          onClick={[Function]}\n          type=\"button\"\n        >\n          View all tags\n        </button>\n      </div>\n    </div>\n  </div>\n</div>\n`;\n\nexports[`Storyshots SingleComment can edit comment 1`] = `\n<div\n  style={\n    Object {\n      \"background\": \"rgba(0, 0, 0, 0.1)\",\n      \"padding\": \"20px 0\",\n      \"width\": \"100%\",\n    }\n  }\n>\n  <div\n    style={\n      Object {\n        \"background\": \"#fff\",\n        \"margin\": \"0 auto\",\n        \"maxWidth\": \"700px\",\n      }\n    }\n  >\n    <div>\n      <div\n        className=\"base_1ekfd5e\"\n      >\n        <div\n          className=\"header_rv4s4m\"\n        >\n          <div\n            style={\n              Object {\n                \"display\": \"inline-block\",\n                \"margin\": \"1px\",\n              }\n            }\n          >\n            <div\n              aria-describedby={null}\n              className=\"MuiAvatar-root MuiAvatar-circle\"\n              onBlur={[Function]}\n              onFocus={[Function]}\n              onMouseLeave={[Function]}\n              onMouseOver={[Function]}\n              onTouchEnd={[Function]}\n              onTouchStart={[Function]}\n              style={\n                Object {\n                  \"backgroundColor\": \"rgb(7, 60, 43)\",\n                  \"color\": \"white\",\n                  \"fontSize\": \"30px\",\n                  \"height\": \"60px\",\n                  \"width\": \"60px\",\n                }\n              }\n              title=\"Bridie Skiles IV\"\n            >\n              <img\n                alt=\"Bridie Skiles IV\"\n                className=\"MuiAvatar-img\"\n                src=\"https://s3.amazonaws.com/uifaces/faces/twitter/devinhalladay/128.jpg\"\n              />\n            </div>\n          </div>\n          <div\n            className=\"nameColumn_14w2fwv\"\n          >\n            <div\n              className=\"name_1mvjcjb\"\n            >\n              <a\n                className=\"authorName_hhj2q7\"\n                href=\"/search?searchByAuthor=true&term=Bridie%20Skiles%20IV\"\n                onClick={[Function]}\n              >\n                Bridie Skiles IV\n              </a>\n            </div>\n            <div\n              className=\"meta_1chol2p\"\n            >\n              <div>\n                <div\n                  className=\"location_l8m5sh\"\n                >\n                  <span>\n                    NYC\n                  </span>\n                </div>\n              </div>\n              <div\n                className=\"details_pzfj3t\"\n              >\n                <div\n                  className=\"row_wo3e5q\"\n                >\n                  <div\n                    className=\"icon_1x4du4h\"\n                  >\n                    <svg\n                      fill=\"currentColor\"\n                      height={20}\n                      preserveAspectRatio=\"xMidYMid meet\"\n                      style={\n                        Object {\n                          \"fill\": \"rgba(0, 0, 0, 0.54)\",\n                          \"verticalAlign\": \"middle\",\n                        }\n                      }\n                      viewBox=\"0 0 24 24\"\n                      width={20}\n                    >\n                      <g>\n                        <path\n                          d=\"M0,0H24V24H0V0Z\"\n                          fill=\"none\"\n                        />\n                        <path\n                          d=\"M20,4H4A2,2,0,0,0,2,6V18a2,2,0,0,0,2,2H20a2,2,0,0,0,2-2V6A2,2,0,0,0,20,4Zm0,14H4V8l8,5,8-5V18Zm-8-7L4,6H20Z\"\n                        />\n                      </g>\n                    </svg>\n                  </div>\n                  <div\n                    className=\"label_d8otiy\"\n                  >\n                    <a\n                      href=\"mailto:name@email.com\"\n                      style={\n                        Object {\n                          \":focus\": Object {\n                            \"outline\": 0,\n                            \"textDecoration\": \"underline\",\n                          },\n                          \"color\": \"rgba(0, 0, 0, 0.54)\",\n                          \"textDecoration\": \"none\",\n                        }\n                      }\n                    >\n                      name@email.com\n                    </a>\n                  </div>\n                </div>\n                <a\n                  className=\"linkFocus_euyru4\"\n                  href=\"/search?searchByAuthor=true&term=test\"\n                  onClick={[Function]}\n                >\n                  <div\n                    className=\"row_wo3e5q\"\n                  >\n                    <div\n                      className=\"icon_1x4du4h\"\n                    >\n                      <svg\n                        fill=\"currentColor\"\n                        height={20}\n                        preserveAspectRatio=\"xMidYMid meet\"\n                        style={\n                          Object {\n                            \"fill\": \"rgba(0, 0, 0, 0.54)\",\n                            \"verticalAlign\": \"middle\",\n                          }\n                        }\n                        viewBox=\"0 0 24 24\"\n                        width={20}\n                      >\n                        <g>\n                          <path\n                            d=\"M20 5H4c-1.1 0-1.99.9-1.99 2L2 17c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm-9 3h2v2h-2V8zm0 3h2v2h-2v-2zM8 8h2v2H8V8zm0 3h2v2H8v-2zm-1 2H5v-2h2v2zm0-3H5V8h2v2zm9 7H8v-2h8v2zm0-4h-2v-2h2v2zm0-3h-2V8h2v2zm3 3h-2v-2h2v2zm0-3h-2V8h2v2z\"\n                          />\n                          <path\n                            d=\"M0 0h24v24H0zm0 0h24v24H0z\"\n                            fill=\"none\"\n                          />\n                        </g>\n                      </svg>\n                    </div>\n                    <div\n                      className=\"label_d8otiy\"\n                    >\n                      test\n                    </div>\n                  </div>\n                </a>\n              </div>\n            </div>\n          </div>\n        </div>\n      </div>\n      <div\n        className=\"base_wgmchy\"\n      >\n        <div\n          className=\"commentTaggingContainer_10fbbuu\"\n        >\n          <div\n            className=\"base_1lhmnx1\"\n          >\n            <div\n              className=\"tagsContainer_5qtx03\"\n            >\n              <h4\n                className=\"offscreen_vluvpa\"\n              >\n                Assigned tags\n              </h4>\n              <p\n                className=\"offscreen_vluvpa\"\n              >\n                Click to remove.\n              </p>\n            </div>\n          </div>\n          <button\n            aria-label=\"Edit Comment Text\"\n            className=\"editButton_kdc080\"\n            onBlur={[Function]}\n            onClick={[Function]}\n            onFocus={[Function]}\n            onMouseEnter={[Function]}\n            onMouseLeave={[Function]}\n          >\n            <svg\n              fill=\"currentColor\"\n              height={20}\n              preserveAspectRatio=\"xMidYMid meet\"\n              style={\n                Object {\n                  \"fill\": \"#185bac\",\n                  \"verticalAlign\": \"middle\",\n                }\n              }\n              viewBox=\"0 0 24 24\"\n              width={20}\n            >\n              <path\n                d=\"M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z\"\n              />\n            </svg>\n          </button>\n        </div>\n        <div\n          className=\"meta_ynnic0\"\n        >\n          <div\n            className=\"metaType_kvauw4\"\n          >\n            <span>\n              Nov. 30, 2016 12:00 AM\n               \n            </span>\n          </div>\n        </div>\n        <style>\n          \n  .comment-body a {\n    text-decoration: underline;\n  }\n\n  .comment-body b {\n    color: #f00;\n  }\n\n  .comment-body::selection,\n  .comment-body *::selection {\n    background: #185bac;\n    borderColor: rgba(255, 255, 255, 1);\n    color: rgba(255, 255, 255, 1);\n  }\n\n  .tag {\n    border-bottom-width: 1px;\n    border-bottom-style: solid;\n  }\n\n  .tag-obscene {\n    border-bottom-color: #d71b60;\n    color: #d71b60;\n  }\n\n  .tag-incoherent {\n    border-bottom-color: #9c28b1;\n    color: #9c28b1;\n  }\n\n  .tag-spam {\n    border-bottom-color: #673bb8;\n    color: #673bb8;\n  }\n\n  .tag-off-topic {\n    border-bottom-color: #3f51b5;\n    color: #3f51b5;\n  }\n\n  .tag-inflammatory {\n    border-bottom-color: #1976d3;\n    color: #1976d3;\n  }\n\n  .tag-unsubstantial {\n    border-bottom-color: #3d5afe;\n    color: #3d5afe;\n  }\n\n  .tag-other {\n    border-bottom-color: #01828f;\n    color: #01828f;\n  }\n\n        </style>\n        <div\n          className=\"body_14k49ou comment-body\"\n        >\n          <div>\n            <div\n              onMouseDown={[Function]}\n              onTouchStart={[Function]}\n            >\n              <div>\n                Omnis quo qui deserunt. Magnam minus mollitia rerum et. Quidem consequuntur voluptate voluptas.\n \nMaxime eos repudiandae deserunt quia quis. Mollitia dolor repellendus velit nulla est et. Voluptates voluptatem quos dolorem at. Qui necessitatibus numquam consectetur adipisci enim.\n \nEt blanditiis assumenda repellendus. Itaque omnis corporis quod. Eos fugiat blanditiis blanditiis est necessitatibus voluptas qui deserunt sint.\n              </div>\n            </div>\n          </div>\n        </div>\n        <div\n          className=\"tags_1joc06t\"\n        />\n        <button\n          aria-label=\"View all tags\"\n          className=\"scoresLink_186dkgy\"\n          onClick={[Function]}\n          type=\"button\"\n        >\n          View all tags\n        </button>\n      </div>\n    </div>\n  </div>\n</div>\n`;\n\nexports[`Storyshots SingleComment has url 1`] = `\n<div\n  style={\n    Object {\n      \"background\": \"rgba(0, 0, 0, 0.1)\",\n      \"padding\": \"20px 0\",\n      \"width\": \"100%\",\n    }\n  }\n>\n  <div\n    style={\n      Object {\n        \"background\": \"#fff\",\n        \"margin\": \"0 auto\",\n        \"maxWidth\": \"700px\",\n      }\n    }\n  >\n    <div>\n      <div\n        className=\"base_1ekfd5e\"\n      >\n        <div\n          className=\"header_rv4s4m\"\n        >\n          <div\n            style={\n              Object {\n                \"display\": \"inline-block\",\n                \"margin\": \"1px\",\n              }\n            }\n          >\n            <div\n              aria-describedby={null}\n              className=\"MuiAvatar-root MuiAvatar-circle\"\n              onBlur={[Function]}\n              onFocus={[Function]}\n              onMouseLeave={[Function]}\n              onMouseOver={[Function]}\n              onTouchEnd={[Function]}\n              onTouchStart={[Function]}\n              style={\n                Object {\n                  \"backgroundColor\": \"rgb(7, 60, 43)\",\n                  \"color\": \"white\",\n                  \"fontSize\": \"30px\",\n                  \"height\": \"60px\",\n                  \"width\": \"60px\",\n                }\n              }\n              title=\"Bridie Skiles IV\"\n            >\n              <img\n                alt=\"Bridie Skiles IV\"\n                className=\"MuiAvatar-img\"\n                src=\"https://s3.amazonaws.com/uifaces/faces/twitter/devinhalladay/128.jpg\"\n              />\n            </div>\n          </div>\n          <div\n            className=\"nameColumn_14w2fwv\"\n          >\n            <div\n              className=\"name_1mvjcjb\"\n            >\n              <a\n                className=\"authorName_hhj2q7\"\n                href=\"/search?searchByAuthor=true&term=Bridie%20Skiles%20IV\"\n                onClick={[Function]}\n              >\n                Bridie Skiles IV\n              </a>\n            </div>\n            <div\n              className=\"meta_1chol2p\"\n            >\n              <div>\n                <div\n                  className=\"location_l8m5sh\"\n                >\n                  <span>\n                    NYC\n                  </span>\n                </div>\n              </div>\n              <div\n                className=\"details_pzfj3t\"\n              >\n                <div\n                  className=\"row_wo3e5q\"\n                >\n                  <div\n                    className=\"icon_1x4du4h\"\n                  >\n                    <svg\n                      fill=\"currentColor\"\n                      height={20}\n                      preserveAspectRatio=\"xMidYMid meet\"\n                      style={\n                        Object {\n                          \"fill\": \"rgba(0, 0, 0, 0.54)\",\n                          \"verticalAlign\": \"middle\",\n                        }\n                      }\n                      viewBox=\"0 0 24 24\"\n                      width={20}\n                    >\n                      <g>\n                        <path\n                          d=\"M0,0H24V24H0V0Z\"\n                          fill=\"none\"\n                        />\n                        <path\n                          d=\"M20,4H4A2,2,0,0,0,2,6V18a2,2,0,0,0,2,2H20a2,2,0,0,0,2-2V6A2,2,0,0,0,20,4Zm0,14H4V8l8,5,8-5V18Zm-8-7L4,6H20Z\"\n                        />\n                      </g>\n                    </svg>\n                  </div>\n                  <div\n                    className=\"label_d8otiy\"\n                  >\n                    <a\n                      href=\"mailto:name@email.com\"\n                      style={\n                        Object {\n                          \":focus\": Object {\n                            \"outline\": 0,\n                            \"textDecoration\": \"underline\",\n                          },\n                          \"color\": \"rgba(0, 0, 0, 0.54)\",\n                          \"textDecoration\": \"none\",\n                        }\n                      }\n                    >\n                      name@email.com\n                    </a>\n                  </div>\n                </div>\n                <a\n                  className=\"linkFocus_euyru4\"\n                  href=\"/search?searchByAuthor=true&term=test\"\n                  onClick={[Function]}\n                >\n                  <div\n                    className=\"row_wo3e5q\"\n                  >\n                    <div\n                      className=\"icon_1x4du4h\"\n                    >\n                      <svg\n                        fill=\"currentColor\"\n                        height={20}\n                        preserveAspectRatio=\"xMidYMid meet\"\n                        style={\n                          Object {\n                            \"fill\": \"rgba(0, 0, 0, 0.54)\",\n                            \"verticalAlign\": \"middle\",\n                          }\n                        }\n                        viewBox=\"0 0 24 24\"\n                        width={20}\n                      >\n                        <g>\n                          <path\n                            d=\"M20 5H4c-1.1 0-1.99.9-1.99 2L2 17c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm-9 3h2v2h-2V8zm0 3h2v2h-2v-2zM8 8h2v2H8V8zm0 3h2v2H8v-2zm-1 2H5v-2h2v2zm0-3H5V8h2v2zm9 7H8v-2h8v2zm0-4h-2v-2h2v2zm0-3h-2V8h2v2zm3 3h-2v-2h2v2zm0-3h-2V8h2v2z\"\n                          />\n                          <path\n                            d=\"M0 0h24v24H0zm0 0h24v24H0z\"\n                            fill=\"none\"\n                          />\n                        </g>\n                      </svg>\n                    </div>\n                    <div\n                      className=\"label_d8otiy\"\n                    >\n                      test\n                    </div>\n                  </div>\n                </a>\n              </div>\n            </div>\n          </div>\n        </div>\n      </div>\n      <div\n        className=\"base_wgmchy\"\n      >\n        <div\n          className=\"commentTaggingContainer_10fbbuu\"\n        >\n          <div\n            className=\"base_1lhmnx1\"\n          >\n            <div\n              className=\"tagsContainer_5qtx03\"\n            >\n              <h4\n                className=\"offscreen_vluvpa\"\n              >\n                Assigned tags\n              </h4>\n              <p\n                className=\"offscreen_vluvpa\"\n              >\n                Click to remove.\n              </p>\n            </div>\n          </div>\n        </div>\n        <div\n          className=\"meta_ynnic0\"\n        >\n          <div\n            className=\"metaType_kvauw4\"\n          >\n            <a\n              className=\"link_1ktjehn\"\n              href=\"/http://www.example.com/\"\n              onClick={[Function]}\n            >\n              Nov. 30, 2016 12:00 AM\n               \n            </a>\n          </div>\n        </div>\n        <style>\n          \n  .comment-body a {\n    text-decoration: underline;\n  }\n\n  .comment-body b {\n    color: #f00;\n  }\n\n  .comment-body::selection,\n  .comment-body *::selection {\n    background: #185bac;\n    borderColor: rgba(255, 255, 255, 1);\n    color: rgba(255, 255, 255, 1);\n  }\n\n  .tag {\n    border-bottom-width: 1px;\n    border-bottom-style: solid;\n  }\n\n  .tag-obscene {\n    border-bottom-color: #d71b60;\n    color: #d71b60;\n  }\n\n  .tag-incoherent {\n    border-bottom-color: #9c28b1;\n    color: #9c28b1;\n  }\n\n  .tag-spam {\n    border-bottom-color: #673bb8;\n    color: #673bb8;\n  }\n\n  .tag-off-topic {\n    border-bottom-color: #3f51b5;\n    color: #3f51b5;\n  }\n\n  .tag-inflammatory {\n    border-bottom-color: #1976d3;\n    color: #1976d3;\n  }\n\n  .tag-unsubstantial {\n    border-bottom-color: #3d5afe;\n    color: #3d5afe;\n  }\n\n  .tag-other {\n    border-bottom-color: #01828f;\n    color: #01828f;\n  }\n\n        </style>\n        <div\n          className=\"body_14k49ou comment-body\"\n        >\n          <div>\n            <div\n              onMouseDown={[Function]}\n              onTouchStart={[Function]}\n            >\n              <div>\n                Omnis quo qui deserunt. Magnam minus mollitia rerum et. Quidem consequuntur voluptate voluptas.\n \nMaxime eos repudiandae deserunt quia quis. Mollitia dolor repellendus velit nulla est et. Voluptates voluptatem quos dolorem at. Qui necessitatibus numquam consectetur adipisci enim.\n \nEt blanditiis assumenda repellendus. Itaque omnis corporis quod. Eos fugiat blanditiis blanditiis est necessitatibus voluptas qui deserunt sint.\n              </div>\n            </div>\n          </div>\n        </div>\n        <div\n          className=\"tags_1joc06t\"\n        />\n        <button\n          aria-label=\"View all tags\"\n          className=\"scoresLink_186dkgy\"\n          onClick={[Function]}\n          type=\"button\"\n        >\n          View all tags\n        </button>\n      </div>\n    </div>\n  </div>\n</div>\n`;\n\nexports[`Storyshots SingleComment tags to add 1`] = `\n<div\n  style={\n    Object {\n      \"background\": \"rgba(0, 0, 0, 0.1)\",\n      \"padding\": \"20px 0\",\n      \"width\": \"100%\",\n    }\n  }\n>\n  <div\n    style={\n      Object {\n        \"background\": \"#fff\",\n        \"margin\": \"0 auto\",\n        \"maxWidth\": \"700px\",\n      }\n    }\n  >\n    <div>\n      <div\n        className=\"base_1ekfd5e\"\n      >\n        <div\n          className=\"header_rv4s4m\"\n        >\n          <div\n            style={\n              Object {\n                \"display\": \"inline-block\",\n                \"margin\": \"1px\",\n              }\n            }\n          >\n            <div\n              aria-describedby={null}\n              className=\"MuiAvatar-root MuiAvatar-circle\"\n              onBlur={[Function]}\n              onFocus={[Function]}\n              onMouseLeave={[Function]}\n              onMouseOver={[Function]}\n              onTouchEnd={[Function]}\n              onTouchStart={[Function]}\n              style={\n                Object {\n                  \"backgroundColor\": \"rgb(7, 60, 43)\",\n                  \"color\": \"white\",\n                  \"fontSize\": \"30px\",\n                  \"height\": \"60px\",\n                  \"width\": \"60px\",\n                }\n              }\n              title=\"Bridie Skiles IV\"\n            >\n              <img\n                alt=\"Bridie Skiles IV\"\n                className=\"MuiAvatar-img\"\n                src=\"https://s3.amazonaws.com/uifaces/faces/twitter/devinhalladay/128.jpg\"\n              />\n            </div>\n          </div>\n          <div\n            className=\"nameColumn_14w2fwv\"\n          >\n            <div\n              className=\"name_1mvjcjb\"\n            >\n              <a\n                className=\"authorName_hhj2q7\"\n                href=\"/search?searchByAuthor=true&term=Bridie%20Skiles%20IV\"\n                onClick={[Function]}\n              >\n                Bridie Skiles IV\n              </a>\n            </div>\n            <div\n              className=\"meta_1chol2p\"\n            >\n              <div>\n                <div\n                  className=\"location_l8m5sh\"\n                >\n                  <span>\n                    NYC\n                  </span>\n                </div>\n              </div>\n              <div\n                className=\"details_pzfj3t\"\n              >\n                <div\n                  className=\"row_wo3e5q\"\n                >\n                  <div\n                    className=\"icon_1x4du4h\"\n                  >\n                    <svg\n                      fill=\"currentColor\"\n                      height={20}\n                      preserveAspectRatio=\"xMidYMid meet\"\n                      style={\n                        Object {\n                          \"fill\": \"rgba(0, 0, 0, 0.54)\",\n                          \"verticalAlign\": \"middle\",\n                        }\n                      }\n                      viewBox=\"0 0 24 24\"\n                      width={20}\n                    >\n                      <g>\n                        <path\n                          d=\"M0,0H24V24H0V0Z\"\n                          fill=\"none\"\n                        />\n                        <path\n                          d=\"M20,4H4A2,2,0,0,0,2,6V18a2,2,0,0,0,2,2H20a2,2,0,0,0,2-2V6A2,2,0,0,0,20,4Zm0,14H4V8l8,5,8-5V18Zm-8-7L4,6H20Z\"\n                        />\n                      </g>\n                    </svg>\n                  </div>\n                  <div\n                    className=\"label_d8otiy\"\n                  >\n                    <a\n                      href=\"mailto:name@email.com\"\n                      style={\n                        Object {\n                          \":focus\": Object {\n                            \"outline\": 0,\n                            \"textDecoration\": \"underline\",\n                          },\n                          \"color\": \"rgba(0, 0, 0, 0.54)\",\n                          \"textDecoration\": \"none\",\n                        }\n                      }\n                    >\n                      name@email.com\n                    </a>\n                  </div>\n                </div>\n                <a\n                  className=\"linkFocus_euyru4\"\n                  href=\"/search?searchByAuthor=true&term=test\"\n                  onClick={[Function]}\n                >\n                  <div\n                    className=\"row_wo3e5q\"\n                  >\n                    <div\n                      className=\"icon_1x4du4h\"\n                    >\n                      <svg\n                        fill=\"currentColor\"\n                        height={20}\n                        preserveAspectRatio=\"xMidYMid meet\"\n                        style={\n                          Object {\n                            \"fill\": \"rgba(0, 0, 0, 0.54)\",\n                            \"verticalAlign\": \"middle\",\n                          }\n                        }\n                        viewBox=\"0 0 24 24\"\n                        width={20}\n                      >\n                        <g>\n                          <path\n                            d=\"M20 5H4c-1.1 0-1.99.9-1.99 2L2 17c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm-9 3h2v2h-2V8zm0 3h2v2h-2v-2zM8 8h2v2H8V8zm0 3h2v2H8v-2zm-1 2H5v-2h2v2zm0-3H5V8h2v2zm9 7H8v-2h8v2zm0-4h-2v-2h2v2zm0-3h-2V8h2v2zm3 3h-2v-2h2v2zm0-3h-2V8h2v2z\"\n                          />\n                          <path\n                            d=\"M0 0h24v24H0zm0 0h24v24H0z\"\n                            fill=\"none\"\n                          />\n                        </g>\n                      </svg>\n                    </div>\n                    <div\n                      className=\"label_d8otiy\"\n                    >\n                      test\n                    </div>\n                  </div>\n                </a>\n              </div>\n            </div>\n          </div>\n        </div>\n      </div>\n      <div\n        className=\"base_wgmchy\"\n      >\n        <div\n          className=\"commentTaggingContainer_10fbbuu\"\n        >\n          <div\n            className=\"base_1lhmnx1\"\n          >\n            <div\n              className=\"tagsContainer_5qtx03\"\n            >\n              <h4\n                className=\"offscreen_vluvpa\"\n              >\n                Assigned tags\n              </h4>\n              <p\n                className=\"offscreen_vluvpa\"\n              >\n                Click to remove.\n              </p>\n            </div>\n            <button\n              aria-label=\"Add tag to comment\"\n              className=\"button_vh6fxh-o_O-addButton_jz3qn7\"\n              onBlur={[Function]}\n              onClick={[Function]}\n              onFocus={[Function]}\n              onMouseEnter={[Function]}\n              onMouseLeave={[Function]}\n            >\n              <span\n                style={\n                  Object {\n                    \"clip\": \"rect(1px, 1px, 1px, 1px)\",\n                    \"height\": 0,\n                    \"position\": \"absolute\",\n                    \"width\": 0,\n                  }\n                }\n              >\n                Add a comment wide tag\n              </span>\n              <svg\n                fill=\"currentColor\"\n                height={24}\n                preserveAspectRatio=\"xMidYMid meet\"\n                style={\n                  Object {\n                    \"fill\": \"#185bac\",\n                    \"verticalAlign\": \"middle\",\n                  }\n                }\n                viewBox=\"0 0 24 24\"\n                width={24}\n              >\n                <g>\n                  <path\n                    d=\"M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z\"\n                  />\n                  <path\n                    d=\"M0 0h24v24H0z\"\n                    fill=\"none\"\n                  />\n                </g>\n              </svg>\n            </button>\n          </div>\n        </div>\n        <div\n          className=\"meta_ynnic0\"\n        >\n          <div\n            className=\"metaType_kvauw4\"\n          >\n            <span>\n              Nov. 30, 2016 12:00 AM\n               \n            </span>\n          </div>\n        </div>\n        <style>\n          \n  .comment-body a {\n    text-decoration: underline;\n  }\n\n  .comment-body b {\n    color: #f00;\n  }\n\n  .comment-body::selection,\n  .comment-body *::selection {\n    background: #185bac;\n    borderColor: rgba(255, 255, 255, 1);\n    color: rgba(255, 255, 255, 1);\n  }\n\n  .tag {\n    border-bottom-width: 1px;\n    border-bottom-style: solid;\n  }\n\n  .tag-obscene {\n    border-bottom-color: #d71b60;\n    color: #d71b60;\n  }\n\n  .tag-incoherent {\n    border-bottom-color: #9c28b1;\n    color: #9c28b1;\n  }\n\n  .tag-spam {\n    border-bottom-color: #673bb8;\n    color: #673bb8;\n  }\n\n  .tag-off-topic {\n    border-bottom-color: #3f51b5;\n    color: #3f51b5;\n  }\n\n  .tag-inflammatory {\n    border-bottom-color: #1976d3;\n    color: #1976d3;\n  }\n\n  .tag-unsubstantial {\n    border-bottom-color: #3d5afe;\n    color: #3d5afe;\n  }\n\n  .tag-other {\n    border-bottom-color: #01828f;\n    color: #01828f;\n  }\n\n        </style>\n        <div\n          className=\"body_14k49ou comment-body\"\n        >\n          <div>\n            <div\n              onMouseDown={[Function]}\n              onTouchStart={[Function]}\n            >\n              <div>\n                Omnis quo qui deserunt. Magnam minus mollitia rerum et. Quidem consequuntur voluptate voluptas.\n \nMaxime eos repudiandae deserunt quia quis. Mollitia dolor repellendus velit nulla est et. Voluptates voluptatem quos dolorem at. Qui necessitatibus numquam consectetur adipisci enim.\n \nEt blanditiis assumenda repellendus. Itaque omnis corporis quod. Eos fugiat blanditiis blanditiis est necessitatibus voluptas qui deserunt sint.\n              </div>\n            </div>\n          </div>\n        </div>\n        <div\n          className=\"tags_1joc06t\"\n        />\n        <button\n          aria-label=\"View all tags\"\n          className=\"scoresLink_186dkgy\"\n          onClick={[Function]}\n          type=\"button\"\n        >\n          View all tags\n        </button>\n      </div>\n    </div>\n  </div>\n</div>\n`;\n\nexports[`Storyshots Slider base 1`] = `\n<div\n  style={\n    Object {\n      \"background\": \"#326891\",\n      \"padding\": \"20px\",\n    }\n  }\n>\n  <div\n    style={\n      Object {\n        \"background\": \"rgba(255, 255, 255, 0.5)\",\n        \"height\": \"6px\",\n        \"position\": \"relative\",\n        \"width\": \"100%\",\n      }\n    }\n  >\n    <div>\n      <div\n        className=\"handleContainer_6q6dga\"\n        onKeyUp={[Function]}\n        onMouseDown={[Function]}\n        onMouseUp={[Function]}\n        onTouchEnd={[Function]}\n        onTouchStart={[Function]}\n        style={\n          Object {\n            \"touchAction\": \"none\",\n            \"transform\": \"translate(0px,0px)\",\n          }\n        }\n        tabIndex={0}\n        transform={null}\n      >\n        <div\n          aria-live=\"polite\"\n          className=\"label_sgfzm3\"\n        />\n        <div\n          className=\"handle_127h6n7\"\n          onBlur={[Function]}\n          onFocus={[Function]}\n          onMouseEnter={[Function]}\n          onMouseLeave={[Function]}\n        >\n          <span\n            className=\"handleDisplay_jktoc1\"\n          />\n        </div>\n      </div>\n    </div>\n    <div>\n      <div\n        className=\"handleContainer_6q6dga\"\n        onKeyUp={[Function]}\n        onMouseDown={[Function]}\n        onMouseUp={[Function]}\n        onTouchEnd={[Function]}\n        onTouchStart={[Function]}\n        style={\n          Object {\n            \"touchAction\": \"none\",\n            \"transform\": \"translate(0px,0px)\",\n          }\n        }\n        tabIndex={0}\n        transform={null}\n      >\n        <div\n          aria-live=\"polite\"\n          className=\"label_sgfzm3-o_O-rightLabel_p4ui66\"\n        />\n        <div\n          className=\"handle_127h6n7-o_O-rightHandle_srz9d1\"\n          onBlur={[Function]}\n          onFocus={[Function]}\n          onMouseEnter={[Function]}\n          onMouseLeave={[Function]}\n        >\n          <span\n            className=\"handleDisplay_jktoc1\"\n          />\n        </div>\n      </div>\n    </div>\n  </div>\n</div>\n`;\n\nexports[`Storyshots TableComponents Title cell 1`] = `\n<div\n  className=\"dataCell_c9brmu-o_O-dataBody_1npqsf6\"\n>\n  <div\n    className=\"dataCell_c9brmu-o_O-textCell_v85ehq\"\n  >\n    <div\n      className=\"superText_jnj9e2\"\n    >\n      <span\n        className=\"categoryLabel_1tey719\"\n      >\n        ChuChu TV Nursery Rhymes & Kids Songs\n      </span>\n      <span>\n        <span>\n          3 hours ago\n        </span>\n      </span>\n    </div>\n    <div\n      className=\"mainText_jro6t0\"\n    >\n      <div>\n        <a\n          className=\"cellLink_18xp2pq-o_O-mainTextText_qx0jz6\"\n          href=\"/\"\n          onClick={[Function]}\n        >\n          IMF chief Christine Lagarde warns Britain on Brexit: ‘It will never be as good as it is now’\n        </a>\n      </div>\n      <div\n        className=\"mainTextLink_d909a\"\n      >\n        <a\n          className=\"cellLink_18xp2pq\"\n          href=\"https://sebastian.name\"\n          target=\"_blank\"\n        >\n          <svg\n            aria-hidden=\"true\"\n            className=\"MuiSvgIcon-root MuiSvgIcon-fontSizeSmall\"\n            focusable=\"false\"\n            viewBox=\"0 0 24 24\"\n          >\n            <path\n              d=\"M19 19H5V5h7V3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2v-7h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z\"\n            />\n          </svg>\n        </a>\n      </div>\n    </div>\n  </div>\n  <div\n    className=\"dataCell_c9brmu-o_O-textCell_v85ehq\"\n  >\n    <div\n      className=\"superText_jnj9e2\"\n    >\n      <span>\n        <span>\n          13 hours ago\n        </span>\n      </span>\n    </div>\n    <div\n      className=\"mainText_jro6t0\"\n    >\n      <div>\n        <a\n          className=\"cellLink_18xp2pq-o_O-mainTextText_qx0jz6\"\n          href=\"/\"\n          onClick={[Function]}\n        >\n          Earum in sequi aut.\n        </a>\n      </div>\n    </div>\n  </div>\n  <div\n    className=\"dataCell_c9brmu-o_O-textCell_v85ehq\"\n  >\n    <div\n      className=\"superText_jnj9e2\"\n    >\n      <span\n        className=\"categoryLabel_1tey719\"\n      >\n        World\n      </span>\n    </div>\n    <div\n      className=\"mainText_jro6t0\"\n    >\n      <div>\n        <a\n          className=\"cellLink_18xp2pq-o_O-mainTextText_qx0jz6\"\n          href=\"/\"\n          onClick={[Function]}\n        >\n          Modi et autem qui porro deleniti atque perferendis voluptatibus.\n        </a>\n      </div>\n    </div>\n  </div>\n  <div\n    className=\"dataCell_c9brmu-o_O-textCell_v85ehq\"\n  >\n    <div\n      className=\"mainText_jro6t0\"\n    >\n      <div>\n        <a\n          className=\"cellLink_18xp2pq-o_O-mainTextText_qx0jz6\"\n          href=\"/\"\n          onClick={[Function]}\n        >\n          Aut fugit et.\n        </a>\n      </div>\n      <div\n        className=\"mainTextLink_d909a\"\n      >\n        <a\n          className=\"cellLink_18xp2pq\"\n          href=\"https://landen.biz\"\n          target=\"_blank\"\n        >\n          <svg\n            aria-hidden=\"true\"\n            className=\"MuiSvgIcon-root MuiSvgIcon-fontSizeSmall\"\n            focusable=\"false\"\n            viewBox=\"0 0 24 24\"\n          >\n            <path\n              d=\"M19 19H5V5h7V3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2v-7h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z\"\n            />\n          </svg>\n        </a>\n      </div>\n    </div>\n  </div>\n</div>\n`;\n\nexports[`Storyshots TagLabelRow Default 1`] = `\n<div>\n  <button\n    aria-label=\"Inflammatory\"\n    onClick={[Function]}\n    style={\n      Object {\n        \"background\": \"transparent\",\n        \"border\": 0,\n        \"padding\": 0,\n        \"width\": \"100%\",\n      }\n    }\n  >\n    <a\n      className=\"link_1pxpy31\"\n      href=\"/categories/1/new/INFLAMMATORY\"\n      onBlur={[Function]}\n      onClick={[Function]}\n      onFocus={[Function]}\n      onMouseEnter={[Function]}\n      onMouseLeave={[Function]}\n      tabIndex={0}\n    >\n      <span\n        className=\"row_u1sz9h\"\n        style={\n          Object {\n            \"backgroundColor\": \"#326891\",\n          }\n        }\n      >\n        <span\n          className=\"selectedIcon_19o69qw\"\n        />\n        <div\n          className=\"labelContainer_13lnak6\"\n        >\n          <span\n            className=\"label_lsoh1j\"\n          >\n            Inflammatory\n          </span>\n          <span\n            className=\"description_wkjp6f\"\n          />\n        </div>\n        <span\n          className=\"dotChart_1i79g4o\"\n        >\n          <img\n            alt=\"chart displaying scores by tag Inflammatory\"\n            className=\"image_gicfu1\"\n            height={264}\n            src=\"\"\n            width={76}\n          />\n        </span>\n      </span>\n    </a>\n  </button>\n  <button\n    aria-label=\"Off Topic\"\n    onClick={[Function]}\n    style={\n      Object {\n        \"background\": \"transparent\",\n        \"border\": 0,\n        \"padding\": 0,\n        \"width\": \"100%\",\n      }\n    }\n  >\n    <a\n      className=\"link_1pxpy31\"\n      href=\"/categories/1/new/OFF_TOPIC\"\n      onBlur={[Function]}\n      onClick={[Function]}\n      onFocus={[Function]}\n      onMouseEnter={[Function]}\n      onMouseLeave={[Function]}\n      tabIndex={0}\n    >\n      <span\n        className=\"row_u1sz9h\"\n        style={\n          Object {\n            \"backgroundColor\": \"#255271\",\n          }\n        }\n      >\n        <span\n          className=\"selectedIcon_19o69qw\"\n        />\n        <div\n          className=\"labelContainer_13lnak6\"\n        >\n          <span\n            className=\"label_lsoh1j\"\n          >\n            Off Topic\n          </span>\n          <span\n            className=\"description_wkjp6f\"\n          />\n        </div>\n        <span\n          className=\"dotChart_1i79g4o\"\n        >\n          <img\n            alt=\"chart displaying scores by tag Off Topic\"\n            className=\"image_gicfu1\"\n            height={264}\n            src=\"\"\n            width={76}\n          />\n        </span>\n      </span>\n    </a>\n  </button>\n</div>\n`;\n\nexports[`Storyshots ThreadedComment default list 1`] = `\n<div\n  style={\n    Object {\n      \"background\": \"rgba(0, 0, 0, 0.1)\",\n      \"padding\": \"20px 0\",\n      \"width\": \"100%\",\n    }\n  }\n>\n  <div\n    style={\n      Object {\n        \"background\": \"#fff\",\n        \"margin\": \"0 auto\",\n        \"maxWidth\": \"700px\",\n      }\n    }\n  >\n    <div\n      className=\"base_azfut6\"\n    >\n      <div\n        className=\"row_gk8los\"\n        onMouseEnter={[Function]}\n        onMouseLeave={[Function]}\n      >\n        <div\n          className=\"body_4fl387\"\n        >\n          <div\n            onMouseEnter={[Function]}\n            onMouseLeave={[Function]}\n          >\n            <div\n              className=\"meta_19ulwq8\"\n            >\n              <div\n                className=\"authorRow_jgupte\"\n              >\n                <a\n                  className=\"reply_1gy5v6q\"\n                  href=\"/articles/undefined/comments/91358/replies\"\n                  onClick={[Function]}\n                >\n                  <svg\n                    fill=\"currentColor\"\n                    height={24}\n                    preserveAspectRatio=\"xMidYMid meet\"\n                    style={\n                      Object {\n                        \"fill\": \"#185bac\",\n                        \"verticalAlign\": \"middle\",\n                      }\n                    }\n                    viewBox=\"0 0 24 24\"\n                    width={24}\n                  >\n                    <g>\n                      <path\n                        d=\"M10 9V5l-7 7 7 7v-4.1c5 0 8.5 1.6 11 5.1-1-5-4-10-11-11z\"\n                      />\n                      <path\n                        d=\"M0 0h24v24H0z\"\n                        fill=\"none\"\n                      />\n                    </g>\n                  </svg>\n                </a>\n                <a\n                  href=\"/search?searchByAuthor=true&term=Bridie%20Skiles%20IV\"\n                  onClick={[Function]}\n                  style={\n                    Object {\n                      \"color\": \"#185bac\",\n                    }\n                  }\n                >\n                  Bridie Skiles IV\n                   \n                </a>\n                <span>\n                  from \n                  NYC\n                   \n                </span>\n                <span\n                  style={\n                    Object {\n                      \"textDecoration\": \"none\",\n                    }\n                  }\n                >\n                   • \n                  over X years\n                   ago \n                </span>\n              </div>\n              <div\n                className=\"actionContainer_11iv8a4\"\n              >\n                <div\n                  style={\n                    Object {\n                      \"marginRight\": \"10px\",\n                    }\n                  }\n                >\n                  <div\n                    className=\"circle_42xzgj\"\n                    id=\"approve\"\n                    style={\n                      Object {\n                        \"backgroundColor\": \"#185bac\",\n                        \"height\": 36,\n                        \"width\": 36,\n                      }\n                    }\n                  >\n                    <svg\n                      fill=\"currentColor\"\n                      height={20}\n                      label=\"Approve\"\n                      preserveAspectRatio=\"xMidYMid meet\"\n                      role=\"alert\"\n                      style={\n                        Object {\n                          \"fill\": \"rgba(255, 255, 255, 1)\",\n                          \"verticalAlign\": \"middle\",\n                        }\n                      }\n                      viewBox=\"0 0 24 24\"\n                      width={20}\n                    >\n                      <g>\n                        <path\n                          d=\"M0,0H24V24H0V0Z\"\n                          fill=\"none\"\n                        />\n                        <path\n                          d=\"M9,16.17L4.83,12,3.41,13.41,9,19,21,7,19.59,5.59Z\"\n                        />\n                      </g>\n                    </svg>\n                  </div>\n                </div>\n                <button\n                  className=\"actionToggle_71yhch\"\n                  type=\"button\"\n                >\n                  <svg\n                    fill=\"currentColor\"\n                    height={20}\n                    preserveAspectRatio=\"xMidYMid meet\"\n                    style={\n                      Object {\n                        \"verticalAlign\": \"middle\",\n                      }\n                    }\n                    viewBox=\"0 0 24 24\"\n                    width={20}\n                  >\n                    <g>\n                      <path\n                        d=\"M0,0H24V24H0V0Z\"\n                        fill=\"none\"\n                      />\n                      <path\n                        d=\"M12,8a2,2,0,1,0-2-2A2,2,0,0,0,12,8Zm0,2a2,2,0,1,0,2,2A2,2,0,0,0,12,10Zm0,6a2,2,0,1,0,2,2A2,2,0,0,0,12,16Z\"\n                      />\n                    </g>\n                  </svg>\n                </button>\n              </div>\n            </div>\n            <div\n              className=\"commentContainer_1s1qcet\"\n            >\n              <div\n                className=\"comment_1b4bkso\"\n              >\n                <div\n                  style={\n                    Object {\n                      \"display\": \"flex\",\n                      \"flexDirection\": \"column\",\n                    }\n                  }\n                >\n                  <p>\n                    <span>\n                      Originating comment text is here\n                    </span>\n                  </p>\n                </div>\n              </div>\n            </div>\n          </div>\n        </div>\n      </div>\n      <div\n        className=\"row_gk8los\"\n        onMouseEnter={[Function]}\n        onMouseLeave={[Function]}\n      >\n        <div\n          className=\"body_4fl387-o_O-replyBody_prxq4h\"\n        >\n          <div\n            onMouseEnter={[Function]}\n            onMouseLeave={[Function]}\n          >\n            <div\n              className=\"meta_19ulwq8\"\n            >\n              <div\n                className=\"authorRow_jgupte\"\n              >\n                <span\n                  style={\n                    Object {\n                      \"textDecoration\": \"none\",\n                    }\n                  }\n                >\n                   • \n                  over X years\n                   ago \n                </span>\n              </div>\n              <div\n                className=\"actionContainer_11iv8a4\"\n              >\n                <button\n                  className=\"actionToggle_71yhch\"\n                  type=\"button\"\n                >\n                  <svg\n                    fill=\"currentColor\"\n                    height={20}\n                    preserveAspectRatio=\"xMidYMid meet\"\n                    style={\n                      Object {\n                        \"verticalAlign\": \"middle\",\n                      }\n                    }\n                    viewBox=\"0 0 24 24\"\n                    width={20}\n                  >\n                    <g>\n                      <path\n                        d=\"M0,0H24V24H0V0Z\"\n                        fill=\"none\"\n                      />\n                      <path\n                        d=\"M12,8a2,2,0,1,0-2-2A2,2,0,0,0,12,8Zm0,2a2,2,0,1,0,2,2A2,2,0,0,0,12,10Zm0,6a2,2,0,1,0,2,2A2,2,0,0,0,12,16Z\"\n                      />\n                    </g>\n                  </svg>\n                </button>\n              </div>\n            </div>\n            <div\n              className=\"commentContainer_1s1qcet\"\n            >\n              <div\n                className=\"comment_1b4bkso\"\n              >\n                <div\n                  style={\n                    Object {\n                      \"display\": \"flex\",\n                      \"flexDirection\": \"column\",\n                    }\n                  }\n                >\n                  <p>\n                    <span>\n                      \n                    </span>\n                  </p>\n                </div>\n              </div>\n            </div>\n          </div>\n        </div>\n      </div>\n      <div\n        className=\"row_gk8los\"\n        onMouseEnter={[Function]}\n        onMouseLeave={[Function]}\n      >\n        <div\n          className=\"body_4fl387-o_O-replyBody_prxq4h\"\n        >\n          <div\n            onMouseEnter={[Function]}\n            onMouseLeave={[Function]}\n          >\n            <div\n              className=\"meta_19ulwq8\"\n            >\n              <div\n                className=\"authorRow_jgupte\"\n              >\n                <span\n                  style={\n                    Object {\n                      \"textDecoration\": \"none\",\n                    }\n                  }\n                >\n                   • \n                  over X years\n                   ago \n                </span>\n              </div>\n              <div\n                className=\"actionContainer_11iv8a4\"\n              >\n                <button\n                  className=\"actionToggle_71yhch\"\n                  type=\"button\"\n                >\n                  <svg\n                    fill=\"currentColor\"\n                    height={20}\n                    preserveAspectRatio=\"xMidYMid meet\"\n                    style={\n                      Object {\n                        \"verticalAlign\": \"middle\",\n                      }\n                    }\n                    viewBox=\"0 0 24 24\"\n                    width={20}\n                  >\n                    <g>\n                      <path\n                        d=\"M0,0H24V24H0V0Z\"\n                        fill=\"none\"\n                      />\n                      <path\n                        d=\"M12,8a2,2,0,1,0-2-2A2,2,0,0,0,12,8Zm0,2a2,2,0,1,0,2,2A2,2,0,0,0,12,10Zm0,6a2,2,0,1,0,2,2A2,2,0,0,0,12,16Z\"\n                      />\n                    </g>\n                  </svg>\n                </button>\n              </div>\n            </div>\n            <div\n              className=\"commentContainer_1s1qcet\"\n            >\n              <div\n                className=\"comment_1b4bkso\"\n              >\n                <div\n                  style={\n                    Object {\n                      \"display\": \"flex\",\n                      \"flexDirection\": \"column\",\n                    }\n                  }\n                >\n                  <p>\n                    <span>\n                      \n                    </span>\n                  </p>\n                </div>\n              </div>\n            </div>\n          </div>\n        </div>\n      </div>\n      <div\n        className=\"row_gk8los\"\n        onMouseEnter={[Function]}\n        onMouseLeave={[Function]}\n      >\n        <div\n          className=\"body_4fl387-o_O-replyBody_prxq4h\"\n        >\n          <div\n            onMouseEnter={[Function]}\n            onMouseLeave={[Function]}\n          >\n            <div\n              className=\"meta_19ulwq8\"\n            >\n              <div\n                className=\"authorRow_jgupte\"\n              >\n                <span\n                  style={\n                    Object {\n                      \"textDecoration\": \"none\",\n                    }\n                  }\n                >\n                   • \n                  over X years\n                   ago \n                </span>\n              </div>\n              <div\n                className=\"actionContainer_11iv8a4\"\n              >\n                <button\n                  className=\"actionToggle_71yhch\"\n                  type=\"button\"\n                >\n                  <svg\n                    fill=\"currentColor\"\n                    height={20}\n                    preserveAspectRatio=\"xMidYMid meet\"\n                    style={\n                      Object {\n                        \"verticalAlign\": \"middle\",\n                      }\n                    }\n                    viewBox=\"0 0 24 24\"\n                    width={20}\n                  >\n                    <g>\n                      <path\n                        d=\"M0,0H24V24H0V0Z\"\n                        fill=\"none\"\n                      />\n                      <path\n                        d=\"M12,8a2,2,0,1,0-2-2A2,2,0,0,0,12,8Zm0,2a2,2,0,1,0,2,2A2,2,0,0,0,12,10Zm0,6a2,2,0,1,0,2,2A2,2,0,0,0,12,16Z\"\n                      />\n                    </g>\n                  </svg>\n                </button>\n              </div>\n            </div>\n            <div\n              className=\"commentContainer_1s1qcet\"\n            >\n              <div\n                className=\"comment_1b4bkso\"\n              >\n                <div\n                  style={\n                    Object {\n                      \"display\": \"flex\",\n                      \"flexDirection\": \"column\",\n                    }\n                  }\n                >\n                  <p>\n                    <span>\n                      \n                    </span>\n                  </p>\n                </div>\n              </div>\n            </div>\n          </div>\n        </div>\n      </div>\n      <div\n        className=\"row_gk8los\"\n        onMouseEnter={[Function]}\n        onMouseLeave={[Function]}\n      >\n        <div\n          className=\"body_4fl387-o_O-replyBody_prxq4h\"\n        >\n          <div\n            onMouseEnter={[Function]}\n            onMouseLeave={[Function]}\n          >\n            <div\n              className=\"meta_19ulwq8\"\n            >\n              <div\n                className=\"authorRow_jgupte\"\n              >\n                <span\n                  style={\n                    Object {\n                      \"textDecoration\": \"none\",\n                    }\n                  }\n                >\n                   • \n                  over X years\n                   ago \n                </span>\n              </div>\n              <div\n                className=\"actionContainer_11iv8a4\"\n              >\n                <button\n                  className=\"actionToggle_71yhch\"\n                  type=\"button\"\n                >\n                  <svg\n                    fill=\"currentColor\"\n                    height={20}\n                    preserveAspectRatio=\"xMidYMid meet\"\n                    style={\n                      Object {\n                        \"verticalAlign\": \"middle\",\n                      }\n                    }\n                    viewBox=\"0 0 24 24\"\n                    width={20}\n                  >\n                    <g>\n                      <path\n                        d=\"M0,0H24V24H0V0Z\"\n                        fill=\"none\"\n                      />\n                      <path\n                        d=\"M12,8a2,2,0,1,0-2-2A2,2,0,0,0,12,8Zm0,2a2,2,0,1,0,2,2A2,2,0,0,0,12,10Zm0,6a2,2,0,1,0,2,2A2,2,0,0,0,12,16Z\"\n                      />\n                    </g>\n                  </svg>\n                </button>\n              </div>\n            </div>\n            <div\n              className=\"commentContainer_1s1qcet\"\n            >\n              <div\n                className=\"comment_1b4bkso\"\n              >\n                <div\n                  style={\n                    Object {\n                      \"display\": \"flex\",\n                      \"flexDirection\": \"column\",\n                    }\n                  }\n                >\n                  <p>\n                    <span>\n                      \n                    </span>\n                  </p>\n                </div>\n              </div>\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n  </div>\n</div>\n`;\n\nexports[`Storyshots Toast base 1`] = `\n<div\n  style={\n    Object {\n      \"background\": \"#ededed\",\n      \"padding\": \"50px 10px\",\n    }\n  }\n>\n  <div\n    aria-labelledby=\"dialog-title\"\n    className=\"base_t386he\"\n    role=\"dialog\"\n    style={\n      Object {\n        \"backgroundColor\": \"rgba(255, 255, 255, 1)\",\n      }\n    }\n  >\n    <div\n      className=\"inner_qt84ef\"\n    >\n      <div\n        style={\n          Object {\n            \"color\": \"rgba(0, 0, 0, 0.87)\",\n            \"fontFamily\": \"LibreFranklin-Medium, sans-serif\",\n            \"fontSize\": 14,\n            \"fontWeight\": 400,\n            \"lineHeight\": 1.5,\n          }\n        }\n      >\n        CONTENT\n      </div>\n    </div>\n  </div>\n</div>\n`;\n\nexports[`Storyshots Toast refresh 1`] = `\n<div\n  style={\n    Object {\n      \"background\": \"#ededed\",\n      \"padding\": \"50px 10px\",\n    }\n  }\n>\n  <div\n    className=\"base_1xdceda\"\n  >\n    <div\n      className=\"comments_1hrhafs\"\n      id=\"dialog-title\"\n    >\n      <div>\n        <div>\n          <svg\n            fill=\"currentColor\"\n            height=\"24\"\n            preserveAspectRatio=\"xMidYMid meet\"\n            style={\n              Object {\n                \"fill\": \"#326891\",\n                \"verticalAlign\": \"middle\",\n              }\n            }\n            viewBox=\"0 0 24 24\"\n            width=\"24\"\n          >\n            <g>\n              <path\n                d=\"M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z\"\n              />\n              <path\n                d=\"M0 0h24v24H0z\"\n                fill=\"none\"\n              />\n            </g>\n          </svg>\n           Refresh\n        </div>\n        <div\n          style={\n            Object {\n              \"fontFamily\": \"LibreFranklin-Medium, sans-serif\",\n              \"fontSize\": 16,\n              \"fontWeight\": 400,\n              \"lineHeight\": 1.5,\n            }\n          }\n        >\n          <div>\n            Approval rating in progress.\n          </div>\n          <div\n            style={\n              Object {\n                \"marginTop\": \"10px\",\n              }\n            }\n          >\n            75% complete.\n          </div>\n        </div>\n      </div>\n    </div>\n    <button\n      className=\"button_54ropa\"\n      onClick={[Function]}\n    >\n      Refresh\n    </button>\n  </div>\n</div>\n`;\n\nexports[`Storyshots Toast undo 1`] = `\n<div\n  style={\n    Object {\n      \"background\": \"#ededed\",\n      \"padding\": \"50px 10px\",\n    }\n  }\n>\n  <div\n    className=\"base_1xdceda\"\n  >\n    <div\n      className=\"comments_1hrhafs\"\n      id=\"dialog-title\"\n    >\n      <div>\n        <div\n          style={\n            Object {\n              \"fontFamily\": \"LibreFranklin-Medium, sans-serif\",\n              \"fontSize\": 20,\n              \"fontWeight\": 400,\n              \"lineHeight\": 1.5,\n            }\n          }\n        >\n          <svg\n            fill=\"currentColor\"\n            height={30}\n            preserveAspectRatio=\"xMidYMid meet\"\n            style={\n              Object {\n                \"fill\": \"#326891\",\n                \"verticalAlign\": \"middle\",\n              }\n            }\n            viewBox=\"0 0 24 24\"\n            width={30}\n          >\n            <g>\n              <path\n                d=\"M0,0H24V24H0V0Z\"\n                fill=\"none\"\n              />\n              <path\n                d=\"M9,16.17L4.83,12,3.41,13.41,9,19,21,7,19.59,5.59Z\"\n              />\n            </g>\n          </svg>\n          135\n        </div>\n        <div\n          style={\n            Object {\n              \"fontFamily\": \"LibreFranklin-Medium, sans-serif\",\n              \"fontSize\": 14,\n              \"fontWeight\": 400,\n              \"lineHeight\": 1.5,\n            }\n          }\n        >\n          Comments approved\n        </div>\n      </div>\n    </div>\n    <button\n      className=\"button_54ropa\"\n      onClick={[Function]}\n    >\n      Undo\n    </button>\n  </div>\n</div>\n`;\n\nexports[`Storyshots ToolTip bottomCenter arrow (single tag) 1`] = `\n<div>\n  <div\n    style={\n      Object {\n        \"backgroundColor\": \"#f00\",\n        \"borderRadius\": \"50%\",\n        \"height\": \"6px\",\n        \"left\": \"300px\",\n        \"position\": \"absolute\",\n        \"top\": \"300px\",\n        \"transform\": \"translate(-50%, -50%)\",\n        \"width\": \"6px\",\n      }\n    }\n  />\n  <div\n    className=\"base_901bvq\"\n    style={\n      Object {\n        \"backgroundColor\": \"#326891\",\n        \"left\": 300,\n        \"position\": \"absolute\",\n        \"top\": 300,\n        \"transform\": \"translate(-50%, calc(-100% + -32px))\",\n        \"zIndex\": 15,\n      }\n    }\n  >\n    <div\n      style={\n        Object {\n          \"width\": 250,\n        }\n      }\n    >\n      <div\n        style={\n          Object {\n            \"alignItems\": \"center\",\n            \"boxSizing\": \"border-box\",\n            \"display\": \"flex\",\n            \"justifyContent\": \"center\",\n            \"padding\": \"24px\",\n            \"position\": \"relative\",\n            \"width\": \"100%\",\n          }\n        }\n      >\n        <p\n          style={\n            Object {\n              \"color\": \"rgba(255, 255, 255, 1)\",\n              \"textAlign\": \"center\",\n            }\n          }\n        >\n          is this spam?\n        </p>\n      </div>\n      <div>\n        <button\n          onClick={[Function]}\n          style={\n            Object {\n              \":hover\": Object {\n                \"backgroundColor\": \"#46779c\",\n              },\n              \"backgroundColor\": \"#326891\",\n              \"border\": \"none\",\n              \"borderRadius\": 0,\n              \"color\": \"rgba(255, 255, 255, 1)\",\n              \"marginBottom\": \"24px\",\n              \"padding\": \"24px\",\n              \"width\": \"50%\",\n            }\n          }\n        >\n          Yes\n        </button>\n        <button\n          onClick={[Function]}\n          style={\n            Object {\n              \":hover\": Object {\n                \"backgroundColor\": \"#46779c\",\n              },\n              \"backgroundColor\": \"#326891\",\n              \"border\": \"none\",\n              \"borderRadius\": 0,\n              \"color\": \"rgba(255, 255, 255, 1)\",\n              \"marginBottom\": \"24px\",\n              \"padding\": \"24px\",\n              \"width\": \"50%\",\n            }\n          }\n        >\n          No\n        </button>\n      </div>\n    </div>\n    <div\n      style={\n        Object {\n          \"borderBottom\": 0,\n          \"borderLeft\": \"16px solid transparent\",\n          \"borderRight\": \"16px solid transparent\",\n          \"borderTop\": \"16px solid #326891\",\n          \"bottom\": \"-16px\",\n          \"height\": 0,\n          \"position\": \"absolute\",\n          \"right\": \"calc(50% - 16px)\",\n          \"width\": 0,\n        }\n      }\n    />\n  </div>\n</div>\n`;\n\nexports[`Storyshots ToolTip bottomLeft arrow (single tag) 1`] = `\n<div>\n  <div\n    style={\n      Object {\n        \"backgroundColor\": \"#f00\",\n        \"borderRadius\": \"50%\",\n        \"height\": \"6px\",\n        \"left\": \"300px\",\n        \"position\": \"absolute\",\n        \"top\": \"300px\",\n        \"transform\": \"translate(-50%, -50%)\",\n        \"width\": \"6px\",\n      }\n    }\n  />\n  <div\n    className=\"base_901bvq\"\n    style={\n      Object {\n        \"backgroundColor\": \"#326891\",\n        \"left\": 300,\n        \"position\": \"absolute\",\n        \"top\": 300,\n        \"transform\": \"translate(-32px, calc(-100% + -32px))\",\n        \"zIndex\": 15,\n      }\n    }\n  >\n    <div\n      style={\n        Object {\n          \"width\": 250,\n        }\n      }\n    >\n      <div\n        style={\n          Object {\n            \"alignItems\": \"center\",\n            \"boxSizing\": \"border-box\",\n            \"display\": \"flex\",\n            \"justifyContent\": \"center\",\n            \"padding\": \"24px\",\n            \"position\": \"relative\",\n            \"width\": \"100%\",\n          }\n        }\n      >\n        <p\n          style={\n            Object {\n              \"color\": \"rgba(255, 255, 255, 1)\",\n              \"textAlign\": \"center\",\n            }\n          }\n        >\n          is this spam?\n        </p>\n      </div>\n      <div>\n        <button\n          onClick={[Function]}\n          style={\n            Object {\n              \":hover\": Object {\n                \"backgroundColor\": \"#46779c\",\n              },\n              \"backgroundColor\": \"#326891\",\n              \"border\": \"none\",\n              \"borderRadius\": 0,\n              \"color\": \"rgba(255, 255, 255, 1)\",\n              \"marginBottom\": \"24px\",\n              \"padding\": \"24px\",\n              \"width\": \"50%\",\n            }\n          }\n        >\n          Yes\n        </button>\n        <button\n          onClick={[Function]}\n          style={\n            Object {\n              \":hover\": Object {\n                \"backgroundColor\": \"#46779c\",\n              },\n              \"backgroundColor\": \"#326891\",\n              \"border\": \"none\",\n              \"borderRadius\": 0,\n              \"color\": \"rgba(255, 255, 255, 1)\",\n              \"marginBottom\": \"24px\",\n              \"padding\": \"24px\",\n              \"width\": \"50%\",\n            }\n          }\n        >\n          No\n        </button>\n      </div>\n    </div>\n    <div\n      style={\n        Object {\n          \"borderBottom\": 0,\n          \"borderLeft\": \"16px solid transparent\",\n          \"borderRight\": \"16px solid transparent\",\n          \"borderTop\": \"16px solid #326891\",\n          \"bottom\": \"-16px\",\n          \"height\": 0,\n          \"left\": \"16px\",\n          \"position\": \"absolute\",\n          \"width\": 0,\n        }\n      }\n    />\n  </div>\n</div>\n`;\n\nexports[`Storyshots ToolTip bottomRight arrow (single tag) 1`] = `\n<div>\n  <div\n    style={\n      Object {\n        \"backgroundColor\": \"#f00\",\n        \"borderRadius\": \"50%\",\n        \"height\": \"6px\",\n        \"left\": \"300px\",\n        \"position\": \"absolute\",\n        \"top\": \"300px\",\n        \"transform\": \"translate(-50%, -50%)\",\n        \"width\": \"6px\",\n      }\n    }\n  />\n  <div\n    className=\"base_901bvq\"\n    style={\n      Object {\n        \"backgroundColor\": \"#326891\",\n        \"left\": 300,\n        \"position\": \"absolute\",\n        \"top\": 300,\n        \"transform\": \"translate(calc(-100% + 32px), calc(-100% + -32px))\",\n        \"zIndex\": 15,\n      }\n    }\n  >\n    <div\n      style={\n        Object {\n          \"width\": 250,\n        }\n      }\n    >\n      <div\n        style={\n          Object {\n            \"alignItems\": \"center\",\n            \"boxSizing\": \"border-box\",\n            \"display\": \"flex\",\n            \"justifyContent\": \"center\",\n            \"padding\": \"24px\",\n            \"position\": \"relative\",\n            \"width\": \"100%\",\n          }\n        }\n      >\n        <p\n          style={\n            Object {\n              \"color\": \"rgba(255, 255, 255, 1)\",\n              \"textAlign\": \"center\",\n            }\n          }\n        >\n          is this spam?\n        </p>\n      </div>\n      <div>\n        <button\n          onClick={[Function]}\n          style={\n            Object {\n              \":hover\": Object {\n                \"backgroundColor\": \"#46779c\",\n              },\n              \"backgroundColor\": \"#326891\",\n              \"border\": \"none\",\n              \"borderRadius\": 0,\n              \"color\": \"rgba(255, 255, 255, 1)\",\n              \"marginBottom\": \"24px\",\n              \"padding\": \"24px\",\n              \"width\": \"50%\",\n            }\n          }\n        >\n          Yes\n        </button>\n        <button\n          onClick={[Function]}\n          style={\n            Object {\n              \":hover\": Object {\n                \"backgroundColor\": \"#46779c\",\n              },\n              \"backgroundColor\": \"#326891\",\n              \"border\": \"none\",\n              \"borderRadius\": 0,\n              \"color\": \"rgba(255, 255, 255, 1)\",\n              \"marginBottom\": \"24px\",\n              \"padding\": \"24px\",\n              \"width\": \"50%\",\n            }\n          }\n        >\n          No\n        </button>\n      </div>\n    </div>\n    <div\n      style={\n        Object {\n          \"borderBottom\": 0,\n          \"borderLeft\": \"16px solid transparent\",\n          \"borderRight\": \"16px solid transparent\",\n          \"borderTop\": \"16px solid #326891\",\n          \"bottom\": \"-16px\",\n          \"height\": 0,\n          \"position\": \"absolute\",\n          \"right\": \"16px\",\n          \"width\": 0,\n        }\n      }\n    />\n  </div>\n</div>\n`;\n\nexports[`Storyshots ToolTip info tooltip 1`] = `\n<div>\n  <div\n    style={\n      Object {\n        \"backgroundColor\": \"#f00\",\n        \"borderRadius\": \"50%\",\n        \"height\": \"6px\",\n        \"left\": \"300px\",\n        \"position\": \"absolute\",\n        \"top\": \"300px\",\n        \"transform\": \"translate(-50%, -50%)\",\n        \"width\": \"6px\",\n      }\n    }\n  />\n  <div\n    className=\"base_901bvq\"\n    style={\n      Object {\n        \"backgroundColor\": \"#326891\",\n        \"left\": 300,\n        \"position\": \"absolute\",\n        \"top\": 300,\n        \"transform\": \"translate(32px, -50%)\",\n        \"zIndex\": 15,\n      }\n    }\n  >\n    <ul\n      style={\n        Object {\n          \"color\": \"#326891\",\n          \"margin\": 0,\n          \"padding\": \"24px\",\n        }\n      }\n    >\n      <li\n        style={\n          Object {\n            \"listStyleType\": \"none\",\n            \"margin\": \"24px 10px\",\n          }\n        }\n      >\n        Keyboard Shortcuts\n      </li>\n      <li\n        style={\n          Object {\n            \"listStyleType\": \"none\",\n            \"margin\": \"24px 10px\",\n          }\n        }\n      >\n        Moderator Guidelines\n      </li>\n      <li\n        style={\n          Object {\n            \"listStyleType\": \"none\",\n            \"margin\": \"24px 10px\",\n          }\n        }\n      >\n        Submit Feedback\n      </li>\n    </ul>\n    <div\n      style={\n        Object {\n          \"borderBottom\": \"16px solid transparent\",\n          \"borderLeft\": 0,\n          \"borderRight\": \"16px solid #326891\",\n          \"borderTop\": \"16px solid transparent\",\n          \"bottom\": \"calc(50% - 16px)\",\n          \"height\": 0,\n          \"left\": \"-16px\",\n          \"position\": \"absolute\",\n          \"width\": 0,\n        }\n      }\n    />\n  </div>\n</div>\n`;\n\nexports[`Storyshots ToolTip leftBottom arrow (single tag) 1`] = `\n<div>\n  <div\n    style={\n      Object {\n        \"backgroundColor\": \"#f00\",\n        \"borderRadius\": \"50%\",\n        \"height\": \"6px\",\n        \"left\": \"300px\",\n        \"position\": \"absolute\",\n        \"top\": \"300px\",\n        \"transform\": \"translate(-50%, -50%)\",\n        \"width\": \"6px\",\n      }\n    }\n  />\n  <div\n    className=\"base_901bvq\"\n    style={\n      Object {\n        \"backgroundColor\": \"#326891\",\n        \"left\": 300,\n        \"position\": \"absolute\",\n        \"top\": 300,\n        \"transform\": \"translate(32px, calc(-100% + 32px))\",\n        \"zIndex\": 15,\n      }\n    }\n  >\n    <div\n      style={\n        Object {\n          \"width\": 250,\n        }\n      }\n    >\n      <div\n        style={\n          Object {\n            \"alignItems\": \"center\",\n            \"boxSizing\": \"border-box\",\n            \"display\": \"flex\",\n            \"justifyContent\": \"center\",\n            \"padding\": \"24px\",\n            \"position\": \"relative\",\n            \"width\": \"100%\",\n          }\n        }\n      >\n        <p\n          style={\n            Object {\n              \"color\": \"rgba(255, 255, 255, 1)\",\n              \"textAlign\": \"center\",\n            }\n          }\n        >\n          is this spam?\n        </p>\n      </div>\n      <div>\n        <button\n          onClick={[Function]}\n          style={\n            Object {\n              \":hover\": Object {\n                \"backgroundColor\": \"#46779c\",\n              },\n              \"backgroundColor\": \"#326891\",\n              \"border\": \"none\",\n              \"borderRadius\": 0,\n              \"color\": \"rgba(255, 255, 255, 1)\",\n              \"marginBottom\": \"24px\",\n              \"padding\": \"24px\",\n              \"width\": \"50%\",\n            }\n          }\n        >\n          Yes\n        </button>\n        <button\n          onClick={[Function]}\n          style={\n            Object {\n              \":hover\": Object {\n                \"backgroundColor\": \"#46779c\",\n              },\n              \"backgroundColor\": \"#326891\",\n              \"border\": \"none\",\n              \"borderRadius\": 0,\n              \"color\": \"rgba(255, 255, 255, 1)\",\n              \"marginBottom\": \"24px\",\n              \"padding\": \"24px\",\n              \"width\": \"50%\",\n            }\n          }\n        >\n          No\n        </button>\n      </div>\n    </div>\n    <div\n      style={\n        Object {\n          \"borderBottom\": \"16px solid transparent\",\n          \"borderLeft\": 0,\n          \"borderRight\": \"16px solid #326891\",\n          \"borderTop\": \"16px solid transparent\",\n          \"bottom\": \"16px\",\n          \"height\": 0,\n          \"left\": \"-16px\",\n          \"position\": \"absolute\",\n          \"width\": 0,\n        }\n      }\n    />\n  </div>\n</div>\n`;\n\nexports[`Storyshots ToolTip leftCenter arrow (single tag) 1`] = `\n<div>\n  <div\n    style={\n      Object {\n        \"backgroundColor\": \"#f00\",\n        \"borderRadius\": \"50%\",\n        \"height\": \"6px\",\n        \"left\": \"300px\",\n        \"position\": \"absolute\",\n        \"top\": \"300px\",\n        \"transform\": \"translate(-50%, -50%)\",\n        \"width\": \"6px\",\n      }\n    }\n  />\n  <div\n    className=\"base_901bvq\"\n    style={\n      Object {\n        \"backgroundColor\": \"#326891\",\n        \"left\": 300,\n        \"position\": \"absolute\",\n        \"top\": 300,\n        \"transform\": \"translate(32px, -50%)\",\n        \"zIndex\": 15,\n      }\n    }\n  >\n    <div\n      style={\n        Object {\n          \"width\": 250,\n        }\n      }\n    >\n      <div\n        style={\n          Object {\n            \"alignItems\": \"center\",\n            \"boxSizing\": \"border-box\",\n            \"display\": \"flex\",\n            \"justifyContent\": \"center\",\n            \"padding\": \"24px\",\n            \"position\": \"relative\",\n            \"width\": \"100%\",\n          }\n        }\n      >\n        <p\n          style={\n            Object {\n              \"color\": \"rgba(255, 255, 255, 1)\",\n              \"textAlign\": \"center\",\n            }\n          }\n        >\n          is this spam?\n        </p>\n      </div>\n      <div>\n        <button\n          onClick={[Function]}\n          style={\n            Object {\n              \":hover\": Object {\n                \"backgroundColor\": \"#46779c\",\n              },\n              \"backgroundColor\": \"#326891\",\n              \"border\": \"none\",\n              \"borderRadius\": 0,\n              \"color\": \"rgba(255, 255, 255, 1)\",\n              \"marginBottom\": \"24px\",\n              \"padding\": \"24px\",\n              \"width\": \"50%\",\n            }\n          }\n        >\n          Yes\n        </button>\n        <button\n          onClick={[Function]}\n          style={\n            Object {\n              \":hover\": Object {\n                \"backgroundColor\": \"#46779c\",\n              },\n              \"backgroundColor\": \"#326891\",\n              \"border\": \"none\",\n              \"borderRadius\": 0,\n              \"color\": \"rgba(255, 255, 255, 1)\",\n              \"marginBottom\": \"24px\",\n              \"padding\": \"24px\",\n              \"width\": \"50%\",\n            }\n          }\n        >\n          No\n        </button>\n      </div>\n    </div>\n    <div\n      style={\n        Object {\n          \"borderBottom\": \"16px solid transparent\",\n          \"borderLeft\": 0,\n          \"borderRight\": \"16px solid #326891\",\n          \"borderTop\": \"16px solid transparent\",\n          \"bottom\": \"calc(50% - 16px)\",\n          \"height\": 0,\n          \"left\": \"-16px\",\n          \"position\": \"absolute\",\n          \"width\": 0,\n        }\n      }\n    />\n  </div>\n</div>\n`;\n\nexports[`Storyshots ToolTip leftTop arrow (single tag) 1`] = `\n<div>\n  <div\n    style={\n      Object {\n        \"backgroundColor\": \"#f00\",\n        \"borderRadius\": \"50%\",\n        \"height\": \"6px\",\n        \"left\": \"300px\",\n        \"position\": \"absolute\",\n        \"top\": \"300px\",\n        \"transform\": \"translate(-50%, -50%)\",\n        \"width\": \"6px\",\n      }\n    }\n  />\n  <div\n    className=\"base_901bvq\"\n    style={\n      Object {\n        \"backgroundColor\": \"#326891\",\n        \"left\": 300,\n        \"position\": \"absolute\",\n        \"top\": 300,\n        \"transform\": \"translate(32px, -32px)\",\n        \"zIndex\": 15,\n      }\n    }\n  >\n    <div\n      style={\n        Object {\n          \"width\": 250,\n        }\n      }\n    >\n      <div\n        style={\n          Object {\n            \"alignItems\": \"center\",\n            \"boxSizing\": \"border-box\",\n            \"display\": \"flex\",\n            \"justifyContent\": \"center\",\n            \"padding\": \"24px\",\n            \"position\": \"relative\",\n            \"width\": \"100%\",\n          }\n        }\n      >\n        <p\n          style={\n            Object {\n              \"color\": \"rgba(255, 255, 255, 1)\",\n              \"textAlign\": \"center\",\n            }\n          }\n        >\n          is this spam?\n        </p>\n      </div>\n      <div>\n        <button\n          onClick={[Function]}\n          style={\n            Object {\n              \":hover\": Object {\n                \"backgroundColor\": \"#46779c\",\n              },\n              \"backgroundColor\": \"#326891\",\n              \"border\": \"none\",\n              \"borderRadius\": 0,\n              \"color\": \"rgba(255, 255, 255, 1)\",\n              \"marginBottom\": \"24px\",\n              \"padding\": \"24px\",\n              \"width\": \"50%\",\n            }\n          }\n        >\n          Yes\n        </button>\n        <button\n          onClick={[Function]}\n          style={\n            Object {\n              \":hover\": Object {\n                \"backgroundColor\": \"#46779c\",\n              },\n              \"backgroundColor\": \"#326891\",\n              \"border\": \"none\",\n              \"borderRadius\": 0,\n              \"color\": \"rgba(255, 255, 255, 1)\",\n              \"marginBottom\": \"24px\",\n              \"padding\": \"24px\",\n              \"width\": \"50%\",\n            }\n          }\n        >\n          No\n        </button>\n      </div>\n    </div>\n    <div\n      style={\n        Object {\n          \"borderBottom\": \"16px solid transparent\",\n          \"borderLeft\": 0,\n          \"borderRight\": \"16px solid #326891\",\n          \"borderTop\": \"16px solid transparent\",\n          \"height\": 0,\n          \"left\": \"-16px\",\n          \"position\": \"absolute\",\n          \"top\": \"16px\",\n          \"width\": 0,\n        }\n      }\n    />\n  </div>\n</div>\n`;\n\nexports[`Storyshots ToolTip no arrow (single tag) 1`] = `\n<div>\n  <div\n    style={\n      Object {\n        \"backgroundColor\": \"#f00\",\n        \"borderRadius\": \"50%\",\n        \"height\": \"6px\",\n        \"left\": \"300px\",\n        \"position\": \"absolute\",\n        \"top\": \"300px\",\n        \"transform\": \"translate(-50%, -50%)\",\n        \"width\": \"6px\",\n      }\n    }\n  />\n  <div\n    className=\"base_901bvq\"\n    style={\n      Object {\n        \"backgroundColor\": \"#326891\",\n        \"left\": 300,\n        \"position\": \"absolute\",\n        \"top\": 300,\n        \"transform\": \"translate(-50%, 32px)\",\n        \"zIndex\": 15,\n      }\n    }\n  >\n    <div\n      style={\n        Object {\n          \"width\": 250,\n        }\n      }\n    >\n      <div\n        style={\n          Object {\n            \"alignItems\": \"center\",\n            \"boxSizing\": \"border-box\",\n            \"display\": \"flex\",\n            \"justifyContent\": \"center\",\n            \"padding\": \"24px\",\n            \"position\": \"relative\",\n            \"width\": \"100%\",\n          }\n        }\n      >\n        <p\n          style={\n            Object {\n              \"color\": \"rgba(255, 255, 255, 1)\",\n              \"textAlign\": \"center\",\n            }\n          }\n        >\n          is this spam?\n        </p>\n      </div>\n      <div>\n        <button\n          onClick={[Function]}\n          style={\n            Object {\n              \":hover\": Object {\n                \"backgroundColor\": \"#46779c\",\n              },\n              \"backgroundColor\": \"#326891\",\n              \"border\": \"none\",\n              \"borderRadius\": 0,\n              \"color\": \"rgba(255, 255, 255, 1)\",\n              \"marginBottom\": \"24px\",\n              \"padding\": \"24px\",\n              \"width\": \"50%\",\n            }\n          }\n        >\n          Yes\n        </button>\n        <button\n          onClick={[Function]}\n          style={\n            Object {\n              \":hover\": Object {\n                \"backgroundColor\": \"#46779c\",\n              },\n              \"backgroundColor\": \"#326891\",\n              \"border\": \"none\",\n              \"borderRadius\": 0,\n              \"color\": \"rgba(255, 255, 255, 1)\",\n              \"marginBottom\": \"24px\",\n              \"padding\": \"24px\",\n              \"width\": \"50%\",\n            }\n          }\n        >\n          No\n        </button>\n      </div>\n    </div>\n    <div\n      style={\n        Object {\n          \"height\": 0,\n          \"position\": \"absolute\",\n          \"width\": 0,\n        }\n      }\n    />\n  </div>\n</div>\n`;\n\nexports[`Storyshots ToolTip rightBottom arrow (single tag) 1`] = `\n<div>\n  <div\n    style={\n      Object {\n        \"backgroundColor\": \"#f00\",\n        \"borderRadius\": \"50%\",\n        \"height\": \"6px\",\n        \"left\": \"300px\",\n        \"position\": \"absolute\",\n        \"top\": \"300px\",\n        \"transform\": \"translate(-50%, -50%)\",\n        \"width\": \"6px\",\n      }\n    }\n  />\n  <div\n    className=\"base_901bvq\"\n    style={\n      Object {\n        \"backgroundColor\": \"#326891\",\n        \"left\": 300,\n        \"position\": \"absolute\",\n        \"top\": 300,\n        \"transform\": \"translate(calc(-100% + -32px), calc(-100% + 32px))\",\n        \"zIndex\": 15,\n      }\n    }\n  >\n    <div\n      style={\n        Object {\n          \"width\": 250,\n        }\n      }\n    >\n      <div\n        style={\n          Object {\n            \"alignItems\": \"center\",\n            \"boxSizing\": \"border-box\",\n            \"display\": \"flex\",\n            \"justifyContent\": \"center\",\n            \"padding\": \"24px\",\n            \"position\": \"relative\",\n            \"width\": \"100%\",\n          }\n        }\n      >\n        <p\n          style={\n            Object {\n              \"color\": \"rgba(255, 255, 255, 1)\",\n              \"textAlign\": \"center\",\n            }\n          }\n        >\n          is this spam?\n        </p>\n      </div>\n      <div>\n        <button\n          onClick={[Function]}\n          style={\n            Object {\n              \":hover\": Object {\n                \"backgroundColor\": \"#46779c\",\n              },\n              \"backgroundColor\": \"#326891\",\n              \"border\": \"none\",\n              \"borderRadius\": 0,\n              \"color\": \"rgba(255, 255, 255, 1)\",\n              \"marginBottom\": \"24px\",\n              \"padding\": \"24px\",\n              \"width\": \"50%\",\n            }\n          }\n        >\n          Yes\n        </button>\n        <button\n          onClick={[Function]}\n          style={\n            Object {\n              \":hover\": Object {\n                \"backgroundColor\": \"#46779c\",\n              },\n              \"backgroundColor\": \"#326891\",\n              \"border\": \"none\",\n              \"borderRadius\": 0,\n              \"color\": \"rgba(255, 255, 255, 1)\",\n              \"marginBottom\": \"24px\",\n              \"padding\": \"24px\",\n              \"width\": \"50%\",\n            }\n          }\n        >\n          No\n        </button>\n      </div>\n    </div>\n    <div\n      style={\n        Object {\n          \"borderBottom\": \"16px solid transparent\",\n          \"borderLeft\": \"16px solid #326891\",\n          \"borderRight\": 0,\n          \"borderTop\": \"16px solid transparent\",\n          \"bottom\": \"16px\",\n          \"height\": 0,\n          \"position\": \"absolute\",\n          \"right\": \"-16px\",\n          \"width\": 0,\n        }\n      }\n    />\n  </div>\n</div>\n`;\n\nexports[`Storyshots ToolTip rightCenter arrow (single tag) 1`] = `\n<div>\n  <div\n    style={\n      Object {\n        \"backgroundColor\": \"#f00\",\n        \"borderRadius\": \"50%\",\n        \"height\": \"6px\",\n        \"left\": \"300px\",\n        \"position\": \"absolute\",\n        \"top\": \"300px\",\n        \"transform\": \"translate(-50%, -50%)\",\n        \"width\": \"6px\",\n      }\n    }\n  />\n  <div\n    className=\"base_901bvq\"\n    style={\n      Object {\n        \"backgroundColor\": \"#326891\",\n        \"left\": 300,\n        \"position\": \"absolute\",\n        \"top\": 300,\n        \"transform\": \"translate(calc(-100% + -32px), -50%)\",\n        \"zIndex\": 15,\n      }\n    }\n  >\n    <div\n      style={\n        Object {\n          \"width\": 250,\n        }\n      }\n    >\n      <div\n        style={\n          Object {\n            \"alignItems\": \"center\",\n            \"boxSizing\": \"border-box\",\n            \"display\": \"flex\",\n            \"justifyContent\": \"center\",\n            \"padding\": \"24px\",\n            \"position\": \"relative\",\n            \"width\": \"100%\",\n          }\n        }\n      >\n        <p\n          style={\n            Object {\n              \"color\": \"rgba(255, 255, 255, 1)\",\n              \"textAlign\": \"center\",\n            }\n          }\n        >\n          is this spam?\n        </p>\n      </div>\n      <div>\n        <button\n          onClick={[Function]}\n          style={\n            Object {\n              \":hover\": Object {\n                \"backgroundColor\": \"#46779c\",\n              },\n              \"backgroundColor\": \"#326891\",\n              \"border\": \"none\",\n              \"borderRadius\": 0,\n              \"color\": \"rgba(255, 255, 255, 1)\",\n              \"marginBottom\": \"24px\",\n              \"padding\": \"24px\",\n              \"width\": \"50%\",\n            }\n          }\n        >\n          Yes\n        </button>\n        <button\n          onClick={[Function]}\n          style={\n            Object {\n              \":hover\": Object {\n                \"backgroundColor\": \"#46779c\",\n              },\n              \"backgroundColor\": \"#326891\",\n              \"border\": \"none\",\n              \"borderRadius\": 0,\n              \"color\": \"rgba(255, 255, 255, 1)\",\n              \"marginBottom\": \"24px\",\n              \"padding\": \"24px\",\n              \"width\": \"50%\",\n            }\n          }\n        >\n          No\n        </button>\n      </div>\n    </div>\n    <div\n      style={\n        Object {\n          \"borderBottom\": \"16px solid transparent\",\n          \"borderLeft\": \"16px solid #326891\",\n          \"borderRight\": 0,\n          \"borderTop\": \"16px solid transparent\",\n          \"height\": 0,\n          \"position\": \"absolute\",\n          \"right\": \"-16px\",\n          \"top\": \"calc(50% - 16px)\",\n          \"width\": 0,\n        }\n      }\n    />\n  </div>\n</div>\n`;\n\nexports[`Storyshots ToolTip rightTop arrow (single tag) 1`] = `\n<div>\n  <div\n    style={\n      Object {\n        \"backgroundColor\": \"#f00\",\n        \"borderRadius\": \"50%\",\n        \"height\": \"6px\",\n        \"left\": \"300px\",\n        \"position\": \"absolute\",\n        \"top\": \"300px\",\n        \"transform\": \"translate(-50%, -50%)\",\n        \"width\": \"6px\",\n      }\n    }\n  />\n  <div\n    className=\"base_901bvq\"\n    style={\n      Object {\n        \"backgroundColor\": \"#326891\",\n        \"left\": 300,\n        \"position\": \"absolute\",\n        \"top\": 300,\n        \"transform\": \"translate(calc(-100% + -32px), -32px)\",\n        \"zIndex\": 15,\n      }\n    }\n  >\n    <div\n      style={\n        Object {\n          \"width\": 250,\n        }\n      }\n    >\n      <div\n        style={\n          Object {\n            \"alignItems\": \"center\",\n            \"boxSizing\": \"border-box\",\n            \"display\": \"flex\",\n            \"justifyContent\": \"center\",\n            \"padding\": \"24px\",\n            \"position\": \"relative\",\n            \"width\": \"100%\",\n          }\n        }\n      >\n        <p\n          style={\n            Object {\n              \"color\": \"rgba(255, 255, 255, 1)\",\n              \"textAlign\": \"center\",\n            }\n          }\n        >\n          is this spam?\n        </p>\n      </div>\n      <div>\n        <button\n          onClick={[Function]}\n          style={\n            Object {\n              \":hover\": Object {\n                \"backgroundColor\": \"#46779c\",\n              },\n              \"backgroundColor\": \"#326891\",\n              \"border\": \"none\",\n              \"borderRadius\": 0,\n              \"color\": \"rgba(255, 255, 255, 1)\",\n              \"marginBottom\": \"24px\",\n              \"padding\": \"24px\",\n              \"width\": \"50%\",\n            }\n          }\n        >\n          Yes\n        </button>\n        <button\n          onClick={[Function]}\n          style={\n            Object {\n              \":hover\": Object {\n                \"backgroundColor\": \"#46779c\",\n              },\n              \"backgroundColor\": \"#326891\",\n              \"border\": \"none\",\n              \"borderRadius\": 0,\n              \"color\": \"rgba(255, 255, 255, 1)\",\n              \"marginBottom\": \"24px\",\n              \"padding\": \"24px\",\n              \"width\": \"50%\",\n            }\n          }\n        >\n          No\n        </button>\n      </div>\n    </div>\n    <div\n      style={\n        Object {\n          \"borderBottom\": \"16px solid transparent\",\n          \"borderLeft\": \"16px solid #326891\",\n          \"borderRight\": 0,\n          \"borderTop\": \"16px solid transparent\",\n          \"height\": 0,\n          \"position\": \"absolute\",\n          \"right\": \"-16px\",\n          \"top\": \"16px\",\n          \"width\": 0,\n        }\n      }\n    />\n  </div>\n</div>\n`;\n\nexports[`Storyshots ToolTip topCenter arrow (single tag) 1`] = `\n<div>\n  <div\n    style={\n      Object {\n        \"backgroundColor\": \"#f00\",\n        \"borderRadius\": \"50%\",\n        \"height\": \"6px\",\n        \"left\": \"300px\",\n        \"position\": \"absolute\",\n        \"top\": \"300px\",\n        \"transform\": \"translate(-50%, -50%)\",\n        \"width\": \"6px\",\n      }\n    }\n  />\n  <div\n    className=\"base_901bvq\"\n    style={\n      Object {\n        \"backgroundColor\": \"#326891\",\n        \"left\": 300,\n        \"position\": \"absolute\",\n        \"top\": 300,\n        \"transform\": \"translate(-50%, 32px)\",\n        \"zIndex\": 15,\n      }\n    }\n  >\n    <div\n      style={\n        Object {\n          \"width\": 250,\n        }\n      }\n    >\n      <div\n        style={\n          Object {\n            \"alignItems\": \"center\",\n            \"boxSizing\": \"border-box\",\n            \"display\": \"flex\",\n            \"justifyContent\": \"center\",\n            \"padding\": \"24px\",\n            \"position\": \"relative\",\n            \"width\": \"100%\",\n          }\n        }\n      >\n        <p\n          style={\n            Object {\n              \"color\": \"rgba(255, 255, 255, 1)\",\n              \"textAlign\": \"center\",\n            }\n          }\n        >\n          is this spam?\n        </p>\n      </div>\n      <div>\n        <button\n          onClick={[Function]}\n          style={\n            Object {\n              \":hover\": Object {\n                \"backgroundColor\": \"#46779c\",\n              },\n              \"backgroundColor\": \"#326891\",\n              \"border\": \"none\",\n              \"borderRadius\": 0,\n              \"color\": \"rgba(255, 255, 255, 1)\",\n              \"marginBottom\": \"24px\",\n              \"padding\": \"24px\",\n              \"width\": \"50%\",\n            }\n          }\n        >\n          Yes\n        </button>\n        <button\n          onClick={[Function]}\n          style={\n            Object {\n              \":hover\": Object {\n                \"backgroundColor\": \"#46779c\",\n              },\n              \"backgroundColor\": \"#326891\",\n              \"border\": \"none\",\n              \"borderRadius\": 0,\n              \"color\": \"rgba(255, 255, 255, 1)\",\n              \"marginBottom\": \"24px\",\n              \"padding\": \"24px\",\n              \"width\": \"50%\",\n            }\n          }\n        >\n          No\n        </button>\n      </div>\n    </div>\n    <div\n      style={\n        Object {\n          \"borderBottom\": \"16px solid #326891\",\n          \"borderLeft\": \"16px solid transparent\",\n          \"borderRight\": \"16px solid transparent\",\n          \"borderTop\": 0,\n          \"height\": 0,\n          \"left\": \"calc(50% - 16px)\",\n          \"position\": \"absolute\",\n          \"top\": \"-16px\",\n          \"width\": 0,\n        }\n      }\n    />\n  </div>\n</div>\n`;\n\nexports[`Storyshots ToolTip topLeft arrow (multiple tags) 1`] = `\n<div>\n  <div\n    style={\n      Object {\n        \"backgroundColor\": \"#f00\",\n        \"borderRadius\": \"50%\",\n        \"height\": \"6px\",\n        \"left\": \"300px\",\n        \"position\": \"absolute\",\n        \"top\": \"300px\",\n        \"transform\": \"translate(-50%, -50%)\",\n        \"width\": \"6px\",\n      }\n    }\n  />\n  <div\n    className=\"base_901bvq\"\n    style={\n      Object {\n        \"backgroundColor\": \"#efefef\",\n        \"left\": 300,\n        \"position\": \"absolute\",\n        \"top\": 300,\n        \"transform\": \"translate(-32px, 32px)\",\n        \"zIndex\": 15,\n      }\n    }\n  >\n    <div\n      style={\n        Object {\n          \"width\": 250,\n        }\n      }\n    >\n      <ul\n        style={\n          Object {\n            \"margin\": 0,\n            \"padding\": \"24px 0\",\n          }\n        }\n      >\n        <li\n          style={\n            Object {\n              \"listStyleType\": \"none\",\n            }\n          }\n        >\n          <button\n            onClick={[Function]}\n            style={\n              Object {\n                \":hover\": Object {\n                  \"backgroundColor\": \"#326891\",\n                  \"color\": \"rgba(255, 255, 255, 1)\",\n                },\n                \"backgroundColor\": \"transparent\",\n                \"border\": \"none\",\n                \"borderRadius\": 0,\n                \"color\": \"#326891\",\n                \"padding\": \"8px 20px\",\n                \"textAlign\": \"left\",\n                \"width\": \"100%\",\n              }\n            }\n          >\n            Obscene\n          </button>\n        </li>\n        <li\n          style={\n            Object {\n              \"listStyleType\": \"none\",\n            }\n          }\n        >\n          <button\n            onClick={[Function]}\n            style={\n              Object {\n                \":hover\": Object {\n                  \"backgroundColor\": \"#326891\",\n                  \"color\": \"rgba(255, 255, 255, 1)\",\n                },\n                \"backgroundColor\": \"transparent\",\n                \"border\": \"none\",\n                \"borderRadius\": 0,\n                \"color\": \"#326891\",\n                \"padding\": \"8px 20px\",\n                \"textAlign\": \"left\",\n                \"width\": \"100%\",\n              }\n            }\n          >\n            Incoherent\n          </button>\n        </li>\n        <li\n          style={\n            Object {\n              \"listStyleType\": \"none\",\n            }\n          }\n        >\n          <button\n            onClick={[Function]}\n            style={\n              Object {\n                \":hover\": Object {\n                  \"backgroundColor\": \"#326891\",\n                  \"color\": \"rgba(255, 255, 255, 1)\",\n                },\n                \"backgroundColor\": \"transparent\",\n                \"border\": \"none\",\n                \"borderRadius\": 0,\n                \"color\": \"#326891\",\n                \"padding\": \"8px 20px\",\n                \"textAlign\": \"left\",\n                \"width\": \"100%\",\n              }\n            }\n          >\n            Spam\n          </button>\n        </li>\n        <li\n          style={\n            Object {\n              \"listStyleType\": \"none\",\n            }\n          }\n        >\n          <button\n            onClick={[Function]}\n            style={\n              Object {\n                \":hover\": Object {\n                  \"backgroundColor\": \"#326891\",\n                  \"color\": \"rgba(255, 255, 255, 1)\",\n                },\n                \"backgroundColor\": \"transparent\",\n                \"border\": \"none\",\n                \"borderRadius\": 0,\n                \"color\": \"#326891\",\n                \"padding\": \"8px 20px\",\n                \"textAlign\": \"left\",\n                \"width\": \"100%\",\n              }\n            }\n          >\n            Off-topic\n          </button>\n        </li>\n        <li\n          style={\n            Object {\n              \"listStyleType\": \"none\",\n            }\n          }\n        >\n          <button\n            onClick={[Function]}\n            style={\n              Object {\n                \":hover\": Object {\n                  \"backgroundColor\": \"#326891\",\n                  \"color\": \"rgba(255, 255, 255, 1)\",\n                },\n                \"backgroundColor\": \"transparent\",\n                \"border\": \"none\",\n                \"borderRadius\": 0,\n                \"color\": \"#326891\",\n                \"padding\": \"8px 20px\",\n                \"textAlign\": \"left\",\n                \"width\": \"100%\",\n              }\n            }\n          >\n            Inflammatory\n          </button>\n        </li>\n        <li\n          style={\n            Object {\n              \"listStyleType\": \"none\",\n            }\n          }\n        >\n          <button\n            onClick={[Function]}\n            style={\n              Object {\n                \":hover\": Object {\n                  \"backgroundColor\": \"#326891\",\n                  \"color\": \"rgba(255, 255, 255, 1)\",\n                },\n                \"backgroundColor\": \"transparent\",\n                \"border\": \"none\",\n                \"borderRadius\": 0,\n                \"color\": \"#326891\",\n                \"padding\": \"8px 20px\",\n                \"textAlign\": \"left\",\n                \"width\": \"100%\",\n              }\n            }\n          >\n            Unsubstantial\n          </button>\n        </li>\n      </ul>\n    </div>\n    <div\n      style={\n        Object {\n          \"borderBottom\": \"16px solid #efefef\",\n          \"borderLeft\": \"16px solid transparent\",\n          \"borderRight\": \"16px solid transparent\",\n          \"borderTop\": 0,\n          \"height\": 0,\n          \"left\": \"16px\",\n          \"position\": \"absolute\",\n          \"top\": \"-16px\",\n          \"width\": 0,\n        }\n      }\n    />\n  </div>\n</div>\n`;\n\nexports[`Storyshots ToolTip topRight arrow (single tag) 1`] = `\n<div>\n  <div\n    style={\n      Object {\n        \"backgroundColor\": \"#f00\",\n        \"borderRadius\": \"50%\",\n        \"height\": \"6px\",\n        \"left\": \"300px\",\n        \"position\": \"absolute\",\n        \"top\": \"300px\",\n        \"transform\": \"translate(-50%, -50%)\",\n        \"width\": \"6px\",\n      }\n    }\n  />\n  <div\n    className=\"base_901bvq\"\n    style={\n      Object {\n        \"backgroundColor\": \"#326891\",\n        \"left\": 300,\n        \"position\": \"absolute\",\n        \"top\": 300,\n        \"transform\": \"translate(calc(-100% + 32px), 32px)\",\n        \"zIndex\": 15,\n      }\n    }\n  >\n    <div\n      style={\n        Object {\n          \"width\": 250,\n        }\n      }\n    >\n      <div\n        style={\n          Object {\n            \"alignItems\": \"center\",\n            \"boxSizing\": \"border-box\",\n            \"display\": \"flex\",\n            \"justifyContent\": \"center\",\n            \"padding\": \"24px\",\n            \"position\": \"relative\",\n            \"width\": \"100%\",\n          }\n        }\n      >\n        <p\n          style={\n            Object {\n              \"color\": \"rgba(255, 255, 255, 1)\",\n              \"textAlign\": \"center\",\n            }\n          }\n        >\n          is this spam?\n        </p>\n      </div>\n      <div>\n        <button\n          onClick={[Function]}\n          style={\n            Object {\n              \":hover\": Object {\n                \"backgroundColor\": \"#46779c\",\n              },\n              \"backgroundColor\": \"#326891\",\n              \"border\": \"none\",\n              \"borderRadius\": 0,\n              \"color\": \"rgba(255, 255, 255, 1)\",\n              \"marginBottom\": \"24px\",\n              \"padding\": \"24px\",\n              \"width\": \"50%\",\n            }\n          }\n        >\n          Yes\n        </button>\n        <button\n          onClick={[Function]}\n          style={\n            Object {\n              \":hover\": Object {\n                \"backgroundColor\": \"#46779c\",\n              },\n              \"backgroundColor\": \"#326891\",\n              \"border\": \"none\",\n              \"borderRadius\": 0,\n              \"color\": \"rgba(255, 255, 255, 1)\",\n              \"marginBottom\": \"24px\",\n              \"padding\": \"24px\",\n              \"width\": \"50%\",\n            }\n          }\n        >\n          No\n        </button>\n      </div>\n    </div>\n    <div\n      style={\n        Object {\n          \"borderBottom\": \"16px solid #326891\",\n          \"borderLeft\": \"16px solid transparent\",\n          \"borderRight\": \"16px solid transparent\",\n          \"borderTop\": 0,\n          \"height\": 0,\n          \"position\": \"absolute\",\n          \"right\": \"16px\",\n          \"top\": \"-16px\",\n          \"width\": 0,\n        }\n      }\n    />\n  </div>\n</div>\n`;\n"
  },
  {
    "path": "packages/frontend-web/tooling/storybook/config.js",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport { addDecorator, configure } from \"@storybook/react\";\nimport React from \"react\";\nimport { Provider } from \"react-redux\";\nimport { store } from \"../../src/app/store\";\n\nconst withProvider = (story) => <Provider store={store}>{story()}</Provider>;\naddDecorator(withProvider);\n\nconst req = require.context(\"../../src/app\", true, /Story\\.tsx?$/);\n\nfunction loadStories() {\n  req.keys().forEach(req);\n}\n\nconfigure(loadStories, module);\n"
  },
  {
    "path": "packages/frontend-web/tooling/storybook/disable-aphrodite-inject.js",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport * as Aphrodite from 'aphrodite';\nimport * as AphroditeNoImportant from 'aphrodite/no-important';\n\nAphrodite.StyleSheetTestUtils.suppressStyleInjection();\nAphroditeNoImportant.StyleSheetTestUtils.suppressStyleInjection();\n"
  },
  {
    "path": "packages/frontend-web/tooling/storybook/jest.config.json",
    "content": "{\n  \"setupFiles\": [\n    \"<rootDir>/register-context.js\",\n    \"<rootDir>/disable-aphrodite-inject.js\"\n  ],\n  \"moduleNameMapper\": {\n    \"\\\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$\": \"<rootDir>/__mocks__/fileMock.js\",\n    \"\\\\.(css|less)$\": \"identity-obj-proxy\"\n  },\n  \"testEnvironment\": \"jest-environment-jsdom-sixteen\"\n}\n"
  },
  {
    "path": "packages/frontend-web/tooling/storybook/preview-head.html",
    "content": "<link rel=\"stylesheet\" href=\"/css/normalize.css\">\n<link rel=\"stylesheet\" href=\"/css/fonts/fonts.css\">\n<link rel=\"stylesheet\" href=\"/css/moderator.css\">\n<style>\n  body {\n    background-color: white;\n  }\n</style>\n"
  },
  {
    "path": "packages/frontend-web/tooling/storybook/register-context.js",
    "content": "/*\nCopyright 2019 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nimport registerRequireContextHook from 'babel-plugin-require-context-hook/register';\nregisterRequireContextHook();\n"
  },
  {
    "path": "packages/frontend-web/tooling/storybook/webpack.config.js",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nconst custom = require('../webpack.config.js');\n\nmodule.exports = async ({config}) => {\n  return {\n    ...config,\n    module: {...config.module, rules: custom.module.rules},\n    resolve: custom.resolve\n  };\n};\n"
  },
  {
    "path": "packages/frontend-web/tooling/webpack.config.js",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nconst path = require('path');\nconst webpack = require('webpack');\nconst CircularDependencyPlugin = require('circular-dependency-plugin');\n\nconst frontend_url = process.env['FRONTEND_URL'];\nconst port = frontend_url ? (new URL(frontend_url)).port : '8000';\n\nmodule.exports = {\n  mode: 'development',\n\n  target: 'web',\n\n  entry: {\n    moderator: [\n      `webpack-dev-server/client?http://0.0.0.0:${port}`,\n      'webpack/hot/only-dev-server',\n      '@babel/polyfill',\n      './src/app/main.tsx'\n    ]\n  },\n\n  output: {\n    path: path.join(__dirname, \"..\", \"build\", \"public\"),\n    filename: \"js/[name].js\"\n  },\n\n  module: {\n    rules: [\n      {\n        test: /\\.tsx?$/,\n        use: 'source-map-loader',\n        enforce: 'pre'\n      },\n      {\n        test: /\\.tsx?$/,\n        use: 'babel-loader',\n        exclude: /node_modules/,\n        enforce: 'post'\n      },\n      {\n        test:/\\.css$/,\n        use:['style-loader','css-loader']\n      },\n    ]\n  },\n\n  devtool: \"source-map\",\n\n  resolve: {\n    extensions: [\".ts\", \".tsx\", \".js\"],\n    alias: {\n      'aphrodite': 'aphrodite/no-important',\n      'ws': 'slugify', // Not a real alias.  But stops webpack from including ws library in bundle\n    },\n    fallback: {\n      'crypto': require.resolve(\"crypto-browserify\"),\n      'stream': require.resolve(\"stream-browserify\"),\n    }\n  },\n\n  plugins: [\n    new webpack.PrefetchPlugin(\"react\"),\n    new webpack.HotModuleReplacementPlugin(),\n    new webpack.DefinePlugin({\n      __DEVELOPMENT__: false,\n      __DEVPANEL__: true,\n      ENV_API_URL: process.env['API_URL'] ? \"'\" + (process.env['API_URL']) + \"'\" : undefined,\n      ENV_APP_NAME: \"'\" + (process.env['APP_NAME'] || 'Moderator') + \"'\",\n      ENV_REQUIRE_REASON_TO_REJECT: (process.env['REQUIRE_REASON_TO_REJECT'] || true),\n      ENV_COMMENTS_EDITABLE_FLAG: (process.env['COMMENTS_EDITABLE_FLAG'] || true),\n      ENV_RESTRICT_TO_SESSION: (process.env['RESTRICT_TO_SESSION'] || true),\n      ENV_MODERATOR_GUIDELINES_URL: \"'\" + (process.env['MODERATOR_GUIDELINES_URL'] || '') + \"'\",\n      ENV_SUBMIT_FEEDBACK_URL: \"'\" + (process.env['SUBMIT_FEEDBACK_URL'] || '') + \"'\"\n    }),\n    new CircularDependencyPlugin({\n      exclude: /node_modules/,\n    }),\n    new webpack.ProvidePlugin({ process: 'process/browser', }),\n  ],\n\n  devServer: {\n    contentBase: path.join(__dirname, \"..\", \"public\"),\n    host: '0.0.0.0',\n    port: port,\n    historyApiFallback: true,\n  },\n};\n"
  },
  {
    "path": "packages/frontend-web/tooling/webpack.config.production.js",
    "content": "/*\nCopyright 2017 Google Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\nconst path = require('path');\nconst webpack = require('webpack');\nconst publicPath = \"/_assets/\";\n\nmodule.exports = {\n  mode: 'production',\n\n  target: 'web',\n\n  entry: {moderator: ['@babel/polyfill', './src/app/main.tsx']},\n\n  output: {\n    path: path.join(__dirname, \"..\", \"build\"),\n    publicPath: publicPath,\n    filename: \"js/[name].js\",\n    chunkFilename: \"[id].js\",\n  },\n\n  module: {\n    rules: [\n      {\n        test: /\\.tsx?$/,\n        use: 'source-map-loader',\n        enforce: 'pre'\n      },\n      {\n        test: /\\.tsx?$/,\n        use: 'babel-loader',\n        exclude: /node_modules/,\n        enforce: 'post'\n      },\n      {\n        test:/\\.css$/,\n        use:['style-loader','css-loader']\n      },\n    ]\n  },\n\n  devtool: \"source-map\",\n  resolve: {\n    extensions: [\".ts\", \".tsx\", \".js\"],\n    alias: {\n      'aphrodite': 'aphrodite/no-important',\n      'ws': 'slugify', // Not a real alias.  But stops webpack from including ws library in bundle,\n      'crypto': require.resolve(\"crypto-browserify\"),\n      'stream': require.resolve(\"stream-browserify\"),\n    }\n  },\n  plugins: [\n    new webpack.PrefetchPlugin(\"react\"),\n    new webpack.DefinePlugin({\n      __DEVELOPMENT__: false,\n      __DEVPANEL__: false,\n      ENV_API_URL: process.env['API_URL'] ? \"'\" + (process.env['API_URL']) + \"'\" : undefined,\n      ENV_APP_NAME: \"'\" + (process.env['APP_NAME'] || 'Moderator') + \"'\",\n      ENV_REQUIRE_REASON_TO_REJECT: (process.env['REQUIRE_REASON_TO_REJECT'] || true),\n      ENV_COMMENTS_EDITABLE_FLAG: (process.env['COMMENTS_EDITABLE_FLAG'] || true),\n      ENV_RESTRICT_TO_SESSION: (process.env['RESTRICT_TO_SESSION'] || true),\n      ENV_MODERATOR_GUIDELINES_URL: \"'\" + (process.env['MODERATOR_GUIDELINES_URL'] || '') + \"'\",\n      ENV_SUBMIT_FEEDBACK_URL: \"'\" + (process.env['SUBMIT_FEEDBACK_URL'] || '') + \"'\",\n    }),\n    new webpack.ProvidePlugin({ process: 'process/browser', }),\n  ]\n};\n"
  },
  {
    "path": "packages/frontend-web/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"es6\",\n    \"module\": \"commonjs\",\n    \"moduleResolution\": \"node\",\n    \"jsx\": \"preserve\",\n    \"experimentalDecorators\": true,\n    \"allowSyntheticDefaultImports\": true,\n    \"noImplicitAny\": true,\n    \"noUnusedParameters\": true,\n    \"noUnusedLocals\": true\n  },\n  \"exclude\": [\n    \"node_modules\",\n    \"build\",\n    \"dist\"\n  ]\n}\n"
  },
  {
    "path": "seed/initial-database.sql",
    "content": "-- MySQL dump 10.13  Distrib 8.0.22, for Linux (x86_64)\n--\n-- Host: localhost    Database: os_moderator_schema_test_migrations\n-- ------------------------------------------------------\n-- Server version\t8.0.22-0ubuntu0.20.04.3\n\n/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;\n/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;\n/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;\n/*!50503 SET NAMES utf8mb4 */;\n/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;\n/*!40103 SET TIME_ZONE='+00:00' */;\n/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;\n/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;\n/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;\n/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;\n\n--\n-- Table structure for table `SequelizeMeta`\n--\n\nDROP TABLE IF EXISTS `SequelizeMeta`;\n/*!40101 SET @saved_cs_client     = @@character_set_client */;\n/*!50503 SET character_set_client = utf8mb4 */;\nCREATE TABLE `SequelizeMeta` (\n  `name` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL,\n  PRIMARY KEY (`name`),\n  UNIQUE KEY `name` (`name`),\n  UNIQUE KEY `SequelizeMeta_name_unique` (`name`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;\n/*!40101 SET character_set_client = @saved_cs_client */;\n\n--\n-- Dumping data for table `SequelizeMeta`\n--\n\nLOCK TABLES `SequelizeMeta` WRITE;\n/*!40000 ALTER TABLE `SequelizeMeta` DISABLE KEYS */;\n/*!40000 ALTER TABLE `SequelizeMeta` ENABLE KEYS */;\nUNLOCK TABLES;\n\n--\n-- Table structure for table `articles`\n--\n\nDROP TABLE IF EXISTS `articles`;\n/*!40101 SET @saved_cs_client     = @@character_set_client */;\n/*!50503 SET character_set_client = utf8mb4 */;\nCREATE TABLE `articles` (\n  `id` int unsigned NOT NULL AUTO_INCREMENT,\n  `sourceId` char(255) NOT NULL,\n  `title` char(255) NOT NULL,\n  `text` longtext NOT NULL,\n  `isAutoModerated` tinyint(1) NOT NULL DEFAULT '1',\n  `createdAt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,\n  `updatedAt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,\n  `url` char(255) NOT NULL,\n  `extra` json DEFAULT NULL,\n  `categoryId` int unsigned DEFAULT NULL,\n  `unprocessedCount` int unsigned NOT NULL DEFAULT '0',\n  `unmoderatedCount` int unsigned NOT NULL DEFAULT '0',\n  `moderatedCount` int unsigned NOT NULL DEFAULT '0',\n  `sourceCreatedAt` datetime DEFAULT NULL,\n  `highlightedCount` int unsigned NOT NULL DEFAULT '0',\n  `approvedCount` int unsigned NOT NULL DEFAULT '0',\n  `rejectedCount` int unsigned NOT NULL DEFAULT '0',\n  `deferredCount` int unsigned NOT NULL DEFAULT '0',\n  `flaggedCount` int unsigned NOT NULL DEFAULT '0',\n  `batchedCount` int unsigned NOT NULL DEFAULT '0',\n  `allCount` int unsigned NOT NULL DEFAULT '0',\n  `ownerId` int unsigned DEFAULT NULL,\n  `lastModeratedAt` datetime DEFAULT NULL,\n  `isCommentingEnabled` tinyint(1) NOT NULL DEFAULT '1',\n  PRIMARY KEY (`id`),\n  UNIQUE KEY `sourceId_index` (`sourceId`),\n  KEY `categoryId` (`categoryId`),\n  KEY `ownerId` (`ownerId`),\n  CONSTRAINT `articles_ibfk_1` FOREIGN KEY (`categoryId`) REFERENCES `categories` (`id`) ON DELETE SET NULL ON UPDATE CASCADE,\n  CONSTRAINT `articles_ibfk_2` FOREIGN KEY (`ownerId`) REFERENCES `users` (`id`) ON DELETE SET NULL ON UPDATE CASCADE\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n/*!40101 SET character_set_client = @saved_cs_client */;\n\n--\n-- Dumping data for table `articles`\n--\n\nLOCK TABLES `articles` WRITE;\n/*!40000 ALTER TABLE `articles` DISABLE KEYS */;\n/*!40000 ALTER TABLE `articles` ENABLE KEYS */;\nUNLOCK TABLES;\n\n--\n-- Table structure for table `categories`\n--\n\nDROP TABLE IF EXISTS `categories`;\n/*!40101 SET @saved_cs_client     = @@character_set_client */;\n/*!50503 SET character_set_client = utf8mb4 */;\nCREATE TABLE `categories` (\n  `id` int unsigned NOT NULL AUTO_INCREMENT,\n  `label` char(255) NOT NULL,\n  `isActive` tinyint(1) DEFAULT '1',\n  `extra` json DEFAULT NULL,\n  `createdAt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,\n  `updatedAt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,\n  `unprocessedCount` int unsigned NOT NULL DEFAULT '0',\n  `unmoderatedCount` int unsigned NOT NULL DEFAULT '0',\n  `moderatedCount` int unsigned NOT NULL DEFAULT '0',\n  `highlightedCount` int unsigned NOT NULL DEFAULT '0',\n  `approvedCount` int unsigned NOT NULL DEFAULT '0',\n  `rejectedCount` int unsigned NOT NULL DEFAULT '0',\n  `deferredCount` int unsigned NOT NULL DEFAULT '0',\n  `flaggedCount` int unsigned NOT NULL DEFAULT '0',\n  `batchedCount` int unsigned NOT NULL DEFAULT '0',\n  `sourceId` char(255) DEFAULT NULL,\n  `allCount` int unsigned NOT NULL DEFAULT '0',\n  `ownerId` int unsigned DEFAULT NULL,\n  PRIMARY KEY (`id`),\n  UNIQUE KEY `label_index` (`label`),\n  KEY `ownerId` (`ownerId`),\n  CONSTRAINT `categories_ibfk_2` FOREIGN KEY (`ownerId`) REFERENCES `users` (`id`) ON DELETE SET NULL ON UPDATE CASCADE\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n/*!40101 SET character_set_client = @saved_cs_client */;\n\n--\n-- Dumping data for table `categories`\n--\n\nLOCK TABLES `categories` WRITE;\n/*!40000 ALTER TABLE `categories` DISABLE KEYS */;\n/*!40000 ALTER TABLE `categories` ENABLE KEYS */;\nUNLOCK TABLES;\n\n--\n-- Table structure for table `comment_flags`\n--\n\nDROP TABLE IF EXISTS `comment_flags`;\n/*!40101 SET @saved_cs_client     = @@character_set_client */;\n/*!50503 SET character_set_client = utf8mb4 */;\nCREATE TABLE `comment_flags` (\n  `id` int unsigned NOT NULL AUTO_INCREMENT,\n  `sourceId` char(255) DEFAULT NULL,\n  `extra` json DEFAULT NULL,\n  `createdAt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,\n  `updatedAt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,\n  `commentId` int unsigned DEFAULT NULL,\n  `label` char(80) NOT NULL,\n  `detail` varchar(255) DEFAULT NULL,\n  `authorSourceId` char(255) DEFAULT NULL,\n  `isResolved` tinyint(1) DEFAULT '0',\n  `isRecommendation` tinyint(1) DEFAULT '0',\n  `resolvedById` int unsigned DEFAULT NULL,\n  `resolvedAt` datetime DEFAULT NULL,\n  PRIMARY KEY (`id`),\n  KEY `comment_flags_commentId_foreign_idx` (`commentId`),\n  KEY `comment_flags_resolvedById_foreign_idx` (`resolvedById`),\n  CONSTRAINT `comment_flags_commentId_foreign_idx` FOREIGN KEY (`commentId`) REFERENCES `comments` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,\n  CONSTRAINT `comment_flags_resolvedById_foreign_idx` FOREIGN KEY (`resolvedById`) REFERENCES `users` (`id`) ON DELETE SET NULL\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n/*!40101 SET character_set_client = @saved_cs_client */;\n\n--\n-- Dumping data for table `comment_flags`\n--\n\nLOCK TABLES `comment_flags` WRITE;\n/*!40000 ALTER TABLE `comment_flags` DISABLE KEYS */;\n/*!40000 ALTER TABLE `comment_flags` ENABLE KEYS */;\nUNLOCK TABLES;\n\n--\n-- Table structure for table `comment_score_requests`\n--\n\nDROP TABLE IF EXISTS `comment_score_requests`;\n/*!40101 SET @saved_cs_client     = @@character_set_client */;\n/*!50503 SET character_set_client = utf8mb4 */;\nCREATE TABLE `comment_score_requests` (\n  `id` int unsigned NOT NULL AUTO_INCREMENT,\n  `commentId` int unsigned DEFAULT NULL,\n  `sentAt` datetime NOT NULL,\n  `doneAt` datetime DEFAULT NULL,\n  `createdAt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,\n  `updatedAt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,\n  `userId` int unsigned DEFAULT NULL,\n  PRIMARY KEY (`id`),\n  KEY `commentId` (`commentId`),\n  KEY `userId_foreign_idx` (`userId`),\n  CONSTRAINT `comment_score_requests_ibfk_1` FOREIGN KEY (`commentId`) REFERENCES `comments` (`id`) ON DELETE SET NULL ON UPDATE CASCADE,\n  CONSTRAINT `userId_foreign_idx` FOREIGN KEY (`userId`) REFERENCES `users` (`id`) ON DELETE SET NULL ON UPDATE CASCADE\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n/*!40101 SET character_set_client = @saved_cs_client */;\n\n--\n-- Dumping data for table `comment_score_requests`\n--\n\nLOCK TABLES `comment_score_requests` WRITE;\n/*!40000 ALTER TABLE `comment_score_requests` DISABLE KEYS */;\n/*!40000 ALTER TABLE `comment_score_requests` ENABLE KEYS */;\nUNLOCK TABLES;\n\n--\n-- Table structure for table `comment_scores`\n--\n\nDROP TABLE IF EXISTS `comment_scores`;\n/*!40101 SET @saved_cs_client     = @@character_set_client */;\n/*!50503 SET character_set_client = utf8mb4 */;\nCREATE TABLE `comment_scores` (\n  `id` int unsigned NOT NULL AUTO_INCREMENT,\n  `commentId` int unsigned DEFAULT NULL,\n  `sourceType` enum('User','Moderator','Machine') NOT NULL,\n  `sourceId` char(255) DEFAULT NULL,\n  `commentScoreRequestId` int unsigned DEFAULT NULL,\n  `score` float unsigned NOT NULL,\n  `annotationStart` int unsigned DEFAULT NULL,\n  `annotationEnd` int unsigned DEFAULT NULL,\n  `extra` json DEFAULT NULL,\n  `createdAt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,\n  `updatedAt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,\n  `tagId` int unsigned DEFAULT NULL,\n  `isConfirmed` tinyint(1) DEFAULT NULL,\n  `confirmedUserId` int unsigned DEFAULT NULL,\n  `userId` int unsigned DEFAULT NULL,\n  PRIMARY KEY (`id`),\n  KEY `commentScoreRequestId` (`commentScoreRequestId`),\n  KEY `commentId_index` (`commentId`),\n  KEY `tagId_foreign_idx` (`tagId`),\n  KEY `comment_scores_confirmed_user_id_foreign_idx` (`confirmedUserId`),\n  KEY `comment_scores_user_id_foreign_idx` (`userId`),\n  KEY `commentId_score_index` (`commentId`,`score`),\n  KEY `commentId_score_tagId_index` (`commentId`,`score`,`tagId`),\n  CONSTRAINT `comment_scores_confirmed_user_id_foreign_idx` FOREIGN KEY (`confirmedUserId`) REFERENCES `users` (`id`) ON DELETE SET NULL ON UPDATE CASCADE,\n  CONSTRAINT `comment_scores_ibfk_1` FOREIGN KEY (`commentId`) REFERENCES `comments` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,\n  CONSTRAINT `comment_scores_ibfk_2` FOREIGN KEY (`commentScoreRequestId`) REFERENCES `comment_score_requests` (`id`) ON DELETE SET NULL ON UPDATE CASCADE,\n  CONSTRAINT `comment_scores_user_id_foreign_idx` FOREIGN KEY (`userId`) REFERENCES `users` (`id`) ON DELETE SET NULL ON UPDATE CASCADE,\n  CONSTRAINT `tagId_foreign_idx` FOREIGN KEY (`tagId`) REFERENCES `tags` (`id`) ON DELETE CASCADE ON UPDATE CASCADE\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n/*!40101 SET character_set_client = @saved_cs_client */;\n\n--\n-- Dumping data for table `comment_scores`\n--\n\nLOCK TABLES `comment_scores` WRITE;\n/*!40000 ALTER TABLE `comment_scores` DISABLE KEYS */;\n/*!40000 ALTER TABLE `comment_scores` ENABLE KEYS */;\nUNLOCK TABLES;\n\n--\n-- Table structure for table `comment_sizes`\n--\n\nDROP TABLE IF EXISTS `comment_sizes`;\n/*!40101 SET @saved_cs_client     = @@character_set_client */;\n/*!50503 SET character_set_client = utf8mb4 */;\nCREATE TABLE `comment_sizes` (\n  `commentId` int unsigned NOT NULL,\n  `width` int unsigned NOT NULL,\n  `height` int unsigned NOT NULL,\n  `createdAt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,\n  `updatedAt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,\n  PRIMARY KEY (`commentId`),\n  UNIQUE KEY `commentId_width_index` (`commentId`,`width`),\n  CONSTRAINT `comment_sizes_ibfk_1` FOREIGN KEY (`commentId`) REFERENCES `comments` (`id`) ON DELETE CASCADE ON UPDATE CASCADE\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n/*!40101 SET character_set_client = @saved_cs_client */;\n\n--\n-- Dumping data for table `comment_sizes`\n--\n\nLOCK TABLES `comment_sizes` WRITE;\n/*!40000 ALTER TABLE `comment_sizes` DISABLE KEYS */;\n/*!40000 ALTER TABLE `comment_sizes` ENABLE KEYS */;\nUNLOCK TABLES;\n\n--\n-- Table structure for table `comment_summary_scores`\n--\n\nDROP TABLE IF EXISTS `comment_summary_scores`;\n/*!40101 SET @saved_cs_client     = @@character_set_client */;\n/*!50503 SET character_set_client = utf8mb4 */;\nCREATE TABLE `comment_summary_scores` (\n  `commentId` int unsigned NOT NULL,\n  `tagId` int unsigned NOT NULL,\n  `score` float unsigned NOT NULL,\n  `isConfirmed` tinyint(1) DEFAULT NULL,\n  `confirmedUserId` int unsigned DEFAULT NULL,\n  PRIMARY KEY (`commentId`,`tagId`),\n  UNIQUE KEY `commentId_tagId_index` (`commentId`,`tagId`),\n  KEY `tagId` (`tagId`),\n  KEY `comment_summary_scores_confirmed_user_id_foreign_idx` (`confirmedUserId`),\n  CONSTRAINT `comment_summary_scores_confirmed_user_id_foreign_idx` FOREIGN KEY (`confirmedUserId`) REFERENCES `users` (`id`) ON DELETE SET NULL ON UPDATE CASCADE,\n  CONSTRAINT `comment_summary_scores_ibfk_1` FOREIGN KEY (`commentId`) REFERENCES `comments` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,\n  CONSTRAINT `comment_summary_scores_ibfk_2` FOREIGN KEY (`tagId`) REFERENCES `tags` (`id`) ON DELETE CASCADE ON UPDATE CASCADE\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n/*!40101 SET character_set_client = @saved_cs_client */;\n\n--\n-- Dumping data for table `comment_summary_scores`\n--\n\nLOCK TABLES `comment_summary_scores` WRITE;\n/*!40000 ALTER TABLE `comment_summary_scores` DISABLE KEYS */;\n/*!40000 ALTER TABLE `comment_summary_scores` ENABLE KEYS */;\nUNLOCK TABLES;\n\n--\n-- Table structure for table `comment_top_scores`\n--\n\nDROP TABLE IF EXISTS `comment_top_scores`;\n/*!40101 SET @saved_cs_client     = @@character_set_client */;\n/*!50503 SET character_set_client = utf8mb4 */;\nCREATE TABLE `comment_top_scores` (\n  `commentId` int unsigned NOT NULL,\n  `tagId` int unsigned NOT NULL,\n  `commentScoreId` int unsigned DEFAULT NULL,\n  PRIMARY KEY (`commentId`,`tagId`),\n  UNIQUE KEY `commentId_tagId_index` (`commentId`,`tagId`),\n  KEY `tagId` (`tagId`),\n  KEY `commentScoreId` (`commentScoreId`),\n  CONSTRAINT `comment_top_scores_ibfk_1` FOREIGN KEY (`commentId`) REFERENCES `comments` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,\n  CONSTRAINT `comment_top_scores_ibfk_2` FOREIGN KEY (`tagId`) REFERENCES `tags` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,\n  CONSTRAINT `comment_top_scores_ibfk_3` FOREIGN KEY (`commentScoreId`) REFERENCES `comment_scores` (`id`) ON DELETE SET NULL ON UPDATE CASCADE\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n/*!40101 SET character_set_client = @saved_cs_client */;\n\n--\n-- Dumping data for table `comment_top_scores`\n--\n\nLOCK TABLES `comment_top_scores` WRITE;\n/*!40000 ALTER TABLE `comment_top_scores` DISABLE KEYS */;\n/*!40000 ALTER TABLE `comment_top_scores` ENABLE KEYS */;\nUNLOCK TABLES;\n\n--\n-- Table structure for table `comments`\n--\n\nDROP TABLE IF EXISTS `comments`;\n/*!40101 SET @saved_cs_client     = @@character_set_client */;\n/*!50503 SET character_set_client = utf8mb4 */;\nCREATE TABLE `comments` (\n  `id` int unsigned NOT NULL AUTO_INCREMENT,\n  `sourceId` char(255) NOT NULL,\n  `articleId` int unsigned DEFAULT NULL,\n  `replyToSourceId` char(255) DEFAULT NULL,\n  `authorSourceId` char(255) NOT NULL,\n  `text` longtext NOT NULL,\n  `author` json NOT NULL,\n  `isScored` tinyint(1) NOT NULL DEFAULT '0',\n  `isAccepted` tinyint(1) DEFAULT NULL,\n  `isDeferred` tinyint(1) DEFAULT '0',\n  `isHighlighted` tinyint(1) DEFAULT '0',\n  `isBatchResolved` tinyint(1) DEFAULT '0',\n  `isAutoResolved` tinyint(1) DEFAULT '0',\n  `sourceCreatedAt` datetime DEFAULT NULL,\n  `sentForScoring` datetime DEFAULT NULL,\n  `extra` json DEFAULT NULL,\n  `createdAt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,\n  `updatedAt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,\n  `sentBackToPublisher` datetime DEFAULT NULL,\n  `isModerated` tinyint(1) NOT NULL DEFAULT '0',\n  `replyId` int unsigned DEFAULT NULL,\n  `maxSummaryScore` float unsigned DEFAULT NULL,\n  `maxSummaryScoreTagId` int unsigned DEFAULT NULL,\n  `ownerId` int unsigned DEFAULT NULL,\n  `unresolvedFlagsCount` int unsigned NOT NULL DEFAULT '0',\n  `flagsSummary` json DEFAULT NULL,\n  PRIMARY KEY (`id`),\n  KEY `authorSourceId_index` (`authorSourceId`),\n  KEY `replyToSourceId_index` (`replyToSourceId`),\n  KEY `isAccepted_index` (`isAccepted`),\n  KEY `isDeferred_index` (`isDeferred`),\n  KEY `isHighlighted_index` (`isHighlighted`),\n  KEY `isBatchResolved_index` (`isBatchResolved`),\n  KEY `isAutoResolved_index` (`isAutoResolved`),\n  KEY `sentForScoring_index` (`sentForScoring`),\n  KEY `sentBackToPublisher_index` (`sentBackToPublisher`),\n  KEY `replyId_index` (`replyId`),\n  KEY `maxSummaryScore_index` (`maxSummaryScore`),\n  KEY `maxSummaryScoreTagId_index` (`maxSummaryScoreTagId`),\n  KEY `articleId` (`articleId`),\n  KEY `ownerId` (`ownerId`),\n  FULLTEXT KEY `comments_text` (`text`),\n  CONSTRAINT `comments_ibfk_1` FOREIGN KEY (`articleId`) REFERENCES `articles` (`id`) ON DELETE SET NULL ON UPDATE CASCADE,\n  CONSTRAINT `comments_ibfk_2` FOREIGN KEY (`replyId`) REFERENCES `comments` (`id`) ON DELETE SET NULL ON UPDATE CASCADE,\n  CONSTRAINT `comments_ibfk_3` FOREIGN KEY (`ownerId`) REFERENCES `users` (`id`) ON DELETE SET NULL ON UPDATE CASCADE\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n/*!40101 SET character_set_client = @saved_cs_client */;\n\n--\n-- Dumping data for table `comments`\n--\n\nLOCK TABLES `comments` WRITE;\n/*!40000 ALTER TABLE `comments` DISABLE KEYS */;\n/*!40000 ALTER TABLE `comments` ENABLE KEYS */;\nUNLOCK TABLES;\n\n--\n-- Table structure for table `configuration_items`\n--\n\nDROP TABLE IF EXISTS `configuration_items`;\n/*!40101 SET @saved_cs_client     = @@character_set_client */;\n/*!50503 SET character_set_client = utf8mb4 */;\nCREATE TABLE `configuration_items` (\n  `id` varchar(255) NOT NULL,\n  `createdAt` datetime DEFAULT NULL,\n  `updatedAt` datetime DEFAULT NULL,\n  `data` json NOT NULL,\n  PRIMARY KEY (`id`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;\n/*!40101 SET character_set_client = @saved_cs_client */;\n\n--\n-- Dumping data for table `configuration_items`\n--\n\nLOCK TABLES `configuration_items` WRITE;\n/*!40000 ALTER TABLE `configuration_items` DISABLE KEYS */;\n/*!40000 ALTER TABLE `configuration_items` ENABLE KEYS */;\nUNLOCK TABLES;\n\n--\n-- Table structure for table `csrfs`\n--\n\nDROP TABLE IF EXISTS `csrfs`;\n/*!40101 SET @saved_cs_client     = @@character_set_client */;\n/*!50503 SET character_set_client = utf8mb4 */;\nCREATE TABLE `csrfs` (\n  `id` int unsigned NOT NULL AUTO_INCREMENT,\n  `clientCSRF` char(255) NOT NULL,\n  `serverCSRF` char(255) NOT NULL,\n  `createdAt` datetime NOT NULL,\n  `updatedAt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,\n  `referrer` char(255) DEFAULT NULL,\n  PRIMARY KEY (`id`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n/*!40101 SET character_set_client = @saved_cs_client */;\n\n--\n-- Dumping data for table `csrfs`\n--\n\nLOCK TABLES `csrfs` WRITE;\n/*!40000 ALTER TABLE `csrfs` DISABLE KEYS */;\n/*!40000 ALTER TABLE `csrfs` ENABLE KEYS */;\nUNLOCK TABLES;\n\n--\n-- Table structure for table `decisions`\n--\n\nDROP TABLE IF EXISTS `decisions`;\n/*!40101 SET @saved_cs_client     = @@character_set_client */;\n/*!50503 SET character_set_client = utf8mb4 */;\nCREATE TABLE `decisions` (\n  `id` int unsigned NOT NULL AUTO_INCREMENT,\n  `commentId` int unsigned DEFAULT NULL,\n  `userId` int unsigned DEFAULT NULL,\n  `moderationRuleId` int unsigned DEFAULT NULL,\n  `status` enum('Accept','Reject','Defer') NOT NULL,\n  `source` enum('User','Rule') NOT NULL,\n  `isCurrentDecision` tinyint(1) NOT NULL DEFAULT '1',\n  `sentBackToPublisher` datetime DEFAULT NULL,\n  `createdAt` datetime NOT NULL,\n  `updatedAt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,\n  PRIMARY KEY (`id`),\n  KEY `commentId` (`commentId`),\n  KEY `userId` (`userId`),\n  KEY `moderationRuleId` (`moderationRuleId`),\n  CONSTRAINT `decisions_ibfk_1` FOREIGN KEY (`commentId`) REFERENCES `comments` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,\n  CONSTRAINT `decisions_ibfk_2` FOREIGN KEY (`userId`) REFERENCES `users` (`id`) ON DELETE SET NULL ON UPDATE CASCADE,\n  CONSTRAINT `decisions_ibfk_3` FOREIGN KEY (`moderationRuleId`) REFERENCES `moderation_rules` (`id`) ON DELETE SET NULL ON UPDATE CASCADE\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n/*!40101 SET character_set_client = @saved_cs_client */;\n\n--\n-- Dumping data for table `decisions`\n--\n\nLOCK TABLES `decisions` WRITE;\n/*!40000 ALTER TABLE `decisions` DISABLE KEYS */;\n/*!40000 ALTER TABLE `decisions` ENABLE KEYS */;\nUNLOCK TABLES;\n\n--\n-- Table structure for table `last_updates`\n--\n\nDROP TABLE IF EXISTS `last_updates`;\n/*!40101 SET @saved_cs_client     = @@character_set_client */;\n/*!50503 SET character_set_client = utf8mb4 */;\nCREATE TABLE `last_updates` (\n  `id` int unsigned NOT NULL,\n  `createdAt` datetime DEFAULT NULL,\n  `updatedAt` datetime DEFAULT NULL,\n  `lastUpdate` int unsigned DEFAULT NULL,\n  PRIMARY KEY (`id`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n/*!40101 SET character_set_client = @saved_cs_client */;\n\n--\n-- Dumping data for table `last_updates`\n--\n\nLOCK TABLES `last_updates` WRITE;\n/*!40000 ALTER TABLE `last_updates` DISABLE KEYS */;\n/*!40000 ALTER TABLE `last_updates` ENABLE KEYS */;\nUNLOCK TABLES;\n\n--\n-- Table structure for table `moderation_rules`\n--\n\nDROP TABLE IF EXISTS `moderation_rules`;\n/*!40101 SET @saved_cs_client     = @@character_set_client */;\n/*!50503 SET character_set_client = utf8mb4 */;\nCREATE TABLE `moderation_rules` (\n  `id` int unsigned NOT NULL AUTO_INCREMENT,\n  `createdBy` int unsigned DEFAULT NULL,\n  `categoryId` int unsigned DEFAULT NULL,\n  `lowerThreshold` float unsigned NOT NULL,\n  `upperThreshold` float unsigned NOT NULL,\n  `action` enum('Accept','Reject','Defer','Highlight') NOT NULL,\n  `createdAt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,\n  `updatedAt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,\n  `tagId` int unsigned NOT NULL,\n  PRIMARY KEY (`id`),\n  KEY `categoryId` (`categoryId`),\n  KEY `tagId` (`tagId`),\n  CONSTRAINT `moderation_rules_ibfk_1` FOREIGN KEY (`tagId`) REFERENCES `tags` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,\n  CONSTRAINT `moderation_rules_ibfk_2` FOREIGN KEY (`categoryId`) REFERENCES `categories` (`id`) ON DELETE CASCADE ON UPDATE CASCADE\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n/*!40101 SET character_set_client = @saved_cs_client */;\n\n--\n-- Dumping data for table `moderation_rules`\n--\n\nLOCK TABLES `moderation_rules` WRITE;\n/*!40000 ALTER TABLE `moderation_rules` DISABLE KEYS */;\n/*!40000 ALTER TABLE `moderation_rules` ENABLE KEYS */;\nUNLOCK TABLES;\n\n--\n-- Table structure for table `moderator_assignments`\n--\n\nDROP TABLE IF EXISTS `moderator_assignments`;\n/*!40101 SET @saved_cs_client     = @@character_set_client */;\n/*!50503 SET character_set_client = utf8mb4 */;\nCREATE TABLE `moderator_assignments` (\n  `id` int unsigned NOT NULL AUTO_INCREMENT,\n  `userId` int unsigned NOT NULL,\n  `articleId` int unsigned NOT NULL,\n  `createdAt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,\n  `updatedAt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,\n  PRIMARY KEY (`id`),\n  UNIQUE KEY `unique_assignment_index` (`userId`,`articleId`),\n  KEY `articleId` (`articleId`),\n  KEY `userId_index` (`userId`),\n  CONSTRAINT `moderator_assignments_ibfk_1` FOREIGN KEY (`userId`) REFERENCES `users` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,\n  CONSTRAINT `moderator_assignments_ibfk_2` FOREIGN KEY (`articleId`) REFERENCES `articles` (`id`) ON DELETE CASCADE ON UPDATE CASCADE\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n/*!40101 SET character_set_client = @saved_cs_client */;\n\n--\n-- Dumping data for table `moderator_assignments`\n--\n\nLOCK TABLES `moderator_assignments` WRITE;\n/*!40000 ALTER TABLE `moderator_assignments` DISABLE KEYS */;\n/*!40000 ALTER TABLE `moderator_assignments` ENABLE KEYS */;\nUNLOCK TABLES;\n\n--\n-- Table structure for table `preselects`\n--\n\nDROP TABLE IF EXISTS `preselects`;\n/*!40101 SET @saved_cs_client     = @@character_set_client */;\n/*!50503 SET character_set_client = utf8mb4 */;\nCREATE TABLE `preselects` (\n  `id` int unsigned NOT NULL AUTO_INCREMENT,\n  `createdBy` int unsigned DEFAULT NULL,\n  `categoryId` int unsigned DEFAULT NULL,\n  `tagId` int unsigned DEFAULT NULL,\n  `lowerThreshold` float unsigned DEFAULT NULL,\n  `upperThreshold` float unsigned DEFAULT NULL,\n  `createdAt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,\n  `updatedAt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,\n  PRIMARY KEY (`id`),\n  KEY `createdBy` (`createdBy`),\n  KEY `categoryId` (`categoryId`),\n  KEY `tagId` (`tagId`),\n  CONSTRAINT `preselects_ibfk_1` FOREIGN KEY (`createdBy`) REFERENCES `users` (`id`) ON DELETE SET NULL ON UPDATE CASCADE,\n  CONSTRAINT `preselects_ibfk_2` FOREIGN KEY (`categoryId`) REFERENCES `categories` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,\n  CONSTRAINT `preselects_ibfk_3` FOREIGN KEY (`tagId`) REFERENCES `tags` (`id`) ON DELETE CASCADE ON UPDATE CASCADE\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n/*!40101 SET character_set_client = @saved_cs_client */;\n\n--\n-- Dumping data for table `preselects`\n--\n\nLOCK TABLES `preselects` WRITE;\n/*!40000 ALTER TABLE `preselects` DISABLE KEYS */;\nINSERT INTO `preselects` VALUES (1,NULL,NULL,NULL,0,0.2,'2017-06-14 17:25:54','2017-06-14 17:25:54');\n/*!40000 ALTER TABLE `preselects` ENABLE KEYS */;\nUNLOCK TABLES;\n\n--\n-- Table structure for table `tagging_sensitivities`\n--\n\nDROP TABLE IF EXISTS `tagging_sensitivities`;\n/*!40101 SET @saved_cs_client     = @@character_set_client */;\n/*!50503 SET character_set_client = utf8mb4 */;\nCREATE TABLE `tagging_sensitivities` (\n  `id` int unsigned NOT NULL AUTO_INCREMENT,\n  `createdBy` int unsigned DEFAULT NULL,\n  `categoryId` int unsigned DEFAULT NULL,\n  `tagId` int unsigned DEFAULT NULL,\n  `lowerThreshold` float unsigned DEFAULT NULL,\n  `upperThreshold` float unsigned DEFAULT NULL,\n  `createdAt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,\n  `updatedAt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,\n  PRIMARY KEY (`id`),\n  KEY `createdBy` (`createdBy`),\n  KEY `categoryId` (`categoryId`),\n  KEY `tagId` (`tagId`),\n  CONSTRAINT `tagging_sensitivities_ibfk_1` FOREIGN KEY (`createdBy`) REFERENCES `users` (`id`) ON DELETE SET NULL ON UPDATE CASCADE,\n  CONSTRAINT `tagging_sensitivities_ibfk_2` FOREIGN KEY (`categoryId`) REFERENCES `categories` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,\n  CONSTRAINT `tagging_sensitivities_ibfk_3` FOREIGN KEY (`tagId`) REFERENCES `tags` (`id`) ON DELETE CASCADE ON UPDATE CASCADE\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n/*!40101 SET character_set_client = @saved_cs_client */;\n\n--\n-- Dumping data for table `tagging_sensitivities`\n--\n\nLOCK TABLES `tagging_sensitivities` WRITE;\n/*!40000 ALTER TABLE `tagging_sensitivities` DISABLE KEYS */;\nINSERT INTO `tagging_sensitivities` VALUES (1,NULL,NULL,NULL,0.65,1,'2017-06-14 17:25:54','2017-06-14 17:25:54');\n/*!40000 ALTER TABLE `tagging_sensitivities` ENABLE KEYS */;\nUNLOCK TABLES;\n\n--\n-- Table structure for table `tags`\n--\n\nDROP TABLE IF EXISTS `tags`;\n/*!40101 SET @saved_cs_client     = @@character_set_client */;\n/*!50503 SET character_set_client = utf8mb4 */;\nCREATE TABLE `tags` (\n  `id` int unsigned NOT NULL AUTO_INCREMENT,\n  `label` char(255) NOT NULL,\n  `color` char(255) NOT NULL,\n  `createdAt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,\n  `updatedAt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,\n  `key` char(255) NOT NULL,\n  `description` char(255) DEFAULT NULL,\n  `isInBatchView` tinyint(1) NOT NULL DEFAULT '0',\n  `inSummaryScore` tinyint(1) NOT NULL DEFAULT '0',\n  `isTaggable` tinyint(1) NOT NULL DEFAULT '0',\n  PRIMARY KEY (`id`),\n  UNIQUE KEY `tags_key` (`key`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n/*!40101 SET character_set_client = @saved_cs_client */;\n\n--\n-- Dumping data for table `tags`\n--\n\nLOCK TABLES `tags` WRITE;\n/*!40000 ALTER TABLE `tags` DISABLE KEYS */;\n/*!40000 ALTER TABLE `tags` ENABLE KEYS */;\nUNLOCK TABLES;\n\n--\n-- Table structure for table `user_category_assignments`\n--\n\nDROP TABLE IF EXISTS `user_category_assignments`;\n/*!40101 SET @saved_cs_client     = @@character_set_client */;\n/*!50503 SET character_set_client = utf8mb4 */;\nCREATE TABLE `user_category_assignments` (\n  `userId` int unsigned NOT NULL,\n  `categoryId` int unsigned NOT NULL,\n  `createdAt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,\n  `updatedAt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,\n  PRIMARY KEY (`userId`,`categoryId`),\n  KEY `categoryId` (`categoryId`),\n  CONSTRAINT `user_category_assignments_ibfk_1` FOREIGN KEY (`userId`) REFERENCES `users` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,\n  CONSTRAINT `user_category_assignments_ibfk_2` FOREIGN KEY (`categoryId`) REFERENCES `categories` (`id`) ON DELETE CASCADE ON UPDATE CASCADE\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n/*!40101 SET character_set_client = @saved_cs_client */;\n\n--\n-- Dumping data for table `user_category_assignments`\n--\n\nLOCK TABLES `user_category_assignments` WRITE;\n/*!40000 ALTER TABLE `user_category_assignments` DISABLE KEYS */;\n/*!40000 ALTER TABLE `user_category_assignments` ENABLE KEYS */;\nUNLOCK TABLES;\n\n--\n-- Table structure for table `user_social_auths`\n--\n\nDROP TABLE IF EXISTS `user_social_auths`;\n/*!40101 SET @saved_cs_client     = @@character_set_client */;\n/*!50503 SET character_set_client = utf8mb4 */;\nCREATE TABLE `user_social_auths` (\n  `id` int unsigned NOT NULL AUTO_INCREMENT,\n  `userId` int unsigned NOT NULL,\n  `socialId` char(255) NOT NULL,\n  `provider` char(150) NOT NULL,\n  `extra` json DEFAULT NULL,\n  `createdAt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,\n  `updatedAt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,\n  PRIMARY KEY (`id`),\n  UNIQUE KEY `unique_user_provider_index` (`provider`,`userId`),\n  UNIQUE KEY `unique_provider_user_index` (`provider`,`socialId`),\n  KEY `userId` (`userId`),\n  CONSTRAINT `user_social_auths_ibfk_1` FOREIGN KEY (`userId`) REFERENCES `users` (`id`) ON DELETE CASCADE ON UPDATE CASCADE\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n/*!40101 SET character_set_client = @saved_cs_client */;\n\n--\n-- Dumping data for table `user_social_auths`\n--\n\nLOCK TABLES `user_social_auths` WRITE;\n/*!40000 ALTER TABLE `user_social_auths` DISABLE KEYS */;\n/*!40000 ALTER TABLE `user_social_auths` ENABLE KEYS */;\nUNLOCK TABLES;\n\n--\n-- Table structure for table `users`\n--\n\nDROP TABLE IF EXISTS `users`;\n/*!40101 SET @saved_cs_client     = @@character_set_client */;\n/*!50503 SET character_set_client = utf8mb4 */;\nCREATE TABLE `users` (\n  `id` int unsigned NOT NULL AUTO_INCREMENT,\n  `group` enum('general','admin','service','moderator','youtube') NOT NULL,\n  `name` char(255) NOT NULL,\n  `email` char(255) DEFAULT NULL,\n  `isActive` tinyint(1) NOT NULL DEFAULT '0',\n  `extra` json DEFAULT NULL,\n  `createdAt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,\n  `updatedAt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,\n  `avatarURL` char(255) DEFAULT NULL,\n  PRIMARY KEY (`id`),\n  UNIQUE KEY `users_email_group` (`email`,`group`),\n  KEY `group_index` (`group`),\n  KEY `isActive_index` (`isActive`),\n  KEY `users_email` (`email`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n/*!40101 SET character_set_client = @saved_cs_client */;\n\n--\n-- Dumping data for table `users`\n--\n\nLOCK TABLES `users` WRITE;\n/*!40000 ALTER TABLE `users` DISABLE KEYS */;\n/*!40000 ALTER TABLE `users` ENABLE KEYS */;\nUNLOCK TABLES;\n/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;\n\n/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;\n/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;\n/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;\n/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;\n/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;\n/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;\n/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;\n\n-- Dump completed on 2020-12-02 11:42:31\n"
  },
  {
    "path": "tslint.json",
    "content": "{\n  \"extends\": [\"tslint-react\"],\n  \"rules\": {\n    \"align\": [true, \"parameters\", \"statements\"],\n    \"arrow-parens\": true,\n    \"class-name\": true,\n    \"comment-format\": [\n      true,\n      \"check-space\"\n    ],\n    \"curly\": true,\n    \"eofline\": true,\n    \"indent\": [\n      true,\n      \"spaces\"\n    ],\n    \"interface-name\": [true, \"always-prefix\"],\n    \"jsdoc-format\": true,\n    \"max-line-length\": [false],\n    \"no-angle-bracket-type-assertion\": true,\n    \"no-arg\": true,\n    \"no-bitwise\": true,\n    \"no-conditional-assignment\": true,\n    \"no-consecutive-blank-lines\": [true],\n    \"no-construct\": true,\n    \"no-debugger\": true,\n    \"no-default-export\": true,\n    \"no-duplicate-variable\": true,\n    \"no-eval\": true,\n    \"no-inferrable-types\": [true],\n    \"no-internal-module\": true,\n    \"no-invalid-this\": false,\n    \"no-namespace\": true,\n    \"no-trailing-whitespace\": true,\n    \"no-shadowed-variable\": true,\n    \"no-var-keyword\": true,\n    \"object-literal-key-quotes\": [true, \"as-needed\"],\n    \"one-line\": [\n      true,\n      \"check-open-brace\",\n      \"check-whitespace\"\n    ],\n    \"one-variable-per-declaration\": [true, \"ignore-for-loop\"],\n    \"quotemark\": [\n      true,\n      \"single\",\n      \"jsx-double\"\n    ],\n    \"radix\": true,\n    \"semicolon\": [\n      true,\n      \"always\"\n    ],\n    \"trailing-comma\": [\n      true,\n      {\n        \"multiline\": \"always\"\n      }\n    ],\n    \"triple-equals\": [\n      true,\n      \"allow-null-check\"\n    ],\n    \"typedef-whitespace\": [\n      true,\n      {\n        \"call-signature\": \"nospace\",\n        \"index-signature\": \"nospace\",\n        \"parameter\": \"nospace\",\n        \"property-declaration\": \"nospace\",\n        \"variable-declaration\": \"nospace\"\n      }\n    ],\n    \"variable-name\": [\n      true,\n      \"ban-keywords\"\n    ],\n    \"whitespace\": [\n      true,\n      \"check-branch\",\n      \"check-decl\",\n      \"check-operator\",\n      \"check-separator\",\n      \"check-type\"\n    ],\n    \"no-unsafe-finally\": true,\n    \"prefer-const\": true,\n    \"array-type\": [true, \"generic\"],\n    \"adjacent-overload-signatures\": true,\n    \"cyclomatic-complexity\": [false],\n    \"label-position\": true,\n    \"new-parens\": true,\n    \"no-empty\": true,\n    \"no-magic-numbers\": false,\n    \"object-literal-shorthand\": false,\n    \"only-arrow-functions\": [true, \"allow-declarations\"],\n    \"use-isnan\": true,\n    \"arrow-return-shorthand\": [true],\n    \"no-unnecessary-initializer\": true,\n    \"no-misused-new\": true,\n    \"prefer-method-signature\": true,\n    \"newline-before-return\": false,\n    \"ban-types\": {\n      \"options\": [\n        [\"Object\", \"Avoid using the `Object` type. Did you mean `object`?\"],\n        [\"Boolean\", \"Avoid using the `Boolean` type. Did you mean `boolean`?\"],\n        [\"Number\", \"Avoid using the `Number` type. Did you mean `number`?\"],\n        [\"String\", \"Avoid using the `String` type. Did you mean `string`?\"],\n        [\"Symbol\", \"Avoid using the `Symbol` type. Did you mean `symbol`?\"]\n      ]\n    },\n    \"no-duplicate-super\": true,\n    \"jsx-no-lambda\": true,\n    \"jsx-no-string-ref\": true,\n    \"ordered-imports\": [true],\n    \"jsx-curly-spacing\": [true, \"never\"],\n    \"jsx-self-close\": true,\n    \"jsx-wrap-multiline\": true,\n    \"jsx-alignment\": true,\n    \"jsx-boolean-value\": [true, \"never\"],\n\n    // Prefer to keep all render code in the same render body.\n    \"jsx-no-multiline-js\": false\n  }\n}\n"
  }
]