more in repo

This commit is contained in:
lingdocs 2021-08-18 15:54:00 +04:00
parent 52b289310b
commit c18a7f7b64
122 changed files with 32461 additions and 1 deletions

5
.firebaserc Normal file
View File

@ -0,0 +1,5 @@
{
"projects": {
"default": "lingdocs"
}
}

33
.github/workflows/deploy-functions.yml vendored Normal file
View File

@ -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}

44
.github/workflows/functions-ci.yml vendored Normal file
View File

@ -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

32
.github/workflows/website-ci.yml vendored Normal file
View File

@ -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

69
.gitignore vendored Normal file
View File

@ -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

2
.npmrc Normal file
View File

@ -0,0 +1,2 @@
@lingdocs:registry=https://npm.lingdocs.com
//npm.lingdocs.com/:_authToken=${LINGDOCS_NPM_TOKEN}

8
LICENSE Normal file
View File

@ -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.

165
README.md
View File

@ -1 +1,164 @@
new monorepo
# 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.

1886
architecture-source.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 124 KiB

3186
architecture.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 324 KiB

15
firebase.json Normal file
View File

@ -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"
}
]
}
}

18
functions/.gitignore vendored Normal file
View File

@ -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

View File

@ -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();

2153
functions/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

35
functions/package.json Normal file
View File

@ -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
}

View File

@ -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;
}

232
functions/src/index.ts Normal file
View File

@ -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<undefined | BT.CouchDbUser> {
const user = await usersDb.find({
selector: {
name: uid,
}
});
if (!user.docs.length) {
return undefined;
}
return user.docs[0] as BT.CouchDbUser;
}

View File

@ -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)}`;
}

238
functions/src/publish.ts Normal file
View File

@ -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<PublishDictionaryResponse> {
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<T.DictionaryEntry[]> {
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",
// };
// }

View File

@ -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<BT.SubmissionsResponse> {
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);
}

View File

@ -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);
}
});
});

View File

@ -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<string> = new Set()): Set<string> {
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<string> = 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");

16
functions/tsconfig.json Normal file
View File

@ -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"
]
}

4
netlify.toml Normal file
View File

@ -0,0 +1,4 @@
[build]
base = "website"
command = "export REACT_APP_BUILD_NO=`git rev-parse --short HEAD` && yarn test && yarn build"
publish = "build"

3
public/index.html Normal file
View File

@ -0,0 +1,3 @@
<html>
Hello World
</html>

23
website/.gitignore vendored Normal file
View File

@ -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*

2
website/.npmrc Normal file
View File

@ -0,0 +1,2 @@
@lingdocs:registry=https://npm.lingdocs.com
//npm.lingdocs.com/:_authToken=${LINGDOCS_NPM_TOKEN}

97
website/package.json Normal file
View File

@ -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"
}
}

View File

@ -0,0 +1 @@
/* /index.html 200

BIN
website/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

62
website/public/index.html Normal file
View File

@ -0,0 +1,62 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="LingDocs Pashto Dictionary" />
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="Pashto Dictionary">
<meta ame="description" content="A dictionary of the Pashto language with inflection, verb conjugation, and smart search capabilities" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<meta name="author" content="lingdocs.com" />
<link rel="canonical" href="https://dictionary.lingdocs.com/" />
<meta property="og:site_name" content="LingDocs Pashto Dictionary"/>
<meta property="og:title" content="LingDocs Pashto Dictionary">
<meta property="og:description" content="A dictionary of the Pashto language with inflection, verb conjugation, and smart search capabilities">
<meta property="og:image" content="%PUBLIC_URL%/icons/icon.png">
<meta property="og:url" content="https://dictionary.lingdocs.com/">
<meta property="og:author" content="lingdocs.com">
<meta property="og:type" content="website">
<meta name="twitter:title" content="LingDocs Pashto Dictionary">
<meta name="twitter:description" content="A dictionary of the Pashto language with inflection, verb conjugation, and smart search capabilities">
<meta name="twitter:image" content="%PUBLIC_URL%/icons/icon.png">
<meta name="twitter:domain" content="https://dictionary.lingdocs.com">
<meta name="twitter:url" content="https://dictionary.lingdocs.com">
<meta name="twitter:creator" content="@lingdocs">
<meta name="twitter:site" content="@lingdocs">
<meta name="twitter:card" content="summary_large_image">
<link rel="shortcut icon" href="%PUBLIC_URL%/icons/favicon.ico">
<link rel="apple-touch-icon" sizes="57x57" href="%PUBLIC_URL%/icons/touch-icon57.png">
<link rel="apple-touch-icon" sizes="76x76" href="%PUBLIC_URL%/icons/touch-icon76.png">
<link rel="apple-touch-icon" sizes="128x128" href="%PUBLIC_URL%/icons/touch-icon128.png">
<link rel="apple-touch-icon" sizes="152x152" href="%PUBLIC_URL%/icons/touch-icon152.png">
<link rel="apple-touch-icon" sizes="167x167" href="%PUBLIC_URL%/icons/touch-icon167.png">
<link rel="apple-touch-icon" sizes="180x180" href="%PUBLIC_URL%/icons/touch-icon180.png">
<meta name="msapplication-TileImage" content="%PUBLIC_URL%/icons/icon.png">
<link rel="icon" sizes="192x192" href="%PUBLIC_URL%/icons/icon192.png">
<link rel="icon" sizes="128x128" href="%PUBLIC_URL%/icons/icon128.png">
<title>LingDocs Pashto Dictionary</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

View File

@ -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": "."
}

View File

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

385
website/src/App.css Normal file
View File

@ -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/ */

779
website/src/App.test.tsx Normal file
View File

@ -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 <div>
<button data-testid="mockSignInButton" onClick={props.firebaseAuth.signIn}>Sign In</button>
</div>;
});
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<BT.UpgradeUserResponse> => {
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(<BrowserRouter><App /></BrowserRouter>);
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(<BrowserRouter><App /></BrowserRouter>);
await waitFor(() => screen.getByText(/error loading/i));
});
test('renders dictionary loaded', async () => {
render(<BrowserRouter><App /></BrowserRouter>);
await waitFor(() => screen.getByText(/lingdocs pashto dictionary/i));
});
test('searches on type', async () => {
const history = createMemoryHistory();
render(<Router history={history}><App /></Router>);
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(<BrowserRouter><App /></BrowserRouter>);
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(<Router history={history}><App /></Router>);
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(<BrowserRouter><App /></BrowserRouter>);
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(<Router history={history}><App /></Router>);
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(<BrowserRouter><App /></BrowserRouter>);
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(<Router history={history}><App /></Router>);
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(<Router history={history}><App /></Router>);
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(<Router history={history}><App /></Router>);
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(<Router history={history}><App /></Router>);
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(<Router history={history}><App /></Router>);
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(<Router history={history}><App /></Router>);
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(<Router history={history}><App /></Router>);
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(<Router history={history}><App /></Router>);
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(<Router history={history}><App /></Router>);
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(<Router history={history}><App /></Router>);
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(<Router history={history}><App /></Router>);
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(<Router history={history}><App /></Router>);
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(<Router history={history}><App /></Router>);
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(<Router history={history}><App /></Router>);
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(<Router history={history}><App /></Router>);
// 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

570
website/src/App.tsx Normal file
View File

@ -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<RouteComponentProps, State> {
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<any> | undefined = undefined;
private reviewTastsSync: PouchDB.Replication.Sync<any> | undefined = undefined;
private async handleLoadUserInfo(): Promise<BT.CouchDbUser | undefined> {
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 <div style={{
paddingTop: this.state.options.searchBarPosition === "top" ? "75px" : "7px",
paddingBottom: "60px",
}}>
<Helmet>
<title>LingDocs Pashto Dictionary</title>
</Helmet>
{this.state.options.searchBarPosition === "top" && <SearchBar
state={this.state}
optionsDispatch={this.handleOptionsUpdate}
handleSearchValueChange={this.handleSearchValueChange}
/>}
<div className="container-fluid" data-testid="body">
{this.state.dictionaryStatus !== "ready" ?
<DictionaryStatusDisplay status={this.state.dictionaryStatus} />
:
<>
<Route path="/" exact>
<div className="text-center mt-4">
<h4 className="font-weight-light p-3 mb-4">LingDocs Pashto Dictionary</h4>
{this.state.options.searchType === "alphabetical" && <div className="mt-4 font-weight-light">
<div className="mb-3"><span className="fa fa-book mr-2" ></span> Alphabetical browsing mode</div>
</div>}
{this.state.options.level === "editor" && <div className="mt-4 font-weight-light">
<div className="mb-3">Editor priveleges active</div>
<Link to="/edit">
<button className="btn btn-secondary">New Entry</button>
</Link>
</div>}
<Link to="/new-entries" className="plain-link font-weight-light">
<div className="my-4">New words this month</div>
</Link>
</div>
</Route>
<Route path="/about">
<About state={this.state} />
</Route>
<Route path="/settings">
<Options options={this.state.options} optionsDispatch={this.handleOptionsUpdate} />
</Route>
<Route path="/search">
<Results state={this.state} isolateEntry={this.handleIsolateEntry} />
</Route>
<Route path="/new-entries">
<h4 className="mb-3">New Words This Month</h4>
{this.state.results.length ?
<Results state={this.state} isolateEntry={this.handleIsolateEntry} />
:
<div>No new words added this month 😓</div>
}
</Route>
<Route path="/account">
<Account level={this.state.options.level} loadUserInfo={this.handleLoadUserInfo} handleSignOut={(() => {
this.props.history.replace("/");
auth.signOut();
})} />
</Route>
<Route path="/word">
<IsolatedEntry
state={this.state}
dictionary={dictionary}
isolateEntry={this.handleIsolateEntry}
/>
</Route>
{wordlistEnabled(this.state) && <Route path="/wordlist">
<Wordlist
state={this.state}
isolateEntry={this.handleIsolateEntry}
optionsDispatch={this.handleOptionsUpdate}
/>
</Route>}
{this.state.options.level === "editor" && <Route path="/edit">
<EntryEditor
state={this.state}
dictionary={dictionary}
searchParams={new URLSearchParams(this.props.history.location.search)}
/>
</Route>}
{this.state.options.level === "editor" && <Route path="/review-tasks">
<ReviewTasks state={this.state} />
</Route>}
</>
}
</div>
<footer className={classNames(
"footer",
{ "bg-white": !["/search", "/word"].includes(this.props.location.pathname) },
{ "footer-thick": this.state.options.searchBarPosition === "bottom" && !["/search", "/word"].includes(this.props.location.pathname) },
{ "wee-less-footer": this.state.options.searchBarPosition === "bottom" && ["/search", "/word"].includes(this.props.location.pathname) },
)}>
<Route path="/" exact>
<div className="buttons-footer">
<BottomNavItem label="About" icon="info-circle" page="/about" />
<BottomNavItem label="Settings" icon="cog" page="/settings" />
<BottomNavItem label={auth.currentUser ? "Account" : "Sign In"} icon="user" page="/account" />
{wordlistEnabled(this.state) &&
<BottomNavItem
label={`Wordlist ${this.state.options.wordlistReviewBadge ? textBadge(forReview(this.state.wordlist).length) : ""}`}
icon="list"
page="/wordlist"
/>
}
{this.state.options.level === "editor" &&
<BottomNavItem
label={`Tasks ${textBadge(this.state.reviewTasks.length)}`}
icon="edit"
page="/review-tasks"
/>
}
</div>
</Route>
<Route path={["/about", "/settings", "/new-entries", "/account", "/wordlist", "/edit", "/review-tasks"]}>
<div className="buttons-footer">
<BottomNavItem label="Home" icon="home" page="/" />
</div>
</Route>
{this.state.options.searchBarPosition === "bottom" && <SearchBar
state={this.state}
optionsDispatch={this.handleOptionsUpdate}
handleSearchValueChange={this.handleSearchValueChange}
onBottom
/>}
</footer>
</div>;
}
}
export default withRouter(App);

12
website/src/Context.ts Normal file
View File

@ -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<React.Dispatch<Action>>(null);

View File

@ -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<string | undefined>(undefined);
const [type, setType] = useState<string | undefined>(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 (
<div className="text-center mb-3">
<audio controls>
{src && <source src={src} type={type} />}
</audio>
</div>
);
}
export default AudioPlayButton;

View File

@ -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 (
<Link to={page} className="plain-link">
<div className="bottom-nav-item" data-testid={dataTestId}>
<i className={`fa fa-${icon}`}></i>
<div data-testid="nav-item-label">{label}</div>
</div>
</Link>
);
} else {
return (
<div className="bottom-nav-item clickable" onClick={handleClick} data-testid={dataTestId}>
<i className={`fa fa-${icon}`}></i>
<div data-testid="nav-item-label">{label}</div>
</div>
);
}
};
export default BottomNavItem;

View File

@ -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 (
<div className="mt-4">
<h4 className="text-center" data-testid="loading-notice">
{status === "loading" ? "Loading" : "Updating"}...
</h4>
<div className="loader"></div>
</div>
);
} else if (status === "error loading") {
return (
<div className="text-center mt-4">
<h4 className="mb-4">Error Loading Dictionary</h4>
<p>Please check your internet connection and reload this page</p>
</div>
);
} else {
return null;
}
};
export default DictionaryStatusDisplay;

View File

@ -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 (
<div
className={classNames("entry", { clickable: !nonClickable })}
onClick={(!nonClickable && isolateEntry) ? () => isolateEntry(entry.ts) : undefined}
data-testid="entry"
>
<div>
<strong>
<InlinePs opts={textOptions}>{{ p: entry.p, f: entry.f }}</InlinePs>
</strong>
{` `}
<em>{entry.c}</em>
</div>
<ExtraEntryInfo
entry={entry}
textOptions={textOptions}
/>
<div className="entry-definition">{entry.e}</div>
</div>
);
};
export default Entry;

View File

@ -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 (
<div className="entry-extra-info" data-testid="inflections-info">
<InlinePs opts={textOptions}>{inf.masc[1][0]}</InlinePs>
{` `}
<InlinePs opts={textOptions}>{inf.fem[0][0]}</InlinePs>
</div>
);
}
// masculine noun
if ("masc" in inf) {
return (
<div className="entry-extra-info" data-testid="inflections-info">
<InlinePs opts={textOptions}>{inf.masc[1][0]}</InlinePs>
</div>
);
}
// 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 (
<div className="entry-extra-info">
Arabic Plural: <InlinePs opts={textOptions}>{{
p: entry.app,
f: entry.apf,
}}</InlinePs>
</div>
);
};
const PresentFormInfo = ({ entry, textOptions }: {
entry: Types.DictionaryEntry,
textOptions: Types.TextOptions,
}) => {
/* istanbul ignore next */
if (!(entry.psp && entry.psf)) {
return null;
}
return (
<div className="entry-extra-info">
Present Form: <InlinePs opts={textOptions}>{{
p: `${entry.psp}ي`,
f: `${entry.psf}ee`,
}}
</InlinePs>
</div>
);
};
const PashtoPluralInfo = ({ entry, textOptions }: {
entry: Types.DictionaryEntry,
textOptions: Types.TextOptions,
}) => {
if (!(entry.ppp && entry.ppf)) {
return null;
}
return (
<div className="entry-extra-info">
Plural: <InlinePs opts={textOptions}>{{
p: entry.ppp,
f: entry.ppf,
}}</InlinePs>
</div>
);
};
// TODO: refactor this in a better way
const ExtraEntryInfo = ({ entry, textOptions }: {
entry: Types.DictionaryEntry,
textOptions: Types.TextOptions,
}) => {
return (
<>
<InflectionsInfo entry={entry} textOptions={textOptions} />
<ArabicPluralInfo entry={entry} textOptions={textOptions} />
<PresentFormInfo entry={entry} textOptions={textOptions} />
<PashtoPluralInfo entry={entry} textOptions={textOptions} />
</>
);
};
export default ExtraEntryInfo;

View File

@ -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<boolean>(false);
const [crop, setCrop] = useState({ height: 0 });
const [completedCrop, setCompletedCrop] = useState(null);
const [imgSrc, setImgSrc] = useState<string | undefined>(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 <div className="mt-2">
<div className="d-flex flex-row justify-content-center">
{!cropping ?
<>
<div>
<button className="btn btn-sm btn-secondary mr-3" onClick={startCropping}>
<i className="fas fa-crop" />
</button>
</div>
<div>
<button className="btn btn-sm btn-secondary" onClick={handleRotateImage}>
<i className="fas fa-sync" />
</button>
</div>
</>
:
<>
<div>
<button className="btn btn-sm btn-secondary mr-3" onClick={cancelCropping}>
<i className="fas fa-times" />
</button>
</div>
<div>
<button className="btn btn-sm btn-secondary" onClick={acceptCrop} disabled={crop.height === 0}>
<i className="fas fa-check" />
</button>
</div>
<div className="d-flex align-items-center">
<small className="text-muted ml-3">select area to crop</small>
</div>
</>
}
</div>
<div className="text-center mt-2">
{(cropping && imgSrc) ?
<div style={{ touchAction: "none" }}>
<ReactCrop
src={imgSrc}
onImageLoaded={onLoad}
crop={crop}
// @ts-ignore
onChange={(c) => setCrop(c)}
// @ts-ignore
onComplete={(c) => setCompletedCrop(c)}
/>
<div style={{ display: "none" }}>
<canvas
ref={previewCanvasRef}
// Rounding is important so the canvas width and height matches/is a multiple for sharpness.
style={{
// @ts-ignore
width: Math.round(completedCrop?.width ?? 0),
// @ts-ignore
height: Math.round(completedCrop?.height ?? 0)
}}
/>
</div>
</div>
:
<WordlistImage word={word} />
}
</div>
</div>;
}
export default ImageEditor;

View File

@ -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 <div className="mb-4">
<div className="mb-2"><strong>{displayFormResult(result.form)}</strong></div>
{result.matches.map((match) => <div className="ml-2">
<InlinePs opts={textOptions}>{match.ps}</InlinePs>
<div className="ml-3 my-2">
<em>
{(transitivity === "grammatically transitive" && isPast)
? "Always 3rd pers. masc. plur."
: `${isVerbPos(match.pos) ? (isErgative ? "Obj.:" : "Subj.:") : ""} ${displayPositionResult(match.pos)}`}
</em>
</div>
</div>)}
</div>;
}
export default InflectionSearchResult;

View File

@ -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);
}
}

View File

@ -0,0 +1,5 @@
import "./LoadingElipses.css";
export default function LoadingElipses() {
return <div className="lds-ellipsis"><div></div><div></div><div></div><div></div></div>;
}

View File

@ -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<HTMLDivElement, 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 <div className="clickable" ref={box} onClick={handleClick}>
{guide && <div className="text-muted" style={{ display: "flex", flexDirection: "row", justifyContent: "space-between", marginBottom: "0.5rem", padding: "0 0.10rem" }}>
<div>😫 fail</div>
<div>took 🤔 time</div>
<div>easy 😄</div>
</div>}
<div
style={{
width: "100%",
height: "3rem",
borderRadius: "5px",
display: "flex",
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
padding: "0 1rem",
color: "rgba(255,255,255,0.85)",
opacity: 0.9,
background: "linear-gradient(90deg, rgba(255,0,0,1) 0%, rgba(142,137,0,1) 39%, rgba(0,237,11,1) 100%)",
}}
>
<div><i className="fas fa-times fa-lg"></i></div>
<div><i className="fas fa-check fa-lg"></i></div>
</div>
</div>;
}
export default ReviewScoreInput;

View File

@ -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 (
<button
className="btn btn-outline-secondary"
onClick={() => optionsDispatch({ type: "toggleLanguage" })}
data-testid="languageToggle"
>
<div aria-label={`language-choice-${language === "Pashto" ? "ps-to-en" : "en-to-ps"}`}>
Ps <span className={`fa fa-arrow-${arrowDirection}`} ></span> En
</div>
</button>
);
}
const SearchTypeToggle = ({ searchType }: { searchType: SearchType }) => {
const icon = (searchType === "alphabetical") ? "book" : "bolt";
return (
<button
className="btn btn-outline-secondary"
onClick={() => optionsDispatch({ type: "toggleSearchType" })}
data-testid="searchTypeToggle"
>
<span className={`fa fa-${icon}`} ></span>
</button>
);
};
const placeholder = (state.options.searchType === "alphabetical" && state.options.language === "Pashto")
? "Browse alphabetically"
: `Search ${state.options.language === "Pashto" ? "Pashto" : "English"}`;
return (
<nav className={`navbar bg-light${!onBottom ? " fixed-top" : ""}`} style={{ zIndex: 50, width: "100%" }}>
<div className="form-inline my-1 my-lg-1">
<div className="input-group">
<input
type="text"
style={{ borderRight: "0px", zIndex: 200 }}
placeholder={placeholder}
value={state.searchValue}
onChange={(e) => handleSearchValueChange(e.target.value)}
name="search"
className="form-control py-2 border-right-0 border"
autoFocus={true}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck={false}
dir="auto"
data-testid="searchInput"
data-lpignore="true"
/>
<span className="input-group-append">
<span
className={`btn btn-outline-secondary${!state.searchValue ? " unclickable" : ""} clear-search-button border-left-0 border`}
style={{ borderRadius: 0 }}
onClick={state.searchValue ? () => handleSearchValueChange("") : () => null}
data-testid="clearButton"
>
<i className="fa fa-times" style={!state.searchValue ? { visibility: "hidden" } : {}}></i>
</span>
</span>
<div className="input-group-append">
{state.options.language === "Pashto" &&
<SearchTypeToggle
searchType={state.options.searchType}
/>
}
{<LanguageToggle
language={state.options.language}
/>}
</div>
</div>
</div>
</nav>
);
};
export default SearchBar;

View File

@ -0,0 +1,40 @@
import { useState, useEffect } from "react";
import { getImageAttachment } from "../lib/wordlist-database";
function WordlistImage({ word }: { word: WordlistWord }) {
const [imgSrc, setImgSrc] = useState<string | undefined>(undefined);
useEffect(() => {
if (!("_attachments" in word)) {
console.error("no image attachment to display");
return;
}
getImageAttachment(word).then((imgB64) => {
setImgSrc(imgB64);
});
}, [word]);
return <div className="text-center" style={{ padding: 0, margin: 0 }}>
{imgSrc ?
<img
className="img-fluid"
src={imgSrc}
alt="wordlist img"
/>
:
"imgSize" in word ?
<canvas
className="img-fluid"
{...word.imgSize}
style={{
display: "block",
border: 0,
padding: 0,
margin: "0 auto",
background: "grey",
}}
/>
: <div>IMG SIZE ERROR</div>
}
</div>;
};
export default WordlistImage;

View File

@ -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<HTMLInputElement>(null);
const audioFileInput = useRef<HTMLInputElement>(null);
const [notes, setNotes] = useState<string>(word.notes);
const [loadingImage, setLoadingImage] = useState<boolean>(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 <div>
<div className="mb-3" {...getRootProps()} style={isDragActive ? droppingStyle : {}}>
<div className="form-group">
<label>Notes/context:</label>
<textarea
rows={3}
dir="auto"
className="form-control"
data-testid="wordlistWordContextForm"
placeholder="Add notes/context here..."
data-lpignore="true"
value={notes}
onChange={(e) => setNotes(e.target.value)}
/>
</div>
<div className="d-flex flex-row justify-content-between" {...getInputProps() }>
<div className="d-flex flex-row">
<div>
<label className="btn btn-sm btn-secondary">
<i className="fas fa-camera" />
<input
type="file"
accept="image/*"
ref={imageFileInput}
onChange={() => handleImageInput()}
hidden
/>
</label>
</div>
{(hasAttachment(word, "image")) && <div>
<button className="btn btn-sm btn-outline-secondary ml-2" onClick={removeImage}><i className="fas fa-trash" /></button>
</div>}
<div className="ml-3">
<label className="btn btn-sm btn-secondary">
<i className="fas fa-microphone mx-1" />
<input
type="file"
accept="audio/*"
ref={imageFileInput}
onChange={() => handleAudioInput()}
hidden
/>
</label>
</div>
{(hasAttachment(word, "audio")) && <div>
<button className="btn btn-sm btn-outline-secondary ml-2" onClick={removeAudio}><i className="fas fa-trash" /></button>
</div>}
</div>
<div>
<button
type="button"
className={classNames("btn", "btn-sm", "btn-secondary", { disabled: notes === word.notes })}
onClick={handleNotesUpdate}
data-testid="editWordSubmitButton"
>
{notes === word.notes ? "Notes Saved" : "Save Notes"}
</button>
</div>
</div>
{hasAttachment(word, "image")
? <ImageEditor word={word} />
: loadingImage
? <div className="mt-2">Loading...</div>
: null}
</div>
</div>;
}
export default WordlistWordEditor;

View File

@ -0,0 +1,36 @@
/**
* 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 Bootstrap and its default variables
// Required
$body-bg: #fefefe;
$body-color: #333;
@import '~bootstrap/scss/bootstrap.scss';
// @import "~bootstrap/scss/bootstrap-grid.scss";
// @import "~bootstrap/scss/bootstrap-reboot.scss";
// @import "~bootstrap/scss/_utilities.scss";
// @import "~bootstrap/scss/_functions.scss";
// @import "~bootstrap/scss/_variables.scss";
// @import "~bootstrap/scss/_mixins.scss";
// @import "~bootstrap/scss/_alert.scss";
// @import "~bootstrap/scss/_list-group.scss";
// @import "~bootstrap/scss/_modal.scss";
// @import "~bootstrap/scss/_type.scss";
// @import "~bootstrap/scss/_nav.scss";
// @import "~bootstrap/scss/_navbar.scss";
// @import "~bootstrap/scss/_buttons.scss";
// @import "~bootstrap/scss/_button-group.scss";
// @import "~bootstrap/scss/_forms.scss";
// @import "~bootstrap/scss/_custom-forms.scss";
// @import "~bootstrap/scss/_input-group.scss";
// @import "~bootstrap/scss/_root.scss";
// // Optional

30
website/src/index.tsx Normal file
View File

@ -0,0 +1,30 @@
/**
* 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 ReactDOM from 'react-dom';
import App from './App';
import * as serviceWorkerRegistration from './serviceWorkerRegistration';
import reportWebVitals from './reportWebVitals';
import { BrowserRouter as Router } from "react-router-dom";
ReactDOM.render(
<React.StrictMode>
<Router>
<App />
</Router>
</React.StrictMode>,
document.getElementById('root')
);
serviceWorkerRegistration.register();
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

View File

@ -0,0 +1,31 @@
export const refreshFunctions = {
submissions: () => null,
wordlist: () => null,
reviewTasks: () => null,
};
export function initializeLocalDb(type: "submissions" | "wordlist" | "reviewTasks", refresh: () => void, uid?: string | undefined) {
if (type === "wordlist") {
// @ts-ignore
refreshFunctions.wordlist = refresh;
}
}
export function refreshWordlist() {
refreshFunctions.wordlist();
}
export function deInitializeLocalDb(type: "submissions" | "wordlist" | "reviewTasks") {
return null;
}
export function startLocalDbSync() {
return null;
}
export function getLocalDbName() {
return "";
}
export function getAllDocsLocalDb() {
return [];
}

View File

@ -0,0 +1,56 @@
import { Types as T } from "@lingdocs/pashto-inflector";
import { dictionary } from "../dictionary";
import { baseSupermemo } from "../spaced-repetition";
import { refreshWordlist } from "./pouch-dbs";
let wordlistDb: {
name: string,
db: WordlistWord[],
} = {
name: "userdb-local",
db: [],
};
export async function addToWordlist({ entry, notes }: {
entry: T.DictionaryEntry,
notes: string,
}): Promise<WordlistWord> {
const newEntry: WordlistWord = {
_id: new Date().toJSON(),
warmup: 0,
supermemo: baseSupermemo,
dueDate: Date.now(),
entry,
notes,
};
wordlistDb = {
...wordlistDb,
db: [...wordlistDb.db, newEntry],
};
refreshWordlist();
return newEntry;
}
export async function updateWordlistWord(word: WordlistWord): Promise<WordlistWord> {
const index = wordlistDb.db.findIndex((w) => w._id === word._id);
const old = wordlistDb.db[index];
const updated = {
...old,
...word,
entry: dictionary.findOneByTs(word.entry.ts) || word.entry,
}
wordlistDb.db[index] = updated;
refreshWordlist();
return updated;
}
export async function getWordlist(limit?: number): Promise<WordlistWord[]> {
return wordlistDb.db;
}
export async function deleteWordFromWordlist(id: string): Promise<void> {
const index = wordlistDb.db.findIndex((w) => w._id === id);
wordlistDb.db.splice(index, 1);
refreshWordlist();
}

View File

@ -0,0 +1,39 @@
/**
* 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 {
addToAttachmentObject,
removeAttachmentFromObject,
} from "./wordlist-database";
export function addAudioToWordlistWord(word: WordlistWord, file: File): WordlistWord {
return {
...word,
_attachments: addToAttachmentObject(
"_attachments" in word ? word._attachments : {},
file.name,
{
"content_type": file.type,
data: file,
},
),
};
}
export function removeAudioFromWordlistWord(word: WordlistWordWAttachments) {
const attachments = "_attachments" in word
? removeAttachmentFromObject(word._attachments, "audio")
: undefined;
const { _attachments, ...rest } = word;
return {
...attachments ? {
_attachments: attachments,
} : {},
...rest
};
}

View File

@ -0,0 +1,56 @@
import { auth } from "./firebase";
import * as BT from "./backend-types";
const functionsBaseUrl = // process.env.NODE_ENV === "development"
// "http://127.0.0.1:5001/lingdocs/europe-west1/"
"https://europe-west1-lingdocs.cloudfunctions.net/";
export async function publishDictionary(): Promise<BT.PublishDictionaryResponse> {
const res = await tokenFetch("publishDictionary");
if (!res) {
throw new Error("Connection error/offline");
}
return res;
}
export async function upgradeAccount(password: string): Promise<BT.UpgradeUserResponse> {
const res = await tokenFetch("upgradeUser", "POST", { password });
if (!res) {
throw new Error("Connection error/offline");
}
return res;
}
export async function postSubmissions(submissions: BT.SubmissionsRequest): Promise<BT.SubmissionsResponse> {
return await tokenFetch("submissions", "POST", submissions) as BT.SubmissionsResponse;
}
export async function loadUserInfo(): Promise<undefined | BT.CouchDbUser> {
const res = await tokenFetch("getUserInfo", "GET") as BT.GetUserInfoResponse;
return "user" in res ? res.user : undefined;
}
// TODO: HARD TYPING OF THIS WITH THE subUrl and return values etc?
async function tokenFetch(subUrl: string, method?: "GET" | "POST", body?: any): Promise<any> {
if (!auth.currentUser) {
throw new Error("not signed in");
}
try {
const token = await auth.currentUser.getIdToken();
const response = await fetch(`${functionsBaseUrl}${subUrl}`, {
method,
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`,
},
...body ? {
body: JSON.stringify(body),
} : {},
});
return await response.json();
} catch (err) {
console.error(err);
throw err;
}
}

