From c18a7f7b643deb25b33d068c0480a8623925c123 Mon Sep 17 00:00:00 2001 From: lingdocs <71590811+lingdocs@users.noreply.github.com> Date: Wed, 18 Aug 2021 15:54:00 +0400 Subject: [PATCH] more in repo --- .firebaserc | 5 + .github/workflows/deploy-functions.yml | 33 + .github/workflows/functions-ci.yml | 44 + .github/workflows/website-ci.yml | 32 + .gitignore | 69 + .npmrc | 2 + LICENSE | 8 + README.md | 165 +- architecture-source.svg | 1886 +++ architecture.svg | 3186 ++++ firebase.json | 15 + functions/.gitignore | 18 + functions/import-wordlist.js | 84 + functions/package-lock.json | 2153 +++ functions/package.json | 35 + functions/src/generate-password.ts | 9 + functions/src/index.ts | 232 + functions/src/lib/userDbName.ts | 12 + functions/src/publish.ts | 238 + functions/src/submissions.ts | 155 + functions/src/word-list-maker.test.ts | 27 + functions/src/word-list-maker.ts | 124 + functions/tsconfig.json | 16 + netlify.toml | 4 + public/index.html | 3 + website/.gitignore | 23 + website/.npmrc | 2 + website/package.json | 97 + website/public/_redirects | 1 + website/public/favicon.ico | Bin 0 -> 103869 bytes website/public/icons/favicon.ico | Bin 0 -> 103869 bytes website/public/icons/icon.png | Bin 0 -> 14258 bytes website/public/icons/icon128.png | Bin 0 -> 3625 bytes website/public/icons/icon144.png | Bin 0 -> 3737 bytes website/public/icons/icon168.png | Bin 0 -> 4313 bytes website/public/icons/icon192.png | Bin 0 -> 5015 bytes website/public/icons/icon48.png | Bin 0 -> 1274 bytes website/public/icons/icon72.png | Bin 0 -> 1894 bytes website/public/icons/icon96.png | Bin 0 -> 2504 bytes website/public/icons/touch-icon128.png | Bin 0 -> 3091 bytes website/public/icons/touch-icon152.png | Bin 0 -> 2720 bytes website/public/icons/touch-icon167.png | Bin 0 -> 2938 bytes website/public/icons/touch-icon180.png | Bin 0 -> 5324 bytes website/public/icons/touch-icon57.png | Bin 0 -> 1290 bytes website/public/icons/touch-icon76.png | Bin 0 -> 1842 bytes website/public/index.html | 62 + website/public/manifest.json | 40 + website/public/robots.txt | 3 + website/src/App.css | 385 + website/src/App.test.tsx | 779 + website/src/App.tsx | 570 + website/src/Context.ts | 12 + website/src/components/AudioPlayButton.tsx | 37 + website/src/components/BottomNavItem.tsx | 41 + .../components/DictionaryStatusDisplay.tsx | 31 + website/src/components/Entry.tsx | 43 + website/src/components/ExtraEntryInfo.tsx | 117 + website/src/components/ImageEditor.tsx | 162 + .../src/components/InflectionSearchResult.tsx | 56 + website/src/components/LoadingElipses.css | 55 + website/src/components/LoadingElipses.tsx | 5 + website/src/components/ReviewScoreInput.tsx | 54 + website/src/components/SearchBar.tsx | 92 + website/src/components/WordlistImage.tsx | 40 + website/src/components/WordlistWordEditor.tsx | 173 + website/src/custom-bootstrap.scss | 36 + website/src/index.tsx | 30 + website/src/lib/__mocks__/pouch-dbs.ts | 31 + .../src/lib/__mocks__/wordlist-database.ts | 56 + website/src/lib/audio-tools.ts | 39 + website/src/lib/backend-calls.ts | 56 + website/src/lib/backend-types.ts | 108 + website/src/lib/badges.ts | 11 + website/src/lib/dictionary-core.test.ts | 243 + website/src/lib/dictionary-core.ts | 232 + website/src/lib/dictionary-mock-fillers.ts | 1397 ++ website/src/lib/dictionary.ts | 384 + website/src/lib/filler-words.ts | 46 + website/src/lib/firebase.ts | 30 + .../lib/fuzzify-pashto/fuzzify-pashto.test.ts | 459 + .../src/lib/fuzzify-pashto/fuzzify-pashto.ts | 150 + website/src/lib/fuzzify-pashto/replacer.ts | 303 + website/src/lib/get-word-id.test.ts | 8 + website/src/lib/get-word-id.ts | 10 + website/src/lib/hitBottom.ts | 12 + website/src/lib/image-tools.ts | 147 + website/src/lib/inflection-search-helpers.ts | 114 + website/src/lib/is-pashto.test.ts | 6 + website/src/lib/is-pashto.ts | 9 + website/src/lib/level-management.ts | 14 + website/src/lib/options-reducer.test.ts | 105 + website/src/lib/options-reducer.ts | 98 + website/src/lib/options-storage.test.ts | 48 + website/src/lib/options-storage.ts | 34 + website/src/lib/pouch-dbs.ts | 170 + website/src/lib/sanitize-pashto.ts | 17 + website/src/lib/search-all-inflections.ts | 79 + website/src/lib/search-pile.test.ts | 55 + website/src/lib/search-pile.ts | 192 + website/src/lib/spaced-repetition.ts | 98 + website/src/lib/string-to-hex.test.ts | 6 + website/src/lib/string-to-hex.ts | 14 + website/src/lib/submissions.ts | 59 + website/src/lib/time-utils.ts | 38 + website/src/lib/wee-bit-fuzzy.ts | 41 + website/src/lib/wordlist-database.ts | 217 + website/src/react-app-env.d.ts | 1 + website/src/reportWebVitals.ts | 15 + website/src/screens/About.tsx | 47 + website/src/screens/Account.tsx | 404 + website/src/screens/EntryEditor.tsx | 342 + website/src/screens/IsolatedEntry.tsx | 227 + website/src/screens/Options.tsx | 236 + website/src/screens/Results.tsx | 234 + website/src/screens/ReviewTasks.tsx | 63 + website/src/screens/Wordlist.tsx | 451 + website/src/service-worker.ts | 80 + website/src/serviceWorkerRegistration.ts | 142 + website/src/setupTests.ts | 12 + website/src/types.d.ts | 157 + website/tsconfig.json | 26 + website/yarn.lock | 13388 ++++++++++++++++ 122 files changed, 32461 insertions(+), 1 deletion(-) create mode 100644 .firebaserc create mode 100644 .github/workflows/deploy-functions.yml create mode 100644 .github/workflows/functions-ci.yml create mode 100644 .github/workflows/website-ci.yml create mode 100644 .gitignore create mode 100644 .npmrc create mode 100644 LICENSE create mode 100644 architecture-source.svg create mode 100644 architecture.svg create mode 100644 firebase.json create mode 100644 functions/.gitignore create mode 100644 functions/import-wordlist.js create mode 100644 functions/package-lock.json create mode 100644 functions/package.json create mode 100644 functions/src/generate-password.ts create mode 100644 functions/src/index.ts create mode 100644 functions/src/lib/userDbName.ts create mode 100644 functions/src/publish.ts create mode 100644 functions/src/submissions.ts create mode 100644 functions/src/word-list-maker.test.ts create mode 100644 functions/src/word-list-maker.ts create mode 100644 functions/tsconfig.json create mode 100644 netlify.toml create mode 100644 public/index.html create mode 100644 website/.gitignore create mode 100644 website/.npmrc create mode 100644 website/package.json create mode 100644 website/public/_redirects create mode 100644 website/public/favicon.ico create mode 100644 website/public/icons/favicon.ico create mode 100644 website/public/icons/icon.png create mode 100644 website/public/icons/icon128.png create mode 100644 website/public/icons/icon144.png create mode 100644 website/public/icons/icon168.png create mode 100644 website/public/icons/icon192.png create mode 100644 website/public/icons/icon48.png create mode 100644 website/public/icons/icon72.png create mode 100644 website/public/icons/icon96.png create mode 100644 website/public/icons/touch-icon128.png create mode 100644 website/public/icons/touch-icon152.png create mode 100644 website/public/icons/touch-icon167.png create mode 100644 website/public/icons/touch-icon180.png create mode 100644 website/public/icons/touch-icon57.png create mode 100644 website/public/icons/touch-icon76.png create mode 100644 website/public/index.html create mode 100644 website/public/manifest.json create mode 100644 website/public/robots.txt create mode 100644 website/src/App.css create mode 100644 website/src/App.test.tsx create mode 100644 website/src/App.tsx create mode 100644 website/src/Context.ts create mode 100644 website/src/components/AudioPlayButton.tsx create mode 100644 website/src/components/BottomNavItem.tsx create mode 100644 website/src/components/DictionaryStatusDisplay.tsx create mode 100644 website/src/components/Entry.tsx create mode 100644 website/src/components/ExtraEntryInfo.tsx create mode 100644 website/src/components/ImageEditor.tsx create mode 100644 website/src/components/InflectionSearchResult.tsx create mode 100644 website/src/components/LoadingElipses.css create mode 100644 website/src/components/LoadingElipses.tsx create mode 100644 website/src/components/ReviewScoreInput.tsx create mode 100644 website/src/components/SearchBar.tsx create mode 100644 website/src/components/WordlistImage.tsx create mode 100644 website/src/components/WordlistWordEditor.tsx create mode 100644 website/src/custom-bootstrap.scss create mode 100644 website/src/index.tsx create mode 100644 website/src/lib/__mocks__/pouch-dbs.ts create mode 100644 website/src/lib/__mocks__/wordlist-database.ts create mode 100644 website/src/lib/audio-tools.ts create mode 100644 website/src/lib/backend-calls.ts create mode 100644 website/src/lib/backend-types.ts create mode 100644 website/src/lib/badges.ts create mode 100644 website/src/lib/dictionary-core.test.ts create mode 100644 website/src/lib/dictionary-core.ts create mode 100644 website/src/lib/dictionary-mock-fillers.ts create mode 100644 website/src/lib/dictionary.ts create mode 100644 website/src/lib/filler-words.ts create mode 100644 website/src/lib/firebase.ts create mode 100644 website/src/lib/fuzzify-pashto/fuzzify-pashto.test.ts create mode 100644 website/src/lib/fuzzify-pashto/fuzzify-pashto.ts create mode 100644 website/src/lib/fuzzify-pashto/replacer.ts create mode 100644 website/src/lib/get-word-id.test.ts create mode 100644 website/src/lib/get-word-id.ts create mode 100644 website/src/lib/hitBottom.ts create mode 100644 website/src/lib/image-tools.ts create mode 100644 website/src/lib/inflection-search-helpers.ts create mode 100644 website/src/lib/is-pashto.test.ts create mode 100644 website/src/lib/is-pashto.ts create mode 100644 website/src/lib/level-management.ts create mode 100644 website/src/lib/options-reducer.test.ts create mode 100644 website/src/lib/options-reducer.ts create mode 100644 website/src/lib/options-storage.test.ts create mode 100644 website/src/lib/options-storage.ts create mode 100644 website/src/lib/pouch-dbs.ts create mode 100644 website/src/lib/sanitize-pashto.ts create mode 100644 website/src/lib/search-all-inflections.ts create mode 100644 website/src/lib/search-pile.test.ts create mode 100644 website/src/lib/search-pile.ts create mode 100644 website/src/lib/spaced-repetition.ts create mode 100644 website/src/lib/string-to-hex.test.ts create mode 100644 website/src/lib/string-to-hex.ts create mode 100644 website/src/lib/submissions.ts create mode 100644 website/src/lib/time-utils.ts create mode 100644 website/src/lib/wee-bit-fuzzy.ts create mode 100644 website/src/lib/wordlist-database.ts create mode 100644 website/src/react-app-env.d.ts create mode 100644 website/src/reportWebVitals.ts create mode 100644 website/src/screens/About.tsx create mode 100644 website/src/screens/Account.tsx create mode 100644 website/src/screens/EntryEditor.tsx create mode 100644 website/src/screens/IsolatedEntry.tsx create mode 100644 website/src/screens/Options.tsx create mode 100644 website/src/screens/Results.tsx create mode 100644 website/src/screens/ReviewTasks.tsx create mode 100644 website/src/screens/Wordlist.tsx create mode 100644 website/src/service-worker.ts create mode 100644 website/src/serviceWorkerRegistration.ts create mode 100644 website/src/setupTests.ts create mode 100644 website/src/types.d.ts create mode 100644 website/tsconfig.json create mode 100644 website/yarn.lock 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 0000000000000000000000000000000000000000..676170195fbb28140c85edbc7f84304b599d63b2 GIT binary patch literal 103869 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 PMRc_h*Q*4h`-uC0HNeA_ literal 0 HcmV?d00001 diff --git a/website/public/icons/favicon.ico b/website/public/icons/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..676170195fbb28140c85edbc7f84304b599d63b2 GIT binary patch literal 103869 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 PMRc_h*Q*4h`-uC0HNeA_ literal 0 HcmV?d00001 diff --git a/website/public/icons/icon.png b/website/public/icons/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..8af9e9d108493baa60823433095e92d7f8356215 GIT binary patch literal 14258 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 literal 0 HcmV?d00001 diff --git a/website/public/icons/icon144.png b/website/public/icons/icon144.png new file mode 100644 index 0000000000000000000000000000000000000000..400249568ab998565b751204c939a869f2992794 GIT binary patch literal 3737 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 NtMyQ`T;pl@{{Tf225JBR literal 0 HcmV?d00001 diff --git a/website/public/icons/icon168.png b/website/public/icons/icon168.png new file mode 100644 index 0000000000000000000000000000000000000000..b2d19cef887e1b0b096ffd8f4108748cb34d7e90 GIT binary patch literal 4313 zcma)AcT`hLx2Hpbl!Pi$q<2w}E+y0eQUwvDD;)wz?-&per1zeyAV?R9l+dJ0m)=4( zz!gXUL8|!9{l2x{UGKlQ*6i75oik<6>@)lK+w01G4L>>a?)4ufqGv2zX<|jp&Gbgd9Bgbl0VRbJQ$yY$*C4nwZ&vQ8!WXQJ{AArVFVzqr`qUn> zm&XN>oh%};c?;OQ;-X?K!TcnP9Q`YRaFv^fziwT^@lYXe|6Am#Kp-Eq>(qN24Gj%n z{_ZRnO^7$-y~fnw;2@^TybjTne#hL%C{RQ25W?$E#zfNtK0P$RjmgjBnW4$sd-e7`JTb!` z+I(ZBLaG@^nqT0CvL_}c?Cf7qP*P$72rKOq_*lqw&-8~!3^5=!7M8hKW3M9m!M;S6 zpZWJ)O?2W>fL#oG2>SKFOaVc`KxuJ7Co(V?JiCNt>;7~0YsqV%fSGaXaamcJ_8#KK zdgR_BS`^!efzEe?qEf5krk zmMo(FX%ph({)=v zma50mRyQ}#Pnn21@~Q%%LeMWbQH-K4wGFTvs$aX{e@2hz$7I73Wo@Nr0&SyQ{@)FFHWQ- zOoD>s0Eg#eXBuOWNRr3zv}mxOr+8PKGPz7n53hR}4CjG?&klO%{Fjo6L*Gg1@>Sf< zmawf2O^c=F_{Sz#3x4klCD*iP=xRqeO^zWFyirxxG1(3W9D&9pWY@cV3?pI&$Yvy-d{c^XP`AUHg4Ug1%o&0RJ6EZZPj9Os-~%}y=1NV*TWKJY)v86_uIKI$(VXq+CweMbhG>O9dU+ z%{Dmd)s=}+e}BbRG;117#`Fe*qhli7j4@O`=hHJZbaoyuMz=+<+_CBkkPM6ZeKKde zy^3uM_HE*LU`QgR<)o;|#0?WCiayf1;~h;wGyiE^c^EkG{W2c&UCEqELj=T%k%>w7 zSc%GbGP`_t#MPnpXYX~j?DX{R)fBFXGyB;JV_t5_Y`RR@;G^ey5qi*Ro{Lk0*G2bS zwMB*s0^|30x6}zGE&HHN&^oz{+q95O$gzuzLod~}a~R9YC1>z=h;kzX8{1H&S(SjP zT1}T~n1MmEV<#`j4YI?xBOdJ?Vh5+8Ci*C_q6;@l)X~?+eH{v*<`=n3tKdAw-jP1V zl8RDZ%dw3(3}H8Z4+OS#LVYess`YGCh_8a~>)@arozijYes@cvh*{-9paoy^b4SNR zgB)w18^yCS_qkff!^6Y9l~4lOTB};EFQ0pPec;W_o{sAa_s?Fd$^pMG{4tRtv>-R$ z$m?^#;gON&AP^|5__`K@Z+NKjrpbY|D*Wo%BcgDuUJgxD$Npyl)ewJRzY1uP{L$RNhOL-*Xa$h#!U=4QX{$tbY!vQWU zWwCv9$o#-uDk^6Bzd}nQ?9IwIAd~EP_*JmvV9cQJ@T*zX&&}XSAS{`9x9WwFuRQcWb!jp4Jp_~sAhr@luVggrbAKG)uc}I=QWl`!Wi$pL-_K##_+=hWZaR0AxjD5`z z3`PiMD|DW36*O(mnA_qAp z9>qwxoMJNm(%ApENE2_hHF+#_8~TYR0?{9&LVGur8>Sz{&r}N*M$D&Qo~}kVVca4} zL2e|{FrOIe&pQIWs>uAcVTsOC#frXElz0NVY0cfeM;Q zb|NpQvhoN%LBxD~gd6slt+u#QGc1tV4_Lz@`VyF8hn2!F?n`X{K3(m6w>(-P8*0W3 zV(UUWy2l6&u&sYFDLCHgFHPpgZ# z-qM6$Q1CyU*N_{=wftScY&2OdKAT#jlG;gZI5P`AmniCxt&y9LJ0rr)i0diaAl_H# zY*RV;v0H$7EQ~H3-1G0W>C|zfB`UU$Dv3!cf&g1|kqsXnl~;nJ{_X_;BGlSOS2y9k z>A2Eqp}Av?#iv*@8b26tmYwxM-jqMHdwVrTkGzlSWj9e478d!A_N>EP+%VCoJyf>% zQ*bC<((BjXGQqDoS4y|a%FErerG2*^*6nVQl`p^ctPvsI0seYeLB%}K>Sq=t6_p(JIv$Lp=xtaC}YQ|X6E-ZE6K#Z1v}2W3f-owwFV7pb8DQ@PmT9Zh^Kt=d5}EO%Ub z^n8M|Kw(E|Wtb8_kDveKOjl*q((vO?3WM^2!g>mvUkDE^Qt&rvr%Gazb^vgL{+m;i zT^zmAU#T+@$2-PYN$=b$!52J0wC)F@W9`$G!8Bg!KYs=|Lm%4h;?zW7C$w69mpOH3 z0Mwj6dt@lWm%ZleCT7_fPfkuAELO4mCncmXxU~PrOqH}@hWgnqt5B-0(eWbsj#@y# z=lEYC8a6h?Tbqqxb{VnU>aq0zavlV#?oGug7G*E*paER8{?yj$zx!f=mP4TzOijJ8 z-V+z>W;2>EE&Ycpb9;QaungK5dWz2{JZZM;=zauiPqJwb`RH66+|PUHV`&AfYdInS zz-Z-5>pfLA7`GjOd!`a<lf2TS&q%o-(C(ZlT;S~|8%!`12Dt{pydsAIU_VDKIE6>;gD$E@*zhq zM7{hgJUEw#im?>pH(zgG$wI2M1dyQDZgo$O(={^#8UWU{OM9M4x6GfWf+7z9yRLS$ z4>-(LnU8w~(tlH9g8_R`6Jne%<2iK4rcJ1_9r48qlYQHf&apXBdlW~H z(&@48BQ({t*BZHv9bAVhRI_U_dQcfdlj;`Sn*I(XnsT6alL@fW<(u=!~+<04Q z&3+?3Co?l1Fs4y5kG`(`*J7t~qG?NppyAPP)f9jzl*{^1%8(4HY5M@Q|A8yp%ir3E zDPLUjuA}ZF19oTi%@Slh7Z0qlVZ=@i$DFH9vjYtMsWuaXgYXhh@pyl2%NGs}M1k<` zCOB`!tb?@S)VGnhIhwC_Ry#X!{_P9Un>>X3$r%VJ+86r zBL$6Jo)Vf$X|9v+Bk@>F4i~bF|Lby#uO>P#y3g$i*|vd!*H5E+e6ch6Ov9kk1+FwW zuMqwLhho4qBl(eo)r1%;Bv&a^rs`37@>BVb6jV!+A*5txxBN~6xCAyLfyQ0;-zvJ^ z-Hg#j7#pXVJMx~hET|M4h&#O2fx%wRc}Ah!2#tId_e!CR)SYayh`o=htoeXuw`E9n zgs{2{G=RkVy3%b&hTd=}W(U4FZkQCMkfB!}U!n4&WJ)welE85v;E&0!U*=kLQ^R_? zyYWISdN3+Nnt>52YvH*+N;}1zSjx^(BwgcfT(Y79$)(awERpO)nu*a!J00hP`SKTU zbCO1Q)b5lrHr}ZAK7PKQn-`y){L4-87T^bQAvy0gB0V_V4(&e2d`BR*2C>|1uZaf1 z;1Vu32P;cty*PK5nk2?OwA1Tl)p(L4*XJ@=5=<;~5xDaJsGlB}0V z!!@DTU&OrR#tSh2A;W#u4T*q@d9*tVN5;M?yRZ;M?q4DkGaV0Ye9coqn5AGKj^^1B zm_mLA$n<&*knGH3S-bxjk&ViTy95OVWu>P>$5X0qQ_+1L9xixhX@p`t=EB@kKjU(B zbp@%B6-m2iM-obP=f7kPa7DJgqK5(e)Wlr)AOHVM5?x8JA`c)zAYKYi z-``xEnmVCVSae5u*%Su^t))H0&Gj(g=e=)MQRv7gAP_5zyF{oPb`#`6f9>hwoXo#M zVK5UDlTch}n=Mp_>_I}|0+5*m_Pj%(ZV$8j_x1Hj1Du0_WW*1+6o~xiE?`>n)QpG9 z;N#JglImso=$cOrB;4HGwbRqnE1H^`w~UA}k;H1c37IP)t&`k8jJA@>0u7|uWIT2f zQc_ZM_4IBP=j6zS0;#HA)|0Ycw}hy~td^FRoAUDVQ-C2Cef|A1ObVGA^klIAQ(_3` Z65!4sA#yXdK7c7iI+_L=wQ6?A{{ht79)$n^ literal 0 HcmV?d00001 diff --git a/website/public/icons/icon192.png b/website/public/icons/icon192.png new file mode 100644 index 0000000000000000000000000000000000000000..cf4733567da16e70591deb35a19661ae11629486 GIT binary patch literal 5015 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 literal 0 HcmV?d00001 diff --git a/website/public/icons/icon72.png b/website/public/icons/icon72.png new file mode 100644 index 0000000000000000000000000000000000000000..4d243626f2acad869fdefa2cb2e264695c32cad3 GIT binary patch literal 1894 zcmV-s2buVZP)C0UUa(VL`31$BEN?H1g^GVfY#*{wWx{s-qU ztFyba&P+4A?jH`~o-=dK`R$o!o^zgO4Fud4i#iDI=1qg<7 z!h{KI&|9`_QLV2VH*TILD0CfBI zZPB*60t5*kv9V*vVn3wl{U1JjND>kf=>7Zk>qr02pFiJed))!ry?eLAH*2w2$n)pV zW!s z(`!3AI>?(hZ>SG->eMN+dGltvB8Lqd<^Y8+(RIB_ENkoWD|NABFYLmxp;rF%fUV4L8( zl$4ZEZyS%uiWMtJdU`rpvt|wTnXFbTId|?HxqtsYdHeP)>Fn&3-LKaG(n?+n=+>=U zbg#w@p1KAQRu*2{f!otEx zettgvY}>YN^ndtK_wLG<@JEn zmLHHC`2o3+ACMdA1`v!Z&Yw}>1nNb)0rcq6Bf9Yn4-Z#tqp5^+0|@8H1~(xXEu{P!^?+!z*&OO|Ce7C;CMHVm*Hg&`gd84z zpob4jUu=bih3vXnvt~Ke;Y@kds8J4)QUnEsDkvymbvO%BOR@mrG>@N%$z-C@PpK*| zFLwa2di843($YfjnVFf{Ba|Ez6htmuxInMPE8c82v%0FPD)}UwNERSr8rz~pi#+PM zJ0O&hkPwF$JRtDNPKK^@7&wN)ELbF20jgS(1?b?xgKVIC_U!SfW3=xMsCz-g^udD% z%ouks0RaKj9Z*xU00|B4*|TRo>Nq7Og-sF$9sxgol_1Aw{rdInd>IfBP8PyJvL_O% z)Rrtj$gbcAeC^sb$(=SgH`A;!LziuV?ZIXh@hz;FckkZyxCVy%^5x4ka|}Nnz+wH* zkYa#5`8JmuS%8qN z#7{ILBEn^A8v=URdSCiE%FbsjmXW-l|Mt7k_8BMAu=+OO&G>?<;svMa&BJFwR@dIWd+FW?eoj(S67_N5FX2&B z)8kc3QULjXv+4>^Sy@@12gGg2odY^{?3naJ9v>9s?&O)T4~v`Wr@z|bSXYQKGJ5hN&f|ikH~7Z{^=GDuc)XfV?{+p zKDYD!i+e6xIpg&&SK557eEj(FpT@?<8gAwME*Xwc+;$?+Y&M5+hRft@g?n3>xIJY^ gufX`_mtRWhf4T%~xrR;hb^rhX07*qoM6N<$f;A4B_W%F@ literal 0 HcmV?d00001 diff --git a/website/public/icons/icon96.png b/website/public/icons/icon96.png new file mode 100644 index 0000000000000000000000000000000000000000..41776f73fc9ba1d04465c7f1042f8f328dc4ed81 GIT binary patch literal 2504 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 literal 0 HcmV?d00001 diff --git a/website/public/icons/touch-icon128.png b/website/public/icons/touch-icon128.png new file mode 100644 index 0000000000000000000000000000000000000000..16c69f2c9745676f3afe6374415de71a6050b97c GIT binary patch literal 3091 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 W|1SuAe_QZK06-aA8@({V#{Uaq*aO1= literal 0 HcmV?d00001 diff --git a/website/public/icons/touch-icon167.png b/website/public/icons/touch-icon167.png new file mode 100644 index 0000000000000000000000000000000000000000..7d3ca7382c9c33dd73a1f83388c76edb0d56a314 GIT binary patch literal 2938 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 literal 0 HcmV?d00001 diff --git a/website/public/icons/touch-icon57.png b/website/public/icons/touch-icon57.png new file mode 100644 index 0000000000000000000000000000000000000000..3154195ad5bcee41b49c09de924e05b86a6bd543 GIT binary patch literal 1290 zcmV+l1@-!gP)z(CUmr~`VEx&l2(U4fpYu0X9M-{0R^`7ItE9;mRekkZo9sHv$*u)kVJmX?;1 zx3@RN#>Udg$%!Om`uh5kfq?<}`}+&_m7V17?k-taSTKW%U>~OUS6JHf7wY6 z4-az&<>ci2YOLqyXR4~IA|oRsHlB%z2`w)#3qB`1$@BAba&&a$h!zwS(An9U=;yDl zu4ravhRVy!$=%(Z8waJau~F0*YVodZZEZ@%IXE~_W@aX}w6xI3$Oz5O&ayHwF+sh( zy;NIUOS!qZ6doQ za+3P{`gwufVPQeeX#^oFNyr`WmN+?R60(v+D5$Baq51jwZ~MWUBE5Th zdXmKs*+`1rgUE1VVq(b5%#50wn`N;@Qj*A8u>TGZ4?ndbM_pQ4VnP4q<%O#UNiQ!i zwyg2h<$$Cl%gV}_u@I3}0hvP~UGQH5Na7HUHXO0!fTSd$(ZE<}qBs}wfx|J{aIC@? zS3qk>9v>gm+}s>(Zf<_t59dE;XJ=;c`1rU|8_E^_sd89h8aSR2BP91tY= z_Vz|0AtB6&ii!%s0V5(JSl`stRHgdp=x8nhFD@?T>hS_?Y;0J4cX#(cP!NV7N$4;z z0NxMUix3hL5;!stt&Wb4nAbEkH00{BWTEHa%R`4%C;{v1>ynUCg&@h2k`iV_XlSUY z0psK2m6SJyw1JYGoJ?+RZrp#qM46S9^=mR;L6Br(Vxs6f2MAA)5fuV5pDgwrE^6Rq zRrOcA7^#m}dZ3kwsyAE&3MIl~7A2AH>PZ*Qlep&`1yzUKPDyF#7Aaz>ln$-5v) zNcAupG4MO^9h8fU3*Ft_{k5MaP9uUOp_;+);Najd4Fo~#DaBW;uC7*U$At~*ghG&9 z5G1Jx;wW)(acmjF3-0ahu@nu<5dsw7xu_~``T6;*4Sh8MQON*e9^X;mUeMawn%!CO z>$9`7S#VbcP9gR6^`b(M91ukkUK81fkB<*oSy?F&16c%mT$}$5np6Z7_)+ePy@zFr zFN0eq+$sL_8ch?>3t74XJxN`Go}{ioPf}O#2ho85@>)~v&Hw-a07*qoM6N<$f;VGY A%m4rY literal 0 HcmV?d00001 diff --git a/website/public/icons/touch-icon76.png b/website/public/icons/touch-icon76.png new file mode 100644 index 0000000000000000000000000000000000000000..7c83089d4c9dfbffb945169998629e10d1116830 GIT binary patch literal 1842 zcmV-22hI42P)YbD2M!#def#!NSy`E;_R`bS$ zm60Py(z|!>RKIHzz>Xa|CU*ek=FOX$+Pix7s%*>A(UBfKdZhYY-GTl7{hI;;0^|-X zTC_-Wn^&$}k?jo~I+X6+yQlhH-GSBD*VCv`qvQ_6$H!}KGbScRwl`wL2zv75iRyQC z2ln8>19EV1kUMbv_;F3`J$v?yMvopX+w<}9p>N;5seWH~U=JTYQ~*14=#XZ%{{H<- z>(;GPte3H|v0UGwJFxff-_zK!W91I``T0?Eb92AjdHnb>ty!~10Sh}M&w>x>4h&km zcCDf#D_5?hy1KePe=ppX^73-px^*i#J3A|EpuivFl}!Mn>UZ#-Q8)}uwiDu!?etuJC`adDlBfN%>aWgUAjc$ z$B*x|)4XNKkRh~q@nXu(&ZgGZ);_h-rhq}OU%#fr#6&i#ECmGbedf%W6crUkr%#`z z`}gnnTl;Jdm?Z3!I8$OhTEBihEm*LCK>$dsOg1KdkWFGW3@W0}N_wYon~JEJ{mDqcdmD(9fSg*_w3n z4_63===BYY;nhdJtZZD?TBJZ+L#Or3@*ox9Xn|I_U-Jv zXe}8Q7^a})sA7k-mJAClH8s^LcW9kuSYUVV+@Z5)&r)V)Cf&Vz*J^eR3rsHwV4M=b zI3<8_N>^aGT9$4fj3QluUAS<8#RY?dgV{OJ2+|c85>HHT9*iJefg!?!+tOXTc4-oe z)Ix?^^f8V1?AgO2M+nYZTZRJ`7#JuYi3$q~t#02cz;KO>NH$7OYZdOdofvJ0 zoR%zEA_FTfF4n5a-O4J!Fm)zPn#8V>5p?Y?TP80rk8J?R+1jvSgV{R&1u*zpvuDp{ zokU7NcZX%Q{`}Qq; z`t(Wld)KdDmpd9A9c{J_vTUR}vu4fe5_g1c)AY{L(o){$_gUP)_V3@%QY=vrbWkfS zBl zHQ33?iG}3V3Z3=v@Q{HZv~Q}Cl9DbdPZ1FjiaNN*mp(%hUT5gm5En2+ouvjN^^r&L zgK~2%<{=VKly~s*20d%aD*Jv&(Ei)PoJ_Vc6W7>l9ISP+Fw!)3_Bv6Pc#^5+pk`|;xcsY+BLbuNc=?#6OwWP zAI|FZ>C+Wyl(%o+riBX^DnL!1Jeftl^^|I0_yK}6La@ty{`{HCP*_-)-0^MOwy|f} zIpM@&jWXx`W9P(3#;8M@Wn*I_mrX5X5im>_p2nMwE|lEdT#FiO3sMaXBTC9j#x$y_ zsZsSFMvtjGf!(n4-4~MOFq)7^V(Zt{T)QMPnWc_LMnnI4W&mMvSxI*2GVoLXGnV28so2r1!G-j-C5>}yWgK;YS1 zJM3?XxPakow{+>!F55hE>5={C=H|u{-ORZH$m!}%nC|fW;pge)U#S)MSrP~D)vH(c z>Q6~!#*7(MP*9*n+jWse*E@(O;(BV=Y>>Y8CsTf>DASYSria zFzjj{KYnCa%lM(@ix)5G%az>%07*qoM6N<$f + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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
+
+
+ +