diff --git a/.firebaserc b/.firebaserc new file mode 100644 index 0000000..b3d1d3d --- /dev/null +++ b/.firebaserc @@ -0,0 +1,5 @@ +{ + "projects": { + "default": "lingdocs" + } +} diff --git a/.github/workflows/deploy-functions.yml b/.github/workflows/deploy-functions.yml new file mode 100644 index 0000000..fb87923 --- /dev/null +++ b/.github/workflows/deploy-functions.yml @@ -0,0 +1,33 @@ +name: Deploy Functions + +on: + push: + branches: + - master + paths: + - 'functions/**' + - '.github/workflows/deploy-functions.yml' + + workflow_dispatch: + +jobs: + deploy-functions: + runs-on: ubuntu-latest + env: + LINGDOCS_NPM_TOKEN: ${{ secrets.LINGDOCS_NPM_TOKEN }} + FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }} + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v1 + with: + node-version: '12.x' + - run: npm install -g firebase-tools + - run: | + cp .npmrc functions + cd website + yarn install + cd .. + cd functions + npm install + - name: deploy functions + run: firebase deploy -f --token ${FIREBASE_TOKEN} \ No newline at end of file diff --git a/.github/workflows/functions-ci.yml b/.github/workflows/functions-ci.yml new file mode 100644 index 0000000..7f515ce --- /dev/null +++ b/.github/workflows/functions-ci.yml @@ -0,0 +1,44 @@ +name: Functions CI + +on: + push: + branches: + - '*' + pull_request: + - '*' + paths: + - 'functions/**' + - '.github/workflows/functions-ci.yml' + + workflow_dispatch: + +jobs: + build-and-serve-functions: + runs-on: ubuntu-latest + env: + LINGDOCS_NPM_TOKEN: ${{ secrets.LINGDOCS_NPM_TOKEN }} + FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }} + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v1 + with: + node-version: '12.x' + - run: npm install -g firebase-tools + - name: build functions + run: | + cp .npmrc functions + cd website + yarn install + cd .. + cd functions + npm install + npm run build + - name: start up emulator once + run: | + cd functions + firebase functions:config:get --token ${FIREBASE_TOKEN} > .runtimeconfig.json + echo '#!/bin/bash' > empty.sh + chmod +x empty.sh + firebase emulators:exec ./empty.sh --only functions --token ${FIREBASE_TOKEN} + rm .runtimeconfig.json + rm empty.sh \ No newline at end of file diff --git a/.github/workflows/website-ci.yml b/.github/workflows/website-ci.yml new file mode 100644 index 0000000..6e37f08 --- /dev/null +++ b/.github/workflows/website-ci.yml @@ -0,0 +1,32 @@ +name: Website CI + +on: + push: + branches: [ '*' ] + paths: + - 'website/**' + - '.github/workflows/website-ci.yml' + pull_request: + branches: [ '*' ] + paths: + - 'website/**' + - '.github/workflows/website-ci.yml' + + workflow_dispatch: + +jobs: + build-and-test: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./website + env: + LINGDOCS_NPM_TOKEN: ${{ secrets.LINGDOCS_NPM_TOKEN }} + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v1 + with: + node-version: '12.x' + - run: yarn install + - run: yarn build + - run: yarn test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c04899d --- /dev/null +++ b/.gitignore @@ -0,0 +1,69 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +firebase-debug.log* +firebase-debug.*.log* + +# Firebase cache +.firebase/ + +# Firebase config + +# Uncomment this if you'd like others to create their own Firebase project. +# For a team working on the same Firebase project(s), it is recommended to leave +# it commented so all members can deploy to the same project(s) in .firebaserc. +# .firebaserc + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env + +# Firebase functions config/env for running functions locally +.runtimeconfig.json \ No newline at end of file diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..b6e362c --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +@lingdocs:registry=https://npm.lingdocs.com +//npm.lingdocs.com/:_authToken=${LINGDOCS_NPM_TOKEN} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..152ffce --- /dev/null +++ b/LICENSE @@ -0,0 +1,8 @@ +The MIT License (MIT) +Copyright © 2021 lingdocs.com + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index c122ed5..e3b48fd 100644 --- a/README.md +++ b/README.md @@ -1 +1,164 @@ -new monorepo \ No newline at end of file +# LingDocs Dictionary Monorepo + +[![Netlify Status](https://api.netlify.com/api/v1/badges/65b633a2-f123-4fcd-91bc-5e6acda43256/deploy-status)](https://app.netlify.com/sites/lingdocs-dictionary/deploys) +![Website CI](https://github.com/lingdocs/dictionary.lingdocs.com/actions/workflows/website-ci.yml/badge.svg) +![Functions CI](https://github.com/lingdocs/dictionary.lingdocs.com/actions/workflows/functions-ci.yml/badge.svg) +![Functions Deploy](https://github.com/lingdocs/dictionary.lingdocs.com/actions/workflows/deploy-functions.yml/badge.svg) + +## Contents + +This monorepo contains: + - `/dictionary-client` the frontend of the dictionary, a React SPA + - `/account` a backend authentication server + - `/functions` backend Firebase functions for use with the dictionary + +### Dictionary Client + +SPA Dictionary Frontend + +Use [Yarn](https://yarnpkg.com/). + +```sh +cd website +yarn install +``` + +#### Development + +```sh +yarn start +``` + +### Account + +Backend authentication server build on express / passport + +#### Development + +Use [npm](https://www.npmjs.com/). + +```sh +cd account +npm install +``` + +### Functions + +Backend Firebase functions + +Use [npm](https://www.npmjs.com/). + +```sh +cd functions +npm install +``` + +#### Development + +```sh +npm run serve +``` + +## Architecture + +![LingDocs Pashto Dictioanry App Architecture](./architecture.svg) + +### Source Layer + +#### GitHub Git Repo + +The monorepo contains both a `website` folder for the frontend PWA and a `functions` folder for the backend functions. Both parts are written in TypeScript and are tied together using the types found in the `@lingdocs/pashto-inflector` package used by both as well as the types found in `./website/src/lib/backend-types.ts` + +##### `./website` frontend + +The front-end website code in `./website` is made with `create-react-app` and written in typescript with `jest` testing. It is a SPA and PWA. + +The deployment is done automatically by netlify upon pushing to the `master` branch. + +##### `./functions` backend + +The backend code found in `./functions` and is written in TypeScript. + +It is compiled and deployed automatically by the repo's GitHub Actions to Firebase Cloud Functions upon pushing to the `master` branch. + +#### Google Sheets Dictionary Source + +The content of the dictionary is based on a Google Sheets documents containing rows with the information for each dictionary entry. This can be edited by an editor directly, or through the website frontend with editor priveledges. + +A cloud function in the backend compiles the dictionary into binary form (protobuf) then uploads it into a Google Cloud Storage bucket. The deployment is triggered from the website by an editor. + +### Backend Layer + +#### Firebase Functions + +Serverless functions are used in conjungtion with Firebase Authentication to: +- check if a user has elevated priveledges +- receive edits or suggestions for the dictionary +- compile and publish the dictionary +- create and clean up elevated users in the CouchDB database + +#### Account Server + +Deployed through a self-hosted actions runner. + +The runner is launched by this line in a crontab + +``` +@reboot ./actions-runner/run.sh +``` + +Process managed by pm2 using this `ecosystem.config.js` + +``` +module.exports = { + apps : [{ + name : "account", + cwd : "./actions-runner/_work/lingdocs-main/lingdocs-main/account", + script: "npm", + args: "start", + env: { + NODE_ENVIRONMENT: "************", + LINGDOCS_EMAIL_HOST: "**************", + LINGDOCS_EMAIL_USER: "**************", + LINGDOCS_EMAIL_PASS: "*****************", + LINGDOCS_COUCHDB: "****************", + LINGDOCS_ACCOUNT_COOKIE_SECRET: "******************", + LINGDOCS_ACCOUNT_GOOGLE_CLIENT_SECRET: "******************", + LINGDOCS_ACCOUNT_TWITTER_CLIENT_SECRET: "******************", + LINGDOCS_ACCOUNT_GITHUB_CLIENT_SECRET: "******************", + LINGDOCS_ACCOUNT_RECAPTCHA_SECRET: "6LcVjAUcAAAAAPWUK-******************", + } + }] +} +``` + +```sh +pm2 start ecosystem.config.js +pm2 save +``` + +#### CouchDB + +When a user upgrades their account level to `student` or `editor`: + +1. A doc in the `_users` db is created with their Firebase Authentication info, account level, and a password they can use for syncing their personal wordlistdb +2. A user database is created (by the firebase functions - *not* by the couchdb_peruser) which they use to sync their personal wordlist. + +There is also a `review-tasks` database which is used to store all the review tasks for editors and syncs with the review tasks in the app for the editor(s). + +#### Google Cloud Storage + +Contains: + +- `dict` - the dictionary content in protobuf format +- `dict-info` - information about the version of the currently available dictionary in protobuf format + +The website fetches `dict-info` and `dict` as needed to check for the latest dictionary version and download it into memory/`lokijs` + +### Frontend Layer + +#### PWA + +The frontend is a static-site PWA/SPA built with `create-react-app` (React/TypeScript) and deployed to Netlify. + + diff --git a/architecture-source.svg b/architecture-source.svg new file mode 100644 index 0000000..5e4f93e --- /dev/null +++ b/architecture-source.svg @@ -0,0 +1,1886 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + code repo + + website + functions + + + + + + PWA static site + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Firebase + + + cloud functions + + account + + deployed by +netlify + deployed by github +actions + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Google +Sheet + dictionary source content + + + + + CouchDB + + + + + + + + + + + + + + + + + + + + + + + + Google Cloud +Storage + published dictionary +and version info + + Source + Backend + + Frontend + + priveledged users +and their wordlist +dbs + review tasks + + sync user +wordlist + +review tasks + + + REST api +calls from PWA + + + fetch dictionary +content + + + + + + + + + + edit and +compile dictionary + + + + account + + + + + + diff --git a/architecture.svg b/architecture.svg new file mode 100644 index 0000000..1c908b3 --- /dev/null +++ b/architecture.svg @@ -0,0 +1,3186 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/firebase.json b/firebase.json new file mode 100644 index 0000000..8c8dcbe --- /dev/null +++ b/firebase.json @@ -0,0 +1,15 @@ +{ + "functions": { + "predeploy": "cp .npmrc functions && cat .npmrc | envsubst > functions/.npmrc && cd functions && npm --prefix \"$RESOURCE_DIR\" run build", + "postdeploy": "rm functions/.npmrc" + }, + "hosting": { + "public": "public", + "rewrites": [ + { + "source": "/authory", + "function": "authory" + } + ] + } +} diff --git a/functions/.gitignore b/functions/.gitignore new file mode 100644 index 0000000..4f794ea --- /dev/null +++ b/functions/.gitignore @@ -0,0 +1,18 @@ +# Debug +ui-debug.log + +# Compiled JavaScript files +lib/**/*.js +lib/**/*.js.map + +# TypeScript v1 declaration files +typings/ + +# Node.js dependency directory +node_modules/ + +# File with private NPM token(s) inserted for deploying function +.npmrc + +# Firebase functions config/env for running functions locally +.runtimeconfig.json diff --git a/functions/import-wordlist.js b/functions/import-wordlist.js new file mode 100644 index 0000000..e150bc1 --- /dev/null +++ b/functions/import-wordlist.js @@ -0,0 +1,84 @@ +const nano = require("nano"); +const oldCouch = nano(process.env.OLD_WORDLIST_COUCHDB); +const newCouch = nano(process.env.LINGDOCS_COUCHDB); +const email = process.argv[2]; +const newEmail = process.argv[3]; + +function stringToHex(str) { + const arr1 = []; + for (let n = 0, l = str.length; n < l; n ++) { + const hex = Number(str.charCodeAt(n)).toString(16); + arr1.push(hex); + } + return arr1.join(''); +} + +async function getOldWordList() { + const usersDb = oldCouch.use("_users"); + const res = await usersDb.find({ + selector: { + originalEmail: email, + }, + }); + const { name } = res.docs[0]; + const tag = stringToHex(name); + const userDb = oldCouch.db.use(`userdb-${tag}`); + const { rows } = await userDb.list({ include_docs: true }); + const allDocs = rows.map((row) => row.doc); + return allDocs +} + +function convertWordList(list) { + const now = Date.now(); + return list.map((item) => ({ + _id: item._id, + warmup: "done", + supermemo: { + interval: 0, + repetition: 0, + efactor: 2.5 + }, + dueDate: now, + entry: { ...item.w }, + notes: item.notes, + })); +} + +async function uploadToNewDb(wordlist) { + const usersDb = newCouch.use("_users"); + const res = await usersDb.find({ + selector: { + email: newEmail || email, + }, + }); + const { name } = res.docs[0]; + const tag = stringToHex(name); + const userDb = newCouch.db.use(`userdb-${tag}`); + await userDb.bulk({ docs: wordlist }); +} + +// async function updateWarmup() { +// const usersDb = newCouch.use("_users"); +// const res = await usersDb.find({ +// selector: { +// email: newEmail || email, +// }, +// }); +// const { name } = res.docs[0]; +// const tag = stringToHex(name); +// const userDb = newCouch.db.use(`userdb-${tag}`); +// const { rows } = await userDb.list({ include_docs: true }); +// const allDocs = rows.map((row) => row.doc); +// const updated = allDocs.map((d) => ({ ...d, warmup: "done" })); +// await userDb.bulk({ docs: updated }); +// } + +async function main() { + const oldWordList = await getOldWordList(); + const newWordList = convertWordList(oldWordList); + uploadToNewDb(newWordList) +} + +main(); + + diff --git a/functions/package-lock.json b/functions/package-lock.json new file mode 100644 index 0000000..9b7b716 --- /dev/null +++ b/functions/package-lock.json @@ -0,0 +1,2153 @@ +{ + "name": "functions", + "requires": true, + "lockfileVersion": 1, + "dependencies": { + "@babel/runtime": { + "version": "7.13.10", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.13.10.tgz", + "integrity": "sha512-4QPkjJq6Ns3V/RgpEahRk+AGfL0eO6RHHtTWoNNr5mO49G6B5+X6d6THgWEAvTrznU5xYpbAlVKRYcsCgh/Akw==", + "requires": { + "regenerator-runtime": "^0.13.4" + } + }, + "@firebase/app-types": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.6.1.tgz", + "integrity": "sha512-L/ZnJRAq7F++utfuoTKX4CLBG5YR7tFO3PLzG1/oXXKEezJ0kRL3CMRoueBEmTCzVb/6SIs2Qlaw++uDgi5Xyg==" + }, + "@firebase/auth-interop-types": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/@firebase/auth-interop-types/-/auth-interop-types-0.1.5.tgz", + "integrity": "sha512-88h74TMQ6wXChPA6h9Q3E1Jg6TkTHep2+k63OWg3s0ozyGVMeY+TTOti7PFPzq5RhszQPQOoCi59es4MaRvgCw==" + }, + "@firebase/component": { + "version": "0.1.21", + "resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.1.21.tgz", + "integrity": "sha512-kd5sVmCLB95EK81Pj+yDTea8pzN2qo/1yr0ua9yVi6UgMzm6zAeih73iVUkaat96MAHy26yosMufkvd3zC4IKg==", + "requires": { + "@firebase/util": "0.3.4", + "tslib": "^1.11.1" + } + }, + "@firebase/database": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/@firebase/database/-/database-0.8.3.tgz", + "integrity": "sha512-i29rr3kcPltIkA8La9M1lgsSxx9bfu5lCQ0T+tbJptZ3UpqpcL1NzCcZa24cJjiLgq3HQNPyLvUvCtcPSFDlRg==", + "requires": { + "@firebase/auth-interop-types": "0.1.5", + "@firebase/component": "0.1.21", + "@firebase/database-types": "0.6.1", + "@firebase/logger": "0.2.6", + "@firebase/util": "0.3.4", + "faye-websocket": "0.11.3", + "tslib": "^1.11.1" + } + }, + "@firebase/database-types": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@firebase/database-types/-/database-types-0.6.1.tgz", + "integrity": "sha512-JtL3FUbWG+bM59iYuphfx9WOu2Mzf0OZNaqWiQ7lJR8wBe7bS9rIm9jlBFtksB7xcya1lZSQPA/GAy2jIlMIkA==", + "requires": { + "@firebase/app-types": "0.6.1" + } + }, + "@firebase/logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/@firebase/logger/-/logger-0.2.6.tgz", + "integrity": "sha512-KIxcUvW/cRGWlzK9Vd2KB864HlUnCfdTH0taHE0sXW5Xl7+W68suaeau1oKNEqmc3l45azkd4NzXTCWZRZdXrw==" + }, + "@firebase/util": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@firebase/util/-/util-0.3.4.tgz", + "integrity": "sha512-VwjJUE2Vgr2UMfH63ZtIX9Hd7x+6gayi6RUXaTqEYxSbf/JmehLmAEYSuxS/NckfzAXWeGnKclvnXVibDgpjQQ==", + "requires": { + "tslib": "^1.11.1" + } + }, + "@google-cloud/common": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/@google-cloud/common/-/common-3.6.0.tgz", + "integrity": "sha512-aHIFTqJZmeTNO9md8XxV+ywuvXF3xBm5WNmgWeeCK+XN5X+kGW0WEX94wGwj+/MdOnrVf4dL2RvSIt9J5yJG6Q==", + "requires": { + "@google-cloud/projectify": "^2.0.0", + "@google-cloud/promisify": "^2.0.0", + "arrify": "^2.0.1", + "duplexify": "^4.1.1", + "ent": "^2.2.0", + "extend": "^3.0.2", + "google-auth-library": "^7.0.2", + "retry-request": "^4.1.1", + "teeny-request": "^7.0.0" + } + }, + "@google-cloud/firestore": { + "version": "4.9.6", + "resolved": "https://registry.npmjs.org/@google-cloud/firestore/-/firestore-4.9.6.tgz", + "integrity": "sha512-GYFOInoirAQaVKEgZqVv/XH232faG/cfW80IWOz9RCZJHeITRj71O/TpVG/zrJoge+cu3bes+VJgBkxa2HR9Dg==", + "optional": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "functional-red-black-tree": "^1.0.1", + "google-gax": "^2.9.2", + "protobufjs": "^6.8.6" + } + }, + "@google-cloud/paginator": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@google-cloud/paginator/-/paginator-3.0.5.tgz", + "integrity": "sha512-N4Uk4BT1YuskfRhKXBs0n9Lg2YTROZc6IMpkO/8DIHODtm5s3xY8K5vVBo23v/2XulY3azwITQlYWgT4GdLsUw==", + "requires": { + "arrify": "^2.0.0", + "extend": "^3.0.2" + } + }, + "@google-cloud/projectify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@google-cloud/projectify/-/projectify-2.0.1.tgz", + "integrity": "sha512-ZDG38U/Yy6Zr21LaR3BTiiLtpJl6RkPS/JwoRT453G+6Q1DhlV0waNf8Lfu+YVYGIIxgKnLayJRfYlFJfiI8iQ==" + }, + "@google-cloud/promisify": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@google-cloud/promisify/-/promisify-2.0.3.tgz", + "integrity": "sha512-d4VSA86eL/AFTe5xtyZX+ePUjE8dIFu2T8zmdeNBSa5/kNgXPCx/o/wbFNHAGLJdGnk1vddRuMESD9HbOC8irw==" + }, + "@google-cloud/storage": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-5.8.1.tgz", + "integrity": "sha512-qP8gCJ2myyMN3JMJN12d82Oo8VBSDO8vO4/x56dtQZX9+WISqcagurntnJVyFX885tIOtS97bsyv8qR1xv6HMg==", + "requires": { + "@google-cloud/common": "^3.6.0", + "@google-cloud/paginator": "^3.0.0", + "@google-cloud/promisify": "^2.0.0", + "arrify": "^2.0.0", + "async-retry": "^1.3.1", + "compressible": "^2.0.12", + "date-and-time": "^0.14.2", + "duplexify": "^4.0.0", + "extend": "^3.0.2", + "gaxios": "^4.0.0", + "gcs-resumable-upload": "^3.1.3", + "get-stream": "^6.0.0", + "hash-stream-validation": "^0.2.2", + "mime": "^2.2.0", + "mime-types": "^2.0.8", + "onetime": "^5.1.0", + "p-limit": "^3.0.1", + "pumpify": "^2.0.0", + "snakeize": "^0.1.0", + "stream-events": "^1.0.1", + "xdg-basedir": "^4.0.0" + } + }, + "@grpc/grpc-js": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.2.10.tgz", + "integrity": "sha512-wj6GkNiorWYaPiIZ767xImmw7avMMVUweTvPFg4mJWOxz2180DKwfuxhJJZ7rpc1+7D3mX/v8vJdxTuIo71Ieg==", + "optional": true, + "requires": { + "@types/node": ">=12.12.47", + "google-auth-library": "^6.1.1", + "semver": "^6.2.0" + }, + "dependencies": { + "@types/node": { + "version": "14.14.32", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.32.tgz", + "integrity": "sha512-/Ctrftx/zp4m8JOujM5ZhwzlWLx22nbQJiVqz8/zE15gOeEW+uly3FSX4fGFpcfEvFzXcMCJwq9lGVWgyARXhg==", + "optional": true + }, + "google-auth-library": { + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-6.1.6.tgz", + "integrity": "sha512-Q+ZjUEvLQj/lrVHF/IQwRo6p3s8Nc44Zk/DALsN+ac3T4HY/g/3rrufkgtl+nZ1TW7DNAw5cTChdVp4apUXVgQ==", + "optional": true, + "requires": { + "arrify": "^2.0.0", + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "fast-text-encoding": "^1.0.0", + "gaxios": "^4.0.0", + "gcp-metadata": "^4.2.0", + "gtoken": "^5.0.4", + "jws": "^4.0.0", + "lru-cache": "^6.0.0" + } + } + } + }, + "@grpc/proto-loader": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.5.6.tgz", + "integrity": "sha512-DT14xgw3PSzPxwS13auTEwxhMMOoz33DPUKNtmYK/QYbBSpLXJy78FGGs5yVoxVobEqPm4iW9MOIoz0A3bLTRQ==", + "optional": true, + "requires": { + "lodash.camelcase": "^4.3.0", + "protobufjs": "^6.8.6" + } + }, + "@jest/types": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz", + "integrity": "sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^15.0.0", + "chalk": "^4.0.0" + } + }, + "@lingdocs/pashto-inflector": { + "version": "0.9.0", + "resolved": "https://npm.lingdocs.com/@lingdocs%2fpashto-inflector/-/pashto-inflector-0.9.0.tgz", + "integrity": "sha512-kiWVshiMGp/eT0vPTheujD9hJrUXAqLKG48a6iqX8RBURlv5hjk9t+CymKvLGYDO0Zrog0kAYcSo/PqkV0UKIw==", + "requires": { + "classnames": "^2.2.6", + "pbf": "^3.2.1", + "rambda": "^6.7.0" + } + }, + "@popperjs/core": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.9.1.tgz", + "integrity": "sha512-DvJbbn3dUgMxDnJLH+RZQPnXak1h4ZVYQ7CWiFWjQwBFkVajT4rfw2PdpHLTSTwxrYfnoEXkuBiwkDm6tPMQeA==" + }, + "@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha1-m4sMxmPWaafY9vXQiToU00jzD78=", + "optional": true + }, + "@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "optional": true + }, + "@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "optional": true + }, + "@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha1-NVy8mLr61ZePntCV85diHx0Ga3A=", + "optional": true + }, + "@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha1-upn7WYYUr2VwDBYZ/wbUVLDYTEU=", + "optional": true, + "requires": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha1-Xp4avctz/Ap8uLKR33jIy9l7h9E=", + "optional": true + }, + "@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha1-/yAOPnzyQp4tyvwRQIKOjMY48Ik=", + "optional": true + }, + "@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha1-bMKyDFya1q0NzP0hynZz2Nf79o0=", + "optional": true + }, + "@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha1-Cf0V8tbTq/qbZbw2ZQbWrXhG/1Q=", + "optional": true + }, + "@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha1-p3c2C1s5oaLlEG+OhY8v0tBgxXA=", + "optional": true + }, + "@restart/context": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@restart/context/-/context-2.1.4.tgz", + "integrity": "sha512-INJYZQJP7g+IoDUh/475NlGiTeMfwTXUEr3tmRneckHIxNolGOW9CTq83S8cxq0CgJwwcMzMJFchxvlwe7Rk8Q==" + }, + "@restart/hooks": { + "version": "0.3.26", + "resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.3.26.tgz", + "integrity": "sha512-7Hwk2ZMYm+JLWcb7R9qIXk1OoUg1Z+saKWqZXlrvFwT3w6UArVNWgxYOzf+PJoK9zZejp8okPAKTctthhXLt5g==", + "requires": { + "lodash": "^4.17.20", + "lodash-es": "^4.17.20" + } + }, + "@tootallnate/once": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", + "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==" + }, + "@types/body-parser": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.0.tgz", + "integrity": "sha512-W98JrE0j2K78swW4ukqMleo8R7h/pFETjM2DQ90MF6XK2i4LO4W3gQ71Lt4w3bfm2EvVSyWHplECvB5sK22yFQ==", + "requires": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "@types/classnames": { + "version": "2.2.11", + "resolved": "https://registry.npmjs.org/@types/classnames/-/classnames-2.2.11.tgz", + "integrity": "sha512-2koNhpWm3DgWRp5tpkiJ8JGc1xTn2q0l+jUNUE7oMKXUf5NpI9AIdC4kbjGNFBdHtcxBD18LAksoudAVhFKCjw==" + }, + "@types/connect": { + "version": "3.4.34", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.34.tgz", + "integrity": "sha512-ePPA/JuI+X0vb+gSWlPKOY0NdNAie/rPUqX2GUPpbZwiKTkSPhjXWuee47E4MtE54QVzGCQMQkAL6JhV2E1+cQ==", + "requires": { + "@types/node": "*" + } + }, + "@types/cors": { + "version": "2.8.10", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.10.tgz", + "integrity": "sha512-C7srjHiVG3Ey1nR6d511dtDkCEjxuN9W1HWAEjGq8kpcwmNM6JJkpC0xvabM7BXTG2wDq8Eu33iH9aQKa7IvLQ==" + }, + "@types/express": { + "version": "4.17.3", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.3.tgz", + "integrity": "sha512-I8cGRJj3pyOLs/HndoP+25vOqhqWkAZsWMEmq1qXy/b/M3ppufecUwaK2/TVDVxcV61/iSdhykUjQQ2DLSrTdg==", + "requires": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "*", + "@types/serve-static": "*" + } + }, + "@types/express-serve-static-core": { + "version": "4.17.18", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.18.tgz", + "integrity": "sha512-m4JTwx5RUBNZvky/JJ8swEJPKFd8si08pPF2PfizYjGZOKr/svUWPcoUmLow6MmPzhasphB7gSTINY67xn3JNA==", + "requires": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*" + } + }, + "@types/google-spreadsheet": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/google-spreadsheet/-/google-spreadsheet-3.0.2.tgz", + "integrity": "sha512-866PHvBTTEIsaRdMv/Ypz/KK3r2Bi9qwQ8WRF/h4My9lT7cJhVwTD06cTvNHHKO0qiDRjUP9TubAXY3PBJrN9w==" + }, + "@types/invariant": { + "version": "2.2.34", + "resolved": "https://registry.npmjs.org/@types/invariant/-/invariant-2.2.34.tgz", + "integrity": "sha512-lYUtmJ9BqUN688fGY1U1HZoWT1/Jrmgigx2loq4ZcJpICECm/Om3V314BxdzypO0u5PORKGMM6x0OXaljV1YFg==" + }, + "@types/istanbul-lib-coverage": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.3.tgz", + "integrity": "sha512-sz7iLqvVUg1gIedBOvlkxPlc8/uVzyS5OwGz1cKjXzkl3FpL3al0crU8YGU1WoHkxn0Wxbw5tyi6hvzJKNzFsw==", + "dev": true + }, + "@types/istanbul-lib-report": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz", + "integrity": "sha512-plGgXAPfVKFoYfa9NpYDAkseG+g6Jr294RqeqcqDixSbU34MZVJRi/P+7Y8GDpzkEwLaGZZOpKIEmeVZNtKsrg==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "*" + } + }, + "@types/istanbul-reports": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.0.tgz", + "integrity": "sha512-nwKNbvnwJ2/mndE9ItP/zc2TCzw6uuodnF4EHYWD+gCQDVBuRQL5UzbZD0/ezy1iKsFU2ZQiDqg4M9dN4+wZgA==", + "dev": true, + "requires": { + "@types/istanbul-lib-report": "*" + } + }, + "@types/jest": { + "version": "26.0.20", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-26.0.20.tgz", + "integrity": "sha512-9zi2Y+5USJRxd0FsahERhBwlcvFh6D2GLQnY2FH2BzK8J9s9omvNHIbvABwIluXa0fD8XVKMLTO0aOEuUfACAA==", + "dev": true, + "requires": { + "jest-diff": "^26.0.0", + "pretty-format": "^26.0.0" + } + }, + "@types/lodash": { + "version": "4.14.168", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.168.tgz", + "integrity": "sha512-oVfRvqHV/V6D1yifJbVRU3TMp8OT6o6BG+U9MkwuJ3U8/CsDHvalRpsxBqivn71ztOFZBTfJMvETbqHiaNSj7Q==", + "dev": true + }, + "@types/long": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.1.tgz", + "integrity": "sha512-5tXH6Bx/kNGd3MgffdmP4dy2Z+G4eaXw0SE81Tq3BNadtnMR5/ySMzX4SLEzHJzSmPNn4HIdpQsBvXMUykr58w==", + "optional": true + }, + "@types/mime": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz", + "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==" + }, + "@types/node": { + "version": "10.17.55", + "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.55.tgz", + "integrity": "sha512-koZJ89uLZufDvToeWO5BrC4CR4OUfHnUz2qoPs/daQH6qq3IN62QFxCTZ+bKaCE0xaoCAJYE4AXre8AbghCrhg==" + }, + "@types/prop-types": { + "version": "15.7.3", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.3.tgz", + "integrity": "sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw==" + }, + "@types/qs": { + "version": "6.9.6", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.6.tgz", + "integrity": "sha512-0/HnwIfW4ki2D8L8c9GVcG5I72s9jP5GSLVF0VIXDW00kmIpA6O33G7a8n59Tmh7Nz0WUC3rSb7PTY/sdW2JzA==" + }, + "@types/range-parser": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.3.tgz", + "integrity": "sha512-ewFXqrQHlFsgc09MK5jP5iR7vumV/BYayNC6PgJO2LPe8vrnNFyjQjSppfEngITi0qvfKtzFvgKymGheFM9UOA==" + }, + "@types/react": { + "version": "17.0.3", + "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.3.tgz", + "integrity": "sha512-wYOUxIgs2HZZ0ACNiIayItyluADNbONl7kt8lkLjVK8IitMH5QMyAh75Fwhmo37r1m7L2JaFj03sIfxBVDvRAg==", + "requires": { + "@types/prop-types": "*", + "@types/scheduler": "*", + "csstype": "^3.0.2" + } + }, + "@types/react-transition-group": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.1.tgz", + "integrity": "sha512-vIo69qKKcYoJ8wKCJjwSgCTM+z3chw3g18dkrDfVX665tMH7tmbDxEAnPdey4gTlwZz5QuHGzd+hul0OVZDqqQ==", + "requires": { + "@types/react": "*" + } + }, + "@types/scheduler": { + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.1.tgz", + "integrity": "sha512-EaCxbanVeyxDRTQBkdLb3Bvl/HK7PBK6UJjsSixB0iHKoWxE5uu2Q/DgtpOhPIojN0Zl1whvOd7PoHs2P0s5eA==" + }, + "@types/serve-static": { + "version": "1.13.9", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.9.tgz", + "integrity": "sha512-ZFqF6qa48XsPdjXV5Gsz0Zqmux2PerNd3a/ktL45mHpa19cuMi/cL8tcxdAx497yRh+QtYPuofjT9oWw9P7nkA==", + "requires": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "@types/tough-cookie": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.0.tgz", + "integrity": "sha512-I99sngh224D0M7XgW1s120zxCt3VYQ3IQsuw3P3jbq5GG4yc79+ZjyKznyOGIQrflfylLgcfekeZW/vk0yng6A==" + }, + "@types/warning": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.0.tgz", + "integrity": "sha1-DSUBJorY+ZYrdA04fEZU9fjiPlI=" + }, + "@types/yargs": { + "version": "15.0.13", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.13.tgz", + "integrity": "sha512-kQ5JNTrbDv3Rp5X2n/iUu37IJBDU2gsZ5R/g1/KHOOEc5IKfUFjXT6DENPGduh08I/pamwtEq4oul7gUqKTQDQ==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + }, + "@types/yargs-parser": { + "version": "20.2.0", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-20.2.0.tgz", + "integrity": "sha512-37RSHht+gzzgYeobbG+KWryeAW8J33Nhr69cjTqSYymXVZEN9NbRYWoYlRtDhHKPVT1FyNKwaTPC1NynKZpzRA==", + "dev": true + }, + "abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "requires": { + "event-target-shim": "^5.0.0" + } + }, + "accepts": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.7.tgz", + "integrity": "sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA==", + "requires": { + "mime-types": "~2.1.24", + "negotiator": "0.6.2" + } + }, + "agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "requires": { + "debug": "4" + } + }, + "ansi-regex": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", + "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==", + "dev": true + }, + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=" + }, + "arrify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz", + "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==" + }, + "async-retry": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.1.tgz", + "integrity": "sha512-aiieFW/7h3hY0Bq5d+ktDBejxuwR78vRu9hDUdR8rNhSaQ29VzPL4AoIRG7D/c7tdenwOcKvgPM6tIxB3cB6HA==", + "requires": { + "retry": "0.12.0" + } + }, + "axios": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz", + "integrity": "sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==", + "requires": { + "follow-redirects": "^1.10.0" + } + }, + "axios-cookiejar-support": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/axios-cookiejar-support/-/axios-cookiejar-support-1.0.1.tgz", + "integrity": "sha512-IZJxnAJ99XxiLqNeMOqrPbfR7fRyIfaoSLdPUf4AMQEGkH8URs0ghJK/xtqBsD+KsSr3pKl4DEQjCn834pHMig==", + "requires": { + "is-redirect": "^1.0.0", + "pify": "^5.0.0" + } + }, + "base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" + }, + "bignumber.js": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.0.1.tgz", + "integrity": "sha512-IdZR9mh6ahOBv/hYGiXyVuyCetmGJhtYkqLBpTStdhEGjegpPlUawydyaF3pbIOFynJTpllEs+NP+CS9jKFLjA==" + }, + "body-parser": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.19.0.tgz", + "integrity": "sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw==", + "requires": { + "bytes": "3.1.0", + "content-type": "~1.0.4", + "debug": "2.6.9", + "depd": "~1.1.2", + "http-errors": "1.7.2", + "iconv-lite": "0.4.24", + "on-finished": "~2.3.0", + "qs": "6.7.0", + "raw-body": "2.4.0", + "type-is": "~1.6.17" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + } + } + }, + "buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk=" + }, + "bytes": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.0.tgz", + "integrity": "sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==" + }, + "call-bind": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", + "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", + "requires": { + "function-bind": "^1.1.1", + "get-intrinsic": "^1.0.2" + } + }, + "chalk": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", + "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "classnames": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.2.6.tgz", + "integrity": "sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q==" + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "requires": { + "mime-db": ">= 1.43.0 < 2" + } + }, + "configstore": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/configstore/-/configstore-5.0.1.tgz", + "integrity": "sha512-aMKprgk5YhBNyH25hj8wGt2+D52Sw1DRRIzqBwLp2Ya9mFmY8KPvvtvmna8SxVR9JMZ4kzMD68N22vlaRpkeFA==", + "requires": { + "dot-prop": "^5.2.0", + "graceful-fs": "^4.1.2", + "make-dir": "^3.0.0", + "unique-string": "^2.0.0", + "write-file-atomic": "^3.0.0", + "xdg-basedir": "^4.0.0" + } + }, + "content-disposition": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.3.tgz", + "integrity": "sha512-ExO0774ikEObIAEV9kDo50o+79VCUdEB6n6lzKgGwupcVeRlhrj3qGAfwq8G6uBJjkqLrhT0qEYFcWng8z1z0g==", + "requires": { + "safe-buffer": "5.1.2" + }, + "dependencies": { + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + } + } + }, + "content-type": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz", + "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==" + }, + "cookie": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.0.tgz", + "integrity": "sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==" + }, + "cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw=" + }, + "cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "requires": { + "object-assign": "^4", + "vary": "^1" + } + }, + "crypto-random-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz", + "integrity": "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==" + }, + "csstype": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.7.tgz", + "integrity": "sha512-KxnUB0ZMlnUWCsx2Z8MUsr6qV6ja1w9ArPErJaJaF8a5SOWoHLIszeCTKGRGRgtLgYrs1E8CHkNSP1VZTTPc9g==" + }, + "date-and-time": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/date-and-time/-/date-and-time-0.14.2.tgz", + "integrity": "sha512-EFTCh9zRSEpGPmJaexg7HTuzZHh6cnJj1ui7IGCFNXzd2QdpsNh05Db5TF3xzJm30YN+A8/6xHSuRcQqoc3kFA==" + }, + "debug": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz", + "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==", + "requires": { + "ms": "2.1.2" + } + }, + "depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=" + }, + "destroy": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz", + "integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=" + }, + "dicer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/dicer/-/dicer-0.3.0.tgz", + "integrity": "sha512-MdceRRWqltEG2dZqO769g27N/3PXfcKl04VhYnBlo2YhH7zPi88VebsjTKclaOyiuMaGU72hTfw3VkUitGcVCA==", + "requires": { + "streamsearch": "0.1.2" + } + }, + "diff-sequences": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-26.6.2.tgz", + "integrity": "sha512-Mv/TDa3nZ9sbc5soK+OoA74BsS3mL37yixCvUAQkiuA4Wz6YtwP/K47n2rv2ovzHZvoiQeA5FTQOschKkEwB0Q==", + "dev": true + }, + "dom-helpers": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.0.tgz", + "integrity": "sha512-Ru5o9+V8CpunKnz5LGgWXkmrH/20cGKwcHwS4m73zIvs54CN9epEmT/HLqFJW3kXpakAFkEdzgy1hzlJe3E4OQ==", + "requires": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "dot-prop": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", + "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==", + "requires": { + "is-obj": "^2.0.0" + } + }, + "duplexify": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.1.tgz", + "integrity": "sha512-DY3xVEmVHTv1wSzKNbwoU6nVjzI369Y6sPoqfYr0/xlx3IdX2n94xIszTcjPO8W8ZIv0Wb0PXNcjuZyT4wiICA==", + "requires": { + "end-of-stream": "^1.4.1", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1", + "stream-shift": "^1.0.0" + } + }, + "ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "requires": { + "safe-buffer": "^5.0.1" + } + }, + "ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=" + }, + "encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=" + }, + "end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "requires": { + "once": "^1.4.0" + } + }, + "ent": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ent/-/ent-2.2.0.tgz", + "integrity": "sha1-6WQhkyWiHQX0RGai9obtbOX13R0=" + }, + "escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=" + }, + "etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=" + }, + "event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==" + }, + "express": { + "version": "4.17.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.17.1.tgz", + "integrity": "sha512-mHJ9O79RqluphRrcw2X/GTh3k9tVv8YcoyY4Kkh4WDMUYKRZUq0h1o0w2rrrxBqM7VoeUVqgb27xlEMXTnYt4g==", + "requires": { + "accepts": "~1.3.7", + "array-flatten": "1.1.1", + "body-parser": "1.19.0", + "content-disposition": "0.5.3", + "content-type": "~1.0.4", + "cookie": "0.4.0", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "~1.1.2", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.1.2", + "fresh": "0.5.2", + "merge-descriptors": "1.0.1", + "methods": "~1.1.2", + "on-finished": "~2.3.0", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.7", + "proxy-addr": "~2.0.5", + "qs": "6.7.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.1.2", + "send": "0.17.1", + "serve-static": "1.14.1", + "setprototypeof": "1.1.1", + "statuses": "~1.5.0", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + } + } + }, + "extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" + }, + "fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "optional": true + }, + "fast-text-encoding": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/fast-text-encoding/-/fast-text-encoding-1.0.3.tgz", + "integrity": "sha512-dtm4QZH9nZtcDt8qJiOH9fcQd1NAgi+K1O2DbE6GG1PPCK/BWfOH3idCTRQ4ImXRUOyopDEgDEnVEE7Y/2Wrig==" + }, + "faye-websocket": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.3.tgz", + "integrity": "sha512-D2y4bovYpzziGgbHYtGCMjlJM36vAl/y+xUyn1C+FVx8szd1E+86KwVw6XvYSzOP8iMpm1X0I4xJD+QtUb36OA==", + "requires": { + "websocket-driver": ">=0.5.1" + } + }, + "finalhandler": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", + "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", + "requires": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "~2.3.0", + "parseurl": "~1.3.3", + "statuses": "~1.5.0", + "unpipe": "~1.0.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + } + } + }, + "firebase-admin": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/firebase-admin/-/firebase-admin-9.5.0.tgz", + "integrity": "sha512-OPXFOTDcAE+NORpfhq7YMEDk+vFClBtjfpkrjm2JHRxb8DpMm+K3AcusonFPU/WOH4FhiVN9JHB0+NPE20S3gQ==", + "requires": { + "@firebase/database": "^0.8.1", + "@firebase/database-types": "^0.6.1", + "@google-cloud/firestore": "^4.5.0", + "@google-cloud/storage": "^5.3.0", + "@types/node": "^10.10.0", + "dicer": "^0.3.0", + "jsonwebtoken": "^8.5.1", + "node-forge": "^0.10.0" + } + }, + "firebase-functions": { + "version": "3.13.2", + "resolved": "https://registry.npmjs.org/firebase-functions/-/firebase-functions-3.13.2.tgz", + "integrity": "sha512-XHgAQZqA62awr4l9mNlJv6qnv5MkMkLuo+hafdW0T7IJj1PgrZtuIo5x+ib2npAcB0XhX5Sg0QR1hMYPAlfbaA==", + "requires": { + "@types/express": "4.17.3", + "cors": "^2.8.5", + "express": "^4.17.1", + "lodash": "^4.17.14" + } + }, + "firebase-functions-test": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/firebase-functions-test/-/firebase-functions-test-0.2.3.tgz", + "integrity": "sha512-zYX0QTm53wCazuej7O0xqbHl90r/v1PTXt/hwa0jo1YF8nDM+iBKnLDlkIoW66MDd0R6aGg4BvKzTTdJpvigUA==", + "dev": true, + "requires": { + "@types/lodash": "^4.14.104", + "lodash": "^4.17.5" + } + }, + "follow-redirects": { + "version": "1.13.3", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.13.3.tgz", + "integrity": "sha512-DUgl6+HDzB0iEptNQEXLx/KhTmDb8tZUHSeLqpnjpknR70H0nC2t9N73BK6fN4hOvJ84pKlIQVQ4k5FFlBedKA==" + }, + "forwarded": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.1.2.tgz", + "integrity": "sha1-mMI9qxF1ZXuMBXPozszZGw/xjIQ=" + }, + "fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=" + }, + "function-bind": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" + }, + "functional-red-black-tree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", + "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=", + "optional": true + }, + "gaxios": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-4.2.0.tgz", + "integrity": "sha512-Ms7fNifGv0XVU+6eIyL9LB7RVESeML9+cMvkwGS70xyD6w2Z80wl6RiqiJ9k1KFlJCUTQqFFc8tXmPQfSKUe8g==", + "requires": { + "abort-controller": "^3.0.0", + "extend": "^3.0.2", + "https-proxy-agent": "^5.0.0", + "is-stream": "^2.0.0", + "node-fetch": "^2.3.0" + } + }, + "gcp-metadata": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-4.2.1.tgz", + "integrity": "sha512-tSk+REe5iq/N+K+SK1XjZJUrFPuDqGZVzCy2vocIHIGmPlTGsa8owXMJwGkrXr73NO0AzhPW4MF2DEHz7P2AVw==", + "requires": { + "gaxios": "^4.0.0", + "json-bigint": "^1.0.0" + } + }, + "gcs-resumable-upload": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/gcs-resumable-upload/-/gcs-resumable-upload-3.1.3.tgz", + "integrity": "sha512-LjVrv6YVH0XqBr/iBW0JgRA1ndxhK6zfEFFJR4im51QVTj/4sInOXimY2evDZuSZ75D3bHxTaQAdXRukMc1y+w==", + "requires": { + "abort-controller": "^3.0.0", + "configstore": "^5.0.0", + "extend": "^3.0.2", + "gaxios": "^4.0.0", + "google-auth-library": "^7.0.0", + "pumpify": "^2.0.0", + "stream-events": "^1.0.4" + } + }, + "get-intrinsic": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz", + "integrity": "sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==", + "requires": { + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.1" + } + }, + "get-stream": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.0.tgz", + "integrity": "sha512-A1B3Bh1UmL0bidM/YX2NsCOTnGJePL9rO/M+Mw3m9f2gUpfokS0hi5Eah0WSUEWZdZhIZtMjkIYS7mDfOqNHbg==" + }, + "google-auth-library": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-7.0.2.tgz", + "integrity": "sha512-vjyNZR3pDLC0u7GHLfj+Hw9tGprrJwoMwkYGqURCXYITjCrP9HprOyxVV+KekdLgATtWGuDkQG2MTh0qpUPUgg==", + "requires": { + "arrify": "^2.0.0", + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "fast-text-encoding": "^1.0.0", + "gaxios": "^4.0.0", + "gcp-metadata": "^4.2.0", + "gtoken": "^5.0.4", + "jws": "^4.0.0", + "lru-cache": "^6.0.0" + } + }, + "google-gax": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/google-gax/-/google-gax-2.10.3.tgz", + "integrity": "sha512-jESs/ME9WgMzfGQKJDu9ea2mEKjznKByRL+5xb8mKfHlbUfS/LxNLNCg/35RgXwVXcNSCqkEY90z8wHxvgdd/Q==", + "optional": true, + "requires": { + "@grpc/grpc-js": "~1.2.0", + "@grpc/proto-loader": "^0.5.1", + "@types/long": "^4.0.0", + "abort-controller": "^3.0.0", + "duplexify": "^4.0.0", + "fast-text-encoding": "^1.0.3", + "google-auth-library": "^7.0.2", + "is-stream-ended": "^0.1.4", + "node-fetch": "^2.6.1", + "protobufjs": "^6.10.2", + "retry-request": "^4.0.0" + } + }, + "google-p12-pem": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/google-p12-pem/-/google-p12-pem-3.0.3.tgz", + "integrity": "sha512-wS0ek4ZtFx/ACKYF3JhyGe5kzH7pgiQ7J5otlumqR9psmWMYc+U9cErKlCYVYHoUaidXHdZ2xbo34kB+S+24hA==", + "requires": { + "node-forge": "^0.10.0" + } + }, + "google-spreadsheet": { + "version": "3.1.15", + "resolved": "https://registry.npmjs.org/google-spreadsheet/-/google-spreadsheet-3.1.15.tgz", + "integrity": "sha512-S5477f3Gf3Mz6AXgCw7dbaYnzu5aHou1AX4sDqrGboQWnAytkxqJGKQiXN+zzRTTcYzSTJCe0g7KqCPZO9xiOw==", + "requires": { + "axios": "^0.21.1", + "google-auth-library": "^6.1.3", + "lodash": "^4.17.20" + }, + "dependencies": { + "google-auth-library": { + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-6.1.6.tgz", + "integrity": "sha512-Q+ZjUEvLQj/lrVHF/IQwRo6p3s8Nc44Zk/DALsN+ac3T4HY/g/3rrufkgtl+nZ1TW7DNAw5cTChdVp4apUXVgQ==", + "requires": { + "arrify": "^2.0.0", + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "fast-text-encoding": "^1.0.0", + "gaxios": "^4.0.0", + "gcp-metadata": "^4.2.0", + "gtoken": "^5.0.4", + "jws": "^4.0.0", + "lru-cache": "^6.0.0" + } + } + } + }, + "graceful-fs": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.6.tgz", + "integrity": "sha512-nTnJ528pbqxYanhpDYsi4Rd8MAeaBA67+RZ10CM1m3bTAVFEDcd5AuA4a6W5YkGZ1iNXHzZz8T6TBKLeBuNriQ==" + }, + "gtoken": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-5.2.1.tgz", + "integrity": "sha512-OY0BfPKe3QnMsY9MzTHTSKn+Vl2l1CcLe6BwDEQj00mbbkl5nyQ/7EUREstg4fQNZ8iYE7br4JJ7TdKeDOPWmw==", + "requires": { + "gaxios": "^4.0.0", + "google-p12-pem": "^3.0.3", + "jws": "^4.0.0" + } + }, + "has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "requires": { + "function-bind": "^1.1.1" + } + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "has-symbols": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz", + "integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==" + }, + "hash-stream-validation": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/hash-stream-validation/-/hash-stream-validation-0.2.4.tgz", + "integrity": "sha512-Gjzu0Xn7IagXVkSu9cSFuK1fqzwtLwFhNhVL8IFJijRNMgUttFbBSIAzKuSIrsFMO1+g1RlsoN49zPIbwPDMGQ==" + }, + "http-errors": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.2.tgz", + "integrity": "sha512-uUQBt3H/cSIVfch6i1EuPNy/YsRSOUBXTVfZ+yR7Zjez3qjBz6i9+i4zjNaoqcoFVI4lQJ5plg63TvGfRSDCRg==", + "requires": { + "depd": "~1.1.2", + "inherits": "2.0.3", + "setprototypeof": "1.1.1", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.0" + }, + "dependencies": { + "inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=" + } + } + }, + "http-parser-js": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.3.tgz", + "integrity": "sha512-t7hjvef/5HEK7RWTdUzVUhl8zkEu+LlaE0IYzdMuvbSDipxBRpOn4Uhw8ZyECEa808iVT8XCjzo6xmYt4CiLZg==" + }, + "http-proxy-agent": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", + "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", + "requires": { + "@tootallnate/once": "1", + "agent-base": "6", + "debug": "4" + } + }, + "https-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz", + "integrity": "sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA==", + "requires": { + "agent-base": "6", + "debug": "4" + } + }, + "iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "requires": { + "safer-buffer": ">= 2.1.2 < 3" + } + }, + "ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==" + }, + "imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=" + }, + "inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "requires": { + "loose-envify": "^1.0.0" + } + }, + "ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==" + }, + "is-obj": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", + "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==" + }, + "is-redirect": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-redirect/-/is-redirect-1.0.0.tgz", + "integrity": "sha1-HQPd7VO9jbDzDCbk+V02/HyH3CQ=" + }, + "is-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.0.tgz", + "integrity": "sha512-XCoy+WlUr7d1+Z8GgSuXmpuUFC9fOhRXglJMx+dwLKTkL44Cjd4W1Z5P+BQZpr+cR93aGP4S/s7Ftw6Nd/kiEw==" + }, + "is-stream-ended": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/is-stream-ended/-/is-stream-ended-0.1.4.tgz", + "integrity": "sha512-xj0XPvmr7bQFTvirqnFr50o0hQIh6ZItDqloxt5aJrR4NQsYeSsyFQERYGCAzfindAcnKjINnwEEgLx4IqVzQw==", + "optional": true + }, + "is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=" + }, + "jest-diff": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-26.6.2.tgz", + "integrity": "sha512-6m+9Z3Gv9wN0WFVasqjCL/06+EFCMTqDEUl/b87HYK2rAPTyfz4ZIuSlPhY51PIQRWx5TaxeF1qmXKe9gfN3sA==", + "dev": true, + "requires": { + "chalk": "^4.0.0", + "diff-sequences": "^26.6.2", + "jest-get-type": "^26.3.0", + "pretty-format": "^26.6.2" + } + }, + "jest-get-type": { + "version": "26.3.0", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-26.3.0.tgz", + "integrity": "sha512-TpfaviN1R2pQWkIihlfEanwOXK0zcxrKEE4MlU6Tn7keoXdN6/3gK/xl0yEh8DOunn5pOVGKf8hB4R9gVh04ig==", + "dev": true + }, + "js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "requires": { + "bignumber.js": "^9.0.0" + } + }, + "jsonwebtoken": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz", + "integrity": "sha512-XjwVfRS6jTMsqYs0EsuJ4LGxXV14zQybNd4L2r0UvbVnSF9Af8x7p5MzbJ90Ioz/9TI41/hTCvznF/loiSzn8w==", + "requires": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^5.6.0" + }, + "dependencies": { + "jwa": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", + "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "requires": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "requires": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==" + } + } + }, + "jwa": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz", + "integrity": "sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==", + "requires": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "jws": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", + "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "requires": { + "jwa": "^2.0.0", + "safe-buffer": "^5.0.1" + } + }, + "lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, + "lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==" + }, + "lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha1-soqmKIorn8ZRA1x3EfZathkDMaY=", + "optional": true + }, + "lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha1-YLuYqHy5I8aMoeUTJUgzFISfVT8=" + }, + "lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha1-bC4XHbKiV82WgC/UOwGyDV9YcPY=" + }, + "lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha1-YZwK89A/iwTDH1iChAt3sRzWg0M=" + }, + "lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha1-POdoEMWSjQM1IwGsKHMX8RwLH/w=" + }, + "lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=" + }, + "lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha1-1SfftUVuynzJu5XV2ur4i6VKVFE=" + }, + "lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=" + }, + "long": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", + "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==", + "optional": true + }, + "loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "requires": { + "js-tokens": "^3.0.0 || ^4.0.0" + } + }, + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "requires": { + "yallist": "^4.0.0" + } + }, + "make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "requires": { + "semver": "^6.0.0" + } + }, + "media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=" + }, + "merge-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", + "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=" + }, + "methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=" + }, + "mime": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.5.2.tgz", + "integrity": "sha512-tqkh47FzKeCPD2PUiPB6pkbMzsCasjxAfC62/Wap5qrUWcb+sFasXUC5I3gYM5iBM8v/Qpn4UK0x+j0iHyFPDg==" + }, + "mime-db": { + "version": "1.46.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.46.0.tgz", + "integrity": "sha512-svXaP8UQRZ5K7or+ZmfNhg2xX3yKDMUzqadsSqi4NCH/KomcH75MAMYAGVlvXn4+b/xOPhS3I2uHKRUzvjY7BQ==" + }, + "mime-types": { + "version": "2.1.29", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.29.tgz", + "integrity": "sha512-Y/jMt/S5sR9OaqteJtslsFZKWOIIqMACsJSiHghlCAyhf7jfVYjKBmLiX8OgpWeW+fjJ2b+Az69aPFPkUOY6xQ==", + "requires": { + "mime-db": "1.46.0" + } + }, + "mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==" + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "nano": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/nano/-/nano-9.0.3.tgz", + "integrity": "sha512-NFI8+6q5ihnozH6qK+BJ+ilnPfZzBhlUswaFgqUvSp2EN5eJ2BMxbzkYiBsN+waa+N95FculCdbneDmzLWfXaQ==", + "requires": { + "@types/tough-cookie": "^4.0.0", + "axios": "^0.21.1", + "axios-cookiejar-support": "^1.0.1", + "qs": "^6.9.4", + "tough-cookie": "^4.0.0" + }, + "dependencies": { + "qs": { + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.10.0.tgz", + "integrity": "sha512-yjACOWijC6L/kmPZZAsVBNY2zfHSIbpdpL977quseu56/8BZ2LoF5axK2bGhbzhVKt7V9xgWTtpyLbxwIoER0Q==", + "requires": { + "side-channel": "^1.0.4" + } + } + } + }, + "negotiator": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.2.tgz", + "integrity": "sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==" + }, + "node-fetch": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz", + "integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==" + }, + "node-forge": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.10.0.tgz", + "integrity": "sha512-PPmu8eEeG9saEUvI97fm4OYxXVB6bFvyNTyiUOBichBpFG8A1Ljw3bY62+5oOjDEMHRnd0Y7HQ+x7uzxOzC6JA==" + }, + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" + }, + "object-inspect": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.9.0.tgz", + "integrity": "sha512-i3Bp9iTqwhaLZBxGkRfo5ZbE07BQRT7MGu8+nNgwW9ItGp1TzCTw2DLEoWwjClxBjOFI/hWljTAmYGCEwmtnOw==" + }, + "on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=", + "requires": { + "ee-first": "1.1.1" + } + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "requires": { + "wrappy": "1" + } + }, + "onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "requires": { + "mimic-fn": "^2.1.0" + } + }, + "p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "requires": { + "yocto-queue": "^0.1.0" + } + }, + "parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" + }, + "path-to-regexp": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", + "integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=" + }, + "pbf": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-3.2.1.tgz", + "integrity": "sha512-ClrV7pNOn7rtmoQVF4TS1vyU0WhYRnP92fzbfF75jAIwpnzdJXf8iTd4CMEqO4yUenH6NDqLiwjqlh6QgZzgLQ==", + "requires": { + "ieee754": "^1.1.12", + "resolve-protobuf-schema": "^2.1.0" + } + }, + "pify": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-5.0.0.tgz", + "integrity": "sha512-eW/gHNMlxdSP6dmG6uJip6FXN0EQBwm2clYYd8Wul42Cwu/DK8HEftzsapcNdYe2MfLiIwZqsDk2RDEsTE79hA==" + }, + "pretty-format": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-26.6.2.tgz", + "integrity": "sha512-7AeGuCYNGmycyQbCqd/3PWH4eOoX/OiCa0uphp57NVTeAGdJGaAliecxwBDHYQCIvrW7aDBZCYeNTP/WX69mkg==", + "dev": true, + "requires": { + "@jest/types": "^26.6.2", + "ansi-regex": "^5.0.0", + "ansi-styles": "^4.0.0", + "react-is": "^17.0.1" + }, + "dependencies": { + "react-is": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.1.tgz", + "integrity": "sha512-NAnt2iGDXohE5LI7uBnLnqvLQMtzhkiAOLXTmv+qnF9Ky7xAPcX8Up/xWIhxvLVGJvuLiNc4xQLtuqDRzb4fSA==", + "dev": true + } + } + }, + "prop-types": { + "version": "15.7.2", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz", + "integrity": "sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==", + "requires": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.8.1" + } + }, + "prop-types-extra": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/prop-types-extra/-/prop-types-extra-1.1.1.tgz", + "integrity": "sha512-59+AHNnHYCdiC+vMwY52WmvP5dM3QLeoumYuEyceQDi9aEhtwN9zIQ2ZNo25sMyXnbh32h+P1ezDsUpUH3JAew==", + "requires": { + "react-is": "^16.3.2", + "warning": "^4.0.0" + } + }, + "protobufjs": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.10.2.tgz", + "integrity": "sha512-27yj+04uF6ya9l+qfpH187aqEzfCF4+Uit0I9ZBQVqK09hk/SQzKa2MUqUpXaVa7LOFRg1TSSr3lVxGOk6c0SQ==", + "optional": true, + "requires": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/long": "^4.0.1", + "@types/node": "^13.7.0", + "long": "^4.0.0" + }, + "dependencies": { + "@types/node": { + "version": "13.13.45", + "resolved": "https://registry.npmjs.org/@types/node/-/node-13.13.45.tgz", + "integrity": "sha512-703YTEp8AwQeapI0PTXDOj+Bs/mtdV/k9VcTP7z/de+lx6XjFMKdB+JhKnK+6PZ5za7omgZ3V6qm/dNkMj/Zow==", + "optional": true + } + } + }, + "protocol-buffers-schema": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.5.1.tgz", + "integrity": "sha512-YVCvdhxWNDP8/nJDyXLuM+UFsuPk4+1PB7WGPVDzm3HTHbzFLxQYeW2iZpS4mmnXrQJGBzt230t/BbEb7PrQaw==" + }, + "proxy-addr": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.6.tgz", + "integrity": "sha512-dh/frvCBVmSsDYzw6n926jv974gddhkFPfiN8hPOi30Wax25QZyZEGveluCgliBnqmuM+UJmBErbAUFIoDbjOw==", + "requires": { + "forwarded": "~0.1.2", + "ipaddr.js": "1.9.1" + } + }, + "psl": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz", + "integrity": "sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ==" + }, + "pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "requires": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "pumpify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pumpify/-/pumpify-2.0.1.tgz", + "integrity": "sha512-m7KOje7jZxrmutanlkS1daj1dS6z6BgslzOXmcSEpIlCxM3VJH7lG5QLeck/6hgF6F4crFf01UtQmNsJfweTAw==", + "requires": { + "duplexify": "^4.1.1", + "inherits": "^2.0.3", + "pump": "^3.0.0" + } + }, + "punycode": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", + "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==" + }, + "qs": { + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz", + "integrity": "sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==" + }, + "rambda": { + "version": "6.8.2", + "resolved": "https://registry.npmjs.org/rambda/-/rambda-6.8.2.tgz", + "integrity": "sha512-fIVr6nuqfHfJTguthGsWF930DkNq/ENraeSpYBj1kYvO/ieacux7rj2NW27JwwFrllFJwXkLpGyFMz99EwCJ3Q==" + }, + "range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==" + }, + "raw-body": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.4.0.tgz", + "integrity": "sha512-4Oz8DUIwdvoa5qMJelxipzi/iJIi40O5cGV1wNYp5hvZP8ZN0T+jiNkL0QepXs+EsQ9XJ8ipEDoiH70ySUJP3Q==", + "requires": { + "bytes": "3.1.0", + "http-errors": "1.7.2", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + } + }, + "react": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/react/-/react-17.0.1.tgz", + "integrity": "sha512-lG9c9UuMHdcAexXtigOZLX8exLWkW0Ku29qPRU8uhF2R9BN96dLCt0psvzPLlHc5OWkgymP3qwTRgbnw5BKx3w==", + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1" + } + }, + "react-bootstrap": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-1.5.1.tgz", + "integrity": "sha512-jbJNGx9n4JvKgxlvT8DLKSeF3VcqnPJXS9LFdzoZusiZCCGoYecZ9qSCBH5n2A+kjmuura9JkvxI9l7HD+bIdQ==", + "requires": { + "@babel/runtime": "^7.4.2", + "@restart/context": "^2.1.4", + "@restart/hooks": "^0.3.21", + "@types/classnames": "^2.2.10", + "@types/invariant": "^2.2.33", + "@types/prop-types": "^15.7.3", + "@types/react": ">=16.9.35", + "@types/react-transition-group": "^4.4.0", + "@types/warning": "^3.0.0", + "classnames": "^2.2.6", + "dom-helpers": "^5.1.2", + "invariant": "^2.2.4", + "prop-types": "^15.7.2", + "prop-types-extra": "^1.1.0", + "react-overlays": "^5.0.0", + "react-transition-group": "^4.4.1", + "uncontrollable": "^7.0.0", + "warning": "^4.0.3" + } + }, + "react-dom": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.1.tgz", + "integrity": "sha512-6eV150oJZ9U2t9svnsspTMrWNyHc6chX0KzDeAOXftRa8bNeOKTTfCJ7KorIwenkHd2xqVTBTCZd79yk/lx/Ug==", + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1", + "scheduler": "^0.20.1" + } + }, + "react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, + "react-lifecycles-compat": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", + "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==" + }, + "react-overlays": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/react-overlays/-/react-overlays-5.0.0.tgz", + "integrity": "sha512-TKbqfAv23TFtCJ2lzISdx76p97G/DP8Rp4TOFdqM9n8GTruVYgE3jX7Zgb8+w7YJ18slTVcDTQ1/tFzdCqjVhA==", + "requires": { + "@babel/runtime": "^7.12.1", + "@popperjs/core": "^2.5.3", + "@restart/hooks": "^0.3.25", + "@types/warning": "^3.0.0", + "dom-helpers": "^5.2.0", + "prop-types": "^15.7.2", + "uncontrollable": "^7.0.0", + "warning": "^4.0.3" + } + }, + "react-transition-group": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.1.tgz", + "integrity": "sha512-Djqr7OQ2aPUiYurhPalTrVy9ddmFCCzwhqQmtN+J3+3DzLO209Fdr70QrN8Z3DsglWql6iY1lDWAfpFiBtuKGw==", + "requires": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + } + }, + "readable-stream": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", + "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + }, + "regenerator-runtime": { + "version": "0.13.7", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz", + "integrity": "sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew==" + }, + "resolve-protobuf-schema": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", + "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", + "requires": { + "protocol-buffers-schema": "^3.3.1" + } + }, + "retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha1-G0KmJmoh8HQh0bC1S33BZ7AcATs=" + }, + "retry-request": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-4.1.3.tgz", + "integrity": "sha512-QnRZUpuPNgX0+D1xVxul6DbJ9slvo4Rm6iV/dn63e048MvGbUZiKySVt6Tenp04JqmchxjiLltGerOJys7kJYQ==", + "requires": { + "debug": "^4.1.1" + } + }, + "safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" + }, + "safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "scheduler": { + "version": "0.20.1", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.20.1.tgz", + "integrity": "sha512-LKTe+2xNJBNxu/QhHvDR14wUXHRQbVY5ZOYpOGWRzhydZUqrLb2JBvLPY7cAqFmqrWuDED0Mjk7013SZiOz6Bw==", + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1" + } + }, + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==" + }, + "send": { + "version": "0.17.1", + "resolved": "https://registry.npmjs.org/send/-/send-0.17.1.tgz", + "integrity": "sha512-BsVKsiGcQMFwT8UxypobUKyv7irCNRHk1T0G680vk88yf6LBByGcZJOTJCrTP2xVN6yI+XjPJcNuE3V4fT9sAg==", + "requires": { + "debug": "2.6.9", + "depd": "~1.1.2", + "destroy": "~1.0.4", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "~1.7.2", + "mime": "1.6.0", + "ms": "2.1.1", + "on-finished": "~2.3.0", + "range-parser": "~1.2.1", + "statuses": "~1.5.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + }, + "dependencies": { + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + } + } + }, + "mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==" + }, + "ms": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", + "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==" + } + } + }, + "serve-static": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.14.1.tgz", + "integrity": "sha512-JMrvUwE54emCYWlTI+hGrGv5I8dEwmco/00EvkzIIsR7MqrHonbD9pO2MOfFnpFntl7ecpZs+3mW+XbQZu9QCg==", + "requires": { + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.17.1" + } + }, + "setprototypeof": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz", + "integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==" + }, + "side-channel": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", + "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "requires": { + "call-bind": "^1.0.0", + "get-intrinsic": "^1.0.2", + "object-inspect": "^1.9.0" + } + }, + "signal-exit": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.3.tgz", + "integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==" + }, + "snakeize": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/snakeize/-/snakeize-0.1.0.tgz", + "integrity": "sha1-EMCI2LWOsHazIpu1oE4jLOEmQi0=" + }, + "statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=" + }, + "stream-events": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz", + "integrity": "sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==", + "requires": { + "stubs": "^3.0.0" + } + }, + "stream-shift": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.1.tgz", + "integrity": "sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ==" + }, + "streamsearch": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-0.1.2.tgz", + "integrity": "sha1-gIudDlb8Jz2Am6VzOOkpkZoanxo=" + }, + "string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "requires": { + "safe-buffer": "~5.2.0" + } + }, + "stubs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz", + "integrity": "sha1-6NK6H6nJBXAwPAMLaQD31fiavls=" + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + }, + "teeny-request": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-7.0.1.tgz", + "integrity": "sha512-sasJmQ37klOlplL4Ia/786M5YlOcoLGQyq2TE4WHSRupbAuDaQW0PfVxV4MtdBtRJ4ngzS+1qim8zP6Zp35qCw==", + "requires": { + "http-proxy-agent": "^4.0.0", + "https-proxy-agent": "^5.0.0", + "node-fetch": "^2.6.1", + "stream-events": "^1.0.5", + "uuid": "^8.0.0" + } + }, + "toidentifier": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz", + "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==" + }, + "tough-cookie": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.0.0.tgz", + "integrity": "sha512-tHdtEpQCMrc1YLrMaqXXcj6AxhYi/xgit6mZu1+EDWUn+qhUf8wMQoFIy9NXuq23zAwtcB0t/MjACGR18pcRbg==", + "requires": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.1.2" + } + }, + "tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + }, + "type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "requires": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + } + }, + "typedarray-to-buffer": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", + "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", + "requires": { + "is-typedarray": "^1.0.0" + } + }, + "typescript": { + "version": "3.9.9", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.9.9.tgz", + "integrity": "sha512-kdMjTiekY+z/ubJCATUPlRDl39vXYiMV9iyeMuEuXZh2we6zz80uovNN2WlAxmmdE/Z/YQe+EbOEXB5RHEED3w==", + "dev": true + }, + "uncontrollable": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-7.2.1.tgz", + "integrity": "sha512-svtcfoTADIB0nT9nltgjujTi7BzVmwjZClOmskKu/E8FW9BXzg9os8OLr4f8Dlnk0rYWJIWr4wv9eKUXiQvQwQ==", + "requires": { + "@babel/runtime": "^7.6.3", + "@types/react": ">=16.9.11", + "invariant": "^2.2.4", + "react-lifecycles-compat": "^3.0.4" + } + }, + "unique-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", + "integrity": "sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==", + "requires": { + "crypto-random-string": "^2.0.0" + } + }, + "universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==" + }, + "unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=" + }, + "util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" + }, + "utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=" + }, + "uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" + }, + "vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=" + }, + "warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "requires": { + "loose-envify": "^1.0.0" + } + }, + "websocket-driver": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", + "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", + "requires": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + } + }, + "websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==" + }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" + }, + "write-file-atomic": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz", + "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==", + "requires": { + "imurmurhash": "^0.1.4", + "is-typedarray": "^1.0.0", + "signal-exit": "^3.0.2", + "typedarray-to-buffer": "^3.1.5" + } + }, + "xdg-basedir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-4.0.0.tgz", + "integrity": "sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q==" + }, + "yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, + "yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==" + } + } +} diff --git a/functions/package.json b/functions/package.json new file mode 100644 index 0000000..cb23eb9 --- /dev/null +++ b/functions/package.json @@ -0,0 +1,35 @@ +{ + "name": "functions", + "scripts": { + "build": "tsc", + "serve": "npm run build && firebase emulators:start --only functions", + "shell": "npm run build && firebase functions:shell", + "start": "npm run shell", + "deploy": "firebase deploy --only functions", + "logs": "firebase functions:log" + }, + "engines": { + "node": "12" + }, + "main": "lib/functions/src/index.js", + "dependencies": { + "@google-cloud/storage": "^5.8.1", + "@lingdocs/pashto-inflector": "^0.9.0", + "@types/cors": "^2.8.10", + "@types/google-spreadsheet": "^3.0.2", + "cors": "^2.8.5", + "firebase-admin": "^9.2.0", + "firebase-functions": "^3.11.0", + "google-spreadsheet": "^3.1.15", + "nano": "^9.0.3", + "react": "^17.0.1", + "react-bootstrap": "^1.5.1", + "react-dom": "^17.0.1" + }, + "devDependencies": { + "@types/jest": "^26.0.20", + "firebase-functions-test": "^0.2.0", + "typescript": "^3.8.0" + }, + "private": true +} diff --git a/functions/src/generate-password.ts b/functions/src/generate-password.ts new file mode 100644 index 0000000..8316696 --- /dev/null +++ b/functions/src/generate-password.ts @@ -0,0 +1,9 @@ +export default function generatePassword(): string { + function makeChunk(): string { + return Math.random().toString(36).slice(2) + } + const password = new Array(4).fill(0).reduce((acc: string): string => ( + acc + makeChunk() + ), ""); + return password; +} \ No newline at end of file diff --git a/functions/src/index.ts b/functions/src/index.ts new file mode 100644 index 0000000..ae63665 --- /dev/null +++ b/functions/src/index.ts @@ -0,0 +1,232 @@ +import * as functions from "firebase-functions"; +import publish from "./publish"; +import { + receiveSubmissions, +} from "./submissions"; +import generatePassword from "./generate-password"; +import * as BT from "../../website/src/lib/backend-types" +import cors from "cors"; +import * as admin from "firebase-admin"; +import { getUserDbName } from "./lib/userDbName"; + +const nano = require("nano")(functions.config().couchdb.couchdb_url); +const usersDb = nano.db.use("_users"); + +admin.initializeApp(); + +const validateFirebaseIdToken = async (req: any, res: any, next: any) => { + if ((!req.headers.authorization || !req.headers.authorization.startsWith('Bearer ')) && + !(req.cookies && req.cookies.__session)) { + res.status(403).send({ message: "Unauthorized" }); + return; + } + + let idToken; + if (req.headers.authorization && req.headers.authorization.startsWith('Bearer ')) { + // Read the ID Token from the Authorization header. + idToken = req.headers.authorization.split('Bearer ')[1]; + } else if(req.cookies) { + // Read the ID Token from cookie. + idToken = req.cookies.__session; + } else { + // No cookie + res.status(403).send({ message: "Unauthorized" }); + return; + } + + try { + const decodedIdToken = await admin.auth().verifyIdToken(idToken); + req.user = decodedIdToken; + next(); + return; + } catch (error) { + console.error('Error while verifying Firebase ID token:', error); + res.status(403).send({ message: "Unauthorized" }); + return; + } +}; + +const isEditor = async (req: any) => { + const uid = req.user.uid as string; + const couchDbUser = await getCouchDbUser(uid); + return !!couchDbUser && couchDbUser.level === "editor"; +} + +export const publishDictionary = functions + .region("europe-west1") + .runWith({ + timeoutSeconds: 200, + memory: "2GB" + }) + .https.onRequest((req, res) => { + return cors({ origin: true })(req, res, () => { + validateFirebaseIdToken(req, res, async () => { + try { + const response = await publish(); + return res.send(response); + } catch (error) { + return res.status(500).send({ + error: error.toString(), + }); + } + }); + }); +}); + +// TODO: BETTER HANDLING OF EXPRESS MIDDLEWARE + +export const submissions = functions + .region("europe-west1") + .runWith({ + timeoutSeconds: 30, + memory: "1GB" + }) + .https.onRequest((req, res) => { + return cors({ origin: true })(req, res, () => { + validateFirebaseIdToken(req, res, async () => { + if (!Array.isArray(req.body)) { + res.status(400).send({ + ok: false, + error: "invalid submission", + }); + return; + } + const suggestions = req.body as BT.SubmissionsRequest; + // @ts-ignore + const uid = req.user.uid as string; + const editor = await isEditor(req); + try { + const response = await receiveSubmissions(suggestions, editor); + // TODO: WARN IF ANY OF THE EDITS DIDN'T HAPPEN + res.send(response); + return; + } catch (error) { + console.error(error); + return res.status(500).send({ + error: error.toString(), + }); + }; + }).catch(console.error); + }); +}); + +export const getUserInfo = functions.region("europe-west1").https.onRequest((req, res) => { + return cors({ origin: true })(req, res, () => { + validateFirebaseIdToken(req, res, async () => { + try { + // @ts-ignore + const uid = req.user.uid as string; + const user = await getCouchDbUser(uid); + if (!user) { + const noneFound: BT.GetUserInfoResponse = { + ok: true, + message: "no couchdb user found", + }; + res.send(noneFound); + return; + } + const userFound: BT.GetUserInfoResponse = { ok: true, user }; + res.send(userFound); + return; + } catch(error) { + console.error(error); + res.status(500).send({ + ok: false, + error: error.message, + }); + } + }).catch(console.error); + }); +}); + +// export const cleanUpUser = functions +// .region("europe-west1") +// .auth.user().onDelete(async (user) => { +// const couchDbUser = await getCouchDbUser(user.uid); +// if (!couchDbUser) return; +// await usersDb.destroy( +// `org.couchdb.user:${user.uid}`, +// couchDbUser._rev, +// ); +// try { +// await nano.db.destroy(getUserDbName(user.uid)); +// } catch (e) { +// console.log("errored destroying", e); +// }; +// }); + +export const upgradeUser = functions.region("europe-west1").https.onRequest((req, res) => { + return cors({ origin: true })(req, res, () => { + validateFirebaseIdToken(req, res, async () => { + const password = (req.body.password || "") as string; + const studentPassword = functions.config().upgrades.student_password as string; + if (password.toLowerCase() !== studentPassword.toLowerCase()) { + const wrongPass: BT.UpgradeUserResponse = { + ok: false, + error: "incorrect password", + }; + res.send(wrongPass); + return; + } + // @ts-ignore + const uid = req.user.uid; + const couchDbUser = await getCouchDbUser(uid); + if (couchDbUser) { + const alreadyUpgraded: BT.UpgradeUserResponse = { + ok: true, + message: "user already upgraded", + }; + res.send(alreadyUpgraded); + return; + } + const user = await admin.auth().getUser(uid); + const userdbPassword = generatePassword(); + const newCouchDbUser: BT.CouchDbUser = { + _id: `org.couchdb.user:${user.uid}`, + type: "user", + name: user.uid, + email: user.email || "", + providerData: user.providerData, + displayName: user.displayName || "", + roles: [], + password: userdbPassword, + level: "student", + userdbPassword, + }; + await usersDb.insert(newCouchDbUser); + // create wordlist database for user + const userDbName = getUserDbName(user.uid); + await nano.db.create(userDbName); + const securityInfo = { + admins: { + names: [user.uid], + roles: ["_admin"] + }, + members: { + names: [user.uid], + roles: ["_admin"], + }, + }; + const userDb = nano.db.use(userDbName); + await userDb.insert(securityInfo, "_security"); + // TODO: SET THE USERDBPASSWORD TO BE userdbPassword; + const upgraded: BT.UpgradeUserResponse = { + ok: true, + message: "user upgraded to student", + }; + res.send(upgraded); + }).catch(console.error); + }); +}); + +async function getCouchDbUser(uid: string): Promise { + const user = await usersDb.find({ + selector: { + name: uid, + } + }); + if (!user.docs.length) { + return undefined; + } + return user.docs[0] as BT.CouchDbUser; +} \ No newline at end of file diff --git a/functions/src/lib/userDbName.ts b/functions/src/lib/userDbName.ts new file mode 100644 index 0000000..9800e08 --- /dev/null +++ b/functions/src/lib/userDbName.ts @@ -0,0 +1,12 @@ +function stringToHex(str: string) { + const arr1 = []; + for (let n = 0, l = str.length; n < l; n ++) { + const hex = Number(str.charCodeAt(n)).toString(16); + arr1.push(hex); + } + return arr1.join(''); +} + +export function getUserDbName(uid: string): string { + return `userdb-${stringToHex(uid)}`; +} \ No newline at end of file diff --git a/functions/src/publish.ts b/functions/src/publish.ts new file mode 100644 index 0000000..a6e4c4a --- /dev/null +++ b/functions/src/publish.ts @@ -0,0 +1,238 @@ +import { GoogleSpreadsheet } from "google-spreadsheet"; +import * as functions from "firebase-functions"; +import { + Types as T, + dictionaryEntryBooleanFields, + dictionaryEntryNumberFields, + dictionaryEntryTextFields, + standardizePashto, + validateEntry, + writeDictionary, + writeDictionaryInfo, + simplifyPhonetics, +} from "@lingdocs/pashto-inflector"; +// import { +// getWordList, +// } from "./word-list-maker"; +import { + PublishDictionaryResponse, +} from "../../website/src/lib/backend-types"; +import { Storage } from "@google-cloud/storage"; +const storage = new Storage({ + projectId: "lingdocs", +}); + +const title = "LingDocs Pashto Dictionary" +const license = "Copyright © 2021 lingdocs.com All Rights Reserved - Licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License - https://creativecommons.org/licenses/by-nc-sa/4.0/"; +const bucketName = "lingdocs"; +const baseUrl = `https://storage.googleapis.com/${bucketName}/`; +const dictionaryFilename = "dictionary"; +const dictionaryInfoFilename = "dictionary-info"; +// const hunspellAffFileFilename = "ps_AFF.aff"; +// const hunspellDicFileFilename = "ps_AFF.dic"; +const url = `${baseUrl}${dictionaryFilename}`; +const infoUrl = `${baseUrl}${dictionaryInfoFilename}`; + +function standardizePhonetics(f: string): string { + return f.replace(/’/g, "'"); +} + +// TODO: Create a seperate function for publishing the Hunspell that can run after the publish function? +// to keep the publish function time down + +export default async function(): Promise { + const entries = await getRawEntries(); + const errors = checkForErrors(entries); + if (errors.length) { + return({ ok: false, errors }); + } + const duplicate = findDuplicateTs(entries); + if (duplicate) { + return({ + ok: false, + errors: [{ + errors: [`${duplicate.ts} is a duplicate ts`], + ts: duplicate.ts, + p: duplicate.p, + f: duplicate.f, + e: duplicate.e, + erroneousFields: ["ts"], + }], + }); + } + const dictionary: T.Dictionary = { + info: { + title, + license, + url, + infoUrl, + release: new Date().getTime(), + numberOfEntries: entries.length, + }, + entries, + } + await uploadDictionaryToStorage(dictionary); + // TODO: make this async and run after publish response + // doHunspell(entries).catch(console.error); + return { + ok: true, + info: dictionary.info + }; + +} + +// async function doHunspell(entries: T.DictionaryEntry[]) { +// const wordlistResponse = getWordList(entries); +// if (!wordlistResponse.ok) { +// throw new Error(JSON.stringify(wordlistResponse.errors)); +// } +// const hunspell = makeHunspell(wordlistResponse.wordlist); +// await uploadHunspellToStorage(hunspell); +// } + +async function getRawEntries(): Promise { + const doc = new GoogleSpreadsheet( + functions.config().sheet.id, + ); + await doc.useServiceAccountAuth({ + client_email: functions.config().serviceacct.email, + private_key: functions.config().serviceacct.key, + }); + await doc.loadInfo(); + const sheet = doc.sheetsByIndex[0]; + const rows = await sheet.getRows(); + const entries = makeEntries(rows); + return entries; +} + +function makeEntries(rows: any[]): T.DictionaryEntry[] { + const entries: T.DictionaryEntry[] = rows.map((row, i): T.DictionaryEntry => { + const e: T.DictionaryEntry = { + i: 1, + ts: parseInt(row.ts), + p: row.p, + f: row.f, + g: simplifyPhonetics(row.f), + e: row.e, + }; + dictionaryEntryNumberFields.forEach((field: T.DictionaryEntryNumberField) => { + if (row[field]) { + e[field] = parseInt(row[field]); + } + }); + dictionaryEntryTextFields.forEach((field: T.DictionaryEntryTextField) => { + if (row[field]) { + const content = field.slice(-1) === "p" ? standardizePashto(row[field]).trim() + : field.slice(-1) === "f" ? standardizePhonetics(row[field]).trim() + : row[field].trim(); + e[field] = content; + } + }); + dictionaryEntryBooleanFields.forEach((field: T.DictionaryEntryBooleanField) => { + if (row[field]) { + e[field] = true; + } + }); + return e; + }); + // add alphabetical index + entries.sort((a, b) => a.p.localeCompare(b.p, "ps")); + const entriesLength = entries.length; + for (let i = 0; i < entriesLength; i++) { + entries[i].i = i; + } + return entries; +} + +function checkForErrors(entries: T.DictionaryEntry[]): T.DictionaryEntryError[] { + return entries.reduce((errors: T.DictionaryEntryError[], entry: T.DictionaryEntry) => { + const response = validateEntry(entry); + if ("errors" in response && response.errors.length) { + return [...errors, response]; + } + if ("checkComplement" in response) { + const complement = entries.find((e) => e.ts === entry.l); + if (!complement) { + const error: T.DictionaryEntryError = { + errors: ["complement link not found in dictonary"], + ts: entry.ts, + p: entry.p, + f: entry.f, + e: entry.e, + erroneousFields: ["l"], + }; + return [...errors, error]; + } + if (!complement.c?.includes("n.") && !complement.c?.includes("adj.") && !complement.c?.includes("adv.")) { + const error: T.DictionaryEntryError = { + errors: ["complement link to invalid complement"], + ts: entry.ts, + p: entry.p, + f: entry.f, + e: entry.e, + erroneousFields: ["l"], + }; + return [...errors, error]; + } + } + return errors; + }, []); +} + +function findDuplicateTs(entries: T.DictionaryEntry[]): T.DictionaryEntry | undefined { + const tsSoFar = new Set(); + // tslint:disable-next-line: prefer-for-of + for (let i = 0; i < entries.length; i++) { + const ts = entries[i].ts; + if (tsSoFar.has(ts)) { + return entries[i]; + } + tsSoFar.add(ts); + } + return undefined; +} + +async function upload(content: Buffer | string, filename: string) { + const isBuffer = typeof content !== "string"; + const file = storage.bucket(bucketName).file(filename); + await file.save(content, { + gzip: isBuffer ? false : true, + predefinedAcl: "publicRead", + metadata: { + contentType: isBuffer + ? "application/octet-stream" + : filename.slice(-5) === ".json" + ? "application/json" + : "text/plain; charset=UTF-8", + cacheControl: "no-cache", + }, + }); +} + +// async function uploadHunspellToStorage(wordlist: { +// affContent: string, +// dicContent: string, +// }) { +// await Promise.all([ +// upload(wordlist.affContent, hunspellAffFileFilename), +// upload(wordlist.dicContent, hunspellDicFileFilename), +// ]); +// } + +async function uploadDictionaryToStorage(dictionary: T.Dictionary) { + const dictionaryBuffer = writeDictionary(dictionary); + const dictionaryInfoBuffer = writeDictionaryInfo(dictionary.info); + await Promise.all([ + upload(JSON.stringify(dictionary), `${dictionaryFilename}.json`), + upload(JSON.stringify(dictionary.info), `${dictionaryInfoFilename}.json`), + upload(dictionaryBuffer as Buffer, dictionaryFilename), + upload(dictionaryInfoBuffer as Buffer, dictionaryInfoFilename), + ]); +} + +// function makeHunspell(wordlist: string[]) { +// return { +// dicContent: wordlist.reduce((acc, word) => acc + word + "\n", wordlist.length + "\n"), +// affContent: "SET UTF-8\nCOMPLEXPREFIXES\nIGNORE ۱۲۳۴۵۶۷۸۹۰-=ًٌٍَُِّْ؛:؟.،,،؟\n", +// }; +// } diff --git a/functions/src/submissions.ts b/functions/src/submissions.ts new file mode 100644 index 0000000..73e56f6 --- /dev/null +++ b/functions/src/submissions.ts @@ -0,0 +1,155 @@ +import { GoogleSpreadsheet } from "google-spreadsheet"; +import { + dictionaryEntryTextFields, + dictionaryEntryBooleanFields, + dictionaryEntryNumberFields, +} from "@lingdocs/pashto-inflector"; +import * as BT from "../../website/src/lib/backend-types"; +import * as functions from "firebase-functions"; + +const fieldsForEdit = [ + ...dictionaryEntryTextFields, + ...dictionaryEntryNumberFields, + ...dictionaryEntryBooleanFields, +].filter(field => !(["ts", "i"].includes(field))); + +// TODO: PASS NANO INTO FUNCTIONu +const nano = require("nano")(functions.config().couchdb.couchdb_url); +const reviewTasksDb = nano.db.use("review-tasks"); + +export async function receiveSubmissions(e: BT.SubmissionsRequest, editor: boolean): Promise { + const { edits, reviewTasks } = sortSubmissions(e); + + // TODO: BETTER PROMISE MULTI-TASKING + // 1. Add review tasks to the couchdb + // 2. Edit dictionary entries + // 3. Add new dictionary entries + + + if (reviewTasks.length) { + const docs = reviewTasks.map((task) => ({ + ...task, + _rev: undefined, + })); + await reviewTasksDb.bulk({ docs }); + } + + if (editor && edits.length) { + + const doc = new GoogleSpreadsheet( + functions.config().sheet.id, + ); + await doc.useServiceAccountAuth({ + client_email: functions.config().serviceacct.email, + private_key: functions.config().serviceacct.key, + }); + await doc.loadInfo(); + const dictionarySheet = doc.sheetsByIndex[0]; + + const { + newEntries, + entryEdits, + entryDeletions, + } = sortEdits(edits); + + if (entryEdits.length || entryDeletions.length) { + const dictRows = await dictionarySheet.getRows(); + entryEdits.forEach(async ({entry}) => { + const i = dictRows.findIndex((r: any) => parseInt(r.ts) === entry.ts); + if (i === -1) { + console.error("Tried editing an entry with a ts that doesn't exist"); + } else { + fieldsForEdit.forEach((field) => { + const toWrite = entry[field]; + const existing = dictRows[i][field]; + if (toWrite) { + // something to write + dictRows[i][field] = toWrite; + } else if (existing && !toWrite) { + // something to erase + dictRows[i][field] = ""; + } + }); + } + try { + await dictRows[i].save(); + } catch (error) { + console.error("error saving edit to entry " + entry.ts); + console.error(error); + } + }); + entryDeletions.forEach(async ({ ts }) => { + const i = dictRows.findIndex((r: any) => parseInt(r.ts) === ts); + if (i === -1) { + console.error("Tried deleting an entry with ats that doesn't exist") + } + try { + await dictRows[i].delete(); + } catch (error) { + console.error("error deleting error " + ts); + console.error(error); + } + }); + } + + if (newEntries.length) { + newEntries.forEach((n) => { + const entry = { ...n.entry }; + // @ts-ignore + delete entry.i; // i not used in dictionary spreadsheet; added while building it + // @ts-ignore + dictionarySheet.addRow(entry).catch(console.error); + }); + } + } + + return { + ok: true, + message: `received ${reviewTasks.length} review task(s), and ${edits.length} edit(s)`, + submissions: e, + }; +} + +type SortedSubmissions = { + edits: BT.Edit[], + reviewTasks: BT.ReviewTask[], +}; + +export function sortSubmissions(submissions: BT.Submission[]): SortedSubmissions { + const base: SortedSubmissions = { + edits: [], + reviewTasks: [], + }; + return submissions.reduce((acc, s): SortedSubmissions => ({ + ...acc, + ...(s.type === "edit suggestion" || s.type === "issue" || s.type === "entry suggestion") ? { + reviewTasks: [...acc.reviewTasks, s], + } : { + edits: [...acc.edits, s], + }, + }), base); +} + +type SortedEdits = { + entryEdits: BT.EntryEdit[], + newEntries: BT.NewEntry[], + entryDeletions: BT.EntryDeletion[], +} + +export function sortEdits(edits: BT.Edit[]): SortedEdits { + const base: SortedEdits = { + entryEdits: [], + newEntries: [], + entryDeletions: [], + } + return edits.reduce((acc, edit): SortedEdits => ({ + ...acc, + ...edit.type === "entry edit" ? { + entryEdits: [...acc.entryEdits, edit], + } : edit.type === "new entry" ? { + newEntries: [...acc.newEntries, edit], + } : edit.type === "entry deletion" ? { + entryDeletions: [...acc.entryDeletions, edit], + } : {}, + }), base); +} diff --git a/functions/src/word-list-maker.test.ts b/functions/src/word-list-maker.test.ts new file mode 100644 index 0000000..7edaef5 --- /dev/null +++ b/functions/src/word-list-maker.test.ts @@ -0,0 +1,27 @@ +import { getWordList } from "./word-list-maker"; + +const entries = [ + { "ts": 0, p:"???", f: "abc", e: "oeu", g: "coeuch", i: 0 }, + {"ts":1581189430959,"p":"پېش","f":"pesh","e":"ahead, in front; earlier, first, before","c":"adv.","g":"pesh","i":2574}, + {"i":4424,"g":"cherta","ts":1527812531,"p":"چېرته","f":"cherta","e":"where (also used for if, when)"}, + {"i":5389,"g":"daase","ts":1527812321,"p":"داسې","f":"daase","e":"such, like this, like that, like","c":"adv."}, +]; +const expectedInflections = [ + "پیش", + "پېش", + "چیرته", + "چېرته", + "داسي", + "داسې", +]; + +describe('Make Wordlist', () => { + it("should return all inflections that can be generated from given entries", () => { + const response = getWordList(entries); + expect(response.ok).toBe(true); + expect("wordlist" in response).toBe(true); + if ("wordlist" in response) { + expect(response.wordlist).toEqual(expectedInflections); + } + }); +}); diff --git a/functions/src/word-list-maker.ts b/functions/src/word-list-maker.ts new file mode 100644 index 0000000..7cb261f --- /dev/null +++ b/functions/src/word-list-maker.ts @@ -0,0 +1,124 @@ +import { + inflectWord, + conjugateVerb, + Types as T, + pashtoConsonants, + isNounAdjOrVerb, +} from "@lingdocs/pashto-inflector"; + + +function search(key: string, object: any): string[] { + // adapted from + // https://www.mikedoesweb.com/2016/es6-depth-first-object-tree-search/ + function inside(needle: string, haystack: any, found: Set = new Set()): Set { + if (haystack === null) { + return found; + } + Object.keys(haystack).forEach((key: string) => { + if(key === needle && typeof haystack[key] === "string") { + haystack[key].split(" ").forEach((word: string) => { + found.add(word); + }); + return; + } + if(typeof haystack[key] === 'object') { + inside(needle, haystack[key], found); + } + return; + }); + return found; + }; + return Array.from(inside(key, object)); +} + +export function getWordList(entries: T.DictionaryEntry[]): { + ok: true, + wordlist: string[], +} | { + ok: false, + errors: T.DictionaryEntryError[], +} { + const allInflections: Set = new Set(); + const errors: T.DictionaryEntryError[] = []; + function getNounAdjInflections(entry: T.DictionaryEntry) { + if (entry.app) allInflections.add(entry.app); + if (entry.ppp) allInflections.add(entry.ppp); + + const inflections = inflectWord(entry); + const wordsFromInf = inflections + ? search("p", inflections) + : []; + wordsFromInf.forEach(w => allInflections.add(w)); + } + function getVerbConjugations(word: T.DictionaryEntry, linked?: T.DictionaryEntry) { + const pWords = search("p", conjugateVerb(word, linked)); + pWords.forEach(w => allInflections.add(w)); + } + // got the entries, make a wordList of all the possible inflections + entries.forEach((entry) => { + try { + if (entry.c && isNounAdjOrVerb(entry) === "nounAdj") { + // it's a noun/adjective - get all inflections and plurals etc. + getNounAdjInflections(entry); + // hack to add some plurals and mayonnaise + if (entry.c.includes("n. m.") && pashtoConsonants.includes(entry.p.slice(-1))) { + allInflections.add(entry.p + "ونه") + allInflections.add(entry.p + "ونو") + allInflections.add(entry.p + "ه"); + } + if (entry.c.includes("n. f.") && entry.p.slice(-1) === "ا") { + allInflections.add(entry.p + "ګانې") + allInflections.add(entry.p + "ګانو"); + } + } else if (entry.c && isNounAdjOrVerb(entry) === "verb") { + // it's a verb - get all the conjugations for it + if (entry.l && entry.c.includes("comp.")) { + // it's a compound verb, conjugate it with the linked complement + const linkedEntry = entries.find((e) => e.ts === entry.l); + getVerbConjugations(entry, linkedEntry); + } else { + // it's a non-compound verb, conjugate it + getVerbConjugations(entry); + } + } else { + // it's something else, just put the word(s) in + entry.p.split(" ").forEach(w => allInflections.add(w)); + } + } catch (error) { + errors.push({ + ts: entry.ts, + p: entry.p, + f: entry.f, + e: entry.e, + erroneousFields: [], + errors: ["error inflecting/conjugating entry", error.toString()], + }); + } + }); + if (errors.length) { + return ({ + ok: false, + errors, + }); + } + + // add ی version of words with ې (to accomadate for some bad spelling) + allInflections.forEach((word: string) => { + // for words with ې in the middle, also have a version with ی in the middle instead + if (eInMiddleRegex.test(word)) { + allInflections.add(word.replace(eInMiddleRegex, "ی")); + } + // for words ending in ې, also have a version ending in ي + if (word.slice(-1) === "ې") { + allInflections.add(word.slice(0, -1) + "ي"); + } + }); + const wordlist = Array.from(allInflections).filter((s) => !(s.includes(".") || s.includes("?"))); + wordlist.sort((a, b) => a.localeCompare(b, "ps")); + return { + ok: true, + wordlist, + }; +} + +const eInMiddleRegex = new RegExp("ې(?=[\u0621-\u065f\u0670-\u06d3\u06d5])", "g"); diff --git a/functions/tsconfig.json b/functions/tsconfig.json new file mode 100644 index 0000000..8fb6498 --- /dev/null +++ b/functions/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "module": "commonjs", + "noImplicitReturns": true, + "noUnusedLocals": true, + "outDir": "lib", + "sourceMap": true, + "strict": true, + "target": "es2017", + "esModuleInterop": true + }, + "compileOnSave": true, + "include": [ + "src" + ] +} diff --git a/netlify.toml b/netlify.toml new file mode 100644 index 0000000..281388f --- /dev/null +++ b/netlify.toml @@ -0,0 +1,4 @@ +[build] + base = "website" + command = "export REACT_APP_BUILD_NO=`git rev-parse --short HEAD` && yarn test && yarn build" + publish = "build" diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..b70e921 --- /dev/null +++ b/public/index.html @@ -0,0 +1,3 @@ + + Hello World + \ No newline at end of file diff --git a/website/.gitignore b/website/.gitignore new file mode 100644 index 0000000..4d29575 --- /dev/null +++ b/website/.gitignore @@ -0,0 +1,23 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/website/.npmrc b/website/.npmrc new file mode 100644 index 0000000..b6e362c --- /dev/null +++ b/website/.npmrc @@ -0,0 +1,2 @@ +@lingdocs:registry=https://npm.lingdocs.com +//npm.lingdocs.com/:_authToken=${LINGDOCS_NPM_TOKEN} diff --git a/website/package.json b/website/package.json new file mode 100644 index 0000000..bff63e0 --- /dev/null +++ b/website/package.json @@ -0,0 +1,97 @@ +{ + "name": "pashto-dictionary-website", + "version": "0.1.0", + "license": "MIT", + "author": "lingdocs.com", + "private": true, + "dependencies": { + "@fortawesome/fontawesome-free": "^5.15.2", + "@lingdocs/pashto-inflector": "^0.9.0", + "@testing-library/jest-dom": "^5.11.4", + "@testing-library/react": "^11.1.0", + "@testing-library/user-event": "^12.1.10", + "@types/jest": "^26.0.20", + "@types/node": "^14.14.33", + "@types/react": "^17.0.3", + "@types/react-dom": "^17.0.2", + "bootstrap": "^4.6.0", + "classnames": "^2.2.6", + "cron": "^1.8.2", + "dayjs": "^1.10.4", + "firebase": "^8.3.0", + "lokijs": "^1.5.11", + "mousetrap": "^1.6.5", + "node-sass": "^5.0.0", + "papaparse": "^5.3.0", + "pbf": "^3.2.1", + "pouchdb": "^7.2.2", + "react": "^17.0.1", + "react-bootstrap": "^1.5.1", + "react-dom": "^17.0.1", + "react-dropzone": "^11.3.2", + "react-firebaseui": "^4.1.0", + "react-ga": "^3.3.0", + "react-helmet": "^6.1.0", + "react-image-crop": "^8.6.9", + "react-image-file-resizer": "^0.4.4", + "react-router-dom": "^5.2.0", + "react-scripts": "4.0.3", + "relevancy": "^0.2.0", + "supermemo": "^2.0.17", + "typescript": "^4.2.3", + "web-vitals": "^0.2.4", + "workbox-background-sync": "^5.1.3", + "workbox-broadcast-update": "^5.1.3", + "workbox-cacheable-response": "^5.1.3", + "workbox-core": "^5.1.3", + "workbox-expiration": "^5.1.3", + "workbox-google-analytics": "^5.1.3", + "workbox-navigation-preload": "^5.1.3", + "workbox-precaching": "^5.1.3", + "workbox-range-requests": "^5.1.3", + "workbox-routing": "^5.1.3", + "workbox-strategies": "^5.1.3", + "workbox-streams": "^5.1.3" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test", + "eject": "react-scripts eject", + "test-ci": "yarn test --watchAll=false" + }, + "eslintConfig": { + "extends": [ + "react-app", + "react-app/jest" + ] + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "devDependencies": { + "@types/cron": "^1.7.2", + "@types/history": "^4.7.8", + "@types/lokijs": "^1.5.3", + "@types/mousetrap": "^1.6.8", + "@types/papaparse": "^5.2.5", + "@types/pouchdb": "^6.4.0", + "@types/react-canvas-draw": "^1.1.0", + "@types/react-helmet": "^6.1.0", + "@types/react-image-crop": "^8.1.2", + "@types/react-router-dom": "^5.1.7", + "fake-indexeddb": "^3.1.2", + "history": "4", + "jest-fetch-mock": "^3.0.3", + "user-event": "^4.0.0" + } +} diff --git a/website/public/_redirects b/website/public/_redirects new file mode 100644 index 0000000..78f7f20 --- /dev/null +++ b/website/public/_redirects @@ -0,0 +1 @@ +/* /index.html 200 \ No newline at end of file diff --git a/website/public/favicon.ico b/website/public/favicon.ico new file mode 100644 index 0000000..6761701 Binary files /dev/null and b/website/public/favicon.ico differ diff --git a/website/public/icons/favicon.ico b/website/public/icons/favicon.ico new file mode 100644 index 0000000..6761701 Binary files /dev/null and b/website/public/icons/favicon.ico differ diff --git a/website/public/icons/icon.png b/website/public/icons/icon.png new file mode 100644 index 0000000..8af9e9d Binary files /dev/null and b/website/public/icons/icon.png differ diff --git a/website/public/icons/icon128.png b/website/public/icons/icon128.png new file mode 100644 index 0000000..0efe840 Binary files /dev/null and b/website/public/icons/icon128.png differ diff --git a/website/public/icons/icon144.png b/website/public/icons/icon144.png new file mode 100644 index 0000000..4002495 Binary files /dev/null and b/website/public/icons/icon144.png differ diff --git a/website/public/icons/icon168.png b/website/public/icons/icon168.png new file mode 100644 index 0000000..b2d19ce Binary files /dev/null and b/website/public/icons/icon168.png differ diff --git a/website/public/icons/icon192.png b/website/public/icons/icon192.png new file mode 100644 index 0000000..cf47335 Binary files /dev/null and b/website/public/icons/icon192.png differ diff --git a/website/public/icons/icon48.png b/website/public/icons/icon48.png new file mode 100644 index 0000000..a9b53aa Binary files /dev/null and b/website/public/icons/icon48.png differ diff --git a/website/public/icons/icon72.png b/website/public/icons/icon72.png new file mode 100644 index 0000000..4d24362 Binary files /dev/null and b/website/public/icons/icon72.png differ diff --git a/website/public/icons/icon96.png b/website/public/icons/icon96.png new file mode 100644 index 0000000..41776f7 Binary files /dev/null and b/website/public/icons/icon96.png differ diff --git a/website/public/icons/touch-icon128.png b/website/public/icons/touch-icon128.png new file mode 100644 index 0000000..16c69f2 Binary files /dev/null and b/website/public/icons/touch-icon128.png differ diff --git a/website/public/icons/touch-icon152.png b/website/public/icons/touch-icon152.png new file mode 100644 index 0000000..3ed5304 Binary files /dev/null and b/website/public/icons/touch-icon152.png differ diff --git a/website/public/icons/touch-icon167.png b/website/public/icons/touch-icon167.png new file mode 100644 index 0000000..7d3ca73 Binary files /dev/null and b/website/public/icons/touch-icon167.png differ diff --git a/website/public/icons/touch-icon180.png b/website/public/icons/touch-icon180.png new file mode 100644 index 0000000..0acf2f9 Binary files /dev/null and b/website/public/icons/touch-icon180.png differ diff --git a/website/public/icons/touch-icon57.png b/website/public/icons/touch-icon57.png new file mode 100644 index 0000000..3154195 Binary files /dev/null and b/website/public/icons/touch-icon57.png differ diff --git a/website/public/icons/touch-icon76.png b/website/public/icons/touch-icon76.png new file mode 100644 index 0000000..7c83089 Binary files /dev/null and b/website/public/icons/touch-icon76.png differ diff --git a/website/public/index.html b/website/public/index.html new file mode 100644 index 0000000..42c7694 --- /dev/null +++ b/website/public/index.html @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + LingDocs Pashto Dictionary + + + +
+ + + diff --git a/website/public/manifest.json b/website/public/manifest.json new file mode 100644 index 0000000..d0dffcb --- /dev/null +++ b/website/public/manifest.json @@ -0,0 +1,40 @@ +{ + "short_name": "Pashto Dictionary", + "name": "LingDocs Pashto Dictionary", + "icons": [ + { + "src": "icons/favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + }, + { + "src": "icons/icon48.png", + "sizes": "48x48", + "type": "image/png" + }, + { + "src": "icons/icon72.png", + "sizes": "72x72", + "type": "image/png" + }, + { + "src": "icons/icon144.png", + "sizes": "144x144", + "type": "image/png" + }, + { + "src": "icons/icon168.png", + "sizes": "168x168", + "type": "image/png" + }, + { + "src": "icons/icon192.png", + "sizes": "192x192", + "type": "image/png" + } + ], + "display": "standalone", + "theme_color": "#333333", + "background_color": "#f9f9f9", + "start_url": "." +} diff --git a/website/public/robots.txt b/website/public/robots.txt new file mode 100644 index 0000000..e9e57dc --- /dev/null +++ b/website/public/robots.txt @@ -0,0 +1,3 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Disallow: diff --git a/website/src/App.css b/website/src/App.css new file mode 100644 index 0000000..61da2a9 --- /dev/null +++ b/website/src/App.css @@ -0,0 +1,385 @@ +/** + * Copyright (c) 2021 lingdocs.com + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + + :root { + --secondary: #00c1fc; + --primary: #ffda54; + + /* change with theme */ + --theme-shade: #fafafa; + --close: #f5f5f5; + --closer: #eee; + --farther: #555; + --farthest: #333; + --high-contrast: #444; + + --input-bg: #fafafa; +} + +[data-theme="dark"] { + --theme-shade: #121418; + --close: #1d1f25; + --closer: #2c3039; + --farther: #bbb; + --farthest: #999; + --high-contrast: #cfcfcf; + + --input-bg: #ccc; +} + +[data-p-text-size="larger"] { + --p-text-size: 1.3rem +} + +[data-p-text-size="largest"] { + --p-text-size: 1.6rem +} + +body { + background-color: var(--theme-shade); + color: var(--high-contrast); + line-height: 1.4; + /* Needed because of fixed-top navbar + padding-top: 75px; + padding-bottom: 60px; */ +} + +.p-text { + font-size: var(--p-text-size); +} + +pre { + color: var(--high-contrast); +} + +.card { + background: var(--closer); +} + +.list-group { + background: var(--closer); +} + +.list-group-item { + background: var(--closer); +} + +hr { + border-top: 1px solid var(--farther); +} + +/* maybe call .box-alt? */ +.bg-light { + background-color: var(--closer) !important; + color: var(--high-contrast); +} +.bg-white { + background-color: var(--theme-shade) !important; +} + +/* TODO: better handling of modals across light and dark modes */ +.modal-body, .modal-title { + color:#1d1f25; +} + +.table { + color: var(--high-contrast); +} + +.width-limiter { + max-width: 700px; +} + +.thin-column { + max-width: 1rem; +} + +.entry { + margin-bottom: 1.25rem; +} + +.entry-extra-info { + color: var(--farther); + margin-left: 1rem; + margin-top: 0.25rem; +} + +.entry-definition { + margin-top: 0.5rem; + margin-left: 1rem; +} + +kbd { + background-color: #eee; + border-radius: 3px; + border: 1px solid #b4b4b4; + box-shadow: 0 1px 1px rgba(0, 0, 0, + .2), 0 2px 0 0 rgba(255, 255, 255, + .7) inset; + color: #333; + display: inline-block; + font-size: .85em; + font-weight: 700; + line-height: 1; + padding: 2px 4px; + white-space: nowrap; +} + +.recording-banner { + position: fixed; + width: 100%; + height: 16.5%; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgb(244, 66, 66, 0.9); + z-index: 2; +} + +/* Remove blue glow thing */ +textarea:focus, +input[type="text"]:focus, +input[type="search"]:focus, +button:focus { + border-color: var(--farther); + box-shadow: none; + outline: 0 none; +} + +.clickable { + cursor: pointer; +} + +.unclickable:hover { + cursor: default !important; +} + +.bottom-left { + position: fixed; + bottom: 2em; + left: 2em; +} + +.bottom-right { + position: fixed; + bottom: 2em; + right: 2em; +} + +.input-group { + border-color: red !important; +} + +input { + background-color: var(--input-bg) !important; +} + +.clear-search-button { + background-color: var(--input-bg) !important; +} + +.clear-search-button:hover { + color: inherit; + cursor: pointer; + background-color: var(--input-bg) !important; +} + +.clear-search-button:active { + background-color: var(--input-bg) !important; + color: var(--input-bg) !important; +} + +.buttons-footer { + display: flex; + flex-direction: row; + justify-content: space-evenly; + align-items: center; + padding-left: 30%; + padding-right: 30%; + padding-top: 8px; + padding-bottom: 5px; +} + +@media screen and (max-width: 900px) { + .buttons-footer { + padding-left: 15%; + padding-right: 15% + } +} + +@media screen and (max-width: 550px) { + .buttons-footer { + padding-left: 0; + padding-right: 0; + } +} + +.footer { + position: fixed; + bottom: 0; + width: 100%; + /* Set the fixed height of the footer here */ + height: 55px; + left: 0; + z-index: 500; +} + +.footer-thick { + height: 112px; +} + +.wee-less-footer { + height: 60px; +} + +.form-control-clear:hover { + cursor: pointer; +} + +.theme-toggle-button { + position: fixed; + top: 90px; + right: 30px; + font-size: 20px; +} + +.conjugation-search-button { + position: fixed; + top: 150px; + right: 400px; + z-index: 1000; + border-radius: 32px; +} + +.conjugation-search-button-with-bottom-searchbar { + top: 110px; +} + +.entry-suggestion-button { + position: fixed; + top: 208px; + right: 400px; + z-index: 1000; + border-radius: 32px; +} + +.entry-suggestion-button-with-bottom-searchbar { + top: 168px; +} + +@media screen and (max-width: 950px) { + .entry-suggestion-button { + right: 15px; + } + .conjugation-search-button { + right: 15px; + } +} + +/* @media screen and (min-width: 800px) { + .entry-suggestion-button { + right: 200px; + } + .conjugation-search-button { + right: 200px; + } +} */ + +.bottom-nav-item { + display: flex; + flex-direction: column; + align-items: center; +} + +.plain-link { + color: inherit; + text-decoration: none; +} + +.plain-link:hover { + text-decoration: none; + color: var(--farther); +} +.clickable:hover { + color: var(--farther); +} + +.clear-search-button { + background-color: white; + margin-left: -2px; + color: #444; + border-color: var(--farther); +} + +.clear-search-button:hover { + color: #555; +} + +.btn.bg-white:active, +.btn.bg-white:hover { + color: #555 !important; +} + +.btn-group.full-width { + display: flex; +} + +.full-width .btn { + flex: 1; +} + +@media screen and (max-width: 550px) { + .show-on-desktop { + display: none; + } +} + +/* Loding animation from https://projects.lukehaas.me/css-loaders/ */ +.loader, +.loader:after { + border-radius: 50%; + width: 10em; + height: 10em; +} +.loader { + margin: 60px auto; + font-size: 10px; + position: relative; + text-indent: -9999em; + border-top: 1.1em solid var(--closer); + border-right: 1.1em solid var(--closer); + border-bottom: 1.1em solid var(--closer); + border-left: 1.1em solid var(--farthest); + -webkit-transform: translateZ(0); + -ms-transform: translateZ(0); + transform: translateZ(0); + -webkit-animation: load8 1.1s infinite linear; + animation: load8 1.1s infinite linear; +} +@-webkit-keyframes load8 { + 0% { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); + } +} +@keyframes load8 { + 0% { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); + } +} +/* End of loading animation from https://projects.lukehaas.me/css-loaders/ */ diff --git a/website/src/App.test.tsx b/website/src/App.test.tsx new file mode 100644 index 0000000..3a3ed66 --- /dev/null +++ b/website/src/App.test.tsx @@ -0,0 +1,779 @@ +/** + * Copyright (c) 2021 lingdocs.com + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +// TODO: IndexedDB mocking not working for couchdb - it defaults to disk storage +// tslint:disable-next-line +// require("fake-indexeddb/auto"); +// // tslint:disable-next-line +// const FDBFactory = require("fake-indexeddb/lib/FDBFactory"); + +import { render, screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react'; +import { Types as T } from "@lingdocs/pashto-inflector"; +import { Router, BrowserRouter } from "react-router-dom"; +import App from './App'; +import { dictionary } from "./lib/dictionary"; +import { + mockResults, +} from "./lib/dictionary-mock-fillers"; +import userEvent from '@testing-library/user-event'; +import { createMemoryHistory } from 'history'; +import { + loadUserInfo, + upgradeAccount, + publishDictionary, +} from "./lib/backend-calls"; +import { + addSubmission, sendSubmissions, +} from "./lib/submissions"; +import * as BT from "./lib/backend-types"; +jest.mock("./lib/submissions"); +jest.mock("./lib/backend-calls"); +jest.mock("./lib/pouch-dbs"); +jest.mock("./lib/wordlist-database"); +jest.mock("react-ga"); + +const mockUserInfo = { + displayName: "Bob Billywinkle", + email: "bob@example.com", + providerData: [], +}; + +const mockCouchDbStudent: BT.CouchDbUser = { + _id: "123", + type: "user", + name: "123", + email: mockUserInfo.email, + providerData: [], + displayName: mockUserInfo.displayName, + roles: [], + level: "student", + userdbPassword: "12345", +} + +const mockCouchDbEditor: BT.CouchDbUser = { + ...mockCouchDbStudent, + level: "editor", +} + +jest.mock("./lib/firebase", (): any => { + class mockAuth { + constructor() { + this.signIn = this.signIn.bind(this); + this.onAuthStateChanged = this.onAuthStateChanged.bind(this); + this.unsubscribeAll = this.unsubscribeAll.bind(this); + } + private mockUser = { + displayName: "Bob Billywinkle", + email: "bob@example.com", + providerData: [], + delete: () => { + this.currentUser = null; + return Promise.resolve(); + }, + }; + private observers: ((user: any) => void)[] = []; + public currentUser: any = null; + onAuthStateChanged (callback: () => void) { + this.observers.push(callback); + callback(); + return () => { this.unsubscribeAll() }; + } + unsubscribeAll () { + this.observers = []; + } + signOut () { + this.currentUser = null; + this.observers.forEach((item) => { + item.call(undefined, this.mockUser); + }); + return null; + } + signIn () { + this.currentUser = this.mockUser; + this.observers.forEach((item) => { + item.call(undefined, this.mockUser); + }); + return null; + } + } + return { + auth: new mockAuth(), + }; +}); + + + +jest.mock('react-firebaseui/StyledFirebaseAuth', () => function (props: any) { + return
+ +
; +}); + +const allMockEntries: T.DictionaryEntry[] = Object.keys(mockResults).reduce((all: T.DictionaryEntry[], key: string) => ( + // @ts-ignore + [...all, ...mockResults[key]] +), []); + +const fakeDictInfo: T.DictionaryInfo = { + title: "not found", + license: "not found", + release: 0, + numberOfEntries: 0, + url: "not found", + infoUrl: "not found", +}; + +const fakeDictionary: DictionaryAPI = { + initialize: () => Promise.resolve({ + response: "loaded from saved", + dictionaryInfo: fakeDictInfo, + }), + update: () => Promise.resolve({ + response: "no need for update", + dictionaryInfo: fakeDictInfo, + }), + search: function(state: State): T.DictionaryEntry[] { + if (state.options.searchType === "alphabetical") { + return state.searchValue === "ا" ? mockResults.alphabeticalA : []; + } + if (state.options.language === "Pashto") { + return state.searchValue === "کور" + ? mockResults.pashtoKor + : []; + } + if (state.options.language === "English") { + return state.searchValue === "tired" + ? mockResults.englishTired as T.DictionaryEntry[] + : []; + } + return []; + }, + getNewWordsThisMonth: function(): T.DictionaryEntry[] { + return []; + }, + findOneByTs: function(ts: number): T.DictionaryEntry | undefined { + return allMockEntries.find((entry) => entry.ts === ts); + }, + findRelatedEntries: function(entry: T.DictionaryEntry): T.DictionaryEntry[] { + // TODO: Better mock + return allMockEntries.filter((e) => e.e.includes("house")); + }, + exactPashtoSearch: function(search: string ): T.DictionaryEntry[] { + return []; + }, +}; + +const dictionaryPublishResponse = "dictionary published"; + +beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(dictionary, "initialize").mockImplementation(() => Promise.resolve("loaded from saved")); + jest.spyOn(dictionary, "search").mockImplementation(fakeDictionary.search); + jest.spyOn(dictionary, "findOneByTs").mockImplementation(fakeDictionary.findOneByTs); + jest.spyOn(dictionary, "findRelatedEntries").mockImplementation(fakeDictionary.findRelatedEntries); + jest.spyOn(dictionary, "exactPashtoSearch").mockImplementation(fakeDictionary.exactPashtoSearch); + loadUserInfo.mockResolvedValue(undefined); + // fetchSuggestions.mockResolvedValue({ ok: true, suggestions: [] }); + upgradeAccount.mockImplementation(async (password: string): Promise => { + if (password === "correct password") { + return { ok: true, message: "user upgraded to student" }; + } + return { + ok: false, + error: "incorrect password", + }; + }); + publishDictionary.mockResolvedValue(dictionaryPublishResponse); + localStorage.clear(); + // indexedDB = new FDBFactory(); +}); + +// TODO: feed it a fake mini dictionary through JSON - to get more realistic testing +// don't mock the dictionary object + +test('renders loading', async () => { + jest.spyOn(dictionary, "initialize").mockImplementation(() => Promise.resolve("loaded from saved")); + render(); + const text = screen.getByText(/loading/i); + expect(text).toBeInTheDocument(); + await waitFor(() => screen.getByText(/lingdocs pashto dictionary/i)); +}); + +test('renders error loading', async () => { + jest.spyOn(dictionary, "initialize").mockImplementation(() => Promise.reject()); + + render(); + await waitFor(() => screen.getByText(/error loading/i)); +}); + +test('renders dictionary loaded', async () => { + render(); + await waitFor(() => screen.getByText(/lingdocs pashto dictionary/i)); +}); + +test('searches on type', async () => { + const history = createMemoryHistory(); + render(); + await waitFor(() => screen.getByText(/lingdocs pashto dictionary/i)); + // Search Pashto + let searchInput = screen.getByPlaceholderText(/search pashto/i); + userEvent.type(searchInput, "کور"); + mockResults.pashtoKor.slice(0, 10).forEach((result) => { + expect(screen.getAllByText(result.e)[0]).toBeInTheDocument(); + expect(screen.getAllByText(result.p)[0]).toBeInTheDocument(); + expect(screen.getAllByText(result.f)[0]).toBeInTheDocument(); + }); + expect(history.location.pathname).toBe("/search"); + // Clear + userEvent.type(searchInput, "{backspace}{backspace}{backspace}"); + mockResults.pashtoKor.slice(0, 10).forEach((result) => { + expect(screen.queryByText(result.e)).toBeNull(); + expect(screen.queryByText(result.p)).toBeNull(); + expect(screen.queryByText(result.f)).toBeNull(); + }); + expect(history.location.pathname).toBe("/"); + // Switch To English + const languageToggle = screen.getByTestId("languageToggle"); + userEvent.click(languageToggle); + expect(screen.queryByPlaceholderText(/search pashto/i)).toBeNull(); + searchInput = screen.getByPlaceholderText(/search english/i); + userEvent.type(searchInput, "tired"); + mockResults.englishTired.slice(0, 10).forEach((result) => { + expect(screen.getAllByText(result.e)[0]).toBeInTheDocument(); + expect(screen.getAllByText(result.p)[0]).toBeInTheDocument(); + expect(screen.getAllByText(result.f)[0]).toBeInTheDocument(); + }); + expect(history.location.pathname).toBe("/search"); + // Clear + const clearButton = screen.getByTestId("clearButton"); + userEvent.click(clearButton); + mockResults.englishTired.slice(0, 10).forEach((result) => { + expect(screen.queryByText(result.e)).toBeNull(); + expect(screen.queryByText(result.p)).toBeNull(); + expect(screen.queryByText(result.f)).toBeNull(); + }); + // Search again + userEvent.type(searchInput, "tired"); + mockResults.englishTired.slice(0, 10).forEach((result) => { + expect(screen.getAllByText(result.e)[0]).toBeInTheDocument(); + expect(screen.getAllByText(result.p)[0]).toBeInTheDocument(); + expect(screen.getAllByText(result.f)[0]).toBeInTheDocument(); + }); + // Go back + history.goBack(); + expect(history.location.pathname).toBe("/"); +}); + +test('does alphabetical browse search', async () => { + render(); + await waitFor(() => screen.getByText(/lingdocs pashto dictionary/i)); + expect(screen.queryByText(/alphabetical browsing mode/i)).toBeNull(); + const searchTypeButton = screen.getByTestId("searchTypeToggle"); + userEvent.click(searchTypeButton); + expect(screen.queryByText(/alphabetical browsing mode/i)).toBeInTheDocument(); + const searchInput = screen.getByPlaceholderText(/browse/i); + userEvent.type(searchInput, "ا"); + mockResults.alphabeticalA.forEach((entry) => { + expect(screen.queryAllByText(entry.e)).toBeTruthy; + }); + userEvent.type(searchInput, "{backspace}"); + userEvent.type(searchInput, "ززززز"); + expect(screen.queryByText(/no results found/i)).toBeInTheDocument(); + expect(screen.queryByText(/You are using alphabetical browsing mode/i)).toBeInTheDocument(); +}); + +test('isolates word on click', async () => { + const history = createMemoryHistory(); + render(); + await waitFor(() => screen.getByText(/lingdocs pashto dictionary/i)); + let searchInput = screen.getByPlaceholderText(/search pashto/i); + userEvent.type(searchInput, "کور"); + expect(history.location.pathname).toBe("/search"); + const firstResult = screen.getByText(mockResults.pashtoKor[0].e); + userEvent.click(firstResult); + expect(screen.getByText(/related words/i)).toBeInTheDocument(); + expect(history.location.pathname).toBe("/word"); + const params = new URLSearchParams(history.location.search); + const wordId = params.get("id"); + expect(wordId && parseInt(wordId)).toBe(mockResults.pashtoKor[0].ts); + // should leave word when going back + history.goBack(); + expect(history.location.pathname).toBe("/search"); + // go back to word when going forward + history.goForward(); + expect(history.location.pathname).toBe("/word"); + expect(screen.getByText(/related words/i)).toBeInTheDocument(); + // leave word when clearing + const clearButton = screen.getByTestId("clearButton"); + userEvent.click(clearButton); + expect(history.location.pathname).toBe("/") + expect(screen.queryByText(/related words/i)).toBeNull(); + userEvent.type(searchInput, "کور"); + expect(history.location.pathname).toBe("/search"); + const firstResultb = screen.getByText(mockResults.pashtoKor[0].e); + userEvent.click(firstResultb); + expect(history.location.pathname).toBe("/word"); + // leave word when searching + const input = screen.getByTestId("searchInput"); + userEvent.type(input, "سړی"); + expect(history.location.pathname).toBe("/search"); + expect(screen.queryByText(/related words/i)).toBeNull(); + expect(screen.queryByText(/no results found/i)).toBeTruthy(); + const clearButton1 = screen.getByTestId("clearButton"); + userEvent.click(clearButton1); + expect(history.location.pathname).toBe("/"); + // search click on a word again + userEvent.type(searchInput, "کور"); + expect(history.location.pathname).toBe("/search"); + const firstResultc = screen.getByText(mockResults.pashtoKor[0].e); + userEvent.click(firstResultc); + expect(history.location.pathname).toBe("/word") + expect(screen.getByText(/related words/i)).toBeInTheDocument(); + expect(history.location.search).toBe(`?id=${mockResults.pashtoKor[0].ts}`); + const relatedEntry = mockResults.pashtoKor.filter((entry) => entry.e.includes("house"))[1] as T.DictionaryEntry; + const otherResult = screen.getByText(relatedEntry.p); + userEvent.click(otherResult); + expect(history.location.pathname).toBe(`/word`); + expect(history.location.search).toBe(`?id=${relatedEntry.ts}`); + // search for a word that uses a complement + userEvent.click(clearButton1); + const languageToggle = screen.getByTestId("languageToggle"); + userEvent.click(languageToggle); + userEvent.type(searchInput, "tired"); + const resultWComplement = mockResults.englishTired.find((entry) => entry.c.includes(" comp.") && entry.l) as T.DictionaryEntry; + userEvent.click(screen.getByText(resultWComplement.e)); + expect(history.location.pathname).toBe(`/word`); + expect(history.location.search).toBe(`?id=${resultWComplement.ts}`); + expect(screen.queryByText(resultWComplement.e)).toBeInTheDocument(); +}); + +test('shows about page', async () => { + render(); + await waitFor(() => screen.getByText(/lingdocs pashto dictionary/i)); + const aboutButton = screen.getByText(/about/i); + userEvent.click(aboutButton); + expect(screen.queryByText(/inspiration and sources/i)).toBeInTheDocument(); + const homeButton = screen.getByText(/home/i); + userEvent.click(homeButton); + expect(screen.queryByText(/inspiration and sources/i)).toBeNull(); +}); + +test('starts on about page when starting from /about', async () => { + const history = createMemoryHistory(); + history.push("/about"); + render(); + await waitFor(() => screen.getAllByText(/about/i)); + expect(screen.queryByText(/inspiration and sources/i)).toBeInTheDocument(); + const homeButton = screen.getByText(/home/i); + userEvent.click(homeButton); + expect(screen.queryByText(/inspiration and sources/i)).toBeNull(); +}); + +test('shows settings page / settings page works', async () => { + render(); + await waitFor(() => screen.getByText(/lingdocs pashto dictionary/i)); + const settingsButton = screen.getAllByText(/settings/i)[0]; + userEvent.click(settingsButton); + expect(screen.queryByText(/diacritics/i)).toBeInTheDocument(); + const homeButton = screen.getByText(/home/i); + userEvent.click(homeButton); + expect(screen.queryByText(/diacritics/i)).toBeNull(); + // play with settings + const settingsButton1 = screen.getAllByText(/settings/i)[0]; + userEvent.click(settingsButton1); + const darkButton = screen.getByText(/dark/i); + userEvent.click(darkButton); + const lightButton = screen.getByText(/light/i); + userEvent.click(lightButton); +}); + +test('starts on settings page when starting from /settings', async () => { + const history = createMemoryHistory(); + history.push("/settings"); + render(); + await waitFor(() => screen.getAllByText(/settings/i)); + expect(screen.queryByText(/diacritics/i)).toBeInTheDocument(); + const homeButton = screen.getByText(/home/i); + userEvent.click(homeButton); + expect(screen.queryByText(/diacritics/i)).toBeNull(); +}); + +test('persists settings', async () => { + const history = createMemoryHistory(); + history.push("/settings"); + const { unmount, rerender } = render(); + await waitFor(() => screen.getAllByText(/settings/i)); + const darkButton = screen.getByText(/dark/i); + const lightButton = screen.getByText(/light/i); + expect(darkButton.className.toString().includes("active")).toBe(false); + expect(lightButton.className.toString().includes("active")).toBe(true); + userEvent.click(darkButton); + expect(darkButton.className.toString().includes("active")).toBe(true); + expect(lightButton.className.toString().includes("active")).toBe(false); + const afghanSp = screen.getByText(/afghan/i); + const pakSp = screen.getByText(/pakistani ی/i); + expect(afghanSp.className.toString().includes("active")).toBe(true); + expect(pakSp.className.toString().includes("active")).toBe(false); + userEvent.click(pakSp); + expect(afghanSp.className.toString().includes("active")).toBe(false); + expect(pakSp.className.toString().includes("active")).toBe(true); + unmount(); + rerender(); + await waitFor(() => screen.getAllByText(/settings/i)); + const afghanSp1 = screen.getByText(/afghan/i); + const pakSp1 = screen.getByText(/pakistani ی/i); + const darkButton1 = screen.getByText(/dark/i); + const lightButton1 = screen.getByText(/light/i); + expect(darkButton1.className.toString().includes("active")).toBe(true); + expect(lightButton1.className.toString().includes("active")).toBe(false); + expect(afghanSp1.className.toString().includes("active")).toBe(false); + expect(pakSp1.className.toString().includes("active")).toBe(true); +}); + +test('starts on home page when starting on invalid page', async () => { + const history = createMemoryHistory(); + history.push("/search"); + render(); + await waitFor(() => screen.getAllByText(/lingdocs pashto dictionary/i)); + expect(history.location.pathname).toBe("/"); +}); + +test('starts on home page when starting on an unauthorized page', async () => { + const history = createMemoryHistory(); + history.push("/edits"); + render(); + await waitFor(() => screen.getAllByText(/lingdocs pashto dictionary/i)); + expect(history.location.pathname).toBe("/"); +}); + +test('starts on isolated word when starting from /word?id=_____', async () => { + const history = createMemoryHistory(); + const entry = mockResults.pashtoKor[0]; + history.push(`/word?id=${entry.ts}`); + render(); + await waitFor(() => screen.getAllByText(/related words/i)); + expect(screen.queryAllByText(entry.p)).toBeTruthy(); +}); + +test('says word not found if starting on /word?id=_____ with an unfound id', async () => { + const history = createMemoryHistory(); + const entry = mockResults.pashtoKor[0]; + history.push(`/word?id=${entry.ts + 20000}`); + render(); + await waitFor(() => screen.getAllByText(/word not found/i)); +}); + +test('goes to home page if starts with /word but without an id param', async () => { + const history = createMemoryHistory(); + const entry = mockResults.pashtoKor[0]; + history.push(`/word?badparam=${entry.ts}`); + render(); + await waitFor(() => screen.getAllByText(/lingdocs pashto dictionary/i)); + expect(history.location.pathname).toBe("/"); +}); + +test('sign in and out of account works', async () => { + const history = createMemoryHistory(); + render(); + await waitFor(() => screen.getByText(/lingdocs pashto dictionary/i)); + userEvent.click(screen.getByText(/sign in/i)); + expect(screen.queryByText(/sign in to be able to/i)).toBeInTheDocument(); + userEvent.click(screen.getByTestId("mockSignInButton")); + expect(screen.queryByText(new RegExp(mockUserInfo.email))).toBeInTheDocument(); + expect(screen.queryByText(new RegExp(mockUserInfo.displayName))).toBeInTheDocument(); + userEvent.click(screen.getByText(/home/i)); + // now to get back to the account page there should be an account button, not a sign-in button + expect(screen.queryByText(/sign in/i)).toBeNull(); + userEvent.click(screen.getByText(/account/i)); + userEvent.click(screen.getByTestId("signoutButton")); + expect(history.location.pathname).toBe("/"); + expect(screen.getByText(/sign in/i)).toBeInTheDocument(); + // sign back in and delete account + userEvent.click(screen.getByText(/sign in/i)); + userEvent.click(screen.getByTestId("mockSignInButton")); + userEvent.click(screen.getByText(/delete account/i)); + expect(screen.queryByText(/yes, delete my account/i)).toBeInTheDocument(); + userEvent.click(screen.getByText(/no, cancel/i)); + await waitForElementToBeRemoved(() => screen.queryByText(/yes, delete my account/i)); + userEvent.click(screen.getByText(/delete account/i)); + userEvent.click(screen.getByText(/yes, delete my account/i)); + await waitFor(() => screen.queryByText(/Your account has been deleted/i)); + expect(history.location.pathname).toBe("/account"); + userEvent.click(screen.getAllByText(/home/i)[0]); + expect(history.location.pathname).toBe("/"); +}); + +test('word edit suggestion works', async () => { + const history = createMemoryHistory(); + render(); + await waitFor(() => screen.getByText(/lingdocs pashto dictionary/i)); + // first try without signing in + expect(screen.getByText(/sign in/i)).toBeInTheDocument(); + let searchInput = screen.getByPlaceholderText(/search pashto/i); + userEvent.type(searchInput, "کور"); + expect(history.location.pathname).toBe("/search"); + let firstResult = screen.getByText(mockResults.pashtoKor[0].e); + userEvent.click(firstResult); + expect(screen.getByText(/related words/i)).toBeInTheDocument(); + // the edit button should not be there + expect(screen.queryByTestId(/editEntryButton/i)).toBeNull(); + // nor should the finalEdit button + expect(screen.queryByTestId(/finalEditEntryButton/i)).toBeNull(); + // sign in to be able to suggest an edit + history.goBack(); + history.goBack(); + userEvent.click(screen.getByText(/sign in/i)); + userEvent.click(screen.getByTestId("mockSignInButton")); + expect(sendSubmissions).toHaveBeenCalledTimes(1); + userEvent.click(screen.getByText(/home/i)); + userEvent.type(searchInput, "کور"); + firstResult = screen.getByText(mockResults.pashtoKor[0].e); + userEvent.click(firstResult); + // the final edit button should not be there + expect(screen.queryByTestId(/finalEditEntryButton/i)).toBeNull(); + userEvent.click(screen.getByTestId(/editEntryButton/i)); + userEvent.type(screen.getByLabelText(/Suggest correction\/edit:/i), "my suggestion"); + userEvent.click(screen.getByText(/cancel/i)); + expect(screen.queryByLabelText(/Suggest correction\/edit:/i)).toBeNull(); + userEvent.click(screen.getByTestId(/editEntryButton/i)); + userEvent.type(screen.getByLabelText(/Suggest correction\/edit:/i), "my comment"); + userEvent.click(screen.getByText(/submit/i)); + expect(screen.queryByText(/Thank you for your help!/i)).toBeInTheDocument(); + expect(addSubmission).toHaveBeenCalledTimes(1); + expect(addSubmission).toHaveBeenCalledWith(expect.objectContaining({ + entry: mockResults.pashtoKor[0], + comment: "my comment", + }), "basic"); + history.goBack(); + history.goBack(); + userEvent.click(screen.getByText(/account/i)); + userEvent.click(screen.getByText(/sign out/i)); +}); + +test('upgrade account works', async () => { + const history = createMemoryHistory(); + render(); + await waitFor(() => screen.getByText(/lingdocs pashto dictionary/i)); + userEvent.click(screen.getByText(/sign in/i)); + expect(screen.queryByText(/sign in to be able to/i)).toBeInTheDocument(); + userEvent.click(screen.getByTestId("mockSignInButton")); + expect(screen.queryByText(new RegExp(mockUserInfo.email))).toBeInTheDocument(); + expect(screen.queryByText(new RegExp(mockUserInfo.displayName))).toBeInTheDocument(); + expect(screen.queryByText(/level: basic/i)).toBeInTheDocument(); + userEvent.click(screen.getByText(/upgrade account/i)); + userEvent.type(screen.getByLabelText(/upgrade password:/i), "something wrong"); + userEvent.click(screen.getByText(/upgrade my account/i)); + await waitFor(() => screen.queryByText(/incorrect password/i)); + userEvent.click(screen.getByText(/cancel/i)); + await waitFor(() => screen.getByText(/upgrade account/i)); + userEvent.click(screen.getByText(/upgrade account/i)); + userEvent.type(screen.getByLabelText(/upgrade password:/i), "correct password"); + loadUserInfo.mockResolvedValue(mockCouchDbStudent); + userEvent.click(screen.getByText(/upgrade my account/i)); + await waitForElementToBeRemoved(() => screen.getAllByText(/upgrade account/i)); + userEvent.click(screen.getByText(/sign out/i)); +}); + +test('editor priveledges show up and allow you to make a final edit of an entry', async () => { + loadUserInfo.mockResolvedValue(mockCouchDbEditor); + const history = createMemoryHistory(); + render(); + await waitFor(() => screen.getByText(/lingdocs pashto dictionary/i)); + userEvent.click(screen.getByText(/sign in/i)); + userEvent.click(screen.getByTestId("mockSignInButton")); + await waitFor(() => screen.getByText(/account level: editor/i)); + expect(sendSubmissions).toHaveBeenCalledTimes(1); + userEvent.click(screen.getByText(/home/i)); + expect(screen.getByText(/editor priveleges active/i)).toBeInTheDocument() + let searchInput = screen.getByPlaceholderText(/search pashto/i); + userEvent.type(searchInput, "کور"); + expect(history.location.pathname).toBe("/search"); + let firstResult = screen.getByText(mockResults.pashtoKor[0].e); + userEvent.click(firstResult); + expect(screen.getByText(/related words/i)).toBeInTheDocument(); + // the edit button should be there + expect(screen.getByTestId("editEntryButton")).toBeInTheDocument(); + // the final edit button should also be there + expect(screen.getByTestId("finalEditEntryButton")).toBeInTheDocument(); + userEvent.click(screen.getByTestId("finalEditEntryButton")); + userEvent.type(screen.getByLabelText(/english/i), " adding more in english"); + userEvent.click(screen.getByLabelText(/no inflection/i)); + userEvent.click(screen.getByText(/submit/i)); + expect(screen.getByText(/edit submitted\/saved/i)).toBeInTheDocument(); + expect(addSubmission).toHaveBeenCalledTimes(1); + expect(addSubmission).toHaveBeenCalledWith(expect.objectContaining({ + type: "entry edit", + entry: { + ...mockResults.pashtoKor[0], + e: mockResults.pashtoKor[0].e + " adding more in english", + noInf: true, + }, + }), "editor"); + userEvent.click(screen.getByTestId(/navItemHome/i)); + userEvent.click(screen.getByText(/account/i)); + userEvent.click(screen.getByText(/sign out/i)); +}); + +test('editor should be able to publish the dictionary', async () => { + loadUserInfo.mockResolvedValue(undefined); + const history = createMemoryHistory(); + render(); + await waitFor(() => screen.getByText(/lingdocs pashto dictionary/i)); + userEvent.click(screen.getByText(/sign in/i)); + userEvent.click(screen.getByTestId("mockSignInButton")); + await waitFor(() => screen.getByText(/account level: basic/i)); + // publish dictionary option should not be available to non editor + expect(screen.queryByText(/publish dictionary/i)).toBeNull(); + userEvent.click(screen.getByText(/sign out/i)); + userEvent.click(screen.getByText(/sign in/i)); + loadUserInfo.mockResolvedValue(mockCouchDbStudent); + userEvent.click(screen.getByTestId("mockSignInButton")); + await waitFor(() => screen.getByText(/account level: student/i)); + // publish dictionary option should not be available to non editor + expect(screen.queryByText(/publish dictionary/i)).toBeNull(); + userEvent.click(screen.getByText(/sign out/i)); + userEvent.click(screen.getByText(/sign in/i)); + loadUserInfo.mockResolvedValue(mockCouchDbEditor); + userEvent.click(screen.getByTestId("mockSignInButton")); + await waitFor(() => screen.getByText(/account level: editor/i)); + // publish dictionary options should only be available to editor + userEvent.click(screen.getByText(/publish dictionary/i)); + expect(screen.getByText(/processing\.\.\./i)).toBeInTheDocument(); + await waitFor(() => screen.getByText(JSON.stringify(dictionaryPublishResponse, null, "\\t"))); + userEvent.click(screen.getByText(/sign out/i)); +}); + +test('wordlist should be hidden from basic users and available for upgraded users', async () => { + loadUserInfo.mockResolvedValue(undefined); + const history = createMemoryHistory(); + render(); + await waitFor(() => screen.getByText(/lingdocs pashto dictionary/i)); + // doesn't exist on basic accounts signed in or not + expect(screen.queryByText(/wordlist/i)).toBeNull(); + userEvent.click(screen.getByText(/sign in/i)); + userEvent.click(screen.getByTestId("mockSignInButton")); + await waitFor(() => screen.queryByText(mockUserInfo.displayName)); + userEvent.click(screen.getByText(/home/i)); + expect(screen.queryByText(/wordlist/i)).toBeNull(); + userEvent.type(screen.getByPlaceholderText(/search pashto/i), "کور"); + expect(history.location.pathname).toBe("/search"); + userEvent.click(screen.getByText(mockResults.pashtoKor[0].e)); + expect(screen.getByText(/related words/i)).toBeInTheDocument(); + // shouldn't be able to see the add to wordlist star + expect(screen.queryByTestId("emptyStarButton")).toBeNull(); + expect(screen.queryByTestId("fullStarButton")).toBeNull(); + history.goBack(); + history.goBack(); + userEvent.click(screen.getByText(/account/i)); + userEvent.click(screen.getByText(/sign out/i)); + loadUserInfo.mockResolvedValue(mockCouchDbStudent); + // does exist for student account + userEvent.click(screen.getByText(/sign in/i)); + userEvent.click(screen.getByTestId("mockSignInButton")); + await waitFor(() => screen.getByText(/level: student/i)); + userEvent.click(screen.getByText(/home/i)); + expect(screen.getByText(/wordlist/i)).toBeInTheDocument(); + userEvent.type(screen.getByPlaceholderText(/search pashto/i), "کور"); + expect(history.location.pathname).toBe("/search"); + userEvent.click(screen.getByText(mockResults.pashtoKor[0].e)); + expect(screen.getByText(/related words/i)).toBeInTheDocument(); + // should be able to see the word list star + expect(screen.queryByTestId("emptyStarButton")).toBeInTheDocument(); + history.goBack(); + history.goBack(); + userEvent.click(screen.getByText(/account/i)); + userEvent.click(screen.getByText(/sign out/i)); + loadUserInfo.mockResolvedValue(mockCouchDbEditor); + // also exists for editor account + userEvent.click(screen.getByText(/sign in/i)); + userEvent.click(screen.getByTestId("mockSignInButton")); + await waitFor(() => screen.getByText(/level: editor/i)); + userEvent.click(screen.getByText(/home/i)); + expect(screen.getByText(/wordlist/i)).toBeInTheDocument(); + userEvent.type(screen.getByPlaceholderText(/search pashto/i), "کور"); + expect(history.location.pathname).toBe("/search"); + userEvent.click(screen.getByText(mockResults.pashtoKor[0].e)); + expect(screen.getByText(/related words/i)).toBeInTheDocument(); + expect(screen.getByTestId("emptyStarButton")).toBeInTheDocument(); + history.goBack(); + history.goBack(); + userEvent.click(screen.getByText(/account/i)); + userEvent.click(screen.getByText(/sign out/i)); +}); + +// test('wordlist adding and removing should work', async () => { +// const wordNotes = "my test notes"; +// const noteAddition = " and some more"; +// const wordToAdd = mockResults.pashtoKor[0]; +// loadUserInfo.mockResolvedValue(mockCouchDbStudent); +// const history = createMemoryHistory(); +// render(); +// await waitFor(() => screen.getByText(/lingdocs pashto dictionary/i)); +// userEvent.click(screen.getByText(/sign in/i)); +// userEvent.click(screen.getByTestId("mockSignInButton")); +// await waitFor(() => screen.getByText(/level: student/i)); +// userEvent.click(screen.getByText(/home/i)); +// expect(screen.getByText(/wordlist/i)).toBeInTheDocument(); +// userEvent.type(screen.getByPlaceholderText(/search pashto/i), "کور"); +// expect(history.location.pathname).toBe("/search"); +// userEvent.click(screen.getByText(wordToAdd.e)); +// // should be able to see the word list star +// expect(screen.getByTestId("emptyStarButton")).toBeInTheDocument(); +// userEvent.click(screen.getByTestId("emptyStarButton")); +// await waitFor(() => screen.getByTestId("fullStarButton")); +// userEvent.type(screen.getByTestId("wordlistWordContextForm"), wordNotes); +// userEvent.click(screen.getByText(/save context/i)); +// userEvent.click(screen.getByTestId("backButton")); +// userEvent.click(screen.getByTestId("backButton")); +// // should have one word in wordlist for review +// userEvent.click(screen.getByText("Wordlist (1)")); +// // should appear on screen with notes +// userEvent.click(screen.getByText(/browse/i)); +// expect(screen.getByText(wordNotes)).toBeInTheDocument(); +// // notes should be editable +// userEvent.click(screen.getByText(wordToAdd.e)); +// userEvent.type(screen.getByText(wordNotes), noteAddition); +// userEvent.click(screen.getByText(/save context/i)); +// await waitFor(() => screen.getByText(/context saved/i)); +// userEvent.click(screen.getByText(wordToAdd.e)); +// expect(screen.queryByText(/context saved/)).toBeNull(); +// expect(screen.getByText(wordNotes + noteAddition)).toBeInTheDocument(); +// // should be able to delete from the browsing screen +// userEvent.click(screen.getByText(wordToAdd.e)); +// userEvent.click(screen.getByText(/delete/i)); +// await waitForElementToBeRemoved(() => screen.getByText(wordToAdd.e)); +// userEvent.click(screen.getByText(/home/i)); +// // now try adding and deleting a word from the isolated word screen +// userEvent.type(screen.getByPlaceholderText(/search pashto/i), "کور"); +// expect(history.location.pathname).toBe("/search"); +// userEvent.click(screen.getByText(wordToAdd.e)); +// expect(screen.getByTestId("emptyStarButton")).toBeInTheDocument(); +// userEvent.click(screen.getByTestId("emptyStarButton")); +// await waitFor(() => screen.getByTestId("fullStarButton")); +// userEvent.click(screen.getByTestId("backButton")); +// userEvent.click(screen.getByTestId("backButton")); +// userEvent.click(screen.getByText(/wordlist.*/i)); +// userEvent.click(screen.getByText(/browse/i)); +// // go back to isolated word screen from the dictionary entry button +// userEvent.click(screen.getByText(wordToAdd.e)); +// userEvent.click(screen.getByText(/dictionary entry/i)); +// expect(screen.getByText(/related words/i)).toBeInTheDocument(); +// expect(history.location.pathname).toBe("/word"); +// // delete the word from the wordlist from the isolated word screen +// userEvent.click(screen.getByTestId("fullStarButton")); +// userEvent.click(screen.getByText(/cancel/i)); +// userEvent.click(screen.getByTestId("fullStarButton")); +// userEvent.click(screen.getByTestId("confirmDeleteFromWordlist")); +// await waitFor(() => screen.getByTestId("emptyStarButton")); +// userEvent.click(screen.getByTestId("backButton")); +// expect(screen.queryByText(/wordlist is empty/i)).toBeInTheDocument(); +// }); + +// TODO: REMOVE waitFor(() => screen.//queryByText// ) + +// TODO: Test review diff --git a/website/src/App.tsx b/website/src/App.tsx new file mode 100644 index 0000000..6a80933 --- /dev/null +++ b/website/src/App.tsx @@ -0,0 +1,570 @@ +/** + * Copyright (c) 2021 lingdocs.com + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { Component } from "react"; +import { defaultTextOptions } from "@lingdocs/pashto-inflector"; +import { withRouter, Route, RouteComponentProps, Link } from "react-router-dom"; +import Helmet from "react-helmet"; +import BottomNavItem from "./components/BottomNavItem"; +import SearchBar from "./components/SearchBar"; +import DictionaryStatusDisplay from "./components/DictionaryStatusDisplay"; +import About from "./screens/About"; +import Options from "./screens/Options"; +import Results from "./screens/Results"; +import Account from "./screens/Account"; +import ReviewTasks from "./screens/ReviewTasks"; +import EntryEditor from "./screens/EntryEditor"; +import IsolatedEntry from "./screens/IsolatedEntry"; +import Wordlist from "./screens/Wordlist"; +import { saveOptions, readOptions } from "./lib/options-storage"; +import { dictionary, pageSize } from "./lib/dictionary"; +import optionsReducer from "./lib/options-reducer"; +import hitBottom from "./lib/hitBottom"; +import getWordId from "./lib/get-word-id"; +import { auth } from "./lib/firebase"; +import { CronJob } from "cron"; +import Mousetrap from "mousetrap"; +import { + sendSubmissions, +} from "./lib/submissions"; +import { + loadUserInfo, +} from "./lib/backend-calls"; +import * as BT from "./lib/backend-types"; +import { + getWordlist, +} from "./lib/wordlist-database"; +import { + wordlistEnabled, +} from "./lib/level-management"; +import { + deInitializeLocalDb, + initializeLocalDb, + startLocalDbSync, + getLocalDbName, + getAllDocsLocalDb, +} from "./lib/pouch-dbs"; +import { + forReview, +} from "./lib/spaced-repetition"; +import { + textBadge, +} from "./lib/badges"; +import ReactGA from "react-ga"; +// tslint:disable-next-line +import "@fortawesome/fontawesome-free/css/all.css"; +import "./custom-bootstrap.scss"; +// tslint:disable-next-line: ordered-imports +import "./App.css"; +import classNames from "classnames"; + +// to allow Moustrap key combos even when input fields are in focus +Mousetrap.prototype.stopCallback = function () { + return false; +} + +const prod = document.location.hostname === "dictionary.lingdocs.com"; + +if (prod) { + ReactGA.initialize("UA-196576671-1"); + ReactGA.set({ anonymizeIp: true }); +} + +const possibleLandingPages = [ + "/", "/about", "/settings", "/word", "/account", "/new-entries", +]; +const editorOnlyPages = [ + "/edit", "/review-tasks", +]; + +class App extends Component { + constructor(props: RouteComponentProps) { + super(props); + const savedOptions = readOptions(); + this.state = { + dictionaryStatus: "loading", + dictionaryInfo: undefined, + options: savedOptions ? savedOptions : { + language: "Pashto", + searchType: "fuzzy", + theme: /* istanbul ignore next */ (window.matchMedia && + window.matchMedia("(prefers-color-scheme: dark)").matches) ? "dark" : "light", + textOptions: defaultTextOptions, + level: "basic", + wordlistMode: "browse", + wordlistReviewLanguage: "Pashto", + wordlistReviewBadge: true, + searchBarPosition: "top", + }, + searchValue: "", + page: 1, + isolatedEntry: undefined, + results: [], + wordlist: [], + reviewTasks: [], + }; + this.handleOptionsUpdate = this.handleOptionsUpdate.bind(this); + this.handleSearchValueChange = this.handleSearchValueChange.bind(this); + this.handleIsolateEntry = this.handleIsolateEntry.bind(this); + this.handleScroll = this.handleScroll.bind(this); + this.handleGoBack = this.handleGoBack.bind(this); + this.handleLoadUserInfo = this.handleLoadUserInfo.bind(this); + this.handleRefreshWordlist = this.handleRefreshWordlist.bind(this); + this.handleRefreshReviewTasks = this.handleRefreshReviewTasks.bind(this); + this.handleDictionaryUpdate = this.handleDictionaryUpdate.bind(this); + } + + public componentDidMount() { + window.addEventListener("scroll", this.handleScroll); + if (!possibleLandingPages.includes(this.props.location.pathname)) { + this.props.history.replace("/"); + } + if (prod && (this.state.options.level !== "editor")) { + ReactGA.pageview(window.location.pathname + window.location.search); + } + dictionary.initialize().then((r) => { + this.setState({ + dictionaryStatus: "ready", + dictionaryInfo: r.dictionaryInfo, + }); + // incase it took forever and timed out - might need to reinitialize the wordlist here ?? + if (wordlistEnabled(this.state)) { + initializeLocalDb("wordlist", this.handleRefreshWordlist, auth.currentUser ? auth.currentUser.uid : undefined); + } + if (this.state.options.level === "editor") { + initializeLocalDb("reviewTasks", this.handleRefreshReviewTasks); + } + if (this.props.location.pathname === "/word") { + const wordId = getWordId(this.props.location.search); + if (wordId) { + const word = dictionary.findOneByTs(wordId); + if (word) { + this.setState({ searchValue: word.p }); + } + this.handleIsolateEntry(wordId); + } else { + // TODO: Make a word not found screen + console.error("somehow had a word path without a word id param"); + this.props.history.replace("/"); + } + } + if (this.props.location.pathname === "/new-entries") { + this.setState({ + results: dictionary.getNewWordsThisMonth(), + page: 1, + }); + } + if (r.response === "loaded from saved") { + this.handleDictionaryUpdate(); + } + }).catch((error) => { + console.error(error); + this.setState({ dictionaryStatus: "error loading" }); + }); + document.documentElement.setAttribute("data-theme", this.state.options.theme); + /* istanbul ignore if */ + if (window.matchMedia) { + const prefersDarkQuery = window.matchMedia("(prefers-color-scheme: dark)"); + prefersDarkQuery.addListener((e) => { + if (e.matches) { + this.handleOptionsUpdate({ type: "changeTheme", payload: "dark" }); + } + }); + const prefersLightQuery = window.matchMedia("(prefers-color-scheme: light)"); + prefersLightQuery.addListener((e) => { + if (e.matches) { + this.handleOptionsUpdate({ type: "changeTheme", payload: "light" }); + } + }); + } + this.unregisterAuthObserver = auth.onAuthStateChanged((user) => { + if (user) { + if (wordlistEnabled(this.state)) { + initializeLocalDb("wordlist", this.handleRefreshWordlist, user.uid); + } + sendSubmissions(); + this.handleLoadUserInfo().catch(console.error); + this.networkCronJob.stop(); + this.networkCronJob.start(); + } else { + // signed out + this.networkCronJob.stop(); + if (this.wordlistSync) { + this.wordlistSync.cancel(); + this.wordlistSync = undefined; + } + if (this.reviewTastsSync) { + this.reviewTastsSync.cancel(); + this.reviewTastsSync = undefined; + } + deInitializeLocalDb("wordlist"); + deInitializeLocalDb("reviewTasks"); + this.handleOptionsUpdate({ type: "changeUserLevel", payload: "basic" }); + } + this.forceUpdate(); + }); + Mousetrap.bind(["ctrl+down", "ctrl+up", "command+down", "command+up"], (e) => { + if (e.repeat) return; + this.handleOptionsUpdate({ type: "toggleLanguage" }); + }); + Mousetrap.bind(["ctrl+b", "command+b"], (e) => { + if (e.repeat) return; + this.handleSearchValueChange(""); + }); + Mousetrap.bind(["ctrl+\\", "command+\\"], (e) => { + if (e.repeat) return; + if (this.state.options.level === "basic") return; + if (this.props.location.pathname !== "/wordlist") { + this.props.history.push("/wordlist"); + } else { + this.handleGoBack(); + } + }); + } + + public componentWillUnmount() { + window.removeEventListener("scroll", this.handleScroll); + this.unregisterAuthObserver(); + this.networkCronJob.stop(); + if (this.wordlistSync) { + this.wordlistSync.cancel(); + } + if (this.reviewTastsSync) { + this.reviewTastsSync.cancel(); + } + Mousetrap.unbind(["ctrl+down", "ctrl+up", "command+down", "command+up"]); + Mousetrap.unbind(["ctrl+b", "command+b"]); + Mousetrap.unbind(["ctrl+\\", "command+\\"]); + } + + public componentDidUpdate(prevProps: RouteComponentProps) { + if (this.props.location.pathname !== prevProps.location.pathname) { + if (prod && (this.state.options.level !== "editor")) { + ReactGA.pageview(window.location.pathname + window.location.search); + } + if (this.props.location.pathname === "/") { + this.handleSearchValueChange(""); + } + if (this.props.location.pathname === "/new-entries") { + this.setState({ + results: dictionary.getNewWordsThisMonth(), + page: 1, + }); + } + if (editorOnlyPages.includes(this.props.location.pathname) && this.state.options.level !== "editor") { + this.props.history.replace("/"); + } + } + if (getWordId(this.props.location.search) !== getWordId(prevProps.location.search)) { + if (prod && (this.state.options.level !== "editor")) { + ReactGA.pageview(window.location.pathname + window.location.search); + } + const wordId = getWordId(this.props.location.search); + /* istanbul ignore else */ + if (wordId) { + this.handleIsolateEntry(wordId, true); + } else { + this.setState({ isolatedEntry: undefined }) + } + } + // if (!["/wordlist", "/settings", "/review-tasks"].includes(this.props.location.pathname)) { + // window.scrollTo(0, 0); + // } + } + + private unregisterAuthObserver() { + // will be filled in on mount + } + + private wordlistSync: PouchDB.Replication.Sync | undefined = undefined; + private reviewTastsSync: PouchDB.Replication.Sync | undefined = undefined; + + private async handleLoadUserInfo(): Promise { + try { + const userInfo = await loadUserInfo(); + const differentUserInfoLevel = userInfo && (userInfo.level !== this.state.options.level); + const needToDowngrade = (!userInfo && wordlistEnabled(this.state)); + if (differentUserInfoLevel || needToDowngrade) { + this.handleOptionsUpdate({ + type: "changeUserLevel", + payload: userInfo ? userInfo.level : "basic", + }); + } + if (!userInfo) return undefined; + // only sync wordlist for upgraded accounts + if (userInfo && wordlistEnabled(userInfo.level)) { + // TODO: GO OVER THIS HORRENDOUS BLOCK + if (userInfo.level === "editor") { + initializeLocalDb("reviewTasks", this.handleRefreshReviewTasks); + if (!this.reviewTastsSync) { + this.reviewTastsSync = startLocalDbSync("reviewTasks", { name: userInfo.name, password: userInfo.userdbPassword }); + } + } + const wordlistName = getLocalDbName("wordlist") ?? ""; + const usersWordlistInitialized = wordlistName.includes(userInfo.name); + if (this.wordlistSync && usersWordlistInitialized) { + // sync already started for the correct db, don't start it again + return userInfo; + } + if (this.wordlistSync) { + this.wordlistSync.cancel(); + this.wordlistSync = undefined; + } + if (!usersWordlistInitialized) { + initializeLocalDb("wordlist", this.handleRefreshWordlist, userInfo.name); + } + this.wordlistSync = startLocalDbSync("wordlist", { name: userInfo.name, password: userInfo.userdbPassword }); + } + return userInfo; + } catch (err) { + console.error("error checking user level", err); + // don't downgrade the level if it's editor/studend and offline (can't check user info) + return undefined; + } + } + + private handleDictionaryUpdate() { + dictionary.update(() => { + this.setState({ dictionaryStatus: "updating" }); + }).then(({ dictionaryInfo }) => { + this.setState({ + dictionaryStatus: "ready", + dictionaryInfo, + }); + }).catch(() => { + this.setState({ dictionaryStatus: "error loading" }); + }); + } + + private handleOptionsUpdate(action: OptionsAction) { + if (action.type === "changeTheme") { + document.documentElement.setAttribute("data-theme", action.payload); + } + const options = optionsReducer(this.state.options, action); + saveOptions(options); + if (action.type === "toggleLanguage" || action.type === "toggleSearchType") { + if (this.props.location.pathname !== "/new-entries") { + this.setState(prevState => ({ + options, + page: 1, + results: dictionary.search({ ...prevState, options }), + })); + window.scrollTo(0, 0); + } else { + this.setState({ options }); + } + } else { + this.setState({ options }); + } + } + + private handleSearchValueChange(searchValue: string) { + if (this.state.dictionaryStatus !== "ready") return; + if (searchValue === "") { + this.setState({ + searchValue: "", + results: [], + page: 1, + }); + if (this.props.location.pathname !== "/") { + this.props.history.replace("/"); + } + return; + } + this.setState(prevState => ({ + searchValue, + results: dictionary.search({ ...prevState, searchValue }), + page: 1, + })); + if (this.props.history.location.pathname !== "/search") { + this.props.history.push("/search"); + } + window.scrollTo(0, 0); + } + + private handleIsolateEntry(ts: number, onlyState?: boolean) { + window.scrollTo(0, 0); + const isolatedEntry = dictionary.findOneByTs(ts); + if (!isolatedEntry) { + console.error("couldn't find word to isolate"); + return; + } + this.setState({ isolatedEntry }); + + if (!onlyState && (this.props.location.pathname !== "/word" || (getWordId(this.props.location.search) !== ts))) { + this.props.history.push(`/word?id=${isolatedEntry.ts}`); + } + } + + private networkCronJob = new CronJob("1/5 * * * *", () => { + // TODO: check for new dictionary (in a seperate cron job - not dependant on the user being signed in) + this.handleLoadUserInfo(); + sendSubmissions(); + this.handleDictionaryUpdate(); + }); + + /* istanbul ignore next */ + private handleScroll() { + if (hitBottom() && this.props.location.pathname === "/search" && this.state.results.length >= (pageSize * this.state.page)) { + const page = this.state.page + 1; + const moreResults = dictionary.search({ ...this.state, page }); + if (moreResults.length > this.state.results.length) { + this.setState({ + page, + results: moreResults, + }); + } + } + } + + private handleGoBack() { + this.props.history.goBack(); + window.scrollTo(0, 0); + } + + private handleRefreshWordlist() { + getWordlist().then((wordlist) => { + this.setState({ wordlist }); + }); + } + + private handleRefreshReviewTasks() { + getAllDocsLocalDb("reviewTasks").then((reviewTasks) => { + this.setState({ reviewTasks }); + }); + } + + render() { + return
+ + LingDocs Pashto Dictionary + + {this.state.options.searchBarPosition === "top" && } +
+ {this.state.dictionaryStatus !== "ready" ? + + : + <> + +
+

LingDocs Pashto Dictionary

+ {this.state.options.searchType === "alphabetical" &&
+
Alphabetical browsing mode
+
} + {this.state.options.level === "editor" &&
+
Editor priveleges active
+ + + +
} + +
New words this month
+ +
+
+ + + + + + + + + + +

New Words This Month

+ {this.state.results.length ? + + : +
No new words added this month 😓
+ } +
+ + { + this.props.history.replace("/"); + auth.signOut(); + })} /> + + + + + {wordlistEnabled(this.state) && + + } + {this.state.options.level === "editor" && + + } + {this.state.options.level === "editor" && + + } + + } +
+
+ +
+ + + + {wordlistEnabled(this.state) && + + } + {this.state.options.level === "editor" && + + } +
+
+ +
+ +
+
+ {this.state.options.searchBarPosition === "bottom" && } +
+
; + } +} + +export default withRouter(App); diff --git a/website/src/Context.ts b/website/src/Context.ts new file mode 100644 index 0000000..02d1593 --- /dev/null +++ b/website/src/Context.ts @@ -0,0 +1,12 @@ +/** + * Copyright (c) 2021 lingdocs.com + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { createContext } from "react"; + +// @ts-ignore +export default createContext>(null); \ No newline at end of file diff --git a/website/src/components/AudioPlayButton.tsx b/website/src/components/AudioPlayButton.tsx new file mode 100644 index 0000000..803cfaf --- /dev/null +++ b/website/src/components/AudioPlayButton.tsx @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2021 lingdocs.com + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { useEffect, useState } from "react"; +import { + getAudioAttachment, +} from "../lib/wordlist-database"; + +export function AudioPlayButton({ word }: { word: WordlistWord }) { + const [src, setSrc] = useState(undefined); + const [type, setType] = useState(undefined); + useEffect(() => { + getAudioAttachment(word).then((audio) => { + if (!audio) return; + const src = URL.createObjectURL(audio); + setSrc(src); + setType("type" in audio ? audio.type : undefined); + return () => { + URL.revokeObjectURL(src); + }; + }).catch(console.error); + }, [word]); + return ( +
+ +
+ ); +} + +export default AudioPlayButton; \ No newline at end of file diff --git a/website/src/components/BottomNavItem.tsx b/website/src/components/BottomNavItem.tsx new file mode 100644 index 0000000..ddbd7d9 --- /dev/null +++ b/website/src/components/BottomNavItem.tsx @@ -0,0 +1,41 @@ +/** + * Copyright (c) lingdocs.com + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +// eslint-disable-next-line +import React from "react"; +import { Link } from "react-router-dom"; + +interface IBottomNavItemProps { + icon: string; + label: string; + page?: string; + handleClick?: () => void; +} + +const BottomNavItem = ({ icon, label, page, handleClick }: IBottomNavItemProps) => { + const dataTestId = `navItem${label}`; + if (page) { + return ( + +
+ +
{label}
+
+ + ); + } else { + return ( +
+ +
{label}
+
+ ); + } +}; + +export default BottomNavItem; diff --git a/website/src/components/DictionaryStatusDisplay.tsx b/website/src/components/DictionaryStatusDisplay.tsx new file mode 100644 index 0000000..7a56f07 --- /dev/null +++ b/website/src/components/DictionaryStatusDisplay.tsx @@ -0,0 +1,31 @@ +/** + * Copyright (c) lingdocs.com + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +function DictionaryStatusDisplay({ status }: { status: DictionaryStatus }) { + if (status === "loading" || status === "updating") { + return ( +
+

+ {status === "loading" ? "Loading" : "Updating"}... +

+
+
+ ); + } else if (status === "error loading") { + return ( +
+

Error Loading Dictionary

+

Please check your internet connection and reload this page

+
+ ); + } else { + return null; + } +}; + +export default DictionaryStatusDisplay; diff --git a/website/src/components/Entry.tsx b/website/src/components/Entry.tsx new file mode 100644 index 0000000..9c78ff2 --- /dev/null +++ b/website/src/components/Entry.tsx @@ -0,0 +1,43 @@ +/** + * Copyright (c) lingdocs.com + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +import ExtraEntryInfo from "../components/ExtraEntryInfo"; +import classNames from "classnames"; +import { + Types as T, + InlinePs, +} from "@lingdocs/pashto-inflector"; + +function Entry({ entry, textOptions, nonClickable, isolateEntry }: { + entry: T.DictionaryEntry, + textOptions: T.TextOptions, + nonClickable?: boolean, + isolateEntry?: (ts: number) => void, +}) { + return ( +
isolateEntry(entry.ts) : undefined} + data-testid="entry" + > +
+ + {{ p: entry.p, f: entry.f }} + + {` `} + {entry.c} +
+ +
{entry.e}
+
+ ); +}; + +export default Entry; diff --git a/website/src/components/ExtraEntryInfo.tsx b/website/src/components/ExtraEntryInfo.tsx new file mode 100644 index 0000000..76d716f --- /dev/null +++ b/website/src/components/ExtraEntryInfo.tsx @@ -0,0 +1,117 @@ +/** + * Copyright (c) 2021 lingdocs.com + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import React from "react"; +import { inflectWord, Types, InlinePs } from "@lingdocs/pashto-inflector"; + +const InflectionsInfo = ({ entry, textOptions }: { + entry: Types.DictionaryEntry, + textOptions: Types.TextOptions, +}) => { + const inf = ((): Types.Inflections | false => { + try { + return inflectWord(entry); + } catch (e) { + console.error("error inflecting entry", entry); + return false; + } + })(); + if (!inf) { + return null; + } + // unisex noun / adjective + if ("masc" in inf && "fem" in inf) { + return ( +
+ {inf.masc[1][0]} + {` `} + {inf.fem[0][0]} +
+ ); + } + // masculine noun + if ("masc" in inf) { + return ( +
+ {inf.masc[1][0]} +
+ ); + } + // shouldn't happen, but in case there are special inflections info on a feminine noun + return null; +}; + +const ArabicPluralInfo = ({ entry, textOptions }: { + entry: Types.DictionaryEntry, + textOptions: Types.TextOptions, +}) => { + if (!(entry.app && entry.apf)) { + return null; + } + return ( +
+ Arabic Plural: {{ + p: entry.app, + f: entry.apf, + }} +
+ ); +}; + +const PresentFormInfo = ({ entry, textOptions }: { + entry: Types.DictionaryEntry, + textOptions: Types.TextOptions, +}) => { + /* istanbul ignore next */ + if (!(entry.psp && entry.psf)) { + return null; + } + return ( +
+ Present Form: {{ + p: `${entry.psp}ي`, + f: `${entry.psf}ee`, + }} + +
+ ); +}; + +const PashtoPluralInfo = ({ entry, textOptions }: { + entry: Types.DictionaryEntry, + textOptions: Types.TextOptions, +}) => { + if (!(entry.ppp && entry.ppf)) { + return null; + } + return ( +
+ Plural: {{ + p: entry.ppp, + f: entry.ppf, + }} +
+ ); +}; + +// TODO: refactor this in a better way +const ExtraEntryInfo = ({ entry, textOptions }: { + entry: Types.DictionaryEntry, + textOptions: Types.TextOptions, +}) => { + return ( + <> + + + + + + ); +}; + +export default ExtraEntryInfo; diff --git a/website/src/components/ImageEditor.tsx b/website/src/components/ImageEditor.tsx new file mode 100644 index 0000000..339d315 --- /dev/null +++ b/website/src/components/ImageEditor.tsx @@ -0,0 +1,162 @@ +import { useState, useEffect, useRef, useCallback } from "react"; +import ReactCrop from "react-image-crop"; +import "react-image-crop/dist/ReactCrop.css"; +import { + addImageToWordlistWord, + blobToFile, + b64toBlob, + rotateImage, +} from "../lib/image-tools"; +import { + getImageAttachment, + updateWordlistWord, +} from "../lib/wordlist-database"; +import WordlistImage from "./WordlistImage"; + +// TODO: !! remember to save the new dimensions whenever modifying the image + +function ImageEditor({ word }: { word: WordlistWord }) { + const imgRef = useRef(null); + const previewCanvasRef = useRef(null); + const [cropping, setCropping] = useState(false); + const [crop, setCrop] = useState({ height: 0 }); + const [completedCrop, setCompletedCrop] = useState(null); + const [imgSrc, setImgSrc] = useState(undefined); + useEffect(() => { + if (!("_attachments" in word)) return; + getImageAttachment(word).then((img) => { + setImgSrc(img); + }).catch(console.error); + }, [word]); + useEffect(() => { + if (!completedCrop || !previewCanvasRef.current || !imgRef.current) { + return; + } + + const image = imgRef.current; + const canvas = previewCanvasRef.current; + const crop = completedCrop; + // @ts-ignore + const scaleX = image.naturalWidth / image.width; + // @ts-ignore + const scaleY = image.naturalHeight / image.height; + // @ts-ignore + const ctx = canvas.getContext('2d'); + const pixelRatio = window.devicePixelRatio; + // @ts-ignore + canvas.width = crop.width * pixelRatio; + // @ts-ignore + canvas.height = crop.height * pixelRatio; + + ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0); + ctx.imageSmoothingQuality = 'high'; + // @ts-ignore + ctx.drawImage(image, crop.x * scaleX, crop.y * scaleY, crop.width * scaleX, crop.height * scaleY, 0, 0, crop.width, crop.height); + }, [completedCrop]); + const onLoad = useCallback((img) => { + imgRef.current = img; + }, []); + function generateCropped(canvas: any, crop: any) { + if (!crop || !canvas) { + return; + } + + canvas.toBlob(async (blob: Blob) => { + const wCropped = await addImageToWordlistWord(word, blobToFile(blob, "cropped.png")); + updateWordlistWord(wCropped); + }, + "image/png", + 1 + ); + setCrop({ height: 0 }); + setCropping(false); + } + function acceptCrop() { + if (crop.height === 0) { + alert("select area to crop"); + return; + } + generateCropped(previewCanvasRef.current, completedCrop); + } + function startCropping() { + setCropping(true); + setCrop({ height: 0 }); + } + function cancelCropping() { + setCropping(false); + setCrop({ height: 0 }); + } + async function handleRotateImage() { + if (!imgSrc) return; + const blob = await b64toBlob(imgSrc); + const rotated = await rotateImage(blobToFile(blob, "rotated")); + const wRotated = await addImageToWordlistWord(word, rotated); + updateWordlistWord(wRotated); + } + + return
+
+ {!cropping ? + <> +
+ +
+
+ +
+ + : + <> +
+ +
+
+ +
+
+ select area to crop +
+ + } +
+
+ {(cropping && imgSrc) ? +
+ setCrop(c)} + // @ts-ignore + onComplete={(c) => setCompletedCrop(c)} + /> +
+ +
+
+ : + + } +
+
; +} + +export default ImageEditor; \ No newline at end of file diff --git a/website/src/components/InflectionSearchResult.tsx b/website/src/components/InflectionSearchResult.tsx new file mode 100644 index 0000000..13b9a6e --- /dev/null +++ b/website/src/components/InflectionSearchResult.tsx @@ -0,0 +1,56 @@ +/** + * Copyright (c) 2021 lingdocs.com + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { + InlinePs, + Types as T, +} from "@lingdocs/pashto-inflector"; +import { + displayFormResult, + displayPositionResult, +} from "../lib/inflection-search-helpers"; + +function InflectionSearchResult( + { result, textOptions, entry }: + { result: InflectionSearchResult, textOptions: T.TextOptions, entry: T.DictionaryEntry } +) { + function getTransitivity(): "transitive" | "intransitive" | "grammatically transitive" { + if (result.form.includes("grammaticallyTransitive")) { + return "grammatically transitive"; + } + if (result.form.includes("transitive")) { + return "transitive"; + } + if (entry.c?.includes("intrans.")) { + return "intransitive"; + } + return "transitive"; + } + const transitivity = getTransitivity(); + const isPast = (result.form.includes("past") || result.form.includes("perfect")); + const isErgative = (transitivity !== "intransitive") && isPast; + const isVerbPos = (x: InflectionName[] | T.Person[] | null) => { + if (x === null) return false; + return (typeof x[0] !== "string"); + }; + return
+
{displayFormResult(result.form)}
+ {result.matches.map((match) =>
+ {match.ps} +
+ + {(transitivity === "grammatically transitive" && isPast) + ? "Always 3rd pers. masc. plur." + : `${isVerbPos(match.pos) ? (isErgative ? "Obj.:" : "Subj.:") : ""} ${displayPositionResult(match.pos)}`} + +
+
)} +
; +} + +export default InflectionSearchResult; diff --git a/website/src/components/LoadingElipses.css b/website/src/components/LoadingElipses.css new file mode 100644 index 0000000..b41f4e8 --- /dev/null +++ b/website/src/components/LoadingElipses.css @@ -0,0 +1,55 @@ +.lds-ellipsis { + display: inline-block; + position: relative; + width: 40px; + height: 40px; + } + .lds-ellipsis div { + position: absolute; + top: 16px; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--farthest);; + animation-timing-function: cubic-bezier(0, 1, 1, 0); + } + .lds-ellipsis div:nth-child(1) { + left: 4px; + animation: lds-ellipsis1 0.6s infinite; + } + .lds-ellipsis div:nth-child(2) { + left: 4px; + animation: lds-ellipsis2 0.6s infinite; + } + .lds-ellipsis div:nth-child(3) { + left: 16px; + animation: lds-ellipsis2 0.6s infinite; + } + .lds-ellipsis div:nth-child(4) { + left: 28px; + animation: lds-ellipsis3 0.6s infinite; + } + @keyframes lds-ellipsis1 { + 0% { + transform: scale(0); + } + 100% { + transform: scale(1); + } + } + @keyframes lds-ellipsis3 { + 0% { + transform: scale(1); + } + 100% { + transform: scale(0); + } + } + @keyframes lds-ellipsis2 { + 0% { + transform: translate(0, 0); + } + 100% { + transform: translate(12px, 0); + } + } \ No newline at end of file diff --git a/website/src/components/LoadingElipses.tsx b/website/src/components/LoadingElipses.tsx new file mode 100644 index 0000000..1c1178b --- /dev/null +++ b/website/src/components/LoadingElipses.tsx @@ -0,0 +1,5 @@ +import "./LoadingElipses.css"; + +export default function LoadingElipses() { + return
; +} \ No newline at end of file diff --git a/website/src/components/ReviewScoreInput.tsx b/website/src/components/ReviewScoreInput.tsx new file mode 100644 index 0000000..caada74 --- /dev/null +++ b/website/src/components/ReviewScoreInput.tsx @@ -0,0 +1,54 @@ +/** + * Copyright (c) 2021 lingdocs.com + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { useRef } from "react"; +import { SuperMemoGrade } from "supermemo"; + +function ReviewScoreInput({ handleGrade, guide }: { + handleGrade: (grade: SuperMemoGrade) => void, + guide: boolean, +}) { + const box = useRef(null); + function handleClick(e: React.MouseEvent) { + // @ts-ignore + const totalWidth = box.current.offsetWidth; + const clickX = e.clientX; + const percentage = clickX / totalWidth; + const exactScore = percentage / (1 / 5); + // bump up the 0 range a tad bit to make it easier to hit with right thumb on phone + const score = Math.round(exactScore < 0.7 ? 0 : exactScore) as 0 | 1 | 2 | 3 | 4 | 5; + handleGrade(score); + } + return
+ {guide &&
+
😫 fail
+
took 🤔 time
+
easy 😄
+
} +
+
+
+
+
; +} + +export default ReviewScoreInput; \ No newline at end of file diff --git a/website/src/components/SearchBar.tsx b/website/src/components/SearchBar.tsx new file mode 100644 index 0000000..3388b4d --- /dev/null +++ b/website/src/components/SearchBar.tsx @@ -0,0 +1,92 @@ +/** + * Copyright (c) lingdocs.com + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +const SearchBar = ({ state, optionsDispatch, handleSearchValueChange, onBottom }: { + state: State + optionsDispatch: (action: OptionsAction) => void, + handleSearchValueChange: (searchValue: string) => void, + onBottom?: boolean, +}) => { + const LanguageToggle = ({ language }: { language: Language }) => { + const arrowDirection = language === "Pashto" ? "right" : "left"; + return ( + + ); + } + const SearchTypeToggle = ({ searchType }: { searchType: SearchType }) => { + const icon = (searchType === "alphabetical") ? "book" : "bolt"; + return ( + + ); + }; + + const placeholder = (state.options.searchType === "alphabetical" && state.options.language === "Pashto") + ? "Browse alphabetically" + : `Search ${state.options.language === "Pashto" ? "Pashto" : "English"}`; + return ( + + ); +}; + +export default SearchBar; \ No newline at end of file diff --git a/website/src/components/WordlistImage.tsx b/website/src/components/WordlistImage.tsx new file mode 100644 index 0000000..8f8c219 --- /dev/null +++ b/website/src/components/WordlistImage.tsx @@ -0,0 +1,40 @@ +import { useState, useEffect } from "react"; +import { getImageAttachment } from "../lib/wordlist-database"; + +function WordlistImage({ word }: { word: WordlistWord }) { + const [imgSrc, setImgSrc] = useState(undefined); + useEffect(() => { + if (!("_attachments" in word)) { + console.error("no image attachment to display"); + return; + } + getImageAttachment(word).then((imgB64) => { + setImgSrc(imgB64); + }); + }, [word]); + return
+ {imgSrc ? + wordlist img + : + "imgSize" in word ? + + :
IMG SIZE ERROR
+ } +
; +}; + +export default WordlistImage; diff --git a/website/src/components/WordlistWordEditor.tsx b/website/src/components/WordlistWordEditor.tsx new file mode 100644 index 0000000..45c1e2b --- /dev/null +++ b/website/src/components/WordlistWordEditor.tsx @@ -0,0 +1,173 @@ +import { useState, useEffect, useRef } from "react"; +import ImageEditor from "./ImageEditor"; +import {useDropzone} from "react-dropzone"; +import classNames from "classnames"; +import { + addImageToWordlistWord, + removeImageFromWordlistWord, +} from "../lib/image-tools"; +import { + addAudioToWordlistWord, + removeAudioFromWordlistWord, +} from "../lib/audio-tools"; +import { + updateWordlistWord, + hasAttachment, +} from "../lib/wordlist-database"; + +const droppingStyle = { + boxShadow: "0 0 5px rgba(81, 203, 238, 1)", + border: "1px solid rgba(81, 203, 238, 1)", +}; + +function WordlistWordEditor({ word }: { + word: WordlistWord, +}) { + const imageFileInput = useRef(null); + const audioFileInput = useRef(null); + const [notes, setNotes] = useState(word.notes); + const [loadingImage, setLoadingImage] = useState(false); + + useEffect(() => { + // TODO: do I really want to do this? changing the notes in the box in real time + // if it changes in the database? + setNotes(word.notes); + if (hasAttachment(word, "image")) { + setLoadingImage(false); + } + }, [word]); + function clearImageFileInput() { + if (imageFileInput.current) { + imageFileInput.current.value = ""; + } + } + function clearAudioFileInput() { + if (audioFileInput.current) { + audioFileInput.current.value = ""; + } + } + function handleNotesUpdate() { + updateWordlistWord({ + ...word, + notes, + }); + } + async function handleImageInput(f?: File) { + const file = f + ? f + : (imageFileInput.current && imageFileInput.current.files && imageFileInput.current.files[0]); + if (!file) { + console.error("no image file input"); + return; + } + setLoadingImage(true); + const wordWImage = await addImageToWordlistWord(word, file); + updateWordlistWord(wordWImage); + clearImageFileInput(); + } + async function handleAudioInput(f?: File) { + const file = f + ? f + : (audioFileInput.current && audioFileInput.current.files && audioFileInput.current.files[0]); + if (!file) { + console.error("no audio file input"); + return; + } + console.log(file); + const wordWAudio = addAudioToWordlistWord(word, file); + updateWordlistWord(wordWAudio); + clearAudioFileInput(); + } + function removeImage() { + if (!("_attachments" in word)) return; + const wordWoutImage = removeImageFromWordlistWord(word); + updateWordlistWord(wordWoutImage); + clearImageFileInput(); + } + function removeAudio() { + if (!("_attachments" in word)) return; + const wordWoutAudio = removeAudioFromWordlistWord(word); + updateWordlistWord(wordWoutAudio); + clearAudioFileInput(); + } + function onDrop(acceptedFiles: File[]) { + const file = acceptedFiles[0]; + if (file.type.includes("image")) { + handleImageInput(file); + } + if (file.type.includes("audio")) { + handleAudioInput(file); + } + }; + + const {getRootProps, getInputProps, isDragActive} = useDropzone({onDrop}); + + return
+
+
+ +