View File

@ -0,0 +1,108 @@
/**
* 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 { Types as T } from "@lingdocs/pashto-inflector";
export type PublishDictionaryResponse = {
ok: true,
info: T.DictionaryInfo,
} | {
ok: false,
errors: T.DictionaryEntryError[],
};
export type UserInfo = {
uid: string,
email: string | null,
displayName: string | null,
}
export type Submission = Edit | ReviewTask;
export type Edit = EntryEdit | NewEntry | EntryDeletion
export type SubmissionBase = {
sTs: number,
user: UserInfo,
_id: string,
}
export type ReviewTask = Issue | EditSuggestion | EntrySuggestion;
export type EntryEdit = SubmissionBase & {
type: "entry edit",
entry: T.DictionaryEntry,
};
export type EntryDeletion = SubmissionBase & {
type: "entry deletion",
ts: number,
}
export type NewEntry = SubmissionBase & {
type: "new entry",
entry: T.DictionaryEntry,
};
export type Issue = SubmissionBase & {
type: "issue",
content: string,
};
export type EditSuggestion = SubmissionBase & {
type: "edit suggestion",
entry: T.DictionaryEntry,
comment: string,
}
export type EntrySuggestion = SubmissionBase & {
type: "entry suggestion",
entry: T.DictionaryEntry,
comment: string,
}
export type SubmissionsRequest = Submission[];
export type SubmissionsResponse = {
ok: true,
message: string,
submissions: Submission[],
};
export type UserLevel = "basic" | "student" | "editor";
export type CouchDbUser = {
_id: string,
type: "user",
_rev?: string,
name: string,
email: string,
providerData: any,
displayName: string,
roles: [],
password?: string,
level: UserLevel,
userdbPassword: string,
}
export type GetUserInfoResponse = {
ok: true,
message: "no couchdb user found",
} | {
ok: true,
user: CouchDbUser,
}
export type UpgradeUserResponse = {
ok: false,
error: "incorrect password",
} | {
ok: true,
message: "user already upgraded" | "user upgraded to student",
};

11
website/src/lib/badges.ts Normal file
View File

@ -0,0 +1,11 @@
/**
* 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.
*
*/
export function textBadge(number: number): string {
return `${number ? ` (${number})` : ""}`;
}

View File

@ -0,0 +1,243 @@
import { DictionaryDb } from "./dictionary-core";
import fetchMock from "jest-fetch-mock";
import {
writeDictionary,
writeDictionaryInfo,
Types as T,
} from "@lingdocs/pashto-inflector"
// tslint:disable-next-line
require("fake-indexeddb/auto");
// tslint:disable-next-line
const FDBFactory = require("fake-indexeddb/lib/FDBFactory");
fetchMock.enableMocks();
beforeAll(() => {
indexedDB = new FDBFactory;
});
afterEach(() => {
jest.clearAllMocks();
});
const dictInfo: T.DictionaryInfo = {
title: "testing dictionary",
license: "none",
url: "https://www.test.com/dict",
infoUrl: "https://www.test.com/dictInfo",
release: 1,
numberOfEntries: 3,
};
const dictionary: T.Dictionary = {
info: dictInfo,
entries: [
{"i":0,"ts":1575073756109,"p":"آب","f":"aab","g":"aab","e":"water (Farsi - poetic); luster, brilliance; honor, respect, dignity, reputation","c":"n. m."},
{"i":1,"ts":1527818508,"p":"آب انبار","f":"aabambaar","g":"aabambaar","e":"reservoir, pool (of water)","c":"n. m."},
{"i":2,"ts":1527820284,"p":"آب باز","f":"aabbáaz","g":"aabbaaz","e":"swimmer","c":"n. m."},
],
}
function makeFakeDictServer(release: 1 | 2 | 3 | "offline") {
if (release === "offline") {
return function() {
return Promise.reject(new Error("connection error"))
};
}
const info: T.DictionaryInfo = { ...dictInfo, release };
const dict: T.Dictionary = { ...dictionary, info };
const dictInfoBuffer = writeDictionaryInfo(info);
const dictBuffer = writeDictionary(dict);
return function fakeDictServer(url: string) {
if (url === "http://test.com/info.json") {
return Promise.resolve({ arrayBuffer: () => Promise.resolve(dictInfoBuffer) });
}
if (url === "http://test.com/dict.json") {
return Promise.resolve({ arrayBuffer: () => Promise.resolve(dictBuffer) });
}
return Promise.resolve({ json: () => Promise.resolve({ message: "404 not found "})});
}
}
test("should fail to initialize w/out internet connection", async () => {
// @ts-ignore
fetch.mockImplementation(makeFakeDictServer("offline"));
const myDict = new DictionaryDb({
url: "http://test.com/dict.json",
infoUrl: "http://test.com/info.json",
infoLocalStorageKey: "mykey",
collectionName: "database",
});
let errored = false;
try {
await myDict.initialize();
} catch (e) {
errored = true;
}
expect(errored).toBe(true);
expect(fetch).toHaveBeenCalledTimes(1);
});
test("should initialize ok", async () => {
// @ts-ignore
fetch.mockImplementation(makeFakeDictServer(1));
const myDict = new DictionaryDb({
url: "http://test.com/dict.json",
infoUrl: "http://test.com/info.json",
infoLocalStorageKey: "mykey",
collectionName: "database",
});
const res = await myDict.initialize();
expect(res.response).toBe("loaded first time");
expect(fetch).toHaveBeenCalledTimes(1);
});
test("should load the existing dictionary if one has already been loaded", async () => {
// @ts-ignore
fetch.mockImplementation(makeFakeDictServer(1));
const myDict = new DictionaryDb({
url: "http://test.com/dict.json",
infoUrl: "http://test.com/info.json",
infoLocalStorageKey: "mykey",
collectionName: "database",
});
const res = await myDict.initialize();
expect(res.response).toBe("loaded from saved");
});
test("should use the saved dictionary if offline", async () => {
// @ts-ignore
fetch.mockImplementation(makeFakeDictServer("offline"));
const myDict = new DictionaryDb({
url: "http://test.com/dict.json",
infoUrl: "http://test.com/info.json",
infoLocalStorageKey: "mykey",
collectionName: "database",
});
const res = await myDict.initialize();
expect(res.response).toBe("loaded from saved");
});
test("shouldn't update if there's no need to", async () => {
// @ts-ignore
fetch.mockImplementation(makeFakeDictServer(1));
const myDict = new DictionaryDb({
url: "http://test.com/dict.json",
infoUrl: "http://test.com/info.json",
infoLocalStorageKey: "mykey",
collectionName: "database",
});
await myDict.initialize();
const res = await myDict.updateDictionary(() => null);
expect(res.response).toBe("no need for update");
});
test("should update if there's a new dictionary available and the update notification function should be called", async () => {
// @ts-ignore
fetch.mockImplementation(makeFakeDictServer(2));
const myDict = new DictionaryDb({
url: "http://test.com/dict.json",
infoUrl: "http://test.com/info.json",
infoLocalStorageKey: "mykey",
collectionName: "database",
});
await myDict.initialize();
const updateNotificationFunction = jest.fn();
const res = await myDict.updateDictionary(updateNotificationFunction);
expect(updateNotificationFunction).toBeCalledTimes(1);
expect(res.response).toBe("updated");
});
test("should report back if unable to check for a new dictionary and the update notification function should not be called", async () => {
// @ts-ignore
fetch.mockImplementation(makeFakeDictServer("offline"));
const myDict = new DictionaryDb({
url: "http://test.com/dict.json",
infoUrl: "http://test.com/info.json",
infoLocalStorageKey: "mykey",
collectionName: "database",
});
await myDict.initialize();
const updateNotificationFunction = jest.fn();
const res = await myDict.updateDictionary(updateNotificationFunction);
expect(updateNotificationFunction).toBeCalledTimes(0);
expect(res.response).toBe("unable to check");
});
test("should update if there's a new dictionary available", async () => {
// @ts-ignore
fetch.mockImplementation(makeFakeDictServer(3));
const myDict = new DictionaryDb({
url: "http://test.com/dict.json",
infoUrl: "http://test.com/info.json",
infoLocalStorageKey: "mykey",
collectionName: "database",
});
await myDict.initialize();
const updateNotificationFunction = jest.fn();
const res = await myDict.updateDictionary(updateNotificationFunction);
expect(updateNotificationFunction).toBeCalledTimes(1);
expect(res.response).toBe("updated");
});
test("collection should be accesible after initialization", async () => {
// @ts-ignore
fetch.mockImplementation(makeFakeDictServer("offline"));
const myDict = new DictionaryDb({
url: "http://test.com/dict.json",
infoUrl: "http://test.com/info.json",
infoLocalStorageKey: "mykey",
collectionName: "database",
});
await myDict.initialize();
// should work after initialzation
const oneWord = myDict.collection.by("ts", 1575073756109);
expect(oneWord).toEqual({
$loki: 1,
i: 0,
ts: 1575073756109,
p: 'آب',
f: 'aab',
g: "aab",
e: 'water (Farsi - poetic); luster, brilliance; honor, respect, dignity, reputation',
c: 'n. m.',
});
});
test("findeOneByTs should work", async () => {
// @ts-ignore
fetch.mockImplementation(makeFakeDictServer("offline"));
const myDict = new DictionaryDb({
url: "http://test.com/dict.json",
infoUrl: "http://test.com/info.json",
infoLocalStorageKey: "mykey",
collectionName: "database",
});
// should return undefined if not initialized
let oneWord = myDict.findOneByTs(1575073756109);
expect(oneWord).toBeUndefined();
await myDict.initialize();
// should work after initialzation
oneWord = myDict.findOneByTs(1575073756109);
expect(oneWord).toEqual({
i: 0,
ts: 1575073756109,
p: 'آب',
f: 'aab',
g: "aab",
e: 'water (Farsi - poetic); luster, brilliance; honor, respect, dignity, reputation',
c: 'n. m.',
});
await myDict.updateDictionary(() => null);
// and after update
oneWord = myDict.findOneByTs(1575073756109);
expect(oneWord).toEqual({
i: 0,
ts: 1575073756109,
p: 'آب',
f: 'aab',
g: "aab",
e: 'water (Farsi - poetic); luster, brilliance; honor, respect, dignity, reputation',
c: 'n. m.',
});
});

View File

@ -0,0 +1,232 @@
import loki from "lokijs";
import {
Types as T,
readDictionary,
readDictionaryInfo,
} from "@lingdocs/pashto-inflector";
const dontUseFaultyIndexedDB = (): boolean => (
/^Apple/.test(navigator.vendor) && /AppleWebKit[/]60.*Version[/][89][.]/.test(navigator.appVersion)
);
export class DictionaryDb {
// config variables
private dictionaryInfoLocalStorageKey: string;
private dictionaryCollectionName: string;
private dictionaryUrl: string;
private dictionaryInfoUrl: string;
private lokidb: loki;
// state
private ready = false;
// @ts-ignore
public collection: Collection<any>;
constructor(options: {
url: string,
infoUrl: string,
collectionName?: string,
infoLocalStorageKey?: string,
}) {
this.dictionaryUrl = options.url;
this.dictionaryInfoUrl = options.infoUrl;
this.dictionaryInfoLocalStorageKey = options.infoLocalStorageKey || "dictionaryInfo";
this.dictionaryCollectionName = options.collectionName || "pdictionary";
if (dontUseFaultyIndexedDB()) {
this.lokidb = new loki(this.dictionaryUrl, {
autoload: false,
autosave: false,
env: "BROWSER",
});
} else {
const LokiIndexedAdapter = new loki("").getIndexedAdapter();
const idbAdapter = new LokiIndexedAdapter();
this.lokidb = new loki(this.dictionaryUrl, {
adapter: idbAdapter,
autoload: false,
autosave: false,
env: "BROWSER",
});
}
}
private putDictionaryInfoInLocalStorage(info: T.DictionaryInfo) {
localStorage.setItem(this.dictionaryInfoLocalStorageKey, JSON.stringify(info));
}
private getDictionaryInfoFromLocalStorage(): T.DictionaryInfo {
const raw = localStorage.getItem(this.dictionaryInfoLocalStorageKey);
if (raw) {
return JSON.parse(raw) as T.DictionaryInfo;
}
return {
title: "not found",
license: "not found",
release: 0,
numberOfEntries: 0,
url: "not found",
infoUrl: "not found",
};
}
private async downloadDictionary(): Promise<T.Dictionary> {
const res = await fetch(this.dictionaryUrl);
const buffer = await res.arrayBuffer();
return readDictionary(buffer as Uint8Array);
}
private async downloadDictionaryInfo(): Promise<T.DictionaryInfo> {
const res = await fetch(this.dictionaryInfoUrl);
const buffer = await res.arrayBuffer();
return readDictionaryInfo(buffer as Uint8Array);
}
private async addDictionaryToLoki(dictionary: T.Dictionary): Promise<"done"> {
return await new Promise((resolve: (value: "done") => void, reject) => {
// Add it to Lokijs
this.collection = this.lokidb.addCollection(this.dictionaryCollectionName, {
// TODO: THIS ISN'T WORKING!
disableMeta: true,
indices: ["i", "p"],
unique: ["ts"],
});
this.collection.insert(dictionary.entries);
this.lokidb.saveDatabase((err) => {
/* istanbul ignore next */
if (err) {
console.error("error saving database: " + err);
reject(err);
} else {
// Once the dictionary has for sure been saved in IndexedDb, save the dictionary info
this.putDictionaryInfoInLocalStorage(dictionary.info);
this.ready = true;
resolve("done");
}
});
});
}
/**
* Initializes the dictionary for use. This will look to make sure the dictionary has the latest version, or will revert to an offline version
*/
public async initialize(): Promise<{
response: "loaded first time" | "loaded from saved",
dictionaryInfo: T.DictionaryInfo,
}> {
try {
return await new Promise((resolve: (value: {
response: "loaded first time" | "loaded from saved",
dictionaryInfo: T.DictionaryInfo,
}) => void, reject) => {
this.lokidb.loadDatabase({}, async (err: Error) => {
/* istanbul ignore next */
if (err) {
console.error(err);
reject(err);
}
// Step 1: Base dictionary initialization
// Check that the dictionary is set up and available
this.collection = this.lokidb.getCollection(this.dictionaryCollectionName);
// TODO: make a better check that the dictionary really is in there - like the size or something
// let firstTimeDownload = false;
const offlineDictionaryExists = !!this.collection;
if (offlineDictionaryExists) {
this.ready = true;
resolve({
response: "loaded from saved",
dictionaryInfo: this.getDictionaryInfoFromLocalStorage(),
});
return;
}
// There is no previously saved offline dictionary
// initialize a new one
localStorage.removeItem(this.dictionaryInfoLocalStorageKey);
// Get the dictionary
try {
const dictionary = await this.downloadDictionary();
await this.addDictionaryToLoki(dictionary);
resolve({
response: "loaded first time",
dictionaryInfo: dictionary.info,
});
} catch (e) {
// console.log("bad error");
// console.log(e);
console.error("error loading dictionary for the first time");
console.error(e);
reject();
}
});
});
} catch (e) {
console.error(e);
throw e;
}
}
public async updateDictionary(notifyUpdateComing: () => void): Promise<{
response: "no need for update" | "updated" | "unable to check",
dictionaryInfo: T.DictionaryInfo,
}> {
const clientDictionaryInfo = this.getDictionaryInfoFromLocalStorage();
let dictionary: T.Dictionary;
try {
const latestDictionaryInfo = await this.downloadDictionaryInfo();
// See if client is up to date with latest published version
if (latestDictionaryInfo.release === clientDictionaryInfo.release) {
return {
response: "no need for update",
dictionaryInfo: clientDictionaryInfo,
};
}
// new version available
// Download the latest dictionary
// Will download new dictionary, remove previous info in case something gets stopped half way
dictionary = await this.downloadDictionary();
} catch (e) {
return {
response: "unable to check",
dictionaryInfo: clientDictionaryInfo,
};
}
try {
notifyUpdateComing();
this.ready = false;
localStorage.removeItem(this.dictionaryInfoLocalStorageKey);
this.collection.clear();
this.lokidb.removeCollection(this.dictionaryCollectionName);
await (async () => {
return new Promise((resolve: (value: "done") => void, reject) => {
this.lokidb.saveDatabase(() => {
resolve("done");
});
});
})();
await this.addDictionaryToLoki(dictionary);
return {
response: "updated",
dictionaryInfo: dictionary.info,
};
} catch (e) {
throw new Error(e);
}
}
/**
* Returns a single word from it's timestamp (ts)
*/
// TODO: not working in app usage now now with new 'this' issues
public findOneByTs(ts: number): T.DictionaryEntry | undefined {
if (!this.ready) {
return undefined;
}
const res = this.collection.by("ts", ts);
if (!res) {
return undefined;
}
// remove $loki and meta
const { $loki, meta, ...word } = res;
return word;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,384 @@
/**
* 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 { DictionaryDb } from "./dictionary-core";
import sanitizePashto from "./sanitize-pashto";
import fillerWords from "./filler-words";
import {
Types,
convertSpelling,
simplifyPhonetics,
} from "@lingdocs/pashto-inflector";
import { isPashtoScript } from "./is-pashto";
import { fuzzifyPashto } from "./fuzzify-pashto/fuzzify-pashto";
// @ts-ignore
import relevancy from "relevancy";
import { makeAWeeBitFuzzy } from "./wee-bit-fuzzy";
// const dictionaryBaseUrl = "https://storage.googleapis.com/lingdocs/";
const dictionaryUrl = `https://storage.googleapis.com/lingdocs/dictionary`;
const dictionaryInfoUrl = `https://storage.googleapis.com/lingdocs/dictionary-info`;
const dictionaryInfoLocalStorageKey = "dictionaryInfo5";
const dictionaryCollectionName = "dictionary3";
// const dictionaryDatabaseName = "dictdb.db";
export const pageSize = 35;
const relevancySorter = new relevancy.Sorter();
const db = indexedDB.open('inPrivate');
db.onerror = (e) => {
console.error(e);
alert("Your browser does not have IndexedDB enabled. This might be because you are using private mode. Please use regular mode or enable IndexedDB to use this dictionary");
}
const dictDb = new DictionaryDb({
url: dictionaryUrl,
infoUrl: dictionaryInfoUrl,
collectionName: dictionaryCollectionName,
infoLocalStorageKey: dictionaryInfoLocalStorageKey,
});
function makeSearchStringSafe(searchString: string): string {
return searchString.replace(/[#-.]|[[-^]|[?|{}]/g, "");
}
function fuzzifyEnglish(input: string): string {
const safeInput = input.trim().replace(/[#-.]|[[-^]|[?|{}]/g, "");
// TODO: Could do: cover british/american things like offense / offence
return safeInput.replace("to ", "")
.replace(/our/g, "ou?r")
.replace(/or/g, "ou?r");
}
function chunkOutArray<T>(arr: T[], chunkSize: number): T[][] {
const R: T[][] = [];
for (let i = 0; i < arr.length; i += chunkSize) {
R.push(arr.slice(i, i + chunkSize));
}
return R;
}
function getExpForInflections(input: string, index: "p" | "f"): RegExp {
let base = input;
if (index === "f") {
if (["e", "é", "a", "á", "ó", "o"].includes(input.slice(-1))) {
base = input.slice(0, -1);
}
return new RegExp(`\\b${base}`);
}
if (["ه", "ې", "و"].includes(input.slice(-1))) {
base = input.slice(0, -1);
}
return new RegExp(`^${base}[و|ې|ه]?`);
}
function tsOneMonthBack(): number {
// https://stackoverflow.com/a/24049314/8620945
const d = new Date();
const m = d.getMonth();
d.setMonth(d.getMonth() - 1);
// If still in same month, set date to last day of
// previous month
if (d.getMonth() === m) d.setDate(0);
d.setHours(0, 0, 0);
d.setMilliseconds(0);
// Get the time value in milliseconds and convert to seconds
return d.getTime();
}
function alphabeticalLookup({ searchString, page }: {
searchString: string,
page: number,
}): Types.DictionaryEntry[] {
const r = new RegExp("^" + sanitizePashto(makeSearchStringSafe(searchString)));
const regexResults = dictDb.collection.find({
$or: [
{p: { $regex: r }},
{g: { $regex: r }},
],
});
const indexNumbers = regexResults.map((mpd) => mpd.i);
// Find the first matching word occuring first in the Pashto Index
let firstIndexNumber = null;
if (indexNumbers.length) {
firstIndexNumber = Math.min(...indexNumbers);
}
// $gt query from that first occurance
if (firstIndexNumber !== null) {
return dictDb.collection.chain()
.find({ i: { $gt: firstIndexNumber - 1 }})
.simplesort("i")
.limit(page * pageSize)
.data();
}
return [];
}
function fuzzyLookup({ searchString, language, page } : {
searchString: string,
language: "Pashto" | "English" | "Both",
page: number,
}) {
return language === "Pashto"
? pashtoFuzzyLookup({ searchString, page })
: englishLookup({ searchString, page });
}
function englishLookup({ searchString, page }: {
searchString: string,
page: number,
}) {
let resultsGiven: number[] = [];
// get exact results
const exactQuery = {
e: {
$regex: new RegExp(`^${fuzzifyEnglish(searchString)}$`, "i"),
},
};
const exactResultsLimit = pageSize < 10 ? Math.floor(pageSize / 2) : 10;
const exactResults = dictDb.collection.chain()
.find(exactQuery)
.limit(exactResultsLimit)
.simplesort("i")
.data();
resultsGiven = exactResults.map((mpd) => mpd.$loki);
// get results with full word match at beginning of string
const startingQuery = {
e: {
$regex: new RegExp(`^${fuzzifyEnglish(searchString)}\\b`, "i"),
},
$loki: { $nin: resultsGiven },
};
const startingResultsLimit = (pageSize * page) - resultsGiven.length;
const startingResults = dictDb.collection.chain()
.find(startingQuery)
.limit(startingResultsLimit)
.simplesort("i")
.data();
resultsGiven = [...resultsGiven, ...startingResults.map((mpd) => mpd.$loki)];
// get results with full word match anywhere
const fullWordQuery = {
e: {
$regex: new RegExp(`\\b${fuzzifyEnglish(searchString)}\\b`, "i"),
},
$loki: { $nin: resultsGiven },
};
const fullWordResultsLimit = (pageSize * page) - resultsGiven.length;
const fullWordResults = dictDb.collection.chain()
.find(fullWordQuery)
.limit(fullWordResultsLimit)
.simplesort("i")
.data();
resultsGiven = [...resultsGiven, ...fullWordResults.map((mpd) => mpd.$loki)]
// get results with partial match anywhere
const partialMatchQuery = {
e: {
$regex: new RegExp(`${fuzzifyEnglish(searchString)}`, "i"),
},
$loki: { $nin: resultsGiven },
};
const partialMatchLimit = (pageSize * page) - resultsGiven.length;
const partialMatchResults = dictDb.collection.chain()
.find(partialMatchQuery)
.limit(partialMatchLimit)
.simplesort("i")
.data();
const results = [
...exactResults,
...startingResults,
...fullWordResults,
...partialMatchResults,
];
return results;
}
function pashtoExactLookup(searchString: string): Types.DictionaryEntry[] {
const index = isPashtoScript(searchString) ? "p" : "g";
const search = index === "g" ? simplifyPhonetics(searchString) : searchString;
return dictDb.collection.find({
[index]: search,
});
}
function pashtoFuzzyLookup({ searchString, page }: {
searchString: string,
page: number,
}): Types.DictionaryEntry[] {
let resultsGiven: number[] = [];
// Check if it's in Pashto or Latin script
const searchStringToUse = sanitizePashto(makeSearchStringSafe(searchString));
const index = isPashtoScript(searchStringToUse) ? "p" : "g";
const search = index === "g" ? simplifyPhonetics(searchStringToUse) : searchStringToUse;
const infIndex = index === "p" ? "p" : "f";
// Get exact matches
const exactExpression = new RegExp("^" + search);
const weeBitFuzzy = new RegExp("^" + makeAWeeBitFuzzy(search, infIndex));
// prepare exact expression for special matching
// TODO: This is all a bit messy and could be done without regex
const expressionForInflections = getExpForInflections(search, infIndex);
const arabicPluralIndex = `ap${infIndex}`;
const pashtoPluralIndex = `pp${infIndex}`;
const presentStemIndex = `ps${infIndex}`;
const firstInfIndex = `infa${infIndex}`;
const secondInfIndex = `infb${infIndex}`;
const pashtoExactResultFields = [
{
[index]: { $regex: exactExpression },
}, {
[arabicPluralIndex]: { $regex: weeBitFuzzy },
}, {
[pashtoPluralIndex]: { $regex: weeBitFuzzy },
}, {
[presentStemIndex]: { $regex: weeBitFuzzy },
},
{
[firstInfIndex]: { $regex: expressionForInflections },
},
{
[secondInfIndex]: { $regex: expressionForInflections },
},
];
const exactQuery = { $or: [...pashtoExactResultFields] };
// just special incase using really small limits
// multiple times scrolling / chunking / sorting might get a bit messed up if using a limit of less than 10
const exactResultsLimit = pageSize < 10 ? Math.floor(pageSize / 2) : 10;
const exactResults = dictDb.collection.chain()
.find(exactQuery)
.limit(exactResultsLimit)
.simplesort("i")
.data();
resultsGiven = exactResults.map((mpd) => mpd.$loki);
// Get fuzzy matches
const pashtoRegExLogic = fuzzifyPashto(search, {
script: index === "p" ? "Pashto" : "Latin",
simplifiedLatin: index === "g",
allowSpacesInWords: true,
matchStart: "word",
});
const fuzzyPashtoExperssion = new RegExp(pashtoRegExLogic);
const pashtoFuzzyQuery = [
{
[index]: { $regex: fuzzyPashtoExperssion },
}, { // TODO: Issue, this fuzzy doesn't line up well because it's not the simplified phonetics - still has 's etc
[arabicPluralIndex]: { $regex: fuzzyPashtoExperssion },
}, {
[presentStemIndex]: { $regex: fuzzyPashtoExperssion },
}
];
// fuzzy results should be allowed to take up the rest of the limit (not used up by exact results)
const fuzzyResultsLimit = (pageSize * page) - resultsGiven.length;
// don't get these fuzzy results if searching in only English
const fuzzyQuery = {
$or: pashtoFuzzyQuery,
$loki: { $nin: resultsGiven },
};
const fuzzyResults = dictDb.collection.chain()
.find(fuzzyQuery)
.limit(fuzzyResultsLimit)
.data();
const results = [...exactResults, ...fuzzyResults];
const chunksToSort = chunkOutArray(results, pageSize);
// sort out each chunk (based on limit used multiple times by infinite scroll)
// so that when infinite scrolling, it doesn't resort the previous chunks given
// TODO: If on the first page, only sort the fuzzyResults
return chunksToSort
.reduce((acc, cur, i) => ((i === 0)
? [
...sortByRelevancy(cur.slice(0, exactResults.length), search, index),
...sortByRelevancy(cur.slice(exactResults.length), search, index),
]
: [
...acc,
...sortByRelevancy(cur, search, index),
]), []);
}
function sortByRelevancy<T>(arr: T[], searchI: string, index: string): T[] {
return relevancySorter.sort(arr, searchI, (obj: any, calc: any) => calc(obj[index]))
}
function relatedWordsLookup(word: Types.DictionaryEntry): Types.DictionaryEntry[] {
const wordArray = word.e.trim()
.replace(/\?/g, "")
.replace(/( |,|\.|!|;|\(|\))/g, " ")
.split(/ +/)
.filter((w: string) => !fillerWords.includes(w));
let results: Types.DictionaryEntry[] = [];
wordArray.forEach((w: string) => {
let r: RegExp;
try {
r = new RegExp(`\\b${w}\\b`, "i");
const relatedToWord = dictDb.collection.chain()
.find({
// don't include the original word
ts: { $ne: word.ts },
e: { $regex: r },
})
.limit(5)
.data();
results = [...results, ...relatedToWord];
// In case there's some weird regex fail
} catch (error) {
/* istanbul ignore next */
console.error(error);
}
});
// Remove duplicate items - https://stackoverflow.com/questions/40811451/remove-duplicates-from-a-array-of-objects
results = results.filter(function(a) {
// @ts-ignore
return !this[a.$loki] && (this[a.$loki] = true);
}, Object.create(null));
return(results);
}
export function allEntries() {
return dictDb.collection.find();
}
export const dictionary: DictionaryAPI = {
// NOTE: For some reason that I do not understand you have to pass the functions from the
// dictionary core class in like this... ie. initialize: dictDb.initialize will mess up the this usage
// in the dictionary core class
initialize: async () => await dictDb.initialize(),
update: async (notifyUpdateComing: () => void) => await dictDb.updateDictionary(notifyUpdateComing),
search: function(state: State): Types.DictionaryEntry[] {
const searchString = convertSpelling(
state.searchValue,
state.options.textOptions.spelling,
);
if (state.searchValue === "") {
return [];
}
return (state.options.searchType === "alphabetical" && state.options.language === "Pashto")
? alphabeticalLookup({
searchString,
page: state.page
})
: fuzzyLookup({
searchString,
language: state.options.language,
page: state.page,
});
},
exactPashtoSearch: pashtoExactLookup,
getNewWordsThisMonth: function(): Types.DictionaryEntry[] {
return dictDb.collection.chain()
.find({ ts: { $gt: tsOneMonthBack() }})
.simplesort("ts")
.data()
.reverse();
},
findOneByTs: (ts: number) => dictDb.findOneByTs(ts),
findRelatedEntries: function(entry: Types.DictionaryEntry): Types.DictionaryEntry[] {
return relatedWordsLookup(entry);
},
}

View File

@ -0,0 +1,46 @@
/**
* 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.
*
*/
const fillerWords = [
"to",
"being",
"through",
"for",
"is",
"by",
"and",
"if",
"a",
"the",
"Arabic",
"plural",
"verb",
"stem",
"of",
"do",
"it",
"be",
"become",
"up",
"when",
"out",
"up",
"inflected",
"attributive",
"etc",
"ie",
"literal",
"figurative",
"lit",
"fig",
"make",
"etc.",
"",
];
export default fillerWords;

View File

@ -0,0 +1,30 @@
import firebase from "firebase/app";
import "firebase/auth";
// Configure Firebase.
const config = {
apiKey: "AIzaSyDZrG2BpQi0MGktEKXL6mIWeAYEn_gFacw",
authDomain: "lingdocs.firebaseapp.com",
projectId: "lingdocs",
};
firebase.initializeApp(config);
export const authUiConfig = {
// Popup signin flow rather than redirect flow.
signInFlow: "popup",
signInOptions: [
firebase.auth.EmailAuthProvider.PROVIDER_ID,
firebase.auth.GithubAuthProvider.PROVIDER_ID,
// twitter auth is set up, but not using because it doesn't provide an email
// firebase.auth.TwitterAuthProvider.PROVIDER_ID,
// firebase.auth.GoogleAuthProvider.PROVIDER_ID,
],
callbacks: {
// Avoid redirects after sign-in.
signInSuccessWithAuthResult: () => false,
},
};
export const auth = firebase.auth();

View File

@ -0,0 +1,459 @@
/**
* Copyright (c) lingdocs.com
*
* This source code is licensed under the GPL-3.0 license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import { fuzzifyPashto } from "./fuzzify-pashto";
type match = [string, string];
interface IDefaultInfoBlock {
matches: match[];
nonMatches: match[];
}
const defaultInfo: IDefaultInfoBlock = {
matches: [
["اوسېدل", "وسېدل"],
["انبیه", "امبیه"],
["سرک", "صړق"],
["انطذاړ", "انتظار"],
["مالوم", "معلوم"],
["معلوم", "مالوم"],
["قېصا", "کيسه"],
["کور", "قوړ"],
["گرزيدل", "ګرځېدل"],
["سنگہ", "څنګه"],
["کار", "قهر"],
["زبا", "ژبه"],
["سڑے", "سړی"],
["استمال", "استعمال"],
["اعمل", "عمل"],
["جنگل", "ځنګل"],
["ځال", "جال"],
["زنگل", "ځنګل"],
["جرل", "ژړل"],
["فرمائيل", "فرمايل"],
// using هٔ as two characters
["وارېدهٔ", "وارېده"],
// using as one character
["واريدۀ", "وارېده"],
["زوی", "زوئے"],
["ئے", "يې"],
// optional ا s in middle
["توقف", "تواقف"],
// option ي s in middle
["مناظره", "مناظيره"],
["بلکل", "بالکل"],
["مهرب", "محراب"],
["مسول", "مسوول"],
["ډارونکي", "ډاروونکي"],
["ډانګره", "ډانګوره"],
["هنداره", "هینداره"],
["متأصفانه", "متاسفانه"],
["وازف", "واظیف"],
["شوریٰ", "شورا"],
["ځنبېدل", "ځمبېدل"],
],
nonMatches: [
["سرک", "ترک"],
["کار", "بېکاري"],
// ا should not be optional in the beginning or end
["اړتیا", "اړتی"],
["ړتیا", "اړتیا"],
// و should not be optional in the begenning or end
["ورور", "رور"],
],
};
const defaultLatinInfo: IDefaultInfoBlock = {
matches: [
// TODO:
["anbiya", "ambiya"],
["lootfun", "lUtfan"],
["sarey", "saRey"],
["senga", "tsanga"],
["daktur", "DakTar"],
["iteebar", "itibaar"],
["dzaal", "jaal"],
["bekaar", "bekáar"],
["bekár", "bekaar"],
["chaai", "cháai"],
["day", "daai"],
["dai", "dey"],
["daktar", "Daktár"],
["sarái", "saRey"],
["beter", "bahtár"],
["doosti", "dostee"],
["dắraghlum", "deraghlum"], // using the ă along with a combining ́
["dar", "dăr"],
["der", "dăr"],
["dur", "dăr"],
["chee", "che"],
["dzooy", "zooy"],
["delta", "dalta"],
["koorbaani", "qUrbaanee"],
["jamaat", "jamaa'at"],
["taaroof", "ta'aarÚf"],
["xudza", "xúdza"],
["ishaak", "is`haaq"],
["lUtfun", "lootfan"],
["miraab", "mihraab"],
["taamul", "tahamul"],
["otsedul", "osedul"],
["ghaara", "ghaaRa"],
],
nonMatches: [
["kor", "por"],
["intizaar", "intizaam"],
["ishaat", "shaat"], // i should not be optional at the beginning
],
};
const withDiacritics: match[] = [
["تتتت", "تِتّتّت"],
["بببب", "بّبّبَب"],
];
const matchesWithAn: match[] = [
["حتمن", "حتماً"],
["لتفن", "لطفاً"],
["کاملا", "کاملاً"],
];
const matchesWithSpaces: match[] = [
["دپاره", "د پاره"],
["بېکار", "بې کار"],
["د پاره", "دپاره"],
["بې کار", "بېکار"],
["کار مند", "کارمند"],
["همنشین", "هم نشین"],
["بغل کشي", "بغلکشي"],
];
const matchesWithSpacesLatin: match[] = [
["dupaara", "du paara"],
["bekaara", "be kaara"],
["du paara", "dupaara"],
["be kaara", "bekaara"],
["oreckbgqjxmroe", "or ec kb gq jxmr oe"],
["cc cc c", "ccccc"],
];
const defaultSimpleLatinInfo: IDefaultInfoBlock = {
matches: [
// TODO:
["anbiya", "ambiya"],
["lootfun", "lUtfan"],
["sarey", "saRey"],
["senga", "tsanga"],
["daktur", "DakTar"],
["iteebar", "itibaar"],
["dzaal", "jaal"],
["bekaar", "bekaar"],
["bekar", "bekaar"],
["chaai", "chaai"],
["day", "daai"],
["dai", "dey"],
["daktar", "Daktar"],
["sarai", "saRey"],
["beter", "bahtar"],
["doosti", "dostee"],
["daraghlum", "deraghlum"], // using the ă along with a combining ́
["dar", "dar"],
["der", "dar"],
["dur", "dar"],
["chee", "che"],
["dzooy", "zooy"],
["delta", "dalta"],
["koorbaani", "qUrbaanee"],
["taaroof", "taaarUf"],
["xudza", "xudza"],
["ishaak", "ishaaq"],
["lUtfun", "lootfan"],
["miraab", "mihraab"],
["taamul", "tahamul"],
["otsedul", "osedul"],
["ghaara", "ghaaRa"],
],
nonMatches: [
["kor", "por"],
["intizaar", "intizaam"],
["ishaat", "shaat"], // i should not be optional at the beginning
],
};
interface ITestOptions {
options: any;
matches?: any;
nonMatches?: any;
viceVersaMatches?: any;
}
const optionsPossibilities: ITestOptions[] = [
{
options: {}, // default
...defaultInfo,
viceVersaMatches: true,
},
{
options: { script: "Latin" },
...defaultLatinInfo,
viceVersaMatches: true,
},
{
options: {matchStart: "word"}, // same as default
...defaultInfo,
viceVersaMatches: true,
},
{
options: { script: "Latin", simplifiedLatin: true },
...defaultSimpleLatinInfo,
viceVersaMatches: true,
},
{
matches: [
...matchesWithSpaces,
],
nonMatches: [],
options: {allowSpacesInWords: true},
viceVersaMatches: true,
},
{
matches: [
...matchesWithSpacesLatin,
],
nonMatches: [],
options: {allowSpacesInWords: true, script: "Latin"},
viceVersaMatches: true,
},
{
matches: [],
nonMatches: matchesWithSpaces,
options: {allowSpacesInWords: false},
},
{
matches: [],
nonMatches: matchesWithSpacesLatin,
options: {allowSpacesInWords: false, script: "Latin"},
},
{
matches: [
["کار", "بېکاري"],
],
nonMatches: [
["سرک", "بېترک"],
],
options: {matchStart: "anywhere"},
},
{
matches: [
["کور", "کور"],
["سری", "سړی"],
],
nonMatches: [
["سړي", "سړيتوب"],
["کور", "کورونه"],
],
options: {matchWholeWordOnly: true},
viceVersaMatches: true,
},
{
matches: [
["کور", "کور ته ځم"],
["سری", "سړی دی"],
],
nonMatches: [
["سړي", " سړيتوب"],
["کور", "خټين کورونه"],
],
options: {matchStart: "string"},
},
{
matches: [
["کور", "کور ته ځم"],
["سری", "سړی دی"],
],
nonMatches: [
["سړي", " سړيتوب"],
["کور", "خټين کورونه"],
],
options: {matchStart: "string"},
},
];
const punctuationToExclude = [
"،", "؟", "؛", "۔", "۲", "۹", "۰", "»", "«", "٫", "!", ".", "؋", "٪", "٬", "×", ")", "(", " ", "\t",
];
optionsPossibilities.forEach((o) => {
o.matches.forEach((m: any) => {
test(`${m[0]} should match ${m[1]}`, () => {
const re = fuzzifyPashto(m[0], o.options);
// eslint-disable-next-line
const result = m[1].match(new RegExp(re));
expect(result).toBeTruthy();
});
});
if (o.viceVersaMatches === true) {
o.matches.forEach((m: any) => {
test(`${m[1]} should match ${m[0]}`, () => {
const re = fuzzifyPashto(m[1], o.options);
// eslint-disable-next-line
const result = m[0].match(new RegExp(re));
expect(result).toBeTruthy();
});
});
}
o.nonMatches.forEach((m: any) => {
test(`${m[0]} should not match ${m[1]}`, () => {
const re = fuzzifyPashto(m[0], o.options);
// eslint-disable-next-line
const result = m[1].match(new RegExp(re));
expect(result).toBeNull();
});
});
});
matchesWithAn.forEach((m: any) => {
test(`matching ${m[0]} should work with ${m[1]}`, () => {
const re = fuzzifyPashto(m[0], { matchWholeWordOnly: true });
// eslint-disable-next-line
const result = m[1].match(new RegExp(re));
expect(result).toBeTruthy();
});
test(`matching ${m[1]} should work with ${m[0]}`, () => {
const re = fuzzifyPashto(m[1], { matchWholeWordOnly: true });
// eslint-disable-next-line
const result = m[0].match(new RegExp(re));
expect(result).toBeTruthy();
});
});
withDiacritics.forEach((m: any) => {
test(`matich ${m[0]} should ignore the diactritics in ${m[1]}`, () => {
const re = fuzzifyPashto(m[0], { ignoreDiacritics: true });
// eslint-disable-next-line
const result = m[1].match(new RegExp(re));
expect(result).toBeTruthy();
});
test(`the diacritics should in ${m[0]} should be ignored when matching with ${m[1]}`, () => {
const re = fuzzifyPashto(m[1], { ignoreDiacritics: true });
// eslint-disable-next-line
const result = m[0].match(new RegExp(re));
expect(result).toBeTruthy();
});
});
test(`وs should be optional if entered in search string`, () => {
const re = fuzzifyPashto("لوتفن");
// eslint-disable-next-line
const result = "لطفاً".match(new RegExp(re));
expect(result).toBeTruthy();
});
test(`matchWholeWordOnly should override matchStart = "anywhere"`, () => {
const re = fuzzifyPashto("کار", { matchWholeWordOnly: true, matchStart: "anywhere" });
// eslint-disable-next-line
const result = "کار کوه، بېکاره مه ګرځه".match(new RegExp(re));
expect(result).toHaveLength(1);
expect(result).toEqual(expect.not.arrayContaining(["بېکاره"]));
});
test(`returnWholeWord should return the whole word`, () => {
// With Pashto Script
const re = fuzzifyPashto("کار", { returnWholeWord: true });
// eslint-disable-next-line
const result = "کارونه کوه، بېکاره مه شه".match(new RegExp(re));
expect(result).toHaveLength(1);
expect(result).toContain("کارونه");
// With Latin Script
const reLatin = fuzzifyPashto("kaar", {
returnWholeWord: true,
script: "Latin",
});
// eslint-disable-next-line
const resultLatin = "kaaroona kawa, bekaara ma gurdza.".match(new RegExp(reLatin));
expect(resultLatin).toHaveLength(1);
expect(resultLatin).toContain("kaaroona");
});
test(`returnWholeWord should return the whole word even when starting the matching in the middle`, () => {
// With Pashto Script
const re = fuzzifyPashto("کار", { returnWholeWord: true, matchStart: "anywhere" });
// eslint-disable-next-line
const result = "کارونه کوه، بېکاره مه شه".match(new RegExp(re, "g"));
expect(result).toHaveLength(2);
expect(result).toContain(" بېکاره");
// With Latin Script
const reLatin = fuzzifyPashto("kaar", {
matchStart: "anywhere",
returnWholeWord: true,
script: "Latin",
});
// eslint-disable-next-line
const resultLatin = "kaaroona kawa bekaara ma gurdza".match(new RegExp(reLatin, "g"));
expect(resultLatin).toHaveLength(2);
expect(resultLatin).toContain("bekaara");
});
test(`returnWholeWord should should not return partial matches if matchWholeWordOnly is true`, () => {
// With Pashto Script
const re = fuzzifyPashto("کار", { returnWholeWord: true, matchStart: "anywhere", matchWholeWordOnly: true });
// eslint-disable-next-line
const result = "کارونه کوه، بېکاره مه ګرځه".match(new RegExp(re));
expect(result).toBeNull();
// With Latin Script
const reLatin = fuzzifyPashto("kaar", {
matchStart: "anywhere",
matchWholeWordOnly: true,
returnWholeWord: true,
script: "Latin",
});
// eslint-disable-next-line
const resultLatin = "kaaroona kawa bekaara ma gurdza".match(new RegExp(reLatin));
expect(resultLatin).toBeNull();
});
punctuationToExclude.forEach((m) => {
test(`${m} should not be considered part of a Pashto word`, () => {
const re = fuzzifyPashto("کور", { returnWholeWord: true, matchStart: "word" });
// ISSUE: This should also work when the word is PRECEDED by the punctuation
// Need to work with a lookbehind equivalent
// eslint-disable-next-line
const result = `زمونږ کورونه${m} دي`.match(new RegExp(re));
expect(result).toHaveLength(1);
expect(result).toContain(" کورونه");
// Matches will unfortunately have a space on the front of the word, issue with missing es2018 lookbehinds
});
});
punctuationToExclude.forEach((m) => {
// tslint:disable-next-line
test(`${m} should not be considered part of a Pashto word (front or back with es2018) - or should fail if using a non es2018 environment`, () => {
let result: any;
let failed = false;
// if environment is not es2018 with lookbehind support (like node 6, 8) this will fail
try {
const re = fuzzifyPashto("کور", { returnWholeWord: true, matchStart: "word", es2018: true });
// eslint-disable-next-line
result = `زمونږ ${m}کورونه${m} دي`.match(new RegExp(re));
} catch (error) {
failed = true;
}
const worked = failed || (result.length === 1 && result.includes("کورونه"));
expect(worked).toBe(true);
});
});
test(`Arabic punctuation or numbers should not be considered part of a Pashto word`, () => {
const re = fuzzifyPashto("کار", { returnWholeWord: true });
// eslint-disable-next-line
const result = "کارونه کوه، بېکاره مه ګرځه".match(new RegExp(re));
expect(result).toHaveLength(1);
expect(result).toContain("کارونه");
});

View File

@ -0,0 +1,150 @@
/**
* Copyright (c) lingdocs.com
*
* This source code is licensed under the GPL-3.0 license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import {
latinReplacerInfo,
latinReplacerRegex,
pashtoReplacerInfo,
pashtoReplacerRegex,
simpleLatinReplacerInfo,
simpleLatinReplacerRegex,
} from "./replacer";
export const pashtoCharacterRange = "\u0621-\u065f\u0670-\u06d3\u06d5";
// Unfortunately, without ES2018 lookbehind assertions word boundary matching is not as clean
// Without lookbehind assertions, we are unable to ignore punctuation directly in front of a word
// and matching results include a space before the word
export const pashtoWordBoundaryBeginning = `(?:^|[^${pashtoCharacterRange}])`;
// These problems are solved by using the ES2018 lookbehind assertions where environments permit
export const pashtoWordBoundaryBeginningWithES2018 = `(?<![${pashtoCharacterRange}])`;
const diacritics = "\u064b-\u065f\u0670\u0674"; // pretty generous diactritic range
interface IFuzzifyOptions {
readonly matchStart?: "word" | "string" | "anywhere";
readonly script?: "Pashto" | "Latin";
readonly matchWholeWordOnly?: boolean;
readonly simplifiedLatin?: boolean;
readonly allowSpacesInWords?: boolean;
readonly returnWholeWord?: boolean;
readonly es2018?: boolean;
readonly ignoreDiacritics?: boolean;
}
function sanitizeInput(input: string, options: IFuzzifyOptions): string {
let safeInput = input.trim().replace(/[#-.]|[[-^]|[?|{}]/g, "");
if (options.allowSpacesInWords) {
safeInput = safeInput.replace(/ /g, "");
}
if (options.ignoreDiacritics) {
// Using literal regular expressions instead of variable for security linting
safeInput = safeInput.replace(/[\u064b-\u065f\u0670\u0674]/g, "");
}
return safeInput;
}
function prepareMainRegexLogicLatin(sanitizedInput: string, options: IFuzzifyOptions): string {
const input = sanitizedInput; // options.allowSpacesInWords ? sanitizedInput.split("").join(" *") : sanitizedInput;
return input.replace(options.simplifiedLatin ? simpleLatinReplacerRegex : latinReplacerRegex, (mtch, offset) => {
const r = (options.simplifiedLatin
? simpleLatinReplacerInfo
: latinReplacerInfo).find((x) => x.char === mtch);
let section: string;
/* istanbul ignore next */
if (!r) {
section = mtch;
} else if (offset === 0 && r.replWhenBeginning) {
section = r.replWhenBeginning;
} else {
section = r.repl;
}
// TODO: Should we allow ignorable letters as we do with the Pashto script?
return options.simplifiedLatin
? section
: `${section}[|'|\`]?${options.allowSpacesInWords ? " ?" : ""}`;
});
}
function prepareMainRegexLogicPashto(sanitizedInput: string, options: IFuzzifyOptions): string {
const input = options.allowSpacesInWords ? sanitizedInput.split("").join(" *") : sanitizedInput;
return input.replace(pashtoReplacerRegex, (mtch, offset) => {
const r = pashtoReplacerInfo.find((x) => x.char === mtch);
let section: string;
if (!r) {
section = mtch;
} else if (!r.range && r.plus) {
const additionalOptionGroups = r.plus.join("|");
section = `(?:${additionalOptionGroups})`;
} else if (r.range && r.plus) {
const additionalOptionGroups = r.plus.join("|");
section = `(?:[${r.range}]|${additionalOptionGroups})`;
} else {
section = `[${r && r.range}]`;
}
const mtchIsInMiddle = (offset !== 0) && (offset !== sanitizedInput.length - 1);
const isBeginningAlefFollowedByWaw = mtch === "ا" && (offset === 0 && sanitizedInput[1] === "و");
const isBeginningWaw = offset === 0 && mtch === "و";
// tslint:disable-next-line // TODO: for some reason mtchIsInMiddle is not working very well on the second letter so I'm just adding this for every match (not ideal)
return `${isBeginningWaw ? "ا?" : ""}${section}${r && (r.ignorable || (r.ignorableIfInMiddle && mtchIsInMiddle) || isBeginningAlefFollowedByWaw) ? "?" : ""}[ا|و|ی|ي|ع]?${options.ignoreDiacritics ? `[${diacritics}]?`: ""}`;
});
}
function getBeginningWithAnywhere(options: IFuzzifyOptions): string {
// Override the "anywhere" when matchWholeWordOnly is true
if (options.matchWholeWordOnly) {
return (options.script === "Latin") ? "\\b" : pashtoWordBoundaryBeginning;
}
if (options.returnWholeWord) {
// Return the whole world even if matching from the middle (if desired)
if (options.script === "Latin") {
return "\\b\\S*";
}
return `${pashtoWordBoundaryBeginning}[${pashtoCharacterRange}]*`;
}
return "";
}
function prepareBeginning(options: IFuzzifyOptions): string {
// options.matchStart can be "string", "anywhere", or "word" (default)
if (options.matchStart === "string") {
return "^";
}
if (options.matchStart === "anywhere") {
return getBeginningWithAnywhere(options);
}
// options.matchStart default "word"
// return the beginning word boundary depending on whether es2018 is enabled or not
if (options.script === "Latin") {
return "\\b";
}
return options.es2018 ? pashtoWordBoundaryBeginningWithES2018 : pashtoWordBoundaryBeginning;
}
function prepareEnding(options: IFuzzifyOptions): string {
if (options.matchWholeWordOnly) {
return (options.script === "Latin") ? "\\b" : `(?![${pashtoCharacterRange}])`;
}
if (options.returnWholeWord) {
return (options.script === "Latin") ? "\\S*\\b" : `[${pashtoCharacterRange}]*(?![${pashtoCharacterRange}])`;
}
return "";
}
// Main function for returning a regular expression based on a string of Pashto text
export function fuzzifyPashto(input: string, options: IFuzzifyOptions = {}): string {
const sanitizedInput = sanitizeInput(input, options);
let mainRegexLogic: string;
if (options.script === "Latin") {
mainRegexLogic = prepareMainRegexLogicLatin(sanitizedInput, options);
} else {
mainRegexLogic = prepareMainRegexLogicPashto(sanitizedInput, options);
}
const beginning = prepareBeginning(options);
const ending = prepareEnding(options);
const logic = `${beginning}${mainRegexLogic}${ending}`;
return logic;
}

View File

@ -0,0 +1,303 @@
/**
* Copyright (c) lingdocs.com
*
* This source code is licensed under the GPL-3.0 license found in the
* LICENSE file in the root directory of this source tree.
*
*/
// TODO: add southern ش س (at beginning of word?)
const sSounds = "صسثڅ";
const zSounds = "زضظذځژ";
const tdSounds = "طتټدډ";
const velarPlosives = "ګغږکقگك";
const rLikeSounds = "رړڑڼ";
const labialPlosivesAndFricatives = "فپب";
// Includes Arabic ى \u0649
const theFiveYeys = "ېۍیيئےى";
const guttural = "ښخشخهحغګ";
interface IReplacerInfoItem {
char: string;
ignorable?: boolean;
ignorableIfInMiddle?: boolean;
}
interface IPashtoReplacerInfoItem extends IReplacerInfoItem {
range?: string;
repl?: string;
plus?: string[];
}
interface IPhoneticsReplacerInfoItem extends IReplacerInfoItem {
repl: string;
replWhenBeginning?: string;
}
export const pashtoReplacerInfo: IPashtoReplacerInfoItem[] = [
{ char: "اً", range: "ان" },
{
char: "ا",
ignorableIfInMiddle: true,
plus: ["اً", "یٰ"],
range: "اأآهع",
}, // TODO: make optional (if not at the beginning of word)
{ char: "آ", range: "اآهأ" },
{ char: "ٱ", range: "اآهأ" },
{ char: "ٲ", range: "اآهأ" },
{ char: "أ", range: "اآهأ" },
{ char: "ٳ", range: "اآهأ" },
{ char: "یٰ", range: "ای", plus: ["یٰ"] },
{ char: "ی", range: theFiveYeys, plus: ["ئی", "ئي", "یٰ"], ignorableIfInMiddle: true },
{ char: "ي", range: theFiveYeys, plus: ["ئی", "ئي", "یٰ"], ignorableIfInMiddle: true },
{ char: "ې", range: theFiveYeys, ignorableIfInMiddle: true },
{ char: "ۍ", range: theFiveYeys },
{ char: "ئي", range: theFiveYeys, plus: ["ئی", "ئي"] },
{ char: "ئی", range: theFiveYeys, plus: ["ئی", "ئي"] },
{ char: "ئے", range: theFiveYeys, plus: ["ئی", "ئي", "يې"]},
{ char: "ئ", range: theFiveYeys, ignorableIfInMiddle: true },
{ char: "ے", range: theFiveYeys },
{ char: "س", range: sSounds },
{ char: "ص", range: sSounds },
{ char: "ث", range: sSounds },
{ char: "څ", range: sSounds + "چ" },
{ char: "ج", range: "چجڅځژ" },
{ char: "چ", range: "چجڅځ" },
{ char: "هٔ", range: "اهحہۀ", plus: ["هٔ"] },
{ char: "ه", range: "اهحہۀ", plus: ["هٔ"] },
{ char: "ۀ", range: "اهحہۀ", plus: ["هٔ"] },
{ char: "ہ", range: "اهحہۀ", plus: ["هٔ"] },
{ char: "ع", range: "اوع", ignorable: true },
{ char: "و", range: "وع", plus: ["وو"], ignorableIfInMiddle: true },
{ char: "ؤ", range: "وع"},
{ char: "ښ", range: guttural },
{ char: "غ", range: guttural },
{ char: "خ", range: guttural },
{ char: "ح", range: guttural },
{ char: "ش", range: "شښ" },
{ char: "ز", range: zSounds },
{ char: "ض", range: zSounds },
{ char: "ذ", range: zSounds },
{ char: "ځ", range: zSounds + "جڅ"},
{ char: "ظ", range: zSounds },
{ char: "ژ", range: "زضظژذځږج" },
{ char: "ر", range: rLikeSounds },
{ char: "ړ", range: rLikeSounds },
{ char: "ڑ", range: rLikeSounds },
{ char: "ت", range: tdSounds },
{ char: "ټ", range: tdSounds },
{ char: "ٹ", range: tdSounds },
{ char: "ط", range: tdSounds },
{ char: "د", range: tdSounds },
{ char: "ډ", range: tdSounds },
{ char: "ڈ", range: tdSounds },
{ char: "مب", plus: ["مب", "نب"] },
{ char: "نب", plus: ["مب", "نب"] },
{ char: "ن", range: "نڼ", plus: ["اً"] }, // allow for words using اٌ at the end to be seached for with ن
{ char: "ڼ", range: "نڼړڑ" },
{ char: "ک", range: velarPlosives },
{ char: "ګ", range: velarPlosives },
{ char: "گ", range: velarPlosives },
{ char: "ق", range: velarPlosives },
{ char: "ږ", range: velarPlosives + "ژ" },
{ char: "ب", range: labialPlosivesAndFricatives },
{ char: "پ", range: labialPlosivesAndFricatives },
{ char: "ف", range: labialPlosivesAndFricatives },
];
// tslint:disable-next-line
export const pashtoReplacerRegex = /اً|أ|ا|آ|ٱ|ٲ|ٳ|ئی|ئي|ئے|یٰ|ی|ي|ې|ۍ|ئ|ے|س|ص|ث|څ|ج|چ|هٔ|ه|ۀ|ہ|ع|و|ؤ|ښ|غ|خ|ح|ش|ز|ض|ذ|ځ|ظ|ژ|ر|ړ|ڑ|ت|ټ|ٹ|ط|د|ډ|ڈ|مب|م|نب|ن|ڼ|ک|ګ|گ|ل|ق|ږ|ب|پ|ف/g;
// TODO: I removed the h? 's at the beginning and ends. was that a good idea?
const aaySoundLatin = "(?:[aá]a?i|[eé]y|[aá]a?y|[aá]h?i)";
const aaySoundSimpleLatin = "(?:aa?i|ey|aa?y|ah?i)";
const longASoundLatin = "(?:[aá]{1,2}'?h?a{0,2}?)h?";
const longASoundSimpleLatin = "(?:a{1,2}'?h?a{0,2}?)h?";
const shortASoundLatin = "(?:[aáă][a|́]?|au|áu|[uú]|[UÚ]|[ií]|[eé])?h?";
const shortASoundSimpleLatin = "(?:aa?|au|u|U|i|e)?h?";
const shwaSoundLatin = "(?:[uú]|[oó]o?|w[uú]|[aáă]|[ií]|[UÚ])?";
const shwaSoundSimpleLatin = "(?:u|oo?|wu|a|i|U)?";
const ooSoundLatin = "(?:[oó]o?|[áa]u|w[uú]|[aá]w|[uú]|[UÚ])(?:h|w)?";
const ooSoundSimpleLatin = "(?:oo?|au|wu|aw|u|U)(?:h|w)?";
const eySoundLatin = "(?:[eé]y|[eé]e?|[uú]y|[aá]y|[ií])";
const eySoundSimpleLatin = "(?:ey|ee?|uy|ay|i)";
const middleESoundLatin = "(?:[eé]e?|[ií]|[aáă]|[eé])[h|y|́]?";
const middleESoundSimpleLatin = "(?:ee?|i|a|e)[h|y]?";
const iSoundLatin = "-?(?:[uú]|[aáă]|[ií]|[eé]e?)?h?-?";
const iSoundSimpleLatin = "-?(?:u|a|i|ee?)?h?";
const iSoundLatinBeginning = "(?:[uú]|[aáă]|[ií]|[eé]e?)h?";
const iSoundSimpleLatinBeginning = "(?:u|a|i|ee?)h?";
export const latinReplacerInfo: IPhoneticsReplacerInfoItem[] = [
{ char: "aa", repl: longASoundLatin },
{ char: "áa", repl: longASoundLatin },
{ char: "aai", repl: aaySoundLatin },
{ char: "áai", repl: aaySoundLatin },
{ char: "ai", repl: aaySoundLatin },
{ char: "ái", repl: aaySoundLatin },
{ char: "aay", repl: aaySoundLatin },
{ char: "áay", repl: aaySoundLatin },
{ char: "ay", repl: aaySoundLatin },
{ char: "áy", repl: aaySoundLatin },
{ char: "a", repl: shortASoundLatin },
{ char: "ă", repl: shortASoundLatin },
{ char: "ắ", repl: shortASoundLatin },
{ char: "á", repl: shortASoundLatin },
{ char: "u", repl: shwaSoundLatin },
{ char: "ú", repl: shwaSoundLatin },
{ char: "U", repl: ooSoundLatin },
{ char: "Ú", repl: ooSoundLatin },
{ char: "o", repl: ooSoundLatin },
{ char: "ó", repl: ooSoundLatin },
{ char: "oo", repl: ooSoundLatin },
{ char: "óo", repl: ooSoundLatin },
{ char: "i", repl: iSoundLatin, replWhenBeginning: iSoundLatinBeginning },
{ char: "í", repl: iSoundLatin, replWhenBeginning: iSoundLatinBeginning },
{ char: "ey", repl: eySoundLatin },
{ char: "éy", repl: eySoundLatin },
{ char: "ee", repl: eySoundLatin },
{ char: "ée", repl: eySoundLatin },
{ char: "uy", repl: eySoundLatin },
{ char: "úy", repl: eySoundLatin },
{ char: "e", repl: middleESoundLatin },
{ char: "é", repl: middleESoundLatin },
{ char: "w", repl: "(?:w{1,2}?[UÚ]?|b)"},
{ char: "y", repl: "[ií]?y?"},
{ char: "ts", repl: "(?:s{1,2}|z{1,2|ts|c)"},
{ char: "s", repl: "(?:s{1,2}|z{1,2|ts|c)"},
{ char: "c", repl: "(?:s{1,2}|z{1,2|ts|c)"},
{ char: "dz", repl: "(?:dz|z{1,2}|j)"},
{ char: "z", repl: "(?:s{1,2}|dz|z{1,2}|ts)"},
{ char: "t", repl: "(?:t{1,2}|T|d{1,2}|D)"},
{ char: "tt", repl: "(?:t{1,2}|T|d{1,2}|D)"},
{ char: "T", repl: "(?:t{1,2}|T|d{1,2}|D)"},
{ char: "d", repl: "(?:t{1,2}|T|d{1,2}|D)"},
{ char: "dd", repl: "(?:t{1,2}|T|d{1,2}|D)"},
{ char: "D", repl: "(?:t{1,2}|T|d{1,2}|D)"},
{ char: "r", repl: "(?:R|r{1,2}|N)"},
{ char: "rr", repl: "(?:R|r{1,2}|N)"},
{ char: "R", repl: "(?:R|r{1,2}|N)"},
{ char: "nb", repl: "(?:nb|mb)"},
{ char: "mb", repl: "(?:nb|mb)"},
{ char: "n", repl: "(?:n{1,2}|N)"},
{ char: "N", repl: "(?:R|r{1,2}|N)"},
{ char: "f", repl: "(?:f{1,2}|p{1,2})"},
{ char: "ff", repl: "(?:f{1,2}|p{1,2})"},
{ char: "b", repl: "(?:b{1,2}|p{1,2})"},
{ char: "bb", repl: "(?:b{1,2}|p{1,2})"},
{ char: "p", repl: "(?:b{1,2}|p{1,2}|f{1,2})"},
{ char: "pp", repl: "(?:b{1,2}|p{1,2}|f{1,2})"},
{ char: "sh", repl: "(?:x|sh|s`h)"},
{ char: "x", repl: "(?:kh|gh|x|h){1,2}"},
{ char: "kh", repl: "(?:kh|gh|x|h){1,2}"},
{ char: "k", repl: "(?:k{1,2}|q{1,2})"},
{ char: "q", repl: "(?:k{1,2}|q{1,2})"},
{ char: "jz", repl: "(?:G|jz)"},
{ char: "G", repl: "(?:jz|G|g)"},
{ char: "g", repl: "(?:gh?|k{1,2}|G)"},
{ char: "gh", repl: "(?:g|gh|kh|G)"},
{ char: "j", repl: "(?:j{1,2}|ch|dz)"},
{ char: "ch", repl: "(?:j{1,2}|ch)"},
{ char: "l", repl: "l{1,2}"},
{ char: "ll", repl: "l{1,2}"},
{ char: "m", repl: "m{1,2}"},
{ char: "mm", repl: "m{1,2}"},
{ char: "h", repl: "k?h?"},
{ char: "'", repl: "['||`]?"},
{ char: "", repl: "['||`]?"},
{ char: "`", repl: "['||`]?"},
];
export const simpleLatinReplacerInfo: IPhoneticsReplacerInfoItem[] = [
{ char: "aa", repl: longASoundSimpleLatin },
{ char: "aai", repl: aaySoundSimpleLatin },
{ char: "ai", repl: aaySoundSimpleLatin },
{ char: "aay", repl: aaySoundSimpleLatin },
{ char: "ay", repl: aaySoundSimpleLatin },
{ char: "a", repl: shortASoundSimpleLatin },
{ char: "u", repl: shwaSoundSimpleLatin },
{ char: "U", repl: ooSoundSimpleLatin },
{ char: "o", repl: ooSoundSimpleLatin },
{ char: "oo", repl: ooSoundSimpleLatin },
{ char: "i", repl: iSoundSimpleLatin, replWhenBeginning: iSoundSimpleLatinBeginning },
{ char: "ey", repl: eySoundSimpleLatin },
{ char: "ee", repl: eySoundSimpleLatin },
{ char: "uy", repl: eySoundSimpleLatin },
{ char: "e", repl: middleESoundSimpleLatin },
{ char: "w", repl: "(?:w{1,2}?[UÚ]?|b)"},
{ char: "y", repl: "[ií]?y?"},
{ char: "ts", repl: "(?:s{1,2}|z{1,2|ts|c)"},
{ char: "s", repl: "(?:s{1,2}|z{1,2|ts|c)"},
{ char: "c", repl: "(?:s{1,2}|z{1,2|ts|c)"},
{ char: "dz", repl: "(?:dz|z{1,2}|j)"},
{ char: "z", repl: "(?:s{1,2}|dz|z{1,2}|ts)"},
{ char: "t", repl: "(?:t{1,2}|T|d{1,2}|D)"},
{ char: "tt", repl: "(?:t{1,2}|T|d{1,2}|D)"},
{ char: "T", repl: "(?:t{1,2}|T|d{1,2}|D)"},
{ char: "d", repl: "(?:t{1,2}|T|d{1,2}|D)"},
{ char: "dd", repl: "(?:t{1,2}|T|d{1,2}|D)"},
{ char: "D", repl: "(?:t{1,2}|T|d{1,2}|D)"},
{ char: "r", repl: "(?:R|r{1,2}|N)"},
{ char: "rr", repl: "(?:R|r{1,2}|N)"},
{ char: "R", repl: "(?:R|r{1,2}|N)"},
{ char: "nb", repl: "(?:nb|mb|nw)"},
{ char: "mb", repl: "(?:nb|mb)"},
{ char: "n", repl: "(?:n{1,2}|N)"},
{ char: "N", repl: "(?:R|r{1,2}|N)"},
{ char: "f", repl: "(?:f{1,2}|p{1,2})"},
{ char: "ff", repl: "(?:f{1,2}|p{1,2})"},
{ char: "b", repl: "(?:b{1,2}|p{1,2}|w)"},
{ char: "bb", repl: "(?:b{1,2}|p{1,2})"},
{ char: "p", repl: "(?:b{1,2}|p{1,2}|f{1,2})"},
{ char: "pp", repl: "(?:b{1,2}|p{1,2}|f{1,2})"},
{ char: "sh", repl: "(?:x|sh|s`h)"},
{ char: "x", repl: "(?:kh|gh|x|h){1,2}"},
{ char: "kh", repl: "(?:kh|gh|x|h){1,2}"},
{ char: "k", repl: "(?:k{1,2}|q{1,2})"},
{ char: "q", repl: "(?:k{1,2}|q{1,2})"},
{ char: "jz", repl: "(?:G|jz)"},
{ char: "G", repl: "(?:jz|G|g)"},
{ char: "g", repl: "(?:gh?|k{1,2}|G)"},
{ char: "gh", repl: "(?:g|gh|kh|G)"},
{ char: "j", repl: "(?:j{1,2}|ch|dz)"},
{ char: "ch", repl: "(?:j{1,2}|ch)"},
{ char: "l", repl: "l{1,2}"},
{ char: "ll", repl: "l{1,2}"},
{ char: "m", repl: "m{1,2}"},
{ char: "mm", repl: "m{1,2}"},
{ char: "h", repl: "k?h?"},
];
// tslint:disable-next-line
export const latinReplacerRegex = /yee|a{1,2}[i|y]|á{1,2}[i|y]|aa|áa|a|ắ|ă|á|U|Ú|u|ú|oo|óo|o|ó|e{1,2}|ée|é|ey|éy|uy|úy|i|í|w|y|q|ts|sh|s|dz|z|tt|t|T|dd|d|D|r{1,2}|R|nb|mb|n{1,2}|N|f{1,2}|b{1,2}|p{1,2}|x|kh|q|k|gh|g|G|j|ch|c|ll|l|m{1,2}|h||'|`/g;
export const simpleLatinReplacerRegex = /yee|a{1,2}[i|y]|aa|a|U|u|oo|o|e{1,2}|ey|uy|i|w|y|q|ts|sh|s|dz|z|tt|t|T|dd|d|D|r{1,2}|R|nb|mb|n{1,2}|N|f{1,2}|b{1,2}|p{1,2}|x|kh|q|k|gh|g|G|j|ch|c|ll|l|m{1,2}|h/g;

View File

@ -0,0 +1,8 @@
import getWordId from "./get-word-id";
test("getWordId should work", () => {
expect(getWordId("?id=12345")).toBe(12345);
expect(getWordId("?page=settings&id=12345")).toBe(12345);
expect(getWordId("")).toBeNull();
expect(getWordId("?page=home")).toBe(null);
});

View File

@ -0,0 +1,10 @@
function getWordId(search: string): number | null {
const params = new URLSearchParams(search);
const id = params.get("id");
if (id) {
return parseInt(id);
}
return null;
}
export default getWordId;

View File

@ -0,0 +1,12 @@
function hitBottom(): boolean {
const windowHeight = "innerHeight" in window ? window.innerHeight : document.documentElement.offsetHeight;
const body = document.body;
const html = document.documentElement;
const docHeight = Math.max(
body.scrollHeight, body.offsetHeight, html.clientHeight, html.scrollHeight, html.offsetHeight,
);
const windowBottom = Math.round(windowHeight + window.pageYOffset);
return (windowBottom + 3) >= docHeight;
}
export default hitBottom;

View File

@ -0,0 +1,147 @@
import Resizer from "react-image-file-resizer";
import {
addToAttachmentObject, removeAttachmentFromObject,
} from "./wordlist-database";
const maxImgSize = {
width: 1200,
height: 1200,
};
export function resizeImage(file: File): Promise<File> {
return new Promise((resolve) => {
Resizer.imageFileResizer(
file,
maxImgSize.width,
maxImgSize.height,
// TODO: WHAT'S THE BEST FORMAT FOR THIS?
"JPEG",
100,
0,
(file) => {
resolve(file as File);
},
"file"
);
});
}
export function rotateImage(file: File): Promise<File> {
return new Promise((resolve) => {
Resizer.imageFileResizer(
file,
maxImgSize.width,
maxImgSize.height,
"JPEG",
100,
90,
(file) => {
resolve(file as File);
},
"file"
);
});
}
// https://stackoverflow.com/a/47786555/8620945
/**
* Returns the dimensions of a given image file in pixels
*
* @param file
* @returns
*/
export function getImageSize(file: File | Blob): Promise<{ height: number, width: number }> {
return new Promise((resolve) => {
const reader = new FileReader();
//Read the contents of Image File.
reader.readAsDataURL(file);
reader.onloadend = function() {
//Initiate the JavaScript Image object.
const image = new Image();
//Set the Base64 string return from FileReader as source.
// @ts-ignore
image.src = reader.result;
//Validate the File Height and Width.
image.onload = function() {
resolve({
// @ts-ignore
height: this.height,
// @ts-ignore
width: this.width,
});
};
}
reader.onerror = function() {
throw new Error("error getting image dimensions");
}
});
};
export async function addImageToWordlistWord(word: WordlistWord, file: File): Promise<WordlistWord> {
const isTooBig = ({ height, width }: { height: number, width: number}): boolean => (
(height > maxImgSize.height) || (width > maxImgSize.width)
);
const initialSize = await getImageSize(file);
const { img, imgSize } = await (async () => {
if (isTooBig(initialSize)) {
const img = await resizeImage(file);
const imgSize = await getImageSize(file);
return { img, imgSize };
}
return { img: file, imgSize: initialSize };
})();
return {
...word,
imgSize,
_attachments: addToAttachmentObject(
"_attachments" in word ? word._attachments : {},
img.name,
{
"content_type": img.type,
data: img,
},
),
};
}
export function removeImageFromWordlistWord(word: WordlistWordWAttachments) {
const attachments = "_attachments" in word
? removeAttachmentFromObject(word._attachments, "image")
: undefined;
const { _attachments, imgSize, ...rest } = word;
return {
...attachments ? {
_attachments: attachments,
} : {},
...rest
};
}
export function prepBase64(type: string, data: string) {
return `data:${type};base64,${data}`;
}
export function imageAttachmentToBase64(img: AttachmentWithData) {
if (typeof img.data === "string") {
return prepBase64(img.content_type, img.data);
}
throw Error("needs to be run with image data as base64");
}
export async function b64toBlob(base64: string) {
const res = await fetch(base64);
return await res.blob();
}
export function blobToFile(theBlob: Blob, fileName: string): File {
let b: any = theBlob;
//A Blob() is almost a File() - it's just missing the two properties below which we will add
b.lastModifiedDate = new Date();
b.name = fileName;
//Cast to a File() type
return theBlob as File;
}

View File

@ -0,0 +1,114 @@
import {
Types as T,
getEnglishPersonInfo,
} from "@lingdocs/pashto-inflector";
function capitalizeFirstLetter(string: string) {
return string.charAt(0).toUpperCase() + string.slice(1);
}
function conflateUnisexPeople(arr: (string | T.Person)[]): (string | T.Person)[] {
let newArr = [...arr];
function remove(value: number) {
var index = newArr.indexOf(value);
if (index > -1) {
newArr.splice(index, 1);
}
}
if (arr.includes(0) && arr.includes(1)) {
remove(0);
remove(1)
newArr = ["1st pers. sing.", ...newArr];
}
if (arr.includes(2) && arr.includes(3)) {
remove(2);
remove(3);
newArr = ["2nd pers. sing.", ...newArr];
}
if (arr.includes(4) && arr.includes(5)) {
remove(4);
remove(5);
newArr = ["3rd pers. sing.", ...newArr];
}
if (arr.includes(6) && arr.includes(7)) {
remove(6);
remove(7);
newArr = ["1st pers. plur.", ...newArr];
}
if (arr.includes(8) && arr.includes(9)) {
remove(8);
remove(9);
newArr = ["2nd pers. plur.", ...newArr];
}
if (arr.includes(10) && arr.includes(11)) {
remove(10);
remove(11);
newArr = ["3rd pers. plur.", ...newArr];
}
if ([0,1,2,3,4,5,6,7,8,9,10,11].every(x => arr.includes(x))) {
newArr = ["Doesn't change"];
}
return newArr;
}
export function displayPositionResult(res: (T.Person | "plain" | "1st" | "2nd")[] | null): string {
const conflated = res
? conflateUnisexPeople(res)
: ["Doesn't change"];
return conflated.map((x) => {
if (x === "plain") {
return "Plain";
}
if (x === "1st") {
return "1st Inflection";
}
if (x === "2nd") {
return "2nd Inflection";
}
if (typeof x === "string") {
return x;
}
return x === null ? "Same for all" : getEnglishPersonInfo(x);
}).join(" / ");
}
export function displayFormResult(res: string[]): string {
if (res.length === 1 && ["masc", "fem"].includes(res[0])) {
return res[0] === "masc"
? "Masculine Form"
: "Feminine Form";
}
return res.map((word) => capitalizeFirstLetter(word)).join(" ")
.replace("Stative", "(In the stative version)")
.replace("Synamic", "(In the dynamic version)")
.replace("GrammaticallyTransitive", "(In the grammatically transitive version)")
.replace("Transitive", "(In the transitive version)")
.replace("Imperfective NonImperative", "Present")
.replace("Perfective NonImperative", "Subjunctive")
.replace("Imperfective Past", "Continuous Past")
.replace("Perfective Past", "Simple Past")
.replace("Imperfective Modal NonImperative", "Present Modal")
.replace("Perfective Modal NonImperative", "Subjunctive Modal")
.replace("Imperfective Modal Past", "Continuous Past Modal")
.replace("Perfective Modal Past", "Simple Past Modal")
.replace("Modal Future", "Future Modal")
.replace("Modal HypotheticalPast", "Hypothetical/Wildcard Modal")
.replace("Participle Past", "Past Participle")
.replace("Participle Present", "Present Participle")
.replace("Perfect HalfPerfect", "Half Perfect")
.replace("Perfect Past", "Past Perfect")
.replace("Perfect Present", "Present Perfect")
.replace("Perfect Subjunctive", "Subjunctive Perfect")
.replace("Perfect Future", "Future Perfect")
.replace("Perfect Affirmational", "Affirmational Perfect")
.replace("Perfect PastSubjunctiveHypothetical", "Past Subjunctive/Hypothetical Perfect")
.replace("Long", "(long version)")
.replace("Short", "(short version)")
.replace("Mini", "(mini version)")
.replace("MascSing", "(with a masc. sing. object)")
.replace("MascPlur", "(with a masc. plur. object)")
.replace("FemSing", "(with a fem. sing. object)")
.replace("FemPlur", "(with a fem. plur. object)")
.replace("Fem", "Fem.")
.replace("Masc", "Masc.")
}

View File

@ -0,0 +1,6 @@
import { isPashtoScript } from "./is-pashto";
test("isPashtoScript works", () => {
expect(isPashtoScript("کور")).toBe(true);
expect(isPashtoScript("kor")).toBe(false);
});

View File

@ -0,0 +1,9 @@
/**
* Determines if a string is written in Pashto script;
*/
const pashtoLetters = /[\u0600-\u06FF]/;
export function isPashtoScript(s: string): boolean {
return pashtoLetters.test(s);
}

View File

@ -0,0 +1,14 @@
/**
* 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.
*
*/
export function wordlistEnabled(state: State | UserLevel): boolean {
const level = (typeof state === "string")
? state
: state.options.level;
return level !== "basic";
}

View File

@ -0,0 +1,105 @@
import optionsReducer from "./options-reducer";
import { defaultTextOptions } from "@lingdocs/pashto-inflector";
const options: Options = {
textOptions: defaultTextOptions,
language: "Pashto",
searchType: "fuzzy",
theme: "light",
level: "basic",
wordlistMode: "browse",
wordlistReviewLanguage: "Pashto",
wordlistReviewBadge: true,
searchBarPosition: "top",
};
test("options reducer should work", () => {
expect(optionsReducer(options, { type: "toggleLanguage" }))
.toEqual({
...options,
language: "English",
});
expect(optionsReducer({ ...options, language: "English" }, { type: "toggleLanguage" }))
.toEqual(options);
expect(optionsReducer(options, { type: "toggleSearchType" }))
.toEqual({
...options,
searchType: "alphabetical",
});
expect(optionsReducer({ ...options, searchType: "alphabetical" }, { type: "toggleSearchType" }))
.toEqual(options);
expect(optionsReducer(options, { type: "changeTheme", payload: "dark" }))
.toEqual({
...options,
theme: "dark",
});
expect(optionsReducer(options, { type: "changeUserLevel", payload: "student" }))
.toEqual({
...options,
level: "student",
});
expect(optionsReducer(options, { type: "changeWordlistMode", payload: "review" }))
.toEqual({
...options,
wordlistMode: "review",
});
expect(optionsReducer(options, { type: "changeWordlistReviewLanguage", payload: "English" }))
.toEqual({
...options,
wordlistReviewLanguage: "English",
});
expect(optionsReducer(options, { type: "changeWordlistReviewBadge", payload: false }))
.toEqual({
...options,
wordlistReviewBadge: false,
});
expect(optionsReducer(options, { type: "changeSearchBarPosition", payload: "bottom" }))
.toEqual({
...options,
searchBarPosition: "bottom",
});
expect(optionsReducer(options, { type: "changePTextSize", payload: "largest" }))
.toEqual({
...options,
textOptions: {
...defaultTextOptions,
pTextSize: "largest",
},
});
expect(optionsReducer(options, { type: "changeSpelling", payload: "Pakistani" }))
.toEqual({
...options,
textOptions: {
...defaultTextOptions,
spelling: "Pakistani",
},
});
expect(optionsReducer(options, { type: "changePhonetics", payload: "ipa" }))
.toEqual({
...options,
textOptions: {
...defaultTextOptions,
phonetics: "ipa",
},
});
expect(optionsReducer(options, { type: "changeDialect", payload: "southern" }))
.toEqual({
...options,
textOptions: {
...defaultTextOptions,
dialect: "southern",
},
});
expect(optionsReducer(options, { type: "changeDiacritics", payload: true }))
.toEqual({
...options,
textOptions: {
...defaultTextOptions,
diacritics: true,
},
});
expect(() => {
// @ts-ignore
optionsReducer(options, { type: "non existent action" });
}).toThrow("action type not recognized in reducer");
})

View File

@ -0,0 +1,98 @@
function optionsReducer(options: Options, action: OptionsAction): Options {
if (action.type === "toggleLanguage") {
return {
...options,
language: options.language === "Pashto" ? "English" : "Pashto",
};
}
if (action.type === "toggleSearchType") {
return {
...options,
searchType: options.searchType === "alphabetical" ? "fuzzy" : "alphabetical",
}
}
if (action.type === "changeTheme") {
return {
...options,
theme: action.payload,
};
}
if (action.type === "changeSearchBarPosition") {
return {
...options,
searchBarPosition: action.payload,
};
}
if (action.type === "changeUserLevel") {
return {
...options,
level: action.payload,
};
}
if (action.type === "changeWordlistMode") {
return {
...options,
wordlistMode: action.payload,
};
}
if (action.type === "changeWordlistReviewBadge") {
return {
...options,
wordlistReviewBadge: action.payload,
};
}
if (action.type === "changeWordlistReviewLanguage") {
return {
...options,
wordlistReviewLanguage: action.payload,
};
}
if (action.type === "changePTextSize") {
return {
...options,
textOptions: {
...options.textOptions,
pTextSize: action.payload,
},
};
}
if (action.type === "changeSpelling") {
return {
...options,
textOptions: {
...options.textOptions,
spelling: action.payload,
}
};
}
if (action.type === "changePhonetics") {
return {
...options,
textOptions: {
...options.textOptions,
phonetics: action.payload,
}
};
}
if (action.type === "changeDialect") {
return {
...options,
textOptions: {
...options.textOptions,
dialect: action.payload,
}
};
}
if (action.type === "changeDiacritics") {
return {
...options,
textOptions: {
...options.textOptions,
diacritics: action.payload,
}
};
}
throw new Error("action type not recognized in reducer");
}
export default optionsReducer;

View File

@ -0,0 +1,48 @@
/**
* 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 { saveOptions, readOptions, optionsLocalStorageName } from "./options-storage";
import {
defaultTextOptions,
} from "@lingdocs/pashto-inflector";
const optionsStub: Options = {
language: "Pashto",
searchType: "fuzzy",
theme: "dark",
textOptions: defaultTextOptions,
level: "student",
wordlistMode: "browse",
wordlistReviewLanguage: "Pashto",
wordlistReviewBadge: true,
searchBarPosition: "top",
};
test("saveOptions should work", () => {
localStorage.clear();
saveOptions(optionsStub);
expect(JSON.parse(
localStorage.getItem(optionsLocalStorageName)
)).toEqual(optionsStub);
});
test("readOptions should work", () => {
localStorage.clear();
expect(readOptions()).toBe(undefined);
saveOptions(optionsStub);
expect(readOptions()).toEqual(optionsStub);
});
test("options should save and be read", () => {
localStorage.clear();
expect(readOptions()).toBe(undefined);
saveOptions(optionsStub);
expect(readOptions()).toEqual(optionsStub);
localStorage.setItem(optionsLocalStorageName, "<<BAD JSON>>>");
expect(readOptions()).toBe(undefined);
});

View File

@ -0,0 +1,34 @@
/**
* 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.
*
*/
export const optionsLocalStorageName = "options2";
export function saveOptions(options: Options): void {
localStorage.setItem(optionsLocalStorageName, JSON.stringify(options));
};
export const readOptions = (): Options | undefined => {
const optionsRaw = localStorage.getItem(optionsLocalStorageName);
if (!optionsRaw) {
return undefined;
}
try {
const options = JSON.parse(optionsRaw) as Options;
// check for new options here
if (options.wordlistReviewBadge === undefined) {
options.wordlistReviewBadge = true;
}
if (options.searchBarPosition === undefined) {
options.searchBarPosition = "top";
}
return options;
} catch (e) {
console.error("error parsing saved state JSON", e);
return undefined;
}
};

View File

@ -0,0 +1,170 @@
import PouchDB from "pouchdb";
import * as BT from "./backend-types";
type LocalDbType = "submissions" | "wordlist" | "reviewTasks";
type LocalDb = null | { refresh: () => void, db: PouchDB.Database };
type DbInput = {
type: "wordlist",
doc: WordlistWord,
} | {
type: "submissions",
doc: BT.Submission,
} | {
type: "reviewTasks",
doc: BT.ReviewTask,
};
const dbs: Record<LocalDbType, LocalDb> = {
/* for anyone logged in - for edits/suggestions submissions */
submissions: null,
/* for students and above - personal wordlist database */
wordlist: null,
/* for editors only - edits/suggestions (submissions) for review */
reviewTasks: null,
};
export function initializeLocalDb(type: LocalDbType, refresh: () => void, uid?: string | undefined) {
const name = type === "wordlist"
? `userdb-${uid? stringToHex(uid) : "guest"}`
: type === "submissions"
? "submissions"
: "review-tasks";
const db = dbs[type];
// only initialize the db if it doesn't exist or if it has a different name
if ((!db) || (db.db?.name !== name)) {
dbs[type] = {
db: new PouchDB(name),
refresh,
};
refresh();
}
}
export function getLocalDbName(type: LocalDbType) {
return dbs[type]?.db.name;
}
export function deInitializeLocalDb(type: LocalDbType) {
dbs[type] = null;
}
export function startLocalDbSync(
type: "wordlist" | "reviewTasks",
auth: { name: string, password: string },
) {
const localDb = dbs[type];
if (!localDb) {
console.error(`unable to start sync because ${type} database is not initialized`);
return;
}
const sync = localDb.db.sync(
`https://${auth.name}:${auth.password}@couchdb.lingdocs.com/${localDb.db.name}`,
{ live: true, retry: true },
).on("change", (info) => {
if (info.direction === "pull") {
localDb.refresh();
}
}).on("error", (error) => {
console.error(error);
});
return sync;
}
export async function addToLocalDb({ type, doc }: DbInput) {
const localDb = dbs[type];
if (!localDb) {
throw new Error(`unable to add doc to ${type} database - not initialiazed`);
}
// @ts-ignore
localDb.db.put(doc, () => {
localDb.refresh();
});
return doc;
}
export async function updateLocalDbDoc({ type, doc }: DbInput, id: string) {
const localDb = dbs[type];
if (!localDb) {
throw new Error(`unable to update doc to ${type} database - not initialized`);
}
const oldDoc = await localDb.db.get(id);
const updated = {
_rev: oldDoc._rev,
...doc,
}
// @ts-ignore
localDb.db.put(updated, () => {
localDb.refresh();
});
return updated;
}
export async function getAllDocsLocalDb(type: "submissions", limit?: number): Promise<BT.Submission[]>;
export async function getAllDocsLocalDb(type: "wordlist", limit?: number): Promise<WordlistWordDoc[]>;
export async function getAllDocsLocalDb(type: "reviewTasks", limit?: number): Promise<BT.ReviewTask[]>
export async function getAllDocsLocalDb(type: LocalDbType, limit?: number): Promise<BT.Submission[] | WordlistWordDoc[] | BT.ReviewTask[]> {
const localDb = dbs[type];
if (!localDb) {
throw new Error(`unable to get all docs from ${type} database - not initialized`);
}
const descending = type !== "reviewTasks";
const result = await localDb.db.allDocs({
descending,
include_docs: true,
[descending ? "startkey" : "endkey"]: "_design",
});
const docs = result.rows.map((row) => row.doc) as unknown;
switch (type) {
case "submissions":
return docs as BT.Submission[];
case "wordlist":
return docs as WordlistWordDoc[];
case "reviewTasks":
return docs as BT.ReviewTask[];
}
}
export async function getAttachment(type: LocalDbType, docId: string, attachmentId: string) {
const localDb = dbs[type];
if (!localDb) {
throw new Error(`unable to get attachment from ${type} database - not initialized`);
}
return await localDb.db.getAttachment(docId, attachmentId);
}
export async function deleteFromLocalDb(type: LocalDbType, id: string | string[]): Promise<void> {
const localDb = dbs[type];
if (!localDb) {
throw new Error(`unable to delete doc from ${type} database - not initialized`);
}
if (typeof id === "object") {
const allDocs = await localDb.db.allDocs({
descending: true,
include_docs: true,
"startkey": "_design",
});
const toRemove = allDocs.rows.filter((doc) => id.includes(doc.id));
if (toRemove.length === 0) {
return;
}
const forDeleting = toRemove.map((doc) => ({
_id: doc.id,
_rev: doc.value.rev,
_deleted: true,
}));
await localDb.db.bulkDocs(forDeleting);
} else {
const doc = await localDb.db.get(id);
await localDb.db.remove(doc);
}
localDb.refresh();
}
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('');
}

View File

@ -0,0 +1,17 @@
/**
* 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 { standardizePashto } from "@lingdocs/pashto-inflector";
export default function sanitizePashto(input: string): string {
return standardizePashto(input.trim())
.replace(/v/g, "w")
// remove diacritics as well
.replace(/[\u0600-\u061e\u064c-\u0670\u06D6-\u06Ed]/g, "");
// TODO: What to do with \u0674 ??
}

View File

@ -0,0 +1,79 @@
import { searchPile } from "../lib/search-pile";
import {
isNounAdjOrVerb,
} from "@lingdocs/pashto-inflector";
import { dictionary } from "../lib/dictionary";
import {
conjugateVerb,
inflectWord,
Types as T,
getVerbInfo,
} from "@lingdocs/pashto-inflector";
import { isPashtoScript } from "./is-pashto";
// 1st iteration: Brute force make every single conjugation and check all - 5300ms
// 2nd iteration: Check if it makes a big difference to search via function - 5100ms
// 3rd interation: First check for the first two letters in the verb info
// if present, conjugation and search the whole conjugation 255ms !! 🎉💪
// That's so much better I'm removing the option of skipping compounds
// ~4th iteration:~ ignore perfective or imperfective if wasn't present in verb info (not worth it - scrapped)
function fFuzzy(f: string): string {
return f.replace(/e|é/g, "[e|é]")
.replace(/i|í/g, "[i|í]")
.replace(/o|ó/g, "[o|ó]")
.replace(/u|ú/g, "[u|ú]")
.replace(/a|á/g, "[a|á]")
.replace(/U|Ú/g, "[Ú|U]");
}
export function searchAllInflections(allDocs: T.DictionaryEntry[], searchValue: string): { entry: T.DictionaryEntry, results: InflectionSearchResult[] }[] {
const timerLabel = "Search inflections";
const beg = fFuzzy(searchValue.slice(0, 2));
const preSearchFun = isPashtoScript(searchValue)
? (ps: T.PsString) => ps.p.slice(0, 2) === beg
: (ps: T.PsString) => !!ps.f.slice(0, 2).match(beg);
const fRegex = new RegExp("^" + fFuzzy(searchValue) + "$");
const searchFun = isPashtoScript(searchValue)
? (ps: T.PsString) => ps.p === searchValue
: (ps: T.PsString) => !!ps.f.match(fRegex);
console.time(timerLabel);
const results = allDocs.reduce((all: { entry: T.DictionaryEntry, results: InflectionSearchResult[] }[], entry) => {
const type = isNounAdjOrVerb(entry);
if (entry.c && type === "verb") {
try {
const complement = (entry.l && entry.c.includes("comp.")) ? dictionary.findOneByTs(entry.l) : undefined;
const verbInfo = getVerbInfo(entry, complement);
const initialResults = searchPile(verbInfo as any, preSearchFun);
if (!initialResults.length) return all;
const conjugation = conjugateVerb(
entry,
complement,
);
const results = searchPile(
conjugation as any,
searchFun,
);
if (results.length) {
return [...all, { entry, results }];
}
return all;
} catch (e) {
console.error(e);
console.error("error inflecting", entry.p);
return all;
}
}
if (entry.c && type === "nounAdj") {
const inflections = inflectWord(entry);
if (!inflections) return all;
const results = searchPile(inflections as any, searchFun);
if (results.length) {
return [...all, { entry, results }];
}
}
return all;
}, []);
console.timeEnd(timerLabel);
return results;
}

View File

@ -0,0 +1,55 @@
import { Types as T } from "@lingdocs/pashto-inflector";
import {
searchRow,
searchVerbBlock,
} from "./search-pile";
const r1: T.PersonLine = [[{ p: "تور", f: "tor"},{ p: "تور", f: "tor"},{ p: "بور", f: "bor"}], [{ p: "کور", f: "kor" }]];
const r2: T.PersonLine = [[{ p: "تور", f: "tor"},{ p: "بور", f: "bor"}], [{ p: "کور", f: "kor" }, { p: "تور", f: "tor"}]];
const r3: T.PersonLine = [[{ p: "بور", f: "bor"}], [{ p: "کور", f: "kor" }, { p: "تور", f: "tor"}, { p: "تور", f: "tor"}]];
test("row search works", () => {
const f = (ps: T.PsString) => ps.f === "tor";
expect(searchRow(r1, f)).toEqual([{ ps: { p: "تور", f: "tor"}, pos: [0] }]);
expect(searchRow(r2, f)).toEqual([{ ps: { p: "تور", f: "tor"}, pos: [0, 1]}]);
expect(searchRow(r3, f)).toEqual([{ ps: { p: "تور", f: "tor"}, pos: [1]}]);
});
const v = [{p: "کوم", f: "kawum"}]
const vb1: T.VerbBlock = [
[[{p: "کوم", f: "kawum"}, {p: "کوم", f: "kawum"}], [{p: "کوو", f: "kawoo"}]],
[[{p: "کوم", f: "kawum"}, {p: "کوم", f: "kawum"}], [{p: "کوو", f: "kawoo"}]],
[[{p: "کوې", f: "kawe"}], [{p: "کوئ", f: "kaweyy"}]],
[[{p: "کوې", f: "kawe"}], [{p: "کوئ", f: "kaweyy"}]],
[[{p: "کوي", f: "kawee"}], [{p: "کوي", f: "kawee"}]],
[[{p: "کوي", f: "kawee"}], [{p: "کوي", f: "kawee"}]],
];
const vb2: T.VerbBlock = [
[[{p: "کوم", f: "kawum"}], [{p: "کوم", f: "kawum"}]],
[[{p: "کوم", f: "kawum"}], [{p: "کوم", f: "kawum"}]],
[[{p: "کوم", f: "kawum"}], [{p: "کوم", f: "kawum"}]],
[[{p: "کوم", f: "kawum"}], [{p: "کوم", f: "kawum"}]],
[[{p: "کوم", f: "kawum"}], [{p: "کوم", f: "kawum"}]],
[[{p: "کوم", f: "kawum"}], [{p: "کوم", f: "kawum"}]],
]
test("verb block search works", () => {
expect(searchVerbBlock(vb1, (ps: T.PsString) => ps.f === "kawum")).toEqual([{
ps: {p: "کوم", f: "kawum"},
pos: [[0, 0], [1, 0]],
}]);
expect(searchVerbBlock(vb1, (ps: T.PsString) => ps.f === "kawe")).toEqual([{
ps: {p: "کوې", f: "kawe"},
pos: [[2, 0], [3, 0]],
}]);
expect(searchVerbBlock(vb1, (ps: T.PsString) => ps.f === "kawee")).toEqual([{
ps: {p: "کوي", f: "kawee"},
pos: [[4, 0], [4, 1], [5, 0], [5, 1]],
}]);
expect(searchVerbBlock(vb2, (ps: T.PsString) => ps.f === "kawum")).toEqual([{
ps: {p: "کوم", f: "kawum"},
pos: [[0, 0], [0, 1], [1, 0], [1, 1], [2, 0], [2, 1], [3, 0], [3, 1], [4, 0], [4, 1], [5, 0], [5, 1]],
}]);
});

View File

@ -0,0 +1,192 @@
/**
* 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 { Types as T } from "@lingdocs/pashto-inflector";
import {
isVerbBlock,
isImperativeBlock,
isInflectionSet,
} from "@lingdocs/pashto-inflector";
import { personFromVerbBlockPos } from "@lingdocs/pashto-inflector";
const inflectionNames: InflectionName[] = ["plain", "1st", "2nd"];
type ObPile = { [key: string]: ObRec; }
type ObRec = T.VerbBlock | T.ImperativeBlock | T.InflectionSet | T.PsString | boolean | null | string | ObPile;
type SinglePsResult = T.PsString | null;
type BlockResult = { ps: T.PsString, pos: T.Person[] | InflectionName[] }[];
type InflectionSetResult = { ps: T.PsString, pos: InflectionName[] }[];
type BlockResultRaw = { ps: T.PsString, pos: [number, number][] }[];
type RowResult = { ps: T.PsString, pos: (0 | 1)[] }[];
function isPsString(x: T.PsString | ObPile): x is T.PsString {
return (
"p" in x &&
"f" in x &&
typeof x.p === "string"
);
}
function isBlockResult(x: InflectionSearchResult[] | BlockResult): x is BlockResult {
return "ps" in x[0];
}
// NOTE: perfectiveSplit needs to be ignored because the [PsString, PsString] structure breaks the search!
const defaultFieldsToIgnore = ["info", "type", "perfectiveSplit"];
export function searchPile(pile: ObPile, searchFun: (s: T.PsString) => boolean, toIgnore: string[] = []): InflectionSearchResult[] {
const fieldsToIgnore = [...defaultFieldsToIgnore, toIgnore];
function searchObRecord(record: ObRec): null | BlockResult | SinglePsResult | InflectionSearchResult[] {
// hit a bottom part a tree, see if what we're looking for is there
if (Array.isArray(record)) {
return searchBlock(record, searchFun);
}
if (typeof record !== "object") return null;
if (!record) return null;
if (isPsString(record)) {
const res = searchFun(record);
return res ? record : null;
}
// look further down the tree recursively
return searchPile(record, searchFun);
}
return Object.entries(pile).reduce((res: InflectionSearchResult[], entry): InflectionSearchResult[] => {
const [name, value] = entry;
if (fieldsToIgnore.includes(name)) {
return res;
}
const result = searchObRecord(value);
// Result: Hit the bottom and nothing found
if (result === null) {
return res;
}
// Result: Hit a PsString with what we want at the bottom
if ("p" in result) {
return [
...res,
{
form: [name],
matches: [{ ps: result, pos: null }],
},
];
}
if (result.length === 0) {
return res;
}
// Result: Hit the bottom and found a Verb or Inflection block with what we want at the bottom
if (isBlockResult(result)) {
return [
...res,
{
form: [name],
matches: result,
}
];
}
// Result: Have to keep looking down recursively
// add in the current path to all the results
const rb: InflectionSearchResult[] = [
...res,
...result.map((r) => ({
...r,
form: [name, ...r.form]
})),
]
return rb;
}, []);
}
function searchBlock(block: T.VerbBlock | T.ImperativeBlock | T.InflectionSet, searchFun: (x: T.PsString) => boolean): null | BlockResult | InflectionSetResult {
if (isVerbBlock(block)) {
const results = searchVerbBlock(block, searchFun);
if (results.length) {
return results.map((result) => ({
...result,
pos: result.pos.map(x => personFromVerbBlockPos(x)),
}));
}
return null;
}
if (isImperativeBlock(block)) {
const results = searchVerbBlock(block, searchFun);
if (results.length) {
return results.map((result) => ({
...result,
pos: result.pos.map(x => personFromVerbBlockPos([x[0] + 2, x[1]])),
}));
}
return null;
}
if (isInflectionSet(block)) {
const results = searchInflectionSet(block, searchFun);
if (results.length) {
return results;
}
return null;
}
return null;
}
export function searchRow(row: T.PersonLine, searchFun: (ps: T.PsString) => boolean): RowResult {
return row.reduce((all: RowResult, col, h): RowResult => {
const i = h as 0 | 1;
const inCol = col.filter(searchFun);
inCol.forEach((ps) => {
const index = all.findIndex((x) => x.ps.f === ps.f);
if (index === -1) {
all.push({ ps, pos: [i] });
} else {
if (!all[index].pos.includes(i)) {
all[index].pos = [...all[index].pos, i];
}
}
});
return all;
}, []);
}
export function searchVerbBlock(vb: T.VerbBlock | T.ImperativeBlock, searchFun: (ps: T.PsString) => boolean): BlockResultRaw {
return vb.reduce((all: BlockResultRaw, row, i): BlockResultRaw => {
const rowResults = searchRow(row, searchFun);
const prev = [...all];
rowResults.forEach(r => {
const index = prev.findIndex(x => x.ps.f === r.ps.f);
if (index !== -1) {
const toAdd = r.pos.map((c): [number, number] => [i, c])
prev[index].pos.push(...toAdd)
} else {
prev.push({
ps: r.ps,
pos: r.pos.map((col): [number, number] => [i, col]),
});
}
});
return prev;
}, []);
}
function searchInflectionSet(inf: T.InflectionSet, searchFun: (ps: T.PsString) => boolean): InflectionSetResult {
return inf.reduce((all: InflectionSetResult, item, i): InflectionSetResult => {
const matching = item.filter(searchFun);
if (i === 0) {
return matching.map(ps => ({ ps, pos: [inflectionNames[i]] }))
}
matching.forEach(it => {
const index = all.findIndex(x => x.ps.f === it.f);
if (index !== -1) {
all[index].pos.push(inflectionNames[i])
} else {
all.push({ ps: it, pos: [inflectionNames[i]] });
}
})
return all;
}, []);
}

View File

@ -0,0 +1,98 @@
/**
* 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 {
supermemo,
SuperMemoGrade,
SuperMemoItem,
} from "supermemo";
import dayjs from "dayjs";
import {
getMillisecondsPeriod,
} from "./time-utils";
/* starting stage of review, based on Pimseleur intervals */
const warmupIntervals = [
0,
getMillisecondsPeriod("minutes", 10),
getMillisecondsPeriod("hours", 2),
];
const oneDay = getMillisecondsPeriod("hours", 24);
export const baseSupermemo: SuperMemoItem = {
interval: 0,
repetition: 0,
efactor: 2.5,
}
/**
* given a wordlist, it returns the words that are ready for review,
* sorted with the most overdue words first
*
* @param wordlist
* @returns
*/
export function forReview(wordlist: WordlistWord[]): WordlistWord[] {
const now = Date.now();
return wordlist.filter((word) => (
// filter out just the words that are due for repetition
now > word.dueDate
)).sort((a, b) => (a.dueDate < b.dueDate) ? -1 : 1);
}
/**
* give a wordlist, it returns the word that has the lowest due date
*
* @param wordlist
* @returns
*/
export function nextUpForReview(wordlist: WordlistWord[]): WordlistWord {
return wordlist.reduce((mostOverdue, w): WordlistWord => (
(mostOverdue.dueDate > w.dueDate) ? w : mostOverdue
), wordlist[0]);
}
export function practiceWord(word: WordlistWord, grade: SuperMemoGrade): WordlistWord {
function handleWarmupRep(): WordlistWord {
const stage = word.warmup as number;
const now = Date.now();
const newLevel = Math.max(0, stage + (
grade === 5 ? 2
: grade === 4 ? 1
: grade === 3 ? 0
: -1
));
const successAfterOneDay = (grade > 3) && ((now - word.dueDate) > oneDay);
const warmup = ((newLevel >= warmupIntervals.length) || successAfterOneDay)
? "done"
: newLevel;
const dueDate = now + (
warmup === "done" ? getMillisecondsPeriod("hours", 16) : warmupIntervals[newLevel]
);
return {
...word,
warmup,
dueDate,
};
}
function handleSupermemoRep(): WordlistWord {
const newSupermemo = supermemo(word.supermemo, grade);
const dueDate = dayjs(Date.now()).add(newSupermemo.interval, "day").valueOf();
return {
...word,
supermemo: newSupermemo,
dueDate,
};
}
if (word.warmup !== "done") {
return handleWarmupRep();
}
return handleSupermemoRep();
}

Some files were not shown because too many files have changed in this diff Show More