diff --git a/.github/workflows/continuous.yaml b/.github/workflows/continuous.yaml index 6c2a8153d0..eeebf4fdc4 100644 --- a/.github/workflows/continuous.yaml +++ b/.github/workflows/continuous.yaml @@ -1,11 +1,7 @@ name: Continuous on: push: - branches: - - "*" pull_request: - branches: - - "*" concurrency: group: ${{ github.ref }} @@ -93,6 +89,8 @@ jobs: # cache-to: type=registry,ref=gcr.io/${{ secrets.DEV_PROJECT }}/sefaria-${{ matrix.app }}/cache, mode=max context: . push: true + build-args: | + TYPE=build file: ./build/${{ matrix.app }}/Dockerfile tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/production-deploy.yaml b/.github/workflows/production-deploy.yaml index 31bb7f228b..0966286206 100644 --- a/.github/workflows/production-deploy.yaml +++ b/.github/workflows/production-deploy.yaml @@ -97,6 +97,8 @@ jobs: # cache-to: type=registry,ref=$gcr.io/${{ secrets.PROD_GKE_PROJECT }}/{{ secrets.IMAGE_NAME }}-${{ matrix.app }}/cache,mode=max context: . push: true + build-args: | + TYPE=build-prod file: ./build/${{ matrix.app }}/Dockerfile tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} @@ -218,7 +220,6 @@ jobs: IMAGE_NAME: "${{ secrets.IMAGE_NAME }}" CHART_VERSION: "${{ steps.chart_version.outputs.chart_version }}" - name: Update workflow default chart - if: github.ref == 'refs/heads/master' run: > curl -L -X PATCH diff --git a/README.mkd b/README.mkd index 582de41930..49b49c8eef 100644 --- a/README.mkd +++ b/README.mkd @@ -108,7 +108,7 @@ If the server isn't running, you may need to run `docker-compose up` again. #### Run locally #### 1) Install Python 3.7 -*We Recommend using the latest Python 3.7 as opposed to later versions of Python (esp 3.10 and up) since it has been known to cause some compatability issues. These are solvable, but for an easier install experience, we currently recommend 3.7* +*We Recommend using the latest Python 3.7 as opposed to later versions of Python (esp 3.10 and up) since it has been known to cause some compatibility issues. These are solvable, but for an easier install experience, we currently recommend 3.7* ###### Linux and macOS Most UNIX systems come with a python interpreter pre-installed. However, this is generally still Python 2. The recommended way to get Python 3, and not mess up any software the OS is dependent on, is by using Pyenv. You can use the instructions [here](https://github.com/pyenv/pyenv#installation) and also [here](https://opensource.com/article/19/5/python-3-default-mac#what-we-should-do). diff --git a/build/ci/production-values.yaml b/build/ci/production-values.yaml index 8c4c4b81e9..b653021544 100644 --- a/build/ci/production-values.yaml +++ b/build/ci/production-values.yaml @@ -193,7 +193,7 @@ secrets: slackWebhook: ref: slack-webhook-production instrumentation: - enabled: true + enabled: false otelEndpoint: "http://otel-collector-collector.monitoring:4317" jaegerEndpoint: "jaeger-agent-dev.monitoring.svc.cluster.local:6831" diff --git a/build/node/Dockerfile b/build/node/Dockerfile index f8f14097d4..4b00650316 100644 --- a/build/node/Dockerfile +++ b/build/node/Dockerfile @@ -1,4 +1,5 @@ FROM gcr.io/production-deployment/base-node:v13.0.0 +ARG TYPE=build-prod WORKDIR /app/ @@ -7,7 +8,7 @@ RUN npm install --unsafe-perm COPY ./node /app/node COPY ./static/js /app/static/js -RUN npm run build-prod +RUN npm run $TYPE COPY . /app/ EXPOSE 3000 diff --git a/build/web/Dockerfile b/build/web/Dockerfile index 07effb5324..7065e16cee 100644 --- a/build/web/Dockerfile +++ b/build/web/Dockerfile @@ -1,4 +1,5 @@ FROM gcr.io/production-deployment/base-web:v3.7-bullseye +ARG TYPE=build-prod WORKDIR /app/ # Copied separately to allow for caching of the `pip install` build step @@ -10,7 +11,7 @@ RUN npm install --unsafe-perm COPY ./node /app/node COPY ./static/js /app/static/js -RUN npm run build-prod +RUN npm run $TYPE COPY . /app/ diff --git a/helm-chart/sefaria-project/templates/configmap/create-mongo-dumps.yaml b/helm-chart/sefaria-project/templates/configmap/create-mongo-dumps.yaml index 20c5a5ee3d..60d126a91b 100644 --- a/helm-chart/sefaria-project/templates/configmap/create-mongo-dumps.yaml +++ b/helm-chart/sefaria-project/templates/configmap/create-mongo-dumps.yaml @@ -67,7 +67,7 @@ data: sleep 2 done - until mongodump --uri="$URI" -v -d $DATABASE --excludeCollection=history --excludeCollection=texts --excludeCollection=sheets --excludeCollection=links --excludeCollection=user_history -o "${DATADIR}/dump" + until mongodump --uri="$URI" -v -d $DATABASE --excludeCollection=history --excludeCollection=texts --excludeCollection=sheets --excludeCollection=links --excludeCollection=django_cache --excludeCollection=user_history -o "${DATADIR}/dump" do echo "trying to dump other stuff again" sleep 2 diff --git a/helm-chart/sefaria-project/templates/configmap/mongo-destroy.yaml b/helm-chart/sefaria-project/templates/configmap/mongo-destroy.yaml index 6874c016c4..3108cb1af0 100644 --- a/helm-chart/sefaria-project/templates/configmap/mongo-destroy.yaml +++ b/helm-chart/sefaria-project/templates/configmap/mongo-destroy.yaml @@ -8,7 +8,7 @@ metadata: {{- include "sefaria.labels" . | nindent 4 }} annotations: helm.sh/hook: post-delete - helm.sh/hook-delete-policy: hook-succeeded + helm.sh/hook-delete-policy: hook-succeeded, hook-failed helm.sh/hook-weight: "5" data: destroy-mongo.sh: |- @@ -37,8 +37,14 @@ data: DB_NAME=$SEFARIA_DB {{ end }} - URI="${URI}${MONGO_HOST}/${DATABASE}?ssl=false&authSource=admin" + echo "APSCHEDULER: ${APSCHEDULER_NAME}" + + if [[ -z "$APSCHEDULER_NAME" ]]; then + APSCHEDULER_NAME={{ tpl .Values.localSettings.APSCHEDULER_NAME . | quote }} + fi + APSCHEDULER_URI="${URI}${MONGO_HOST}/${APSCHEDULER_NAME}?ssl=false&authSource=admin" + URI="${URI}${MONGO_HOST}/${DB_NAME}?ssl=false&authSource=admin" if [[ ! -z "$MONGO_REPLICASET_NAME" ]]; then URI="${URI}&replicaSet=${MONGO_REPLICASET_NAME}" diff --git a/package-lock.json b/package-lock.json index 2f6296b5f5..0a2bd0d49b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2410,6 +2410,29 @@ "integrity": "sha512-Wha1UwsB3CYdqUm2PPzh/1gujGCNtWVUYF0mB00fJFoR4gTyWTDPjSm+zBF787Ahw8vSGgBja90MkgFwvB86Dg==", "dev": true }, + "node_modules/@types/prop-types": { + "version": "15.7.5", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz", + "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==", + "peer": true + }, + "node_modules/@types/react": { + "version": "18.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.14.tgz", + "integrity": "sha512-A0zjq+QN/O0Kpe30hA1GidzyFjatVvrpIvWLxD+xv67Vt91TWWgco9IvrJBkeyHm1trGaFS/FSGqPlhyeZRm0g==", + "peer": true, + "dependencies": { + "@types/prop-types": "*", + "@types/scheduler": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/scheduler": { + "version": "0.16.3", + "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.3.tgz", + "integrity": "sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==", + "peer": true + }, "node_modules/@types/stack-utils": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-1.0.1.tgz", @@ -2969,6 +2992,188 @@ "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.12.0.tgz", "integrity": "sha512-NmWvPnx0F1SfrQbYwOi7OeaNGokp9XhzNioJ/CSBs8Qa4vxug81mhJEAVZwxXuBmYB5KDRfMq/F3RR0BIU7sWg==" }, + "node_modules/babel-code-frame": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-code-frame/-/babel-code-frame-6.26.0.tgz", + "integrity": "sha512-XqYMR2dfdGMW+hd0IUZ2PwK+fGeFkOxZJ0wY+JaQAHzt1Zx8LcvpiZD2NiGkEG8qx0CfkAOr5xt76d1e8vG90g==", + "dev": true, + "peer": true, + "dependencies": { + "chalk": "^1.1.3", + "esutils": "^2.0.2", + "js-tokens": "^3.0.2" + } + }, + "node_modules/babel-code-frame/node_modules/ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==", + "dev": true, + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/babel-code-frame/node_modules/ansi-styles": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", + "integrity": "sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA==", + "dev": true, + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/babel-code-frame/node_modules/chalk": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "integrity": "sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==", + "dev": true, + "peer": true, + "dependencies": { + "ansi-styles": "^2.2.1", + "escape-string-regexp": "^1.0.2", + "has-ansi": "^2.0.0", + "strip-ansi": "^3.0.0", + "supports-color": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/babel-code-frame/node_modules/js-tokens": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-3.0.2.tgz", + "integrity": "sha512-RjTcuD4xjtthQkaWH7dFlH85L+QaVtSoOyGdZ3g6HFhS9dFNDfLyqgm2NFe2X6cQpeFmt0452FJjFG5UameExg==", + "dev": true, + "peer": true + }, + "node_modules/babel-code-frame/node_modules/strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==", + "dev": true, + "peer": true, + "dependencies": { + "ansi-regex": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/babel-code-frame/node_modules/supports-color": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", + "integrity": "sha512-KKNVtd6pCYgPIKU4cp2733HWYCpplQhddZLBUryaAHou723x+FRzQ5Df824Fj+IyyuiQTRoub4SnIFfIcrp70g==", + "dev": true, + "peer": true, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/babel-core": { + "version": "6.26.3", + "resolved": "https://registry.npmjs.org/babel-core/-/babel-core-6.26.3.tgz", + "integrity": "sha512-6jyFLuDmeidKmUEb3NM+/yawG0M2bDZ9Z1qbZP59cyHLz8kYGKYwpJP0UwUKKUiTRNvxfLesJnTedqczP7cTDA==", + "dev": true, + "peer": true, + "dependencies": { + "babel-code-frame": "^6.26.0", + "babel-generator": "^6.26.0", + "babel-helpers": "^6.24.1", + "babel-messages": "^6.23.0", + "babel-register": "^6.26.0", + "babel-runtime": "^6.26.0", + "babel-template": "^6.26.0", + "babel-traverse": "^6.26.0", + "babel-types": "^6.26.0", + "babylon": "^6.18.0", + "convert-source-map": "^1.5.1", + "debug": "^2.6.9", + "json5": "^0.5.1", + "lodash": "^4.17.4", + "minimatch": "^3.0.4", + "path-is-absolute": "^1.0.1", + "private": "^0.1.8", + "slash": "^1.0.0", + "source-map": "^0.5.7" + } + }, + "node_modules/babel-core/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "peer": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/babel-core/node_modules/json5": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/json5/-/json5-0.5.1.tgz", + "integrity": "sha512-4xrs1aW+6N5DalkqSVA8fxh458CXvR99WU8WLKmq4v8eWAL86Xo3BVqyd3SkA9wEVjCMqyvvRRkshAdOnBp5rw==", + "dev": true, + "peer": true, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/babel-core/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "peer": true + }, + "node_modules/babel-core/node_modules/slash": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-1.0.0.tgz", + "integrity": "sha512-3TYDR7xWt4dIqV2JauJr+EJeW356RXijHeUlO+8djJ+uBXPn8/2dpzBc8yQhh583sVvc9CvFAeQVgijsH+PNNg==", + "dev": true, + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/babel-generator": { + "version": "6.26.1", + "resolved": "https://registry.npmjs.org/babel-generator/-/babel-generator-6.26.1.tgz", + "integrity": "sha512-HyfwY6ApZj7BYTcJURpM5tznulaBvyio7/0d4zFOeMPUmfxkCjHocCuoLa2SAGzBI8AREcH3eP3758F672DppA==", + "dev": true, + "peer": true, + "dependencies": { + "babel-messages": "^6.23.0", + "babel-runtime": "^6.26.0", + "babel-types": "^6.26.0", + "detect-indent": "^4.0.0", + "jsesc": "^1.3.0", + "lodash": "^4.17.4", + "source-map": "^0.5.7", + "trim-right": "^1.0.1" + } + }, + "node_modules/babel-generator/node_modules/jsesc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-1.3.0.tgz", + "integrity": "sha512-Mke0DA0QjUWuJlhsE0ZPPhYiJkRap642SmI/4ztCFaUs6V2AiH1sfecc+57NgaryfAA2VR3v6O+CSjC1jZJKOA==", + "dev": true, + "peer": true, + "bin": { + "jsesc": "bin/jsesc" + } + }, + "node_modules/babel-helpers": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-helpers/-/babel-helpers-6.24.1.tgz", + "integrity": "sha512-n7pFrqQm44TCYvrCDb0MqabAF+JUBq+ijBvNMUxpkLjJaAu32faIexewMumrH5KLLJ1HDyT0PTEqRyAe/GwwuQ==", + "dev": true, + "peer": true, + "dependencies": { + "babel-runtime": "^6.22.0", + "babel-template": "^6.24.1" + } + }, "node_modules/babel-jest": { "version": "24.9.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-24.9.0.tgz", @@ -3024,6 +3229,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/babel-messages": { + "version": "6.23.0", + "resolved": "https://registry.npmjs.org/babel-messages/-/babel-messages-6.23.0.tgz", + "integrity": "sha512-Bl3ZiA+LjqaMtNYopA9TYE9HP1tQ+E5dLxE0XrAzcIJeK2UqF0/EaqXwBn9esd4UmTfEab+P+UYQ1GnioFIb/w==", + "dev": true, + "peer": true, + "dependencies": { + "babel-runtime": "^6.22.0" + } + }, "node_modules/babel-plugin-istanbul": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-5.2.0.tgz", @@ -3112,6 +3327,140 @@ "@babel/core": "^7.0.0" } }, + "node_modules/babel-register": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-register/-/babel-register-6.26.0.tgz", + "integrity": "sha512-veliHlHX06wjaeY8xNITbveXSiI+ASFnOqvne/LaIJIqOWi2Ogmj91KOugEz/hoh/fwMhXNBJPCv8Xaz5CyM4A==", + "dev": true, + "peer": true, + "dependencies": { + "babel-core": "^6.26.0", + "babel-runtime": "^6.26.0", + "core-js": "^2.5.0", + "home-or-tmp": "^2.0.0", + "lodash": "^4.17.4", + "mkdirp": "^0.5.1", + "source-map-support": "^0.4.15" + } + }, + "node_modules/babel-register/node_modules/core-js": { + "version": "2.6.12", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz", + "integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==", + "deprecated": "core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.", + "dev": true, + "hasInstallScript": true, + "peer": true + }, + "node_modules/babel-runtime": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz", + "integrity": "sha512-ITKNuq2wKlW1fJg9sSW52eepoYgZBggvOAHC0u/CYu/qxQ9EVzThCgR69BnSXLHjy2f7SY5zaQ4yt7H9ZVxY2g==", + "dev": true, + "peer": true, + "dependencies": { + "core-js": "^2.4.0", + "regenerator-runtime": "^0.11.0" + } + }, + "node_modules/babel-runtime/node_modules/core-js": { + "version": "2.6.12", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz", + "integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==", + "deprecated": "core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.", + "dev": true, + "hasInstallScript": true, + "peer": true + }, + "node_modules/babel-runtime/node_modules/regenerator-runtime": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz", + "integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==", + "dev": true, + "peer": true + }, + "node_modules/babel-template": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-template/-/babel-template-6.26.0.tgz", + "integrity": "sha512-PCOcLFW7/eazGUKIoqH97sO9A2UYMahsn/yRQ7uOk37iutwjq7ODtcTNF+iFDSHNfkctqsLRjLP7URnOx0T1fg==", + "dev": true, + "peer": true, + "dependencies": { + "babel-runtime": "^6.26.0", + "babel-traverse": "^6.26.0", + "babel-types": "^6.26.0", + "babylon": "^6.18.0", + "lodash": "^4.17.4" + } + }, + "node_modules/babel-traverse": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-traverse/-/babel-traverse-6.26.0.tgz", + "integrity": "sha512-iSxeXx7apsjCHe9c7n8VtRXGzI2Bk1rBSOJgCCjfyXb6v1aCqE1KSEpq/8SXuVN8Ka/Rh1WDTF0MDzkvTA4MIA==", + "dev": true, + "peer": true, + "dependencies": { + "babel-code-frame": "^6.26.0", + "babel-messages": "^6.23.0", + "babel-runtime": "^6.26.0", + "babel-types": "^6.26.0", + "babylon": "^6.18.0", + "debug": "^2.6.8", + "globals": "^9.18.0", + "invariant": "^2.2.2", + "lodash": "^4.17.4" + } + }, + "node_modules/babel-traverse/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "peer": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/babel-traverse/node_modules/globals": { + "version": "9.18.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-9.18.0.tgz", + "integrity": "sha512-S0nG3CLEQiY/ILxqtztTWH/3iRRdyBLw6KMDxnKMchrtbj2OFmehVh0WUCfW3DUrIgx/qFrJPICrq4Z4sTR9UQ==", + "dev": true, + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/babel-traverse/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "peer": true + }, + "node_modules/babel-types": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-types/-/babel-types-6.26.0.tgz", + "integrity": "sha512-zhe3V/26rCWsEZK8kZN+HaQj5yQ1CilTObixFzKW1UWjqG7618Twz6YEsCnjfg5gBcJh02DrpCkS9h98ZqDY+g==", + "dev": true, + "peer": true, + "dependencies": { + "babel-runtime": "^6.26.0", + "esutils": "^2.0.2", + "lodash": "^4.17.4", + "to-fast-properties": "^1.0.3" + } + }, + "node_modules/babel-types/node_modules/to-fast-properties": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-1.0.3.tgz", + "integrity": "sha512-lxrWP8ejsq+7E3nNjwYmUBMAgjMTZoTI+sdBOpvNyijeDLa29LUn9QaoXAHv4+Z578hbmHHJKZknzxVtvo77og==", + "dev": true, + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/babel-watch": { "version": "2.0.8", "resolved": "https://registry.npmjs.org/babel-watch/-/babel-watch-2.0.8.tgz", @@ -3387,6 +3736,16 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/babylon": { + "version": "6.18.0", + "resolved": "https://registry.npmjs.org/babylon/-/babylon-6.18.0.tgz", + "integrity": "sha512-q/UEjfGJ2Cm3oKV71DJz9d25TPnq5rhBVL2Q4fA5wcC3jcrdn7+SssEybFIxwAvvP+YCsCYNKughoF33GxgycQ==", + "dev": true, + "peer": true, + "bin": { + "babylon": "bin/babylon.js" + } + }, "node_modules/bail": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/bail/-/bail-1.0.5.tgz", @@ -4551,6 +4910,12 @@ "cssom": "0.3.x" } }, + "node_modules/csstype": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", + "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==", + "peer": true + }, "node_modules/dashdash": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", @@ -4695,6 +5060,19 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detect-indent": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-4.0.0.tgz", + "integrity": "sha512-BDKtmHlOzwI7iRuEkhzsnPoi5ypEhWAJB5RvHWe1kMr06js3uK5B3734i3ui5Yd+wOJV1cpE4JnivPD283GU/A==", + "dev": true, + "peer": true, + "dependencies": { + "repeating": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/detect-newline": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-2.1.0.tgz", @@ -6274,6 +6652,29 @@ "node": ">= 0.4.0" } }, + "node_modules/has-ansi": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", + "integrity": "sha512-C8vBJ8DwUCx19vhm7urhTuUsr4/IyP6l4VzNQDv+ryHQObW3TTTp9yB68WpYgRe2bbaGuZ/se74IqFeVnMnLZg==", + "dev": true, + "peer": true, + "dependencies": { + "ansi-regex": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/has-ansi/node_modules/ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==", + "dev": true, + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/has-bigints": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", @@ -6388,6 +6789,20 @@ "react-is": "^16.7.0" } }, + "node_modules/home-or-tmp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/home-or-tmp/-/home-or-tmp-2.0.0.tgz", + "integrity": "sha512-ycURW7oUxE2sNiPVw1HVEFsW+ecOpJ5zaj7eC0RlwhibhRBod20muUN8qu/gzx956YrLolVvs1MTXwKgC2rVEg==", + "dev": true, + "peer": true, + "dependencies": { + "os-homedir": "^1.0.0", + "os-tmpdir": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/hosted-git-info": { "version": "2.8.9", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", @@ -6871,6 +7286,19 @@ "node": ">=0.10.0" } }, + "node_modules/is-finite": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-finite/-/is-finite-1.1.0.tgz", + "integrity": "sha512-cdyMtqX/BOqqNBBiKlIVkytNHm49MtMlYyn1zxzvJKWmFMlGzm+ry5BBfYyeY9YmNKbRSo/o7OX9w9ale0wg3w==", + "dev": true, + "peer": true, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-fullwidth-code-point": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", @@ -9361,6 +9789,16 @@ "node": ">= 0.8.0" } }, + "node_modules/os-homedir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz", + "integrity": "sha512-B5JU3cabzk8c67mRRd3ECmROafjYMXbuzlwtqdM8IbS8ktlTix8aFGb2bAGKrSRIlnfKwovGUUr72JUPyOb6kQ==", + "dev": true, + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/os-tmpdir": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", @@ -9969,6 +10407,16 @@ "node": ">= 6" } }, + "node_modules/private": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/private/-/private-0.1.8.tgz", + "integrity": "sha512-VvivMrbvd2nKkiG38qjULzlc+4Vx4wm/whI9pQD35YrARNnhxeiRktSOhSukRLFNlzg6Br/cJPet5J/u19r/mg==", + "dev": true, + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -10579,6 +11027,19 @@ "node": ">=0.10" } }, + "node_modules/repeating": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/repeating/-/repeating-2.0.1.tgz", + "integrity": "sha512-ZqtSMuVybkISo2OWvqvm7iHSWngvdaW3IpsT9/uP8v4gMi591LY6h35wdOfvQdWCKFWZWm2Y1Opp4kV7vQKT6A==", + "dev": true, + "peer": true, + "dependencies": { + "is-finite": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/request": { "version": "2.88.2", "resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz", @@ -12409,6 +12870,16 @@ "punycode": "^2.1.0" } }, + "node_modules/trim-right": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/trim-right/-/trim-right-1.0.1.tgz", + "integrity": "sha512-WZGXGstmCWgeevgTL54hrCuw1dyMQIzWy7ZfqRJfSmJZBwklI15egmQytFP6bPidmw3M8d5yEowl1niq4vmqZw==", + "dev": true, + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/triple-beam": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.3.0.tgz", diff --git a/reader/views.py b/reader/views.py index 9231944f8f..81c0878869 100644 --- a/reader/views.py +++ b/reader/views.py @@ -845,7 +845,8 @@ def search(request): } return render_template(request,'base.html', props, { "title": (search_params["query"] + " | " if search_params["query"] else "") + _("Sefaria Search"), - "desc": _("Search 3,000 years of Jewish texts in Hebrew and English translation.") + "desc": _("Search 3,000 years of Jewish texts in Hebrew and English translation."), + "noindex": True }) @@ -1214,7 +1215,6 @@ def edit_text(request, ref=None, lang=None, version=None): mode = "Add" else: # Pull a particular section to edit - version = version.replace("_", " ") if version else None #text = get_text(ref, lang=lang, version=version) text = TextFamily(Ref(ref), lang=lang, version=version).contents() text["mode"] = request.path.split("/")[1] @@ -1388,15 +1388,11 @@ def texts_api(request, tref): commentary = bool(int(request.GET.get("commentary", False))) pad = bool(int(request.GET.get("pad", 1))) versionEn = request.GET.get("ven", None) + versionHe = request.GET.get("vhe", None) firstAvailableRef = bool(int(request.GET.get("firstAvailableRef", False))) # use first available ref, which may not be the same as oref if firstAvailableRef: temp_oref = oref.first_available_section_ref() oref = temp_oref or oref # don't overwrite oref if first available section ref fails - if versionEn: - versionEn = versionEn.replace("_", " ") - versionHe = request.GET.get("vhe", None) - if versionHe: - versionHe = versionHe.replace("_", " ") layer_name = request.GET.get("layer", None) alts = bool(int(request.GET.get("alts", True))) wrapLinks = bool(int(request.GET.get("wrapLinks", False))) @@ -1552,9 +1548,6 @@ def social_image_api(request, tref): ref = Ref(tref) ref_str = ref.normal() if lang == "en" else ref.he_normal() - if version: - version = version.replace("_", " ") - tf = TextFamily(ref, stripItags=True, lang=lang, version=version, context=0, commentary=False).contents() he = tf["he"] if type(tf["he"]) is list else [tf["he"]] @@ -3102,14 +3095,14 @@ def add_new_topic_api(request): data = json.loads(request.POST["json"]) isTopLevelDisplay = data["category"] == Topic.ROOT t = Topic({'slug': "", "isTopLevelDisplay": isTopLevelDisplay, "data_source": "sefaria", "numSources": 0}) - update_topic_titles(t, data) + update_topic_titles(t, **data) if not isTopLevelDisplay: # not Top Level so create an IntraTopicLink to category new_link = IntraTopicLink({"toTopic": data["category"], "fromTopic": t.slug, "linkType": "displays-under", "dataSource": "sefaria"}) new_link.save() if data["category"] == 'authors': - t = update_authors_place_and_time(t, data) + t = update_authors_place_and_time(t, **data) t.description_published = True t.data_source = "sefaria" # any topic edited manually should display automatically in the TOC and this flag ensures this @@ -3164,15 +3157,15 @@ def topics_api(request, topic, v2=False): if not request.user.is_staff: return jsonResponse({"error": "Adding topics is locked.

Please email hello@sefaria.org if you believe edits are needed."}) topic_data = json.loads(request.POST["json"]) - topic_obj = Topic().load({'slug': topic_data["origSlug"]}) + topic = Topic().load({'slug': topic_data["origSlug"]}) topic_data["manual"] = True author_status_changed = (topic_data["category"] == "authors") ^ (topic_data["origCategory"] == "authors") - topic_obj = update_topic(topic_obj, **topic_data) + topic = update_topic(topic, **topic_data) if author_status_changed: library.build_topic_auto_completer() def protected_index_post(request): - return jsonResponse(topic_obj.contents()) + return jsonResponse(topic.contents()) return protected_index_post(request) @@ -3292,6 +3285,16 @@ def recommend_topics_api(request, ref_list=None): return response +@api_view(["GET"]) +@catch_error_as_json +def portals_api(request, slug): + """ + API to get data for a Portal object by slug + """ + portal = Portal.init(slug) + return jsonResponse(portal.contents(), callback=request.GET.get("callback", None)) + + @ensure_csrf_cookie @sanitize_get_params def global_activity(request, page=1): @@ -4011,8 +4014,17 @@ def random_text_api(request): """ Return Texts API data for a random ref. """ - categories = set(request.GET.get('categories', '').split('|')) - titles = set(request.GET.get('titles', '').split('|')) + + if "categories" in request.GET: + categories = set(request.GET.get('categories', '').split('|')) + else: + categories = None + + if "titles" in request.GET: + titles = set(request.GET.get('titles', '').split('|')) + else: + titles = None + response = redirect(iri_to_uri("/api/texts/" + random_ref(categories, titles)) + "?commentary=0&context=0", permanent=False) return response @@ -4567,4 +4579,3 @@ def isNodeJsReachable(): logger.warn("Failed rollout healthcheck. Healthcheck Response: {}".format(resp)) return http.JsonResponse(resp, status=statusCode) - diff --git a/requirements.txt b/requirements.txt index e82dd864ad..0bd6dda6f8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -26,7 +26,7 @@ elasticsearch==8.8.2 git+https://github.com/Sefaria/elasticsearch-dsl-py@v8.0.0#egg=elasticsearch-dsl geojson==2.5.0 geopy==2.3.0 -gevent==20.12.0 +gevent==20.12.0; sys_platform != 'darwin' google-api-python-client==1.12.5 google-cloud-logging==1.15.1 google-cloud-storage==1.32.0 @@ -42,7 +42,8 @@ google-auth==1.24.0 google-auth-oauthlib==0.4.2 p929==0.6.1 pathos==0.2.6 -pillow==8.0.1 +pillow==8.0.1; sys_platform == 'linux' +pillow==10.0.1; sys_platform != 'linux' psycopg2==2.8.6 py2-py3-django-email-as-username==1.7.1 pymongo==3.12.* @@ -63,6 +64,8 @@ user-agents==2.2.0 sentry-sdk==1.26.0 babel python-bidi +requests +Cerberus opentelemetry-distro opentelemetry-exporter-otlp diff --git a/scripts/migrations/add_topic_images.py b/scripts/migrations/add_topic_images.py new file mode 100644 index 0000000000..ed42ba96dd --- /dev/null +++ b/scripts/migrations/add_topic_images.py @@ -0,0 +1,40 @@ +import django + +django.setup() + +from sefaria.helper.topic import add_image_to_topic + +## Adding images + +hardcodedTopicImagesMap = { + 'rosh-hashanah': {'image_uri': 'https://storage.googleapis.com/img.sefaria.org/topics/rosh-hashanah.jpeg', + 'enCaption': 'Rosh Hashanah, Arthur Szyk (1894-1951) Tempera and ink on paper. New Canaan, 1948. Collection of Yeshiva University Museum. Gift of Charles Frost', + 'heCaption': 'ראש השנה, ארתור שיק, ארה״ב 1948. אוסף ישיבה יוניברסיטי'}, + + 'yom-kippur': {'image_uri': 'https://storage.googleapis.com/img.sefaria.org/topics/yom-kippur.jpeg', + 'enCaption': 'Micrography of Jonah being swallowed by the fish. Germany, 1300-1500, The British Library', + 'heCaption': 'מיקרוגרפיה של יונה בבטן הדג, מתוך ספר יונה ההפטרה של יום כיפור, 1300-1500'}, + + 'the-four-species': {'image_uri': 'https://storage.googleapis.com/img.sefaria.org/topics/the-four-species.jpg', + 'enCaption': 'Etrog container, K B, late 19th century, Germany. The Jewish Museum, Gift of Dr. Harry G. Friedman', + 'heCaption': 'תיבת אתרוג, סוף המאה ה19, גרמניה. המוזיאון היהודי בניו יורק, מתנת דר. הארי ג. פרידמן '}, + + 'sukkot': {'image_uri': 'https://storage.googleapis.com/img.sefaria.org/topics/sukkot.jpg', + 'enCaption': 'Detail of a painting of a sukkah. Image taken from f. 316v of Forli Siddur. 1383, Italian rite. The British Library', + 'heCaption': 'פרט ציור של סוכה עם שולחן פרוש ושלוש דמויות. דימוי מתוך סידור פורלי, 1383 איטליה'}, + + 'simchat-torah': {'image_uri': 'https://storage.googleapis.com/img.sefaria.org/topics/simchat-torah.jpg', + 'enCaption': 'Rosh Hashanah postcard: Hakafot, Haim Yisroel Goldberg (1888-1943) Publisher: Williamsburg Post Card Co. Germany, ca. 1915 Collection of Yeshiva University Museum', + 'heCaption': 'גלויה לראש השנה: הקפות, חיים גולדברג, גרמניה 1915, אוסף ישיבה יוניברסיטי'}, + + 'shabbat': {'image_uri': 'https://storage.googleapis.com/img.sefaria.org/topics/shabbat.jpg', + 'enCaption': 'Friday Evening, Isidor Kaufmann, Austria c. 1920. The Jewish Museum, Gift of Mr. and Mrs. M. R. Schweitzer', + 'heCaption': 'שישי בערב, איזידור קאופמן, וינה 1920. המוזיאון היהודי בניו יורק, מתנת מר וגברת מ.ר. שוויצר'}, + +} + +for topic in hardcodedTopicImagesMap: + add_image_to_topic(topic, + image_uri=hardcodedTopicImagesMap[topic]["image_uri"], + en_caption=hardcodedTopicImagesMap[topic]["enCaption"], + he_caption=hardcodedTopicImagesMap[topic]["heCaption"]) \ No newline at end of file diff --git a/sefaria/forms.py b/sefaria/forms.py index 8d72976778..4268968211 100644 --- a/sefaria/forms.py +++ b/sefaria/forms.py @@ -30,6 +30,10 @@ class SefariaDeleteUserForm(EmailAuthenticationForm): email = forms.EmailField(max_length=75, widget=forms.EmailInput(attrs={'placeholder': _("Email Address to delete")})) password = forms.CharField(widget=forms.PasswordInput(attrs={'placeholder': _("Admin Password")})) +class SefariaDeleteSheet(forms.Form): + sid = forms.CharField(max_length=20, widget=forms.TextInput(attrs={'placeholder': _("Sheet ID to delete")})) + password = forms.CharField(widget=forms.PasswordInput(attrs={'placeholder': _("Admin Password")})) + class SefariaLoginForm(EmailAuthenticationForm): email = forms.EmailField(max_length=75, widget=forms.EmailInput(attrs={'placeholder': _("Email Address")})) diff --git a/sefaria/helper/linker.py b/sefaria/helper/linker.py index a0f09659a2..998f346a05 100644 --- a/sefaria/helper/linker.py +++ b/sefaria/helper/linker.py @@ -62,10 +62,11 @@ class _FindRefsTextOptions: class _FindRefsText: title: str body: str + lang: str - def __post_init__(self): - from sefaria.utils.hebrew import is_mostly_hebrew - self.lang = 'he' if is_mostly_hebrew(self.body) else 'en' + # def __post_init__(self): + # from sefaria.utils.hebrew import is_mostly_hebrew + # self.lang = 'he' if is_mostly_hebrew(self.body) else 'en' def _unpack_find_refs_request(request): @@ -75,9 +76,11 @@ def _unpack_find_refs_request(request): def _create_find_refs_text(post_body) -> _FindRefsText: + from sefaria.utils.hebrew import is_mostly_hebrew title = post_body['text']['title'] body = post_body['text']['body'] - return _FindRefsText(title, body) + lang = post_body['lang'] if 'lang' in post_body else 'he' if is_mostly_hebrew(body) else 'en' + return _FindRefsText(title, body, lang) def _create_find_refs_options(get_body: dict, post_body: dict) -> _FindRefsTextOptions: diff --git a/sefaria/helper/tests/topic_test.py b/sefaria/helper/tests/topic_test.py index 66a635f394..242539b5dc 100644 --- a/sefaria/helper/tests/topic_test.py +++ b/sefaria/helper/tests/topic_test.py @@ -52,10 +52,10 @@ def grandchild_of_root_with_self_link(child_of_root_with_self_link): @pytest.fixture(autouse=True, scope='module') -def root_wout_self_link(): - # create second branch of tree starting with root_wout_self_link +def author_root(): + # create second branch of tree starting with author_root t = Topic({'slug': "", "isTopLevelDisplay": True, "data_source": "sefaria", "numSources": 0}) - title = "Normal Root" + title = "Authors" he_title = title[::-1] t.add_primary_titles(title, he_title) t.set_slug_to_primary_title() @@ -66,48 +66,61 @@ def root_wout_self_link(): @pytest.fixture(autouse=True, scope='module') -def child_of_root_wout_self_link(root_wout_self_link): +def actual_author(author_root): t = Topic({'slug': "", "isTopLevelDisplay": False, "data_source": "sefaria", "numSources": 0}) - title = "Normal Root Leaf Node" + title = "Author Dude" he_title = title[::-1] t.add_primary_titles(title, he_title) t.set_slug_to_primary_title() t.save() l = IntraTopicLink({"linkType": "displays-under", "fromTopic": t.slug, - "toTopic": root_wout_self_link["topic"].slug, "dataSource": "sefaria", - "class": "intraTopic"}).save() # root_wout_self_link has child leaf_node + "toTopic": author_root["topic"].slug, "dataSource": "sefaria", + "class": "intraTopic"}).save() # author_root has child leaf_node yield {"topic": t, "link": l} t.delete() l.delete() -def test_title_and_desc(root_wout_self_link, child_of_root_wout_self_link, root_with_self_link, child_of_root_with_self_link, grandchild_of_root_with_self_link): - for t in [root_wout_self_link, child_of_root_wout_self_link, root_with_self_link, child_of_root_with_self_link, grandchild_of_root_with_self_link]: - new_values = {"title": "new title", "heTitle": "new hebrew title", "description": {"en": "desc", "he": "hebrew desc"}} +def test_title_and_desc(author_root, actual_author, root_with_self_link, child_of_root_with_self_link, grandchild_of_root_with_self_link): + for count, t in enumerate([author_root, actual_author, root_with_self_link, child_of_root_with_self_link, grandchild_of_root_with_self_link]): + new_values = {"title": f"new title {count+1}", + "altTitles": {"en": [f"New Alt title {count+1}"], "he": [f"New He Alt Title {count+1}"]}, + "heTitle": f"new hebrew title {count+1}", "description": {"en": f"new desc", "he": "new hebrew desc"}} update_topic(t["topic"], **new_values) assert t["topic"].description == new_values["description"] - assert t["topic"].get_primary_title('en') == new_values['title'] assert t["topic"].get_primary_title('he') == new_values['heTitle'] - - -def test_change_categories_and_titles(root_wout_self_link, root_with_self_link): + assert t["topic"].get_titles('en') == [new_values["title"]]+new_values["altTitles"]['en'] + +def test_author_root(author_root, actual_author): + new_values = {"category": "authors", "title": actual_author["topic"].get_primary_title('en'), + "heTitle": actual_author["topic"].get_primary_title('he'), + "birthPlace": "Kyoto, Japan", "birthYear": 1300} + assert Place().load({'key': new_values["birthPlace"]}) is None + update_topic(actual_author["topic"], **new_values) + assert Place().load({'key': new_values["birthPlace"]}) + assert actual_author["topic"].properties["birthYear"]["value"] == 1300 + Place().load({'key': new_values["birthPlace"]}).delete() + +def test_change_categories_and_titles(author_root, root_with_self_link): # tests moving both root categories down the tree and back up and asserting that moving down the tree changes the tree # and assert that moving it back to the root position yields the original tree. - # also tests - - orig_tree_from_normal_root = library.get_topic_toc_json_recursive(root_wout_self_link["topic"]) + orig_tree_from_normal_root = library.get_topic_toc_json_recursive(author_root["topic"]) orig_tree_from_root_with_self_link = library.get_topic_toc_json_recursive(root_with_self_link["topic"]) orig_trees = [orig_tree_from_normal_root, orig_tree_from_root_with_self_link] - roots = [root_wout_self_link["topic"], root_with_self_link["topic"]] + roots = [author_root["topic"], root_with_self_link["topic"]] orig_titles = [roots[0].get_primary_title('en'), roots[1].get_primary_title('en')] + orig_he_titles = [roots[0].get_primary_title('he'), roots[1].get_primary_title('he')] for i, root in enumerate(roots): other_root = roots[1 - i] - update_topic(root, title=f"fake new title {i+1}", category=other_root.slug) # move root to be child of other root + update_topic(root, title=f"fake new title {i+1}", heTitle=f"fake new he title {i+1}", category=other_root.slug) # move root to be child of other root new_tree = library.get_topic_toc_json_recursive(other_root) assert new_tree != orig_trees[i] # assert that the changes in the tree have occurred - assert root.get_primary_title('en') != orig_titles[i] - update_topic(root, title=orig_titles[i], category=Topic.ROOT) # move it back to the main menu - assert root.get_primary_title('en') == orig_titles[i] + assert root.get_titles('en') != [orig_titles[i]] + assert root.get_titles('he') != [orig_he_titles[i]] + update_topic(root, title=orig_titles[i], heTitle=orig_he_titles[i], category=Topic.ROOT) # move it back to the main menu + assert root.get_titles('en') == [orig_titles[i]] + assert root.get_titles('he') == [orig_he_titles[i]] + final_tree_from_normal_root = library.get_topic_toc_json_recursive(roots[0]) final_tree_from_root_with_self_link = library.get_topic_toc_json_recursive(roots[1]) @@ -115,24 +128,24 @@ def test_change_categories_and_titles(root_wout_self_link, root_with_self_link): assert final_tree_from_root_with_self_link == orig_tree_from_root_with_self_link -def test_change_categories(root_wout_self_link, child_of_root_wout_self_link, root_with_self_link, child_of_root_with_self_link, grandchild_of_root_with_self_link): +def test_change_categories(author_root, actual_author, root_with_self_link, child_of_root_with_self_link, grandchild_of_root_with_self_link): # tests moving topics across the tree to a different root - orig_tree_from_normal_root = library.get_topic_toc_json_recursive(root_wout_self_link["topic"]) + orig_tree_from_normal_root = library.get_topic_toc_json_recursive(author_root["topic"]) orig_tree_from_root_with_self_link = library.get_topic_toc_json_recursive(root_with_self_link["topic"]) - topic_change_category(child_of_root_with_self_link["topic"], root_wout_self_link["topic"].slug) - topic_change_category(child_of_root_wout_self_link["topic"], root_with_self_link["topic"].slug) + topic_change_category(child_of_root_with_self_link["topic"], author_root["topic"].slug) + topic_change_category(actual_author["topic"], root_with_self_link["topic"].slug) - new_tree_from_normal_root = library.get_topic_toc_json_recursive(root_wout_self_link["topic"]) + new_tree_from_normal_root = library.get_topic_toc_json_recursive(author_root["topic"]) new_tree_from_root_with_self_link = library.get_topic_toc_json_recursive(root_with_self_link["topic"]) assert new_tree_from_normal_root != orig_tree_from_normal_root assert new_tree_from_root_with_self_link != orig_tree_from_root_with_self_link topic_change_category(child_of_root_with_self_link["topic"], root_with_self_link["topic"].slug) - topic_change_category(child_of_root_wout_self_link["topic"], root_wout_self_link["topic"].slug) + topic_change_category(actual_author["topic"], author_root["topic"].slug) - new_tree_from_normal_root = library.get_topic_toc_json_recursive(root_wout_self_link["topic"]) + new_tree_from_normal_root = library.get_topic_toc_json_recursive(author_root["topic"]) new_tree_from_root_with_self_link = library.get_topic_toc_json_recursive(root_with_self_link["topic"]) assert new_tree_from_normal_root == orig_tree_from_normal_root assert new_tree_from_root_with_self_link == orig_tree_from_root_with_self_link diff --git a/sefaria/helper/topic.py b/sefaria/helper/topic.py index 36ebef5f48..5b9094fe78 100644 --- a/sefaria/helper/topic.py +++ b/sefaria/helper/topic.py @@ -50,12 +50,7 @@ def get_topic(v2, topic, with_html=True, with_links=True, annotate_links=True, w ref_links = group_links_by_type('refTopic', ref_links, False, False) response['refs'] = ref_links if with_indexes and isinstance(topic_obj, AuthorTopic): - response['indexes'] = [ - { - "text": text_dict, - "url": url - } for (url, text_dict) in topic_obj.get_aggregated_urls_for_authors_indexes() - ] + response['indexes'] = topic_obj.get_aggregated_urls_for_authors_indexes() if getattr(topic_obj, 'isAmbiguous', False): possibility_links = topic_obj.link_set(_class="intraTopic", query_kwargs={"linkType": TopicLinkType.possibility_type}) @@ -114,7 +109,7 @@ def merge_props_for_similar_refs(curr_link, new_link): # as well as datasource and descriptions of all the similar refs data_source = new_link.get('dataSource', None) if data_source: - curr_link = update_refs(curr_link, new_link, data_source) + curr_link = update_refs(curr_link, new_link) curr_link = update_data_source_in_link(curr_link, new_link, data_source) if not curr_link['is_sheet']: @@ -128,20 +123,9 @@ def update_data_source_in_link(curr_link, new_link, data_source): del new_link['dataSource'] return curr_link -def is_data_source_learning_team(func): - def wrapper(curr_link, new_link, data_source): - if data_source == 'learning-team': - return func(curr_link, new_link) - else: - return curr_link - return wrapper - -@is_data_source_learning_team def update_refs(curr_link, new_link): - # in case the new_link was created by the learning team, we want to use ref of learning team link - # in the case when both links are from the learning team, use whichever ref covers a smaller range - if 'learning-team' not in curr_link['dataSources'] or len(curr_link['expandedRefs']) > len( - new_link['expandedRefs']): + # use whichever ref covers a smaller range + if len(curr_link['expandedRefs']) > len(new_link['expandedRefs']): curr_link['ref'] = new_link['ref'] curr_link['expandedRefs'] = new_link['expandedRefs'] return curr_link @@ -171,6 +155,20 @@ def update_curated_primacy(curr_link, new_link): curr_link['order']['curatedPrimacy'] = curr_curated_primacy return curr_link +def is_learning_team(dataSource): + return dataSource == 'learning-team' or dataSource == 'learning-team-editing-tool' + +def iterate_and_merge(new_ref_links, new_link, subset_ref_map, temp_subset_refs): + # temp_subset_refs contains the refs within link's expandedRefs that overlap with other refs + # subset_ref_map + new_ref_links contain mappings to get from the temp_subset_refs to the actual link objects + for seg_ref in temp_subset_refs: + for index in subset_ref_map[seg_ref]: + new_ref_links[index]['similarRefs'] += [new_link] + curr_link_learning_team = any([is_learning_team(dataSource) for dataSource in new_ref_links[index]['dataSources']]) + if not curr_link_learning_team: # if learning team, ignore source with overlapping refs + new_ref_links[index] = merge_props_for_similar_refs(new_ref_links[index], new_link) + return new_ref_links + def sort_and_group_similar_refs(ref_links): ref_links.sort(key=cmp_to_key(sort_refs_by_relevance)) subset_ref_map = defaultdict(list) @@ -178,11 +176,11 @@ def sort_and_group_similar_refs(ref_links): for link in ref_links: del link['topic'] temp_subset_refs = subset_ref_map.keys() & set(link.get('expandedRefs', [])) - for seg_ref in temp_subset_refs: - for index in subset_ref_map[seg_ref]: - new_ref_links[index]['similarRefs'] += [link] - new_ref_links[index] = merge_props_for_similar_refs(new_ref_links[index], link) - if len(temp_subset_refs) == 0: + new_data_source = link.get("dataSource", None) + should_merge = len(temp_subset_refs) > 0 and not is_learning_team(new_data_source) # learning team links should be handled separately from one another and not merged + if should_merge: + new_ref_links = iterate_and_merge(new_ref_links, link, subset_ref_map, temp_subset_refs) + else: link['similarRefs'] = [] link['dataSources'] = {} if link.get('dataSource', None): @@ -1039,26 +1037,24 @@ def topic_change_category(topic_obj, new_category, old_category="", rebuild=Fals rebuild_topic_toc(topic_obj, category_changed=True) return topic_obj -def update_topic_titles(topic_obj, data): - for lang in ['en', 'he']: - for title in topic_obj.get_titles(lang): - topic_obj.remove_title(title, lang) - for title in data['altTitles'][lang]: - topic_obj.add_title(title, lang) - - topic_obj.add_title(data['title'], 'en', True, True) - topic_obj.add_title(data['heTitle'], 'he', True, True) - return topic_obj +def update_topic_titles(topic, title="", heTitle="", **kwargs): + new_primary = {"en": title, "he": heTitle} + for lang in ['en', 'he']: # first remove all titles and add new primary and then alt titles + for title in topic.get_titles(lang): + topic.remove_title(title, lang) + topic.add_title(new_primary[lang], lang, True, False) + if 'altTitles' in kwargs: + for title in kwargs['altTitles'][lang]: + topic.add_title(title, lang) + return topic -def update_authors_place_and_time(topic_obj, data, dataSource='learning-team-editing-tool'): +def update_authors_place_and_time(topic, dataSource='learning-team-editing-tool', **kwargs): # update place info added to author, then update year and era info - if not hasattr(topic_obj, 'properties'): - topic_obj.properties = {} - process_topic_place_change(topic_obj, data) - topic_obj = update_author_era(topic_obj, data, dataSource=dataSource) - - return topic_obj + if not hasattr(topic, 'properties'): + topic.properties = {} + process_topic_place_change(topic, **kwargs) + return update_author_era(topic, dataSource=dataSource, **kwargs) def update_properties(topic_obj, dataSource, k, v): if v == '': @@ -1066,54 +1062,54 @@ def update_properties(topic_obj, dataSource, k, v): else: topic_obj.properties[k] = {'value': v, 'dataSource': dataSource} -def update_author_era(topic_obj, data, dataSource='learning-team-editing-tool'): +def update_author_era(topic_obj, dataSource='learning-team-editing-tool', **kwargs): for k in ["birthYear", "deathYear"]: - if k in data.keys(): # only change property value if key is in data, otherwise it indicates no change - year = data[k] + if k in kwargs.keys(): # only change property value if key exists, otherwise it indicates no change + year = kwargs[k] update_properties(topic_obj, dataSource, k, year) - if 'era' in data.keys(): # only change property value if key is in data, otherwise it indicates no change + if 'era' in kwargs.keys(): # only change property value if key is in data, otherwise it indicates no change prev_era = topic_obj.properties.get('era', {}).get('value') - era = data['era'] + era = kwargs['era'] update_properties(topic_obj, dataSource, 'era', era) if era != '': create_era_link(topic_obj, prev_era_to_delete=prev_era) return topic_obj -def update_topic(topic_obj, **kwargs): +def update_topic(topic, **kwargs): """ Can update topic object's title, hebrew title, category, description, and categoryDescription fields - :param topic_obj: (Topic) The topic to update + :param topic: (Topic) The topic to update :param **kwargs can be title, heTitle, category, description, categoryDescription, and rebuild_toc where `title`, `heTitle`, and `category` are strings. `description` and `categoryDescription` are dictionaries where the fields are `en` and `he`. The `category` parameter should be the slug of the new category. `rebuild_topic_toc` is a boolean and is assumed to be True :return: (model.Topic) The modified topic """ old_category = "" - orig_slug = topic_obj.slug - update_topic_titles(topic_obj, kwargs) + orig_slug = topic.slug + update_topic_titles(topic, **kwargs) if kwargs.get('category') == 'authors': - topic_obj = update_authors_place_and_time(topic_obj, kwargs) + topic = update_authors_place_and_time(topic, **kwargs) - if 'category' in kwargs and kwargs['category'] != kwargs['origCategory']: - orig_link = IntraTopicLink().load({"linkType": "displays-under", "fromTopic": topic_obj.slug, "toTopic": {"$ne": topic_obj.slug}}) + if 'category' in kwargs and kwargs['category'] != kwargs.get('origCategory', kwargs['category']): + orig_link = IntraTopicLink().load({"linkType": "displays-under", "fromTopic": topic.slug, "toTopic": {"$ne": topic.slug}}) old_category = orig_link.toTopic if orig_link else Topic.ROOT if old_category != kwargs['category']: - topic_obj = topic_change_category(topic_obj, kwargs["category"], old_category=old_category) # can change topic and intratopiclinks + topic = topic_change_category(topic, kwargs["category"], old_category=old_category) # can change topic and intratopiclinks if kwargs.get('manual', False): - topic_obj.data_source = "sefaria" # any topic edited manually should display automatically in the TOC and this flag ensures this - topic_obj.description_published = True + topic.data_source = "sefaria" # any topic edited manually should display automatically in the TOC and this flag ensures this + topic.description_published = True if "description" in kwargs or "categoryDescription" in kwargs: - topic_obj.change_description(kwargs.get("description", None), kwargs.get("categoryDescription", None)) + topic.change_description(kwargs.get("description", None), kwargs.get("categoryDescription", None)) - topic_obj.save() + topic.save() if kwargs.get('rebuild_topic_toc', True): - rebuild_topic_toc(topic_obj, orig_slug=orig_slug, category_changed=(old_category != kwargs.get('category', ""))) - return topic_obj + rebuild_topic_toc(topic, orig_slug=orig_slug, category_changed=(old_category != kwargs.get('category', ""))) + return topic def rebuild_topic_toc(topic_obj, orig_slug="", category_changed=False): @@ -1250,12 +1246,12 @@ def delete_ref_topic_link(tref, to_topic, link_type, lang): if link is None: return {"error": f"Link between {tref} and {to_topic} doesn't exist."} - if lang in link.order['availableLangs']: + if lang in link.order.get('availableLangs', []): link.order['availableLangs'].remove(lang) - if lang in link.order['curatedPrimacy']: + if lang in link.order.get('curatedPrimacy', []): link.order['curatedPrimacy'].pop(lang) - if len(link.order['availableLangs']) > 0: + if len(link.order.get('availableLangs', [])) > 0: link.save() return {"status": "ok"} else: # deleted in both hebrew and english so delete link object @@ -1263,4 +1259,23 @@ def delete_ref_topic_link(tref, to_topic, link_type, lang): link.delete() return {"status": "ok"} else: - return {"error": f"Cannot delete link between {tref} and {to_topic}."} \ No newline at end of file + return {"error": f"Cannot delete link between {tref} and {to_topic}."} + + +def add_image_to_topic(topic_slug, image_uri, en_caption, he_caption): + """ + A function to add an image to a Topic in the database. Helper for data migration. + This function queries the desired Topic, adds the image data, and then saves. + :param topic_slug String: A valid slug for a Topic + :param image_uri String: The URI of the image stored in the GCP images bucket, in the topics subdirectory. + NOTE: Incorrectly stored, or external images, will not pass validation for save + :param en_caption String: The English caption for a Topic image + :param he_caption String: The Hebrew caption for a Topic image + """ + topic = Topic.init(topic_slug) + topic.image = {"image_uri": image_uri, + "image_caption": { + "en": en_caption, + "he": he_caption + }} + topic.save() \ No newline at end of file diff --git a/sefaria/model/__init__.py b/sefaria/model/__init__.py index b5acc4cc97..83661bc1cb 100644 --- a/sefaria/model/__init__.py +++ b/sefaria/model/__init__.py @@ -41,6 +41,7 @@ from .webpage import WebPage, WebPageSet from .media import Media, MediaSet from .topic import Topic, PersonTopic, AuthorTopic, TopicLinkType, IntraTopicLink, RefTopicLink, TopicLinkType, TopicDataSource, TopicSet, PersonTopicSet, AuthorTopicSet, TopicLinkTypeSet, RefTopicLinkSet, IntraTopicLinkSet, TopicLinkSetHelper +from .portal import Portal from .manuscript import Manuscript, ManuscriptSet, ManuscriptPage, ManuscriptPageSet from .linker.ref_part import RawRef from .linker.ref_resolver import RefResolver diff --git a/sefaria/model/abstract.py b/sefaria/model/abstract.py index ad2d0ddc66..2057c0e91a 100644 --- a/sefaria/model/abstract.py +++ b/sefaria/model/abstract.py @@ -3,6 +3,7 @@ """ abstract.py - abstract classes for Sefaria models """ +from cerberus import Validator import collections import structlog import copy @@ -15,7 +16,7 @@ from bson.objectid import ObjectId from sefaria.system.database import db -from sefaria.system.exceptions import InputError +from sefaria.system.exceptions import InputError, SluggedMongoRecordMissingError logger = structlog.get_logger(__name__) @@ -31,6 +32,7 @@ class AbstractMongoRecord(object): criteria_override_field = None # If a record type uses a different primary key (such as 'title' for Index records), and the presence of an override field in a save indicates that the primary attribute is changing ("oldTitle" in Index records) then this class attribute has that override field name used. required_attrs = [] # list of names of required attributes optional_attrs = [] # list of names of optional attributes + attr_schemas = {} # schemas to validate that an attribute is in the right format. Keys are attribute names, values are schemas in Cerberus format. track_pkeys = False pkeys = [] # list of fields that others may depend on history_noun = None # Label for history records @@ -242,6 +244,16 @@ def _validate(self): " not in " + ",".join(self.required_attrs) + " or " + ",".join(self.optional_attrs)) return False """ + for attr, schema in self.attr_schemas.items(): + v = Validator(schema) + try: + value = getattr(self, attr) + if not v.validate(value): + raise InputError(v.errors) + except AttributeError: + # not checking here if value exists, that is done above. + # assumption is if value doesn't exist, it's optional + pass return True def _normalize(self): @@ -382,17 +394,20 @@ class SluggedAbstractMongoRecord(AbstractMongoRecord, metaclass=SluggedAbstractM cacheable = False @classmethod - def init(cls, slug: str) -> 'AbstractMongoRecord': + def init(cls, slug: str, slug_field_idx: int = None) -> 'AbstractMongoRecord': """ Convenience func to avoid using .load() when you're only passing a slug Applicable only if class defines `slug_fields` - :param slug: - :return: + @param slug: + @param slug_field_idx: Optional index of slug field in case `cls` has multiple slug fields. Index should be between 0 and len(cls.slug_fields) - 1 + @return: instance of `cls` with slug `slug` """ - if len(cls.slug_fields) != 1: - raise Exception("Can only call init() if exactly one slug field is defined.") + if len(cls.slug_fields) != 1 and slug_field_idx is None: + raise Exception("Can only call init() if exactly one slug field is defined or `slug_field_idx` is passed as" + " a parameter.") + slug_field_idx = slug_field_idx or 0 if not cls.cacheable or slug not in cls._init_cache: - instance = cls().load({cls.slug_fields[0]: slug}) + instance = cls().load({cls.slug_fields[slug_field_idx]: slug}) if cls.cacheable: cls._init_cache[slug] = instance else: @@ -427,6 +442,20 @@ def _normalize(self): for slug_field in self.slug_fields: setattr(self, slug_field, self.normalize_slug_field(slug_field)) + @classmethod + def validate_slug_exists(cls, slug: str, slug_field_idx: int = None): + """ + Validate that `slug` points to an existing object of type `cls`. Pass `slug_field` if `cls` has multiple slugs + associated with it (e.g. TopicLinkType) + @param slug: Slug to look up + @param slug_field_idx: Optional index of slug field in case `cls` has multiple slug fields. Index should be + between 0 and len(cls.slug_fields) - 1 + @return: raises SluggedMongoRecordMissingError is slug doesn't match an existing object + """ + instance = cls.init(slug, slug_field_idx) + if not instance: + raise SluggedMongoRecordMissingError(f"{cls.__name__} with slug '{slug}' does not exist.") + class Cloneable: diff --git a/sefaria/model/autospell.py b/sefaria/model/autospell.py index 38d2b48c93..f9d8379bb9 100644 --- a/sefaria/model/autospell.py +++ b/sefaria/model/autospell.py @@ -29,7 +29,6 @@ + "\u05c1\u05c2" \ + "\u05d0\u05d1\u05d2\u05d3\u05d4\u05d5\u05d6\u05d7\u05d8\u05d9\u05da\u05db\u05dc\u05dd\u05de\u05df" \ + "\u05e0\u05e1\u05e2\u05e3\u05e4\u05e5\u05e6\u05e7\u05e8\u05e9\u05ea" \ - + "\u05f3\u05f4" \ + "\u200e\u200f\u2013\u201c\u201d\ufeff" \ + " Iabcdefghijklmnopqrstuvwxyz1234567890[]`:;.-,*$()'&?/\"" @@ -218,6 +217,7 @@ def complete(self, instring, limit=0, redirected=False): :return: completions list, completion objects list """ instring = instring.strip() # A terminal space causes some kind of awful "include everything" behavior + instring = self.normalizer(instring) if len(instring) >= self.max_completion_length: return [], [] cm = Completions(self, self.lang, instring, limit, diff --git a/sefaria/model/count.py b/sefaria/model/count.py deleted file mode 100644 index 63b31a9552..0000000000 --- a/sefaria/model/count.py +++ /dev/null @@ -1,93 +0,0 @@ -""" -count.py -Writes to MongoDB Collection: counts -""" -import structlog -logger = structlog.get_logger(__name__) - -from . import abstract as abst -import sefaria.datatype.jagged_array as ja -from sefaria.system.exceptions import BookNameError - - -class Count(abst.AbstractMongoRecord): - """ - """ - collection = 'counts' - - required_attrs = [ - "textComplete", - "percentAvailable", - "availableCounts" - ] - optional_attrs = [ - "categories", - "availableTexts", - "title", - "linksCount", - "estimatedCompleteness", - "flags", - "allVersionCounts" - ] - - def _set_derived_attributes(self): - from . import text - - if getattr(self, "title", None): - try: - indx = text.library.get_index(self.title) - attrs = indx.contents() - #del attrs["_id"] - self.index_attr_keys = list(attrs.keys()) - self.__dict__.update(attrs) - except BookNameError as e: - logger.warning("Count object failed to get Index for {} : {} Normal right after Index name change.".format(self.title, e)) - - #todo: this needs to be considered. What happens when the data is modified? etc. - if getattr(self, "allVersionCounts", None) is not None: - self._allVersionCountsJA = ja.JaggedIntArray(self.allVersionCounts) - - #remove uneccesary and dangerous categories attr from text counts - #This assumes that category nodes have no title element - #todo: review this. Do we need to subclass text and category counts? - def _saveable_attr_keys(self): - attrs = super(Count, self)._saveable_attr_keys() - if getattr(self, "title", None): - attrs.remove("categories") - return attrs - - def contents(self, **kwargs): - attrs = super(Count, self).contents() - for key in self.index_attr_keys: - attrs[key] = getattr(self, key, None) - return attrs - - #deprecated - use JA directly - def next_address(self, starting_points=None): - starting_points = starting_points or [] - if len(starting_points) > 0: - starting_points[-1] += 1 - return self._allVersionCountsJA.next_index(starting_points) - - #deprecated - use JA directly - def prev_address(self, starting_points=None): - starting_points = starting_points or [] - if len(starting_points) > 0: - starting_points[-1] -= 1 - return self._allVersionCountsJA.prev_index(starting_points) - - #deprecated - use JA directly - def section_length(self, section_numbers): - """ - :param section_numbers: The list of 1-based (E.g. Chapter 5 is section_number 5) section numbers - :return: The length of that section - """ - return self._allVersionCountsJA.sub_array_length([s - 1 for s in section_numbers]) - - -class CountSet(abst.AbstractMongoSet): - recordClass = Count - - -def process_index_delete_in_counts(indx, **kwargs): - CountSet({"title":indx.title}).delete() diff --git a/sefaria/model/place.py b/sefaria/model/place.py index a39bf0e610..6f5b2729b5 100644 --- a/sefaria/model/place.py +++ b/sefaria/model/place.py @@ -64,7 +64,11 @@ def create_new_place(cls, en, he=None): def city_to_coordinates(self, city): geolocator = Nominatim(user_agent='hello@sefaria.org') location = geolocator.geocode(city) - self.point_location(lon=location.longitude, lat=location.latitude) + if location and location.raw['type'] in ['administrative', 'city', 'town', 'municipality']: + self.point_location(lon=location.longitude, lat=location.latitude) + else: + raise InputError(f"{city} is not a real city.") + def point_location(self, lon=None, lat=None): if lat is None and lon is None: @@ -105,14 +109,14 @@ def process_index_place_change(indx, **kwargs): if kwargs['new'] != '': Place.create_new_place(en=kwargs['new'], he=he_new_val) -def process_topic_place_change(topic_obj, data): +def process_topic_place_change(topic_obj, **kwargs): keys = ["birthPlace", "deathPlace"] for key in keys: - if key in data.keys(): # only change property value if key is in data, otherwise it indicates no change - new_val = data[key] + if key in kwargs.keys(): # only change property value if key is in data, otherwise it indicates no change + new_val = kwargs[key] if new_val != '': he_key = get_he_key(key) - he_new_val = data.get(he_key, '') + he_new_val = kwargs.get(he_key, '') place = Place.create_new_place(en=new_val, he=he_new_val) topic_obj.properties[key] = {"value": place.primary_name('en'), 'dataSource': 'learning-team-editing-tool'} else: diff --git a/sefaria/model/portal.py b/sefaria/model/portal.py new file mode 100644 index 0000000000..36984ceaad --- /dev/null +++ b/sefaria/model/portal.py @@ -0,0 +1,136 @@ +from . import abstract as abst +from sefaria.system.validators import validate_url, validate_http_method +import structlog + +logger = structlog.get_logger(__name__) + + +class Portal(abst.SluggedAbstractMongoRecord): + collection = 'portals' + slug_fields = ['slug'] + + required_attrs = [ + "slug", + "about", + "name", + ] + optional_attrs = [ + "mobile", + "newsletter", + "organization" + ] + attr_schemas = { + "about": { + "title": { + "type": "dict", + "required": True, + "schema": { + "en": {"type": "string", "required": True}, + "he": {"type": "string", "required": True} + } + }, + "title_url": {"type": "string"}, + "image_uri": {"type": "string"}, + "image_caption": { + "type": "dict", + "schema": { + "en": {"type": "string"}, + "he": {"type": "string"} + } + }, + "description": { + "type": "dict", + "schema": { + "en": {"type": "string", "required": True}, + "he": {"type": "string", "required": True} + } + }, + }, + "mobile": { + "title": { + "type": "dict", + "required": True, + "schema": { + "en": {"type": "string", "required": True}, + "he": {"type": "string", "required": True} + } + }, + "description": { + "type": "dict", + "schema": { + "en": {"type": "string"}, + "he": {"type": "string"} + } + }, + "android_link": {"type": "string"}, + "ios_link": {"type": "string"} + }, + "organization": { + "title": { + "type": "dict", + "required": True, + "schema": { + "en": {"type": "string", "required": True}, + "he": {"type": "string", "required": True} + } + }, + "description": { + "type": "dict", + "schema": { + "en": {"type": "string", "required": True}, + "he": {"type": "string", "required": True} + } + }, + }, + "newsletter": { + "title": { + "type": "dict", + "required": True, + "schema": { + "en": {"type": "string", "required": True}, + "he": {"type": "string", "required": True} + } + }, + "description": { + "type": "dict", + "schema": { + "en": {"type": "string", "required": True}, + "he": {"type": "string", "required": True} + } + }, + "title_url": {"type": "string"}, + "api_schema": { + "type": "dict", + "schema": { + "http_method": {"type": "string", "required": True}, + "payload": { + "type": "dict", + "schema": { + "first_name_key": {"type": "string"}, + "last_name_key": {"type": "string"}, + "email_key": {"type": "string"} + } + }, + } + } + } + } + + def _validate(self): + super(Portal, self)._validate() + if hasattr(self, "about"): + title_url = self.about.get("title_url") + if title_url: validate_url(title_url) + if hasattr(self, "mobile"): + android_link = self.mobile.get("android_link") + if android_link: validate_url(android_link) + ios_link = self.mobile.get("ios_link") + if ios_link: validate_url(ios_link) + if hasattr(self, "newsletter"): + http_method = self.newsletter.get("api_schema", {}).get("http_method") + if http_method: validate_http_method(http_method) + return True + + +class PortalSet(abst.AbstractMongoSet): + recordClass = Portal diff --git a/sefaria/model/schema.py b/sefaria/model/schema.py index 305dda01b8..568f0b89a5 100644 --- a/sefaria/model/schema.py +++ b/sefaria/model/schema.py @@ -125,6 +125,9 @@ def secondary_titles(self, lang=None): return [t for t in self.all_titles(lang) if t != self.primary_title(lang)] def remove_title(self, text, lang): + is_primary = len([t for t in self.titles if (t["lang"] == lang and t["text"] == text and t.get('primary'))]) + if is_primary: + self._primary_title[lang] = None self.titles = [t for t in self.titles if not (t["lang"] == lang and t["text"] == text)] return self @@ -1073,12 +1076,11 @@ def full_regex(self, title, lang, anchored=True, compiled=True, capture_title=Fa def address_regex(self, lang, **kwargs): group = "a0" reg = self._addressTypes[0].regex(lang, group, **kwargs) - if not self._addressTypes[0].stop_parsing(lang): - for i in range(1, self.depth): - group = "a{}".format(i) - reg += "(" + self.after_address_delimiter_ref + self._addressTypes[i].regex(lang, group, **kwargs) + ")" - if not kwargs.get("strict", False): - reg += "?" + for i in range(1, self.depth): + group = "a{}".format(i) + reg += "(" + self.after_address_delimiter_ref + self._addressTypes[i].regex(lang, group, **kwargs) + ")" + if not kwargs.get("strict", False): + reg += "?" if kwargs.get("match_range"): # TODO there is a potential error with this regex. it fills in toSections starting from highest depth and going to lowest. @@ -1089,14 +1091,13 @@ def address_regex(self, lang, **kwargs): reg += r"(?=\S)" # must be followed by something (Lookahead) group = "ar0" reg += self._addressTypes[0].regex(lang, group, **kwargs) - if not self._addressTypes[0].stop_parsing(lang): - reg += "?" - for i in range(1, self.depth): - reg += r"(?:(?:" + self.after_address_delimiter_ref + r")?" - group = "ar{}".format(i) - reg += "(" + self._addressTypes[i].regex(lang, group, **kwargs) + ")" - # assuming strict isn't relevant on ranges # if not kwargs.get("strict", False): - reg += ")?" + reg += "?" + for i in range(1, self.depth): + reg += r"(?:(?:" + self.after_address_delimiter_ref + r")?" + group = "ar{}".format(i) + reg += "(" + self._addressTypes[i].regex(lang, group, **kwargs) + ")" + # assuming strict isn't relevant on ranges # if not kwargs.get("strict", False): + reg += ")?" reg += r")?" # end range clause return reg @@ -2072,15 +2073,6 @@ def hebrew_number_regex(): [\u05d0-\u05d8]? # One or zero alef-tet (1-9) )""" - def stop_parsing(self, lang): - """ - If this is true, the regular expression will stop parsing at this address level for this language. - It is currently checked for only in the first address position, and is used for Hebrew Talmud addresses. - :param lang: "en" or "he" - :return bool: - """ - return False - def toNumber(self, lang, s): """ Return the numerical form of s in this address scheme @@ -2351,11 +2343,6 @@ def _core_regex(self, lang, group_id=None, **kwargs): return reg - def stop_parsing(self, lang): - if lang == "he": - return True - return False - def toNumber(self, lang, s, **kwargs): amud_b_list = ['b', 'B', 'ᵇ'] if lang == "en": @@ -2490,11 +2477,6 @@ def _core_regex(self, lang, group_id=None, **kwargs): return reg - def stop_parsing(self, lang): - if lang == "he": - return True - return False - def toNumber(self, lang, s, **kwargs): if lang == "en": try: diff --git a/sefaria/model/tests/abstract_test.py b/sefaria/model/tests/abstract_test.py index ac6d876eeb..0b1efd1031 100644 --- a/sefaria/model/tests/abstract_test.py +++ b/sefaria/model/tests/abstract_test.py @@ -9,6 +9,7 @@ # cascade functions are tested in person_test.py + def setup_module(module): global record_classes global set_classes @@ -21,7 +22,12 @@ def setup_module(module): print(record_classes) -class Test_Mongo_Record_Models(object): +def get_record_classes_with_slugs(): + classes = abstract.get_record_classes() + return filter(lambda x: getattr(x, 'slug_fields', None) is not None and x.__name__ != "Portal", classes) + + +class TestMongoRecordModels(object): def test_class_attribute_collection(self): for sub in record_classes: @@ -59,7 +65,7 @@ def test_slug(slug, final_slug): test_slug('blah/blah', 'blah-blah') test_slug('blah == בלה', 'blah-בלה') - @pytest.mark.parametrize("sub", filter(lambda x: getattr(x, 'slug_fields', None) is not None, abstract.get_record_classes())) + @pytest.mark.parametrize("sub", get_record_classes_with_slugs()) def test_normalize_slug_field(self, sub): """ diff --git a/sefaria/model/tests/chunk_test.py b/sefaria/model/tests/chunk_test.py index 28e99bc099..b1c725c745 100644 --- a/sefaria/model/tests/chunk_test.py +++ b/sefaria/model/tests/chunk_test.py @@ -196,6 +196,17 @@ def test_text_family_alts(): c = tf.contents() assert c.get("alts") +def test_text_family_version_with_underscores(): + with_spaces = TextFamily( + Ref("Amos 1"), lang="he", lang2="en", commentary=False, + version="Miqra according to the Masorah", + version2="Tanakh: The Holy Scriptures, published by JPS") + with_underscores = TextFamily( + Ref("Amos 1"), lang="he", lang2="en", commentary=False, + version="Miqra_according_to_the_Masorah", + version2="Tanakh:_The_Holy_Scriptures,_published_by_JPS") + assert with_spaces.he == with_underscores.he + assert with_spaces.text == with_underscores.text def test_validate(): passing_refs = [ diff --git a/sefaria/model/tests/he_ref_test.py b/sefaria/model/tests/he_ref_test.py index 55fa32975e..b5c615b9ed 100644 --- a/sefaria/model/tests/he_ref_test.py +++ b/sefaria/model/tests/he_ref_test.py @@ -100,6 +100,10 @@ def test_talmud(self): assert r.sections[0] == 58 assert len(r.sections) == 1 + r = m.Ref("סוטה לה א:יא") + assert r.book == 'Sotah' + assert r.sections == [69, 11] + def test_length_catching(self): with pytest.raises(InputError): r = m.Ref('דברים שם') diff --git a/sefaria/model/tests/portal_test.py b/sefaria/model/tests/portal_test.py new file mode 100644 index 0000000000..dfe9aed7f0 --- /dev/null +++ b/sefaria/model/tests/portal_test.py @@ -0,0 +1,548 @@ +import pytest +from sefaria.model.portal import Portal # Replace with your actual validation function +from sefaria.model.topic import Topic +from sefaria.system.exceptions import SluggedMongoRecordMissingError +from sefaria.system.database import db + +valids = [ + { + "slug": "English Title", + "name": {"en": "a", "he": "b"}, + "about": { + "title": { + "en": "English Title", + "he": "Hebrew Title" + }, + "title_url": "https://example.com", + "image_uri": "gs://your-bucket/image.jpg", + "description": { + "en": "English Description", + "he": "Hebrew Description" + } + }, + "mobile": { + "title": { + "en": "Mobile Title", + "he": "Mobile Hebrew Title" + }, + "android_link": "https://android-link.com", + "ios_link": "https://ios-link.com" + }, + "newsletter": { + "title": { + "en": "Newsletter Title", + "he": "Newsletter Hebrew Title" + }, + "description": { + "en": "Newsletter English Description", + "he": "Newsletter Hebrew Description" + }, + "api_schema": { + "http_method": "POST", + "payload": { + "first_name_key": "fname", + "last_name_key": "lname", + "email_key": "email" + } + } + } + }, + { + "slug": "English Title", + "name": {"en": "a", "he": "b"}, + "about": { + "title": { + "en": "English Title", + "he": "Hebrew Title" + }, + "description": { + "en": "English Description", + "he": "Hebrew Description" + } + }, + "mobile": { + "title": { + "en": "Mobile Title", + "he": "Mobile Hebrew Title" + } + }, + "newsletter": { + "title": { + "en": "Newsletter Title", + "he": "Newsletter Hebrew Title" + }, + "api_schema": { + "http_method": "GET" + } + } + }, +{ + "slug": "English Title", + "name": {"en": "a", "he": "b"}, + "about": { + "title": { + "en": "English Title", + "he": "Hebrew Title" + }, + "title_url": "https://example.com", + "image_uri": "gs://your-bucket/image.jpg", + "description": { + "en": "English Description", + "he": "Hebrew Description" + } + }, + "mobile": { + "title": { + "en": "Mobile Title", + "he": "Mobile Hebrew Title" + }, + "android_link": "https://android-link.com", + "ios_link": "https://ios-link.com" + }, + "newsletter": { + "title": { + "en": "Newsletter Title", + "he": "Newsletter Hebrew Title" + }, + "description": { + "en": "Newsletter English Description", + "he": "Newsletter Hebrew Description" + }, + "api_schema": { + "http_method": "POST", + "payload": { + "first_name_key": "fname", + "last_name_key": "lname", + "email_key": "email" + } + } + } + }, + { + "slug": "English Title", + "name": {"en": "a", "he": "b"}, + "about": { + "title": { + "en": "English Title", + "he": "Hebrew Title" + } + }, + "mobile": { + "title": { + "en": "Mobile Title", + "he": "Mobile Hebrew Title" + } + }, + "newsletter": { + "title": { + "en": "Newsletter Title", + "he": "Newsletter Hebrew Title" + }, + "api_schema": { + "http_method": "GET" + } + } + }, + { + "slug": "English Title", + "name": {"en": "a", "he": "b"}, + "about": { + "title": { + "en": "English Title", + "he": "Hebrew Title" + } + }, + "mobile": { + "title": { + "en": "Mobile Title", + "he": "Mobile Hebrew Title" + }, + "android_link": "https://android-link.com" + }, + "newsletter": { + "title": { + "en": "Newsletter Title", + "he": "Newsletter Hebrew Title" + } + } + }, + { + "slug": "English Title", + "name": {"en": "a", "he": "b"}, + "about": { + "title": { + "en": "English Title", + "he": "Hebrew Title" + }, + "title_url": "https://example.com", + "image_uri": "gs://your-bucket/image.jpg", + "description": { + "en": "English Description", + "he": "Hebrew Description" + } + }, + "mobile": { + "title": { + "en": "Mobile Title", + "he": "Mobile Hebrew Title" + }, + "android_link": "https://android-link.com", + "ios_link": "https://ios-link.com" + }, + "newsletter": { + "title": { + "en": "Newsletter Title", + "he": "Newsletter Hebrew Title" + }, + "description": { + "en": "Newsletter English Description", + "he": "Newsletter Hebrew Description" + }, + "api_schema": { + "http_method": "POST", + "payload": { + "first_name_key": "fname", + "last_name_key": "lname", + "email_key": "email" + } + } + } + }, + { + "slug": "English Title", + "name": {"en": "a", "he": "b"}, + "about": { + "title": { + "en": "English Title", + "he": "Hebrew Title" + } + }, + "mobile": { + "title": { + "en": "Mobile Title", + "he": "Mobile Hebrew Title" + } + }, + "newsletter": { + "title": { + "en": "Newsletter Title", + "he": "Newsletter Hebrew Title" + }, + "api_schema": { + "http_method": "GET" + } + } + }, + { + "slug": "English Title", + "name": {"en": "a", "he": "b"}, + "about": { + "title": { + "en": "English Title", + "he": "Hebrew Title" + }, + "image_uri": "gs://your-bucket/image.jpg" + }, + "mobile": { + "title": { + "en": "Mobile Title", + "he": "Mobile Hebrew Title" + } + }, + "newsletter": { + "title": { + "en": "Newsletter Title", + "he": "Newsletter Hebrew Title" + }, + "api_schema": { + "http_method": "POST", + "payload": { + "first_name_key": "fname", + "last_name_key": "lname", + "email_key": "email" + } + } + } + }, + { + "slug": "English Title", + "name": {"en": "a", "he": "b"}, + "about": { + "title": { + "en": "About Us", + "he": "עלינו" + } + } + } +] + +invalids = [ + # Missing "about" key + { + "slug": "English Title", + "name": {"en": "a", "he": "b"}, + "mobile": { + "title": { + "en": "Mobile Title", + "he": "Mobile Hebrew Title" + }, + "android_link": "https://android-link.com", + "ios_link": "https://ios-link.com" + }, + "newsletter": { + "title": { + "en": "Newsletter Title", + "he": "Newsletter Hebrew Title" + }, + "api_schema": { + "http_method": "POST", + "payload": { + "first_name_key": "fname", + "last_name_key": "lname", + "email_key": "email" + } + } + } + }, + # Invalid "about.title_url" (not a URL) + { + "slug": "English Title", + "name": {"en": "a", "he": "b"}, + "about": { + "title": { + "en": "English Title", + "he": "Hebrew Title" + }, + "title_url": "invalid-url", + "image_uri": "gs://your-bucket/image.jpg", + "description": { + "en": "English Description", + "he": "Hebrew Description" + } + }, + "mobile": { + "title": { + "en": "Mobile Title", + "he": "Mobile Hebrew Title" + }, + "android_link": "https://android-link.com", + "ios_link": "https://ios-link.com" + }, + "newsletter": { + "title": { + "en": "Newsletter Title", + "he": "Newsletter Hebrew Title" + }, + "description": { + "en": "Newsletter English Description", + "he": "Newsletter Hebrew Description" + }, + "api_schema": { + "http_method": "POST", + "payload": { + "first_name_key": "fname", + "last_name_key": "lname", + "email_key": "email" + } + } + } + }, + # Including invalid field "newsletter.description.fr" + { + "slug": "English Title", + "name": {"en": "a", "he": "b"}, + "about": { + "title": { + "en": "English Title", + "he": "Hebrew Title" + }, + "title_url": "https://example.com", + "image_uri": "gs://your-bucket/image.jpg", + "description": { + "en": "English Description", + "he": "Hebrew Description" + } + }, + "mobile": { + "title": { + "en": "Mobile Title", + "he": "Mobile Hebrew Title" + }, + "android_link": "https://android-link.com" + }, + "newsletter": { + "title": { + "fr": "Titre de la newsletter", + "he": "Newsletter Hebrew Title" + }, + "description": { + "en": "Newsletter English Description", + "he": "Newsletter Hebrew Description" + }, + "api_schema": { + "http_method": "POST", + "payload": { + "first_name_key": "fname", + "last_name_key": "lname", + "email_key": "email" + } + } + } + }, + # Invalid "newsletter.api_schema.http_method" (not a valid HTTP method) + { + "slug": "English Title", + "name": {"en": "a", "he": "b"}, + "about": { + "title": { + "en": "English Title", + "he": "Hebrew Title" + }, + "image_uri": "gs://your-bucket/image.jpg", + "description": { + "en": "English Description", + "he": "Hebrew Description" + } + }, + "mobile": { + "title": { + "en": "Mobile Title", + "he": "Mobile Hebrew Title" + }, + "android_link": "https://android-link.com", + "ios_link": "https://ios-link.com" + }, + "newsletter": { + "title": { + "en": "Newsletter Title", + "he": "Newsletter Hebrew Title" + }, + "description": { + "en": "Newsletter English Description", + "he": "Newsletter Hebrew Description" + }, + "api_schema": { + "http_method": "INVALID_METHOD", + "payload": { + "first_name_key": "fname", + "last_name_key": "lname", + "email_key": "email" + } + } + } + }, + # Invalid data types: + { + "slug": "English Title", + "name": {"en": "a", "he": "b"}, + "about": { + "title": { + "en": "About Us", + "he": "עלינו" + }, + "image_uri": 67890, + "description": { + "en": "Description in English", + "he": "תיאור בעברית" + } + } + }, +{ + # Incorrect field names + "slug": "English Title", + "name": {"en": "a", "he": "b"}, + "about": { + "title": { + "en": "About Us", + "he": "עלינו" + }, + "image_uri": "gs://bucket/image.jpg", + "description": { + "en": "Description in English", + "he": "תיאור בעברית" + } + }, + "mobile": { + "title": { + "en": "Mobile App", + "he": "אפליקציה ניידת" + }, + "android_link": "https://play.google.com/store/apps/details?id=com.example.app", + "ios_link": "https://apps.apple.com/us/app/example-app/id1234567890", + "invalid_field": "This field should not be here" + } +} + + +] +@pytest.mark.parametrize("data", valids) +def test_valid_schema(data): + p = Portal(data) + assert p._validate() == True + +@pytest.mark.parametrize("invalid_case", invalids) +def test_invalid_schema(invalid_case): + with pytest.raises(Exception): + p = Portal(invalid_case) + p._validate() + + +@pytest.fixture() +def simple_portal(): + raw_portal = valids[0] + portal = Portal(raw_portal) + portal.save() + + yield portal + + portal.delete() + + +@pytest.fixture() +def simple_portal_saved_directly_to_mongo(): + raw_portal = valids[0] + inserted_result = db.portals.insert_one(raw_portal) + + yield Portal(raw_portal) + + db.portals.delete_one({"_id": inserted_result.inserted_id}) + + +@pytest.fixture() +def simple_topic(simple_portal): + topic = Topic({ + "slug": "blah", + "titles": [{"text": "Blah", "lang": "en", "primary": True}], + "portal_slug": simple_portal.slug, + }) + topic.save() + + yield topic + + topic.delete() + + +def test_save_simple_portal(simple_portal): + """ + Tests that simple_portal was saved properly and has a normalized slug + """ + assert simple_portal.slug == "english-title" + + +def test_topic_validates_portal_exists(simple_topic): + assert simple_topic is not None + + +def test_topic_validation_fails_for_non_existent_portal(): + with pytest.raises(SluggedMongoRecordMissingError): + topic = Topic({ + "slug": "blah", + "titles": [{"text": "Blah", "lang": "en", "primary": True}], + "portal_slug": "non-existent-portal", + }) + topic.save() + + +def test_load_portal(simple_portal_saved_directly_to_mongo): + portal = Portal().load({"slug": simple_portal_saved_directly_to_mongo.slug}) + assert portal is not None diff --git a/sefaria/model/tests/text_test.py b/sefaria/model/tests/text_test.py index 00192188e2..6609c17354 100644 --- a/sefaria/model/tests/text_test.py +++ b/sefaria/model/tests/text_test.py @@ -169,9 +169,9 @@ def test_invalid_index_save_no_category(): def test_best_time_period(): i = model.library.get_index("Rashi on Genesis") - assert i.best_time_period().period_string('en') == ' (c.1075 - c.1105 CE)' + assert i.best_time_period().period_string('en') == ' (c.1075 – c.1105 CE)' i.compDate = None - assert i.best_time_period().period_string('en') == ' (1040 - 1105 CE)' # now that compDate is None, period_string should return Rashi's birth to death years + assert i.best_time_period().period_string('en') == ' (1040 – 1105 CE)' # now that compDate is None, period_string should return Rashi's birth to death years def test_invalid_index_save_no_hebrew_collective_title(): title = 'Bartenura (The Next Generation)' diff --git a/sefaria/model/tests/topic_test.py b/sefaria/model/tests/topic_test.py index 78e8a89ec5..59f49d205b 100644 --- a/sefaria/model/tests/topic_test.py +++ b/sefaria/model/tests/topic_test.py @@ -2,6 +2,7 @@ from sefaria.model.topic import Topic, TopicSet, IntraTopicLink, RefTopicLink, TopicLinkHelper, IntraTopicLinkSet, RefTopicLinkSet from sefaria.model.text import Ref from sefaria.system.database import db +from sefaria.system.exceptions import SluggedMongoRecordMissingError def make_topic(slug): @@ -219,7 +220,7 @@ def test_validate(self, topic_graph): 'dataSource': 'blahblah' } l = IntraTopicLink(attrs) - with pytest.raises(AssertionError): + with pytest.raises(SluggedMongoRecordMissingError): l.save() # non-existant toTopic @@ -230,7 +231,7 @@ def test_validate(self, topic_graph): 'dataSource': 'sefaria' } l = IntraTopicLink(attrs) - with pytest.raises(AssertionError): + with pytest.raises(SluggedMongoRecordMissingError): l.save() # non-existant fromTopic @@ -241,7 +242,7 @@ def test_validate(self, topic_graph): 'dataSource': 'sefaria' } l = IntraTopicLink(attrs) - with pytest.raises(AssertionError): + with pytest.raises(SluggedMongoRecordMissingError): l.save() # non-existant linkType @@ -252,7 +253,7 @@ def test_validate(self, topic_graph): 'dataSource': 'sefaria' } l = IntraTopicLink(attrs) - with pytest.raises(AssertionError): + with pytest.raises(SluggedMongoRecordMissingError): l.save() # duplicate for symmetric linkType diff --git a/sefaria/model/text.py b/sefaria/model/text.py index 6346fd5374..e1d398a9e5 100644 --- a/sefaria/model/text.py +++ b/sefaria/model/text.py @@ -687,6 +687,12 @@ def _validate(self): if not Category().load({"path": self.categories}): raise InputError("You must create category {} before adding texts to it.".format("/".join(self.categories))) + for date_key in ['compDate', 'pubDate']: + if hasattr(self, date_key): + val = getattr(self, date_key) + if not isinstance(val, list) or not all([isinstance(x, int) for x in val]): + raise InputError(f"Optional attribute '{date_key}' must be list of integers.") + ''' for cat in self.categories: if not hebrew_term(cat): @@ -1296,7 +1302,7 @@ def _validate(self): """ languageCodeRe = re.search(r"\[([a-z]{2})\]$", getattr(self, "versionTitle", None)) if languageCodeRe and languageCodeRe.group(1) != getattr(self,"actualLanguage",None): - raise InputError("Version actualLanguage does not match bracketed language") + self.actualLanguage = languageCodeRe.group(1) if getattr(self,"language", None) not in ["en", "he"]: raise InputError("Version language must be either 'en' or 'he'") index = self.get_index() @@ -2281,6 +2287,11 @@ def __init__(self, oref, context=1, commentary=True, version=None, lang=None, elif oref.has_default_child(): oref = oref.default_child_ref() + if version: + version = version.replace("_", " ") + if version2: + version2 = version2.replace("_", " ") + self.ref = oref.normal() self.heRef = oref.he_normal() self.isComplex = oref.index.is_complex() diff --git a/sefaria/model/timeperiod.py b/sefaria/model/timeperiod.py index bb0da09560..33bd3d389a 100644 --- a/sefaria/model/timeperiod.py +++ b/sefaria/model/timeperiod.py @@ -56,6 +56,8 @@ +---------------+------------+-----------------+-------------------------------+-----------------------+ """ +DASH = '–' + class TimePeriod(abst.AbstractMongoRecord): """ TimePeriod is used both for the saved time periods - Eras and Generations @@ -144,10 +146,11 @@ def period_string(self, lang): if lang == "en": if getattr(self, "symbol", "") == "CO" or getattr(self, "end", None) is None: - name += " ({}{} {} - )".format( + name += " ({}{} {} {} )".format( approxMarker[0], abs(int(self.start)), - labels[1]) + labels[1], + DASH) return name elif int(self.start) == int(self.end): name += " ({}{} {})".format( @@ -155,19 +158,21 @@ def period_string(self, lang): abs(int(self.start)), labels[1]) else: - name += " ({}{} {} - {}{} {})".format( + name += " ({}{} {} {} {}{} {})".format( approxMarker[0], abs(int(self.start)), labels[0], + DASH, approxMarker[1], abs(int(self.end)), labels[1]) if lang == "he": if getattr(self, "symbol", "") == "CO" or getattr(self, "end", None) is None: - name += " ({} {} {} - )".format( + name += " ({} {} {} {} )".format( abs(int(self.start)), labels[1], - approxMarker[0]) + approxMarker[0], + DASH) return name elif int(self.start) == int(self.end): name += " ({}{}{})".format( @@ -177,18 +182,20 @@ def period_string(self, lang): else: both_approx = approxMarker[0] and approxMarker[1] if both_approx: - name += " ({}{} - {}{} {})".format( + name += " ({}{} {} {}{} {})".format( abs(int(self.start)), " " + labels[0] if labels[0] else "", + DASH, abs(int(self.end)), " " + labels[1] if labels[1] else "", approxMarker[1] ) else: - name += " ({}{}{} - {}{}{})".format( + name += " ({}{}{} {} {}{}{})".format( abs(int(self.start)), " " + labels[0] if labels[0] else "", " " + approxMarker[0] if approxMarker[0] else "", + DASH, abs(int(self.end)), " " + labels[1] if labels[1] else "", " " + approxMarker[1] if approxMarker[1] else "" @@ -218,6 +225,16 @@ def get_people_in_generation(self, include_doubles = True): else: return topic.Topic({"properties.generation.value": self.symbol}) + def determine_year_estimate(self): + start = getattr(self, 'start', None) + end = getattr(self, 'end', None) + if start != None and end != None: + return round((int(start) + int(end)) / 2) + elif start != None: + return int(start) + elif end != None: + return int(end) + class TimePeriodSet(abst.AbstractMongoSet): recordClass = TimePeriod @@ -234,3 +251,41 @@ def get_generations(include_doubles = False): arg = {"$in": ["Generation", "Two Generations"]} if include_doubles else "Generation" return TimePeriodSet._get_typed_set(arg) +class LifePeriod(TimePeriod): + + def period_string(self, lang): + + if getattr(self, "start", None) == None and getattr(self, "end", None) == None: + return + + labels = self.getYearLabels(lang) + approxMarker = self.getApproximateMarkers(lang) + abs_birth = abs(int(getattr(self, "start", 0))) + abs_death = abs(int(getattr(self, "end", 0))) + if lang == "en": + birth = 'b.' + death = 'd.' + order_vars_by_lang = lambda year, label, approx: (approx, '', year, label) + else: + birth = 'נו׳' + death = 'נפ׳' + order_vars_by_lang = lambda year, label, approx: (year, ' ', label, approx) + + if getattr(self, "symbol", "") == "CO" or getattr(self, "end", None) is None: + name = '{} {}{}{} {}'.format(birth, *order_vars_by_lang(abs_birth, labels[1], approxMarker[0])) + elif getattr(self, "start", None) is None: + name = '{} {}{}{} {}'.format(death, *order_vars_by_lang(abs_death, labels[1], approxMarker[0])) + elif int(self.start) == int(self.end): + name = '{}{}{} {}'.format(*order_vars_by_lang(abs_birth, labels[1], approxMarker[0])) + else: + both_approx = approxMarker[0] and approxMarker[1] + if lang == 'he' and both_approx: + birth_string = '{}{}{}'.format(*order_vars_by_lang(abs_birth, labels[0], approxMarker[0])[:-1]) + else: + birth_string = '{}{}{} {}'.format(*order_vars_by_lang(abs_birth, labels[0], approxMarker[0])) + death_string = '{}{}{} {}'.format(*order_vars_by_lang(abs_death, labels[1], approxMarker[0])) + name = f'{birth_string} {DASH} {death_string}' + + name = f' ({" ".join(name.split())})' + return name + diff --git a/sefaria/model/topic.py b/sefaria/model/topic.py index 0127bc723b..2304a7a5b4 100644 --- a/sefaria/model/topic.py +++ b/sefaria/model/topic.py @@ -4,11 +4,14 @@ from .text import Ref, IndexSet, AbstractTextRecord from .category import Category from sefaria.system.exceptions import InputError, DuplicateRecordError -from sefaria.model.timeperiod import TimePeriod +from sefaria.model.timeperiod import TimePeriod, LifePeriod +from sefaria.system.validators import validate_url +from sefaria.model.portal import Portal from sefaria.system.database import db import structlog, bleach from sefaria.model.place import Place import regex as re +from typing import Type logger = structlog.get_logger(__name__) @@ -43,8 +46,35 @@ class Topic(abst.SluggedAbstractMongoRecord, AbstractTitledObject): 'good_to_promote', 'description_published', # bool to keep track of which descriptions we've vetted 'isAmbiguous', # True if topic primary title can refer to multiple other topics - "data_source" #any topic edited manually should display automatically in the TOC and this flag ensures this + "data_source", #any topic edited manually should display automatically in the TOC and this flag ensures this + 'image', + "portal_slug", # slug to relevant Portal object ] + + attr_schemas = { + "image": { + "image_uri": { + "type": "string", + "required": True, + "regex": "^https://storage\.googleapis\.com/img\.sefaria\.org/topics/.*?" + }, + "image_caption": { + "type": "dict", + "required": True, + "schema": { + "en": { + "type": "string", + "required": True + }, + "he": { + "type": "string", + "required": True + } + } + } + } + } + ROOT = "Main Menu" # the root of topic TOC is not a topic, so this is a fake slug. we know it's fake because it's not in normal form # this constant is helpful in the topic editor tool functions in this file @@ -68,6 +98,11 @@ def _validate(self): super(Topic, self)._validate() if getattr(self, 'subclass', False): assert self.subclass in self.subclass_map, f"Field `subclass` set to {self.subclass} which is not one of the valid subclass keys in `Topic.subclass_map`. Valid keys are {', '.join(self.subclass_map.keys())}" + if getattr(self, 'portal_slug', None): + Portal.validate_slug_exists(self.portal_slug) + if getattr(self, "image", False): + img_url = self.image.get("image_uri") + if img_url: validate_url(img_url) def _normalize(self): super()._normalize() @@ -412,8 +447,9 @@ def annotate_place(self, d): if place and heKey not in properties: value, dataSource = place['value'], place['dataSource'] place_obj = Place().load({"key": value}) - name = place_obj.primary_name('he') - d['properties'][heKey] = {'value': name, 'dataSource': dataSource} + if place_obj: + name = place_obj.primary_name('he') + d['properties'][heKey] = {'value': name, 'dataSource': dataSource} return d def contents(self, **kwargs): @@ -421,7 +457,7 @@ def contents(self, **kwargs): d = super(PersonTopic, self).contents(**kwargs) if annotate_time_period: d = self.annotate_place(d) - tp = self.most_accurate_time_period() + tp = self.most_accurate_life_period() if tp is not None: d['timePeriod'] = { "name": { @@ -437,26 +473,42 @@ def contents(self, **kwargs): # A person may have an era, a generation, or a specific birth and death years, which each may be approximate. # They may also have none of these... - def most_accurate_time_period(self) -> Optional[TimePeriod]: + def _most_accurate_period(self, time_period_class: Type[TimePeriod]) -> Optional[LifePeriod]: if self.get_property("birthYear") and self.get_property("deathYear"): - return TimePeriod({ + return time_period_class({ "start": self.get_property("birthYear"), "startIsApprox": self.get_property("birthYearIsApprox", False), "end": self.get_property("deathYear"), "endIsApprox": self.get_property("deathYearIsApprox", False) }) elif self.get_property("birthYear") and self.get_property("era", "CO"): - return TimePeriod({ + return time_period_class({ "start": self.get_property("birthYear"), "startIsApprox": self.get_property("birthYearIsApprox", False), }) + elif self.get_property("deathYear"): + return time_period_class({ + "end": self.get_property("deathYear"), + "endIsApprox": self.get_property("deathYearIsApprox", False) + }) elif self.get_property("generation"): - return TimePeriod().load({"symbol": self.get_property("generation")}) + return time_period_class().load({"symbol": self.get_property("generation")}) elif self.get_property("era"): - return TimePeriod().load({"symbol": self.get_property("era")}) + return time_period_class().load({"symbol": self.get_property("era")}) else: return None + def most_accurate_time_period(self): + ''' + :return: most accurate period as TimePeriod (used when a person's LifePeriod should be formatted like a general TimePeriod) + ''' + return self._most_accurate_period(TimePeriod) + + def most_accurate_life_period(self): + ''' + :return: most accurate period as LifePeriod. currently the only difference from TimePeriod is the way the time period is formatted as a string. + ''' + return self._most_accurate_period(LifePeriod) class AuthorTopic(PersonTopic): """ @@ -536,16 +588,21 @@ def index_is_commentary(index): def get_aggregated_urls_for_authors_indexes(self) -> list: """ Aggregates author's works by category when possible and - returns list of tuples. Each tuple is of shape (url, {"en", "he"}) corresponding to an index or category of indexes of this author's works. + returns a dictionary. Each dictionary is of shape {"url": str, "title": {"en": str, "he": str}, "description": {"en": str, "he": str}} + corresponding to an index or category of indexes of this author's works. """ from .schema import Term from .text import Index index_or_cat_list = self.aggregate_authors_indexes_by_category() - unique_urls = {} # {url: {lang: title}}. This dict arbitrarily chooses one title per URL. + unique_urls = [] for index_or_cat, collective_title_term, base_category in index_or_cat_list: + en_desc = getattr(index_or_cat, 'enShortDesc', None) + he_desc = getattr(index_or_cat, 'heShortDesc', None) if isinstance(index_or_cat, Index): - unique_urls[f'/{index_or_cat.title.replace(" ", "_")}'] = {"en": index_or_cat.get_title('en'), "he": index_or_cat.get_title('he')} + unique_urls.append({"url":f'/{index_or_cat.title.replace(" ", "_")}', + "title": {"en": index_or_cat.get_title('en'), "he": index_or_cat.get_title('he')}, + "description":{"en": en_desc, "he": he_desc}}) else: if collective_title_term is None: cat_term = Term().load({"name": index_or_cat.sharedTitle}) @@ -555,8 +612,10 @@ def get_aggregated_urls_for_authors_indexes(self) -> list: base_category_term = Term().load({"name": base_category.sharedTitle}) en_text = f'{collective_title_term.get_primary_title("en")} on {base_category_term.get_primary_title("en")}' he_text = f'{collective_title_term.get_primary_title("he")} על {base_category_term.get_primary_title("he")}' - unique_urls[f'/texts/{"/".join(index_or_cat.path)}'] = {"en": en_text, "he": he_text} - return list(unique_urls.items()) + unique_urls.append({"url": f'/texts/{"/".join(index_or_cat.path)}', + "title": {"en": en_text, "he": he_text}, + "description":{"en": en_desc, "he": he_desc}}) + return unique_urls @staticmethod def is_author(slug): @@ -659,14 +718,10 @@ def _validate(self): super(IntraTopicLink, self)._validate() # check everything exists - link_type = TopicLinkType().load({"slug": self.linkType}) - assert link_type is not None, "Link type '{}' does not exist".format(self.linkType) - from_topic = Topic.init(self.fromTopic) - assert from_topic is not None, "fromTopic '{}' does not exist".format(self.fromTopic) - to_topic = Topic.init(self.toTopic) - assert to_topic is not None, "toTopic '{}' does not exist".format(self.toTopic) - data_source = TopicDataSource().load({"slug": self.dataSource}) - assert data_source is not None, "dataSource '{}' does not exist".format(self.dataSource) + TopicLinkType.validate_slug_exists(self.linkType, 0) + Topic.validate_slug_exists(self.fromTopic) + Topic.validate_slug_exists(self.toTopic) + TopicDataSource.validate_slug_exists(self.dataSource) # check for duplicates duplicate = IntraTopicLink().load({"linkType": self.linkType, "fromTopic": self.fromTopic, "toTopic": self.toTopic, @@ -676,6 +731,7 @@ def _validate(self): "Duplicate intra topic link for linkType '{}', fromTopic '{}', toTopic '{}'".format( self.linkType, self.fromTopic, self.toTopic)) + link_type = TopicLinkType.init(self.linkType, 0) if link_type.slug == link_type.inverseSlug: duplicate_inverse = IntraTopicLink().load({"linkType": self.linkType, "toTopic": self.fromTopic, "fromTopic": self.toTopic, "class": getattr(self, 'class'), "_id": {"$ne": getattr(self, "_id", None)}}) @@ -685,6 +741,8 @@ def _validate(self): duplicate_inverse.linkType, duplicate_inverse.fromTopic, duplicate_inverse.toTopic)) # check types of topics are valid according to validFrom/To + from_topic = Topic.init(self.fromTopic) + to_topic = Topic.init(self.toTopic) if getattr(link_type, 'validFrom', False): assert from_topic.has_types(set(link_type.validFrom)), "from topic '{}' does not have valid types '{}' for link type '{}'. Instead, types are '{}'".format(self.fromTopic, ', '.join(link_type.validFrom), self.linkType, ', '.join(from_topic.get_types())) if getattr(link_type, 'validTo', False): @@ -780,10 +838,10 @@ def _normalize(self): self.expandedRefs = [r.normal() for r in Ref(self.ref).all_segment_refs()] def _validate(self): + Topic.validate_slug_exists(self.toTopic) + TopicLinkType.validate_slug_exists(self.linkType, 0) to_topic = Topic.init(self.toTopic) - assert to_topic is not None, "toTopic '{}' does not exist".format(self.toTopic) - link_type = TopicLinkType().load({"slug": self.linkType}) - assert link_type is not None, "Link type '{}' does not exist".format(self.linkType) + link_type = TopicLinkType.init(self.linkType, 0) if getattr(link_type, 'validTo', False): assert to_topic.has_types(set(link_type.validTo)), "to topic '{}' does not have valid types '{}' for link type '{}'. Instead, types are '{}'".format(self.toTopic, ', '.join(link_type.validTo), self.linkType, ', '.join(to_topic.get_types())) @@ -871,11 +929,10 @@ def _validate(self): # Check that validFrom and validTo contain valid topic slugs if exist for validToTopic in getattr(self, 'validTo', []): - assert Topic.init(validToTopic) is not None, "ValidTo topic '{}' does not exist".format(self.validToTopic) + Topic.validate_slug_exists(validToTopic) for validFromTopic in getattr(self, 'validFrom', []): - assert Topic.init(validFromTopic) is not None, "ValidTo topic '{}' does not exist".format( - self.validFrom) + Topic.validate_slug_exists(validFromTopic) def get(self, attr, is_inverse, default=None): attr = 'inverse{}{}'.format(attr[0].upper(), attr[1:]) if is_inverse else attr diff --git a/sefaria/model/version_state.py b/sefaria/model/version_state.py index 62aea25423..2fef65b10e 100644 --- a/sefaria/model/version_state.py +++ b/sefaria/model/version_state.py @@ -20,78 +20,53 @@ except ImportError: USE_VARNISH = False ''' -old count docs were: - c["allVersionCounts"] - c["availableTexts"] = { - "en": - "he": - } - - c["availableCounts"] = { # - "en": - "he": - } - - c["percentAvailable"] = { - "he": - "en": - } - - c["textComplete"] = { - "he": - "en" - } - - c['estimatedCompleteness'] = { - "he": { - 'estimatedPercent': - 'availableSegmentCount': # is availableCounts[-1] - 'percentAvailableInvalid': - 'percentAvailable': # duplicate - 'isSparse': - } - "en": - } - - -and now self.content is: - { - "_en": { - "availableTexts": - "availableCounts": - "percentAvailable": - "textComplete": - 'completenessPercent': - 'percentAvailableInvalid': - 'sparseness': # was isSparse - } - "_he": ... - "_all" { - "availableTexts": - "shape": - For depth 1: Integer - length - For depth 2: List of chapter lengths - For depth 3: List of list of chapter lengths? - } - } - ''' class VersionState(abst.AbstractMongoRecord, AbstractSchemaContent): """ This model overrides default init/load/save behavior, since there is one and only one VersionState record for each Index record. + + The `content` attribute is a dictionary which is the root of a tree, mirroring the shape of a Version, where the leaf nodes of the tree are dictionaries with a shape like the following: + { + "_en": { + "availableTexts": Mask of what texts are available in this language. Boolean values (0 or 1) in the shape of the JaggedArray + "availableCounts": Array, with length == depth of the node. Each element is the number of available elements at that depth. e.g [chapters, verses] + "percentAvailable": Percent of this text available in this language TODO: Only used on the dashboard. Remove? + 'percentAvailableInvalid': Boolean. Whether the value of "percentAvailable" can be trusted. TODO: Only used on the dashboard. Remove? + "textComplete": Boolean. Whether the text is complete in this language. TODO: Not used outside of this file. Should be removed. + 'completenessPercent': Percent of this text complete in this language TODO: Not used outside of this file. Should be removed. + 'sparseness': Legacy - present on some records, but no longer in code TODO: remove + } + "_he": {...} # same keys as _en + "_all" { + "availableTexts": Mask what texts are available in this text overall. Boolean values (0 or 1) in the shape of the JaggedArray + "shape": + For depth 1: Integer -length + For depth 2: List of section lengths + For depth 3: List of list of section lengths + } + } + + For example: + - the `content` attribute for a simple text like `Genesis` will be a dictionary with keys "_en", "_he", and "_all", as above. + - the `content` attribute for `Pesach Haggadah` will be a dictionary with keys: "Kadesh", "Urchatz", "Karpas" ... each with a value of a dictionary like the above. + The key "Magid" has a value of a dictionary, where each key is a different sub-section of Magid. + The value for each key is a dictionary as detailed above, specific to each sub-section. + So for example, one key will be "Ha Lachma Anya" and the value will be a dictionary, like the above, specific to the details of "Ha Lachma Anya". + + Every JaggedArrayNode has a corresponding vstate dictionary. So for complex texts, each leaf node (and leaf nodes by definition must be JaggedArrayNodes) has this corresponding dictionary. """ collection = 'vstate' required_attrs = [ "title", # Index title - "content" # tree of data about nodes + "content" # tree of data about nodes. See above. ] optional_attrs = [ - "flags", - "linksCount", - "first_section_ref" + "flags", # "heComplete" : Bool, "enComplete" : Bool + "linksCount", # Integer + "first_section_ref" # Normal text Ref ] langs = ["en", "he"] diff --git a/sefaria/pagesheetrank.py b/sefaria/pagesheetrank.py index 1c2087ed2b..603de805bc 100644 --- a/sefaria/pagesheetrank.py +++ b/sefaria/pagesheetrank.py @@ -212,8 +212,8 @@ def put_link_in_graph(ref1, ref2, weight=1.0): refs = [Ref(r) for r in link.refs] tp1 = refs[0].index.best_time_period() tp2 = refs[1].index.best_time_period() - start1 = int(tp1.start) if tp1 else 3000 - start2 = int(tp2.start) if tp2 else 3000 + start1 = int(tp1.determine_year_estimate()) if tp1 else 3000 + start2 = int(tp2.determine_year_estimate()) if tp2 else 3000 older_ref, newer_ref = (refs[0], refs[1]) if start1 < start2 else (refs[1], refs[0]) diff --git a/sefaria/system/exceptions.py b/sefaria/system/exceptions.py index ff64c89336..72d3493f78 100644 --- a/sefaria/system/exceptions.py +++ b/sefaria/system/exceptions.py @@ -56,5 +56,46 @@ class SheetNotFoundError(InputError): class ManuscriptError(Exception): pass + class MissingKeyError(Exception): pass + + +class SluggedMongoRecordMissingError(Exception): + pass + + +class SchemaValidationException(Exception): + def __init__(self, key, expected_type): + self.key = key + self.expected_type = expected_type + self.message = f"Invalid value for key '{key}'. Expected type: {expected_type}" + super().__init__(self.message) + + +class SchemaRequiredFieldException(Exception): + def __init__(self, key): + self.key = key + self.message = f"Required field '{key}' is missing." + super().__init__(self.message) + + +class SchemaInvalidKeyException(Exception): + def __init__(self, key): + self.key = key + self.message = f"Invalid key '{key}' found in data dictionary." + super().__init__(self.message) + + +class InvalidURLException(Exception): + def __init__(self, url): + self.url = url + self.message = f"'{url}' is not a valid URL." + super().__init__(self.message) + + +class InvalidHTTPMethodException(Exception): + def __init__(self, method): + self.method = method + self.message = f"'{method}' is not a valid HTTP API method." + super().__init__(self.message) diff --git a/sefaria/system/validators.py b/sefaria/system/validators.py new file mode 100644 index 0000000000..f36ba8282f --- /dev/null +++ b/sefaria/system/validators.py @@ -0,0 +1,45 @@ +""" +Pre-written validation functions +Useful for validating model schemas when overriding AbstractMongoRecord._validate() +""" + +import urllib.parse +from urllib.parse import urlparse +from django.core.validators import URLValidator +from django.core.exceptions import ValidationError +from sefaria.system.exceptions import SchemaValidationException, SchemaInvalidKeyException, SchemaRequiredFieldException\ + , InvalidHTTPMethodException, InvalidURLException + + +def validate_url(url): + try: + # Attempt to parse the URL + validator = URLValidator() + validator(url) + return True + + except ValidationError: + # URL parsing failed + raise InvalidURLException(url) + + +def validate_http_method(method): + """ + Validate if a string represents a valid HTTP API method. + + Args: + method (str): The HTTP method to validate. + + Raises: + InvalidHTTPMethodException: If the method is not valid. + + Returns: + bool: True if the method is valid, False otherwise. + """ + valid_methods = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"] + + # Convert the method to uppercase and check if it's in the list of valid methods + if method.upper() in valid_methods: + return True + else: + raise InvalidHTTPMethodException(method) diff --git a/sefaria/urls.py b/sefaria/urls.py index b97e18771a..757fcef1aa 100644 --- a/sefaria/urls.py +++ b/sefaria/urls.py @@ -85,12 +85,12 @@ url(r'^settings/account?$', reader_views.account_settings), url(r'^settings/profile?$', reader_views.edit_profile), url(r'^interface/(?Penglish|hebrew)$', reader_views.interface_language_redirect), - url(r'^api/profile/user_history$', reader_views.user_history_api), + url(r'^api/profile/user_history$', reader_views.user_history_api), url(r'^api/profile/sync$', reader_views.profile_sync_api), url(r'^api/profile/upload-photo$', reader_views.profile_upload_photo), url(r'^api/profile$', reader_views.profile_api), url(r'^settings/account/user$', reader_views.account_user_update), - url(r'^api/profile/(?P[^/]+)$', reader_views.profile_get_api), + url(r'^api/profile/(?P[^/]+)$', reader_views.profile_get_api), url(r'^api/profile/(?P[^/]+)/(?Pfollowers|following)$', reader_views.profile_follow_api), url(r'^api/user_history/saved$', reader_views.saved_history_for_ref), ] @@ -98,7 +98,7 @@ # Topics urlpatterns += [ url(r'^topics/category/(?P.+)?$', reader_views.topics_category_page), - url(r'^topics/all/(?P.)$', reader_views.all_topics_page), + url(r'^topics/all/(?P.)$', reader_views.all_topics_page), url(r'^topics/?$', reader_views.topics_page), url(r'^topics/b/(?P.+)$', reader_views.topic_page_b), url(r'^topics/(?P.+)$', reader_views.topic_page), @@ -189,9 +189,9 @@ url(r'^api/sheets/?$', sheets_views.save_sheet_api), url(r'^api/sheets/(?P\d+)/delete$', sheets_views.delete_sheet_api), url(r'^api/sheets/(?P\d+)/add$', sheets_views.add_source_to_sheet_api), - url(r'^api/sheets/(?P\d+)/add_ref$', sheets_views.add_ref_to_sheet_api), + url(r'^api/sheets/(?P\d+)/add_ref$', sheets_views.add_ref_to_sheet_api), url(r'^api/sheets/(?P.+)/get_aliyot$', sheets_views.get_aliyot_by_parasha_api), - url(r'^api/sheets/(?P\d+)/copy_source$', sheets_views.copy_source_to_sheet_api), + url(r'^api/sheets/(?P\d+)/copy_source$', sheets_views.copy_source_to_sheet_api), url(r'^api/sheets/(?P\d+)/topics$', sheets_views.update_sheet_topics_api), url(r'^api/sheets/(?P\d+)$', sheets_views.sheet_api), url(r'^api/sheets/(?P\d+)\.(?P\d+)$', sheets_views.sheet_node_api), @@ -229,7 +229,7 @@ url(r'^api/collections/for-sheet/(?P\d+)$', sheets_views.collections_for_sheet_api), url(r'^api/collections(/(?P[^/]+))?$', sheets_views.collections_api), url(r'^api/collections/(?P[^/]+)/set-role/(?P\d+)/(?P[^/]+)$', sheets_views.collections_role_api), - url(r'^api/collections/(?P[^/]+)/invite/(?P[^/]+)(?P\/uninvite)?$', sheets_views.collections_invite_api), + url(r'^api/collections/(?P[^/]+)/invite/(?P[^/]+)(?P\/uninvite)?$', sheets_views.collections_invite_api), url(r'^api/collections/(?P[^/]+)/(?P(add|remove))/(?P\d+)', sheets_views.collections_inclusion_api), url(r'^api/collections/(?P[^/]+)/(?P(add|remove))/(?P\d+)', sheets_views.collections_inclusion_api), url(r'^api/collections/(?P[^/]+)/pin-sheet/(?P\d+)', sheets_views.collections_pin_sheet_api), @@ -268,6 +268,11 @@ url(r'^api/recommend/topics(/(?P.+))?', reader_views.recommend_topics_api), ] +# Portals API +urlpatterns += [ + url(r'^api/portals/(?P.+)$', reader_views.portals_api), +] + # History API urlpatterns += [ url(r'^api/history/(?P.+)/(?P\w\w)/(?P.+)$', reader_views.texts_history_api), @@ -285,8 +290,8 @@ urlpatterns += [ url(r'^api/locktext/(?P.+)/(?P<lang>\w\w)/(?P<version>.+)$', reader_views.lock_text_api), url(r'^api/version/flags/(?P<title>.+)/(?P<lang>\w\w)/(?P<version>.+)$', reader_views.flag_text_api), -] -# SEC-AUDIT: do we also want to maybe move these to 'admin' +] +# SEC-AUDIT: do we also want to maybe move these to 'admin' # Discussions urlpatterns += [ @@ -385,11 +390,12 @@ # Email Subscribe urlpatterns += [ - url(r'^api/subscribe/(?P<email>.+)$', sefaria_views.subscribe), + url(r'^api/subscribe/(?P<org>.+)/(?P<email>.+)$', sefaria_views.generic_subscribe_to_newsletter_api), + url(r'^api/subscribe/(?P<email>.+)$', sefaria_views.subscribe_sefaria_newsletter_view), ] # Admin -urlpatterns += [ +urlpatterns += [ url(r'^admin/reset/varnish/(?P<tref>.+)$', sefaria_views.reset_varnish), url(r'^admin/reset/cache$', sefaria_views.reset_cache), url(r'^admin/reset/cache/(?P<title>.+)$', sefaria_views.reset_index_cache_for_text), @@ -403,6 +409,7 @@ url(r'^admin/reset-websites-data', sefaria_views.reset_websites_data), url(r'^admin/delete/orphaned-counts', sefaria_views.delete_orphaned_counts), url(r'^admin/delete/user-account', sefaria_views.delete_user_by_email, name="delete/user-account"), + url(r'^admin/delete/sheet$', sefaria_views.delete_sheet_by_id, name="delete/sheet"), url(r'^admin/rebuild/auto-links/(?P<title>.+)$', sefaria_views.rebuild_auto_links), url(r'^admin/rebuild/citation-links/(?P<title>.+)$', sefaria_views.rebuild_citation_links), url(r'^admin/delete/citation-links/(?P<title>.+)$', sefaria_views.delete_citation_links), diff --git a/sefaria/views.py b/sefaria/views.py index ab42bd7ff0..76dd901c98 100644 --- a/sefaria/views.py +++ b/sefaria/views.py @@ -38,10 +38,11 @@ from sefaria.helper.crm.crm_mediator import CrmMediator from sefaria.system.cache import in_memory_cache from sefaria.client.util import jsonResponse, send_email, read_webpack_bundle -from sefaria.forms import SefariaNewUserForm, SefariaNewUserFormAPI, SefariaDeleteUserForm +from sefaria.forms import SefariaNewUserForm, SefariaNewUserFormAPI, SefariaDeleteUserForm, SefariaDeleteSheet from sefaria.settings import MAINTENANCE_MESSAGE, USE_VARNISH, MULTISERVER_ENABLED from sefaria.model.user_profile import UserProfile, user_link -from sefaria.model.collection import CollectionSet +from sefaria.model.collection import CollectionSet, process_sheet_deletion_in_collections +from sefaria.model.notification import process_sheet_deletion_in_notifications from sefaria.export import export_all as start_export_all from sefaria.datatype.jagged_array import JaggedTextArray # noinspection PyUnresolvedReferences @@ -58,7 +59,7 @@ from sefaria.system.multiserver.coordinator import server_coordinator from sefaria.google_storage_manager import GoogleStorageManager from sefaria.sheets import get_sheet_categorization_info -from reader.views import base_props, render_template +from reader.views import base_props, render_template from sefaria.helper.link import add_links_from_csv, delete_links_from_text, get_csv_links_by_refs if USE_VARNISH: @@ -169,28 +170,65 @@ def accounts(request): }) -def subscribe(request, email): +def generic_subscribe_to_newsletter_api(request, org, email): """ - API for subscribing to mailing lists, in `lists` url param. - Currently active lists are: - "Announcements_General", "Announcements_General_Hebrew", "Announcements_Edu", "Announcements_Edu_Hebrew" + Generic view for subscribing a user to a newsletter """ + org_subscribe_fn_map = { + "sefaria": subscribe_sefaria_newsletter, + "steinsaltz": subscribe_steinsaltz, + } body = json.loads(request.body) - language = body.get("language", "") - educator = body.get("educator", False) first_name = body.get("firstName", None) last_name = body.get("lastName", None) try: - crm_mediator = CrmMediator() - if crm_mediator.subscribe_to_lists(email, first_name, last_name, educator=educator, lang=language): + subscribe = org_subscribe_fn_map.get(org) + if not subscribe: + return jsonResponse({"error": f"Organization '{org}' not recognized."}) + if subscribe(request, email, first_name, last_name): return jsonResponse({"status": "ok"}) else: - logger.error("Failed to subscribe to list") + logger.error(f"Failed to subscribe to list") return jsonResponse({"error": _("Sorry, there was an error.")}) except ValueError as e: logger.error(f"Failed to subscribe to list: {e}") return jsonResponse({"error": _("Sorry, there was an error.")}) + +def subscribe_sefaria_newsletter_view(request, email): + return generic_subscribe_to_newsletter_api(request, 'sefaria', email) + + +def subscribe_sefaria_newsletter(request, email, first_name, last_name): + """ + API for subscribing to mailing lists, in `lists` url param. + Currently active lists are: + "Announcements_General", "Announcements_General_Hebrew", "Announcements_Edu", "Announcements_Edu_Hebrew" + """ + body = json.loads(request.body) + language = body.get("language", "") + educator = body.get("educator", False) + crm_mediator = CrmMediator() + return crm_mediator.subscribe_to_lists(email, first_name, last_name, educator=educator, lang=language) + + +def subscribe_steinsaltz(request, email, first_name, last_name): + """ + API for subscribing to Steinsaltz newsletter + """ + import requests + + data = { + "first_name": first_name, + "last_name": last_name, + "email": email, + } + headers = {'Content-Type': 'application/json'} + response = requests.post('https://steinsaltz-center.org/api/mailer', + data=json.dumps(data), headers=headers) + return response.ok + + @login_required def unlink_gauth(request): profile = UserProfile(id=request.user.id) @@ -202,7 +240,7 @@ def unlink_gauth(request): return redirect(f"/profile/{profile.slug}") else: return jsonResponse({"status": "ok"}) - except: + except: return jsonResponse({"error": "Failed to delete Google account"}) @@ -501,7 +539,7 @@ def collections_image_upload(request, resize_image=True): image = Image.open(temp_uploaded_file) resized_image_file = BytesIO() if resize_image: - image.thumbnail(MAX_FILE_DIMENSIONS, Image.ANTIALIAS) + image.thumbnail(MAX_FILE_DIMENSIONS, Image.LANCZOS) image.save(resized_image_file, optimize=True, quality=70, format="PNG") resized_image_file.seek(0) bucket_name = GoogleStorageManager.COLLECTIONS_BUCKET @@ -971,6 +1009,56 @@ def delete_user_by_email(request): +@staff_member_required +def delete_sheet_by_id(request): + + from django.contrib.auth.models import User + from sefaria.utils.user import delete_user_account + if request.method == 'GET': + form = SefariaDeleteSheet() + return render_template(request, "delete-sheet.html", None, {'form': form, 'next': next}) + elif request.method == 'POST': + user = User.objects.get(id=request.user.id) + sheet_id = request.POST.get("sid") + password = request.POST.get("password") + try: + if not user.check_password(password): + return jsonResponse({"failure": "incorrect password"}) + except: + return jsonResponse({"failure": "incorrect password"}) + try: + + import sefaria.search as search + id = int(sheet_id) + sheet = db.sheets.find_one({"id": id}) + if not sheet: + return jsonResponse({"error": "Sheet %d not found." % id}) + + db.sheets.remove({"id": id}) + process_sheet_deletion_in_collections(id) + process_sheet_deletion_in_notifications(id) + + try: + es_index_name = search.get_new_and_current_index_names("sheet")['current'] + search.delete_sheet(es_index_name, id) + except NewConnectionError as e: + logger.warn("Failed to connect to elastic search server on sheet delete.") + except AuthorizationException as e: + logger.warn("Failed to connect to elastic search server on sheet delete.") + + + return jsonResponse({"success": f"deleted sheet {sheet_id}"}) + + except: + return jsonResponse({"failure": "sheet not deleted: try again or contact a developer"}) + + + + + + + + def purge_spammer_account_data(spammer_id, delete_from_crm=True): from django.contrib.auth.models import User diff --git a/sourcesheets/views.py b/sourcesheets/views.py index d9b0eb80de..f177423023 100644 --- a/sourcesheets/views.py +++ b/sourcesheets/views.py @@ -1157,7 +1157,7 @@ def upload_sheet_media(request): else: im = Image.open(img_file_in_mem) img_file = BytesIO() - im.thumbnail(max_img_size, Image.ANTIALIAS) + im.thumbnail(max_img_size, Image.LANCZOS) im.save(img_file, format=im.format) img_file.seek(0) diff --git a/static/css/s2.css b/static/css/s2.css index 47facc132e..490ccb31d2 100644 --- a/static/css/s2.css +++ b/static/css/s2.css @@ -607,7 +607,7 @@ input.noselect { } .mobileNavMenu { position: fixed; - height: calc(100% - 60px); + height: calc(100vh - 60px); box-sizing: border-box; top: 60px; width: 100%; @@ -616,6 +616,18 @@ input.noselect { z-index: 1000; overflow-y: scroll; } +div:has(#bannerMessage) + .readerApp.singlePanel .mobileNavMenu { + position: fixed; /*This takes the 60px of the header plus 120px of the banner into account */ + height: calc(100vh - 180px); + top: 180px; +} +@supports not selector(:has(a, b)) { + /* Fallback for when :has() is unsupported */ + .mobileNavMenu { + position: absolute; + } +} + .mobileNavMenu.closed { display: none; } @@ -701,7 +713,7 @@ input.noselect { color: #999; } .ui-autocomplete .ui-menu-item.search-override { - border-top: solid 1px #ccc; + border-bottom: solid 1px #ccc; padding-top: 12px; } .ui-autocomplete .ui-menu-item.hebrew-result a { @@ -751,6 +763,7 @@ input.noselect { } .loggedIn .help img { height: 24px; + margin-bottom: 3px; } .accountLinks.anon .help { margin-top: 6px; @@ -2277,6 +2290,71 @@ div.interfaceLinks-row a { font-family: "Taamey Frank", "adobe-garamond-pro", "Crimson Text", Georgia, "Times New Roman", serif; margin-bottom: -3px; } +.imageWithCaptionPhoto{ + border: 1px solid #EDEDEC; + max-width: 100%; + height: auto; + padding: 0 44; + top: 121px; + left: 835px; +} +.imageCaption .int-en { + font-family: Roboto; +} +.imageCaption .int-he { + font-family: Roboto; + } +.imageCaption { + font-size: 12px; + font-weight: 400; + line-height: 15px; + letter-spacing: 0em; + color: var(--dark-grey); + width: 100%; +} +.topicImage{ + padding-left: 44px; + padding-right: 44px; +} +.navSidebarModule .portalMobile .button { + margin-top: 0; +} +.portalTopicImageWrapper { + padding-top: 5px; + margin-bottom: 25px; +} +.portalTopicImageWrapper .topicImage { + padding-left: 0; + padding-right: 0; +} +@media (max-width: 600px) { + .imageWithCaptionPhoto{ + height: auto; + max-width: calc(66.67vw); + max-height: calc(66.67vw); + margin-bottom: 10px; + } + .topicImage{ + padding-left: 0; + padding-right: 0; + margin-top: 30px; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + } + .imageCaption { + font-size: 12px; + font-weight: 400; + line-height: 15px; + letter-spacing: 0em; + color: var(--dark-grey); + width: 80vw; + margin-bottom: 30px; + text-align: center; + } + +} .readerPanel .translationsPage h2 { margin: 40px 0 0 0; font-size: 24px; @@ -2333,7 +2411,6 @@ h1 .languageToggle .he { } .authorIndexTitle { margin-top: 30px; - margin-bottom: 17px; } .sectionTitleText.authorIndexTitle .int-en { text-transform: none; @@ -4446,6 +4523,7 @@ body .ui-autocomplete.dictionary-toc-autocomplete .ui-menu-item a.ui-state-focus } .readerControls.transLangPrefSuggBann { background-color: #EDEDEC; + z-index: 99; } .readerControls .readerControlsInner.transLangPrefSuggBannInner { justify-content: center; @@ -4632,11 +4710,20 @@ body .ui-autocomplete.dictionary-toc-autocomplete .ui-menu-item a.ui-state-focus width: 40px; height: 56px; line-height: 56px; - color: #999; + color: #666666; font-size: 20px; display: inline-block; cursor: pointer; } +.readerOptions .int-en { + margin-right: 4px; +} +.readerOptions .int-he { + margin-left: 8px; +} +.readerOptions .int-he img { + height: 18px; +} .rightButtons .readerOptions { vertical-align: middle; } @@ -5345,7 +5432,7 @@ But not to use a display block directive that might break continuous mode for ot } .segment .segmentNumber, .textRagnge .numberLabel { - color: #666; + color: #000; top: 0; } .dark .segment .segmentNumber, @@ -6996,6 +7083,7 @@ But not to use a display block directive that might break continuous mode for ot justify-content: center; align-items: center; flex-direction: column; + margin-bottom: 4px; } .profile-page { background-color: var(--lightest-grey); @@ -10950,6 +11038,11 @@ body .homeFeedWrapper .content { border: 0; margin: 0; } + .topicPanel .navSidebar { + width: unset; + border-top: 30px solid #FBFBFA; + margin: 0; + } } .sideColumn .topicSideColumn { margin-bottom: 20px; diff --git a/static/icons/bookmark-filled.svg b/static/icons/bookmark-filled.svg index b3c5cabfef..bb38f47693 100644 --- a/static/icons/bookmark-filled.svg +++ b/static/icons/bookmark-filled.svg @@ -1,3 +1,3 @@ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> -<path d="M19 0H5V24L12 19.3896L19 24V0Z" fill="#999999"/> +<path d="M19 0H5V24L12 19.3896L19 24V0Z" fill="#666666"/> </svg> diff --git a/static/icons/bookmark.svg b/static/icons/bookmark.svg index 3638245685..84eaf9cf14 100644 --- a/static/icons/bookmark.svg +++ b/static/icons/bookmark.svg @@ -1,3 +1,3 @@ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> -<path d="M17.0556 1.75V20.1343L12 17.0612L6.94444 20.1343V1.75H17.0556ZM19 0H5V24L12 19.3896L19 24V0Z" fill="#999999"/> +<path d="M17.0556 1.75V20.1343L12 17.0612L6.94444 20.1343V1.75H17.0556ZM19 0H5V24L12 19.3896L19 24V0Z" fill="#666666"/> </svg> diff --git a/static/img/headshots/nissa.jpg b/static/img/headshots/nissa.jpg deleted file mode 100644 index fd5a3f2407..0000000000 Binary files a/static/img/headshots/nissa.jpg and /dev/null differ diff --git a/static/img/headshots/yedida.png b/static/img/headshots/yedida.png new file mode 100644 index 0000000000..9098f6e9be Binary files /dev/null and b/static/img/headshots/yedida.png differ diff --git a/static/img/lang_icon_english.svg b/static/img/lang_icon_english.svg new file mode 100644 index 0000000000..c30df8b811 --- /dev/null +++ b/static/img/lang_icon_english.svg @@ -0,0 +1,3 @@ +<svg width="17" height="16" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M11.6162 13.6672C11.8047 14.2327 11.9225 14.6568 11.9225 14.9161C11.9225 15.1517 11.5926 15.2931 11.0978 15.3402L10.4144 15.4109C10.2495 15.5287 10.273 15.9057 10.4615 16C11.1449 15.9764 12.0403 15.9293 13.1714 15.9293C14.2318 15.9293 15.0565 15.9529 16.0227 16C16.1405 15.9057 16.1876 15.5287 16.0227 15.4109L15.3864 15.3402C14.3732 15.2224 14.0668 14.8689 13.3835 12.8424L10.1552 3.36966C9.80175 2.33284 9.42472 1.2489 9.11839 0.212077C9.07126 0.0471289 8.90631 0 8.76493 0C8.50572 0.306333 8.10513 0.730486 7.5396 0.895436C7.61029 1.27246 7.4689 1.72018 7.13901 2.56848L4.73547 8.93078C3.74578 11.57 3.08599 13.1016 2.66183 14.1856C2.33194 15.0339 1.81353 15.2931 1.24799 15.3402L0.470377 15.4109C0.328993 15.5287 0.376121 15.9057 0.517505 16C1.15373 15.9764 2.00204 15.9293 2.77966 15.9293C3.88717 15.9529 4.61765 15.9529 5.23032 16C5.44239 15.9057 5.44239 15.5287 5.27745 15.4109L4.49983 15.3402C3.93429 15.2931 3.81647 15.1517 3.81647 14.8689C3.81647 14.6097 3.98142 13.9735 4.33488 12.9131L5.15963 10.4389C5.32457 9.94404 5.46596 9.87334 6.05506 9.87334H9.61323C10.3202 9.87334 10.4144 9.94404 10.5794 10.4624L11.6162 13.6672ZM7.39821 4.2651C7.65742 3.55817 7.89306 2.99264 7.96375 2.87482H8.01088C8.15226 3.15758 8.36434 3.79381 8.55285 4.35935L9.82531 8.2003C10.0374 8.83652 9.99026 8.93078 9.3069 8.93078H6.40852C5.74873 8.93078 5.72516 8.90722 5.91368 8.36524L7.39821 4.2651Z" fill="#666666"/> +</svg> diff --git a/static/img/lang_icon_hebrew.svg b/static/img/lang_icon_hebrew.svg new file mode 100644 index 0000000000..a5a410036d --- /dev/null +++ b/static/img/lang_icon_hebrew.svg @@ -0,0 +1,3 @@ +<svg width="12" height="16" viewBox="0 0 12 16" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M9.74648 1.35037C10.712 1.70345 11.1963 2.33527 11.1963 3.25203C11.156 4.10664 10.8341 4.92262 10.2825 5.56872H10.0511V5.05459C10.062 4.92522 10.031 4.79578 9.96273 4.68602C9.90486 4.6086 9.77085 4.56833 9.56069 4.56833C9.1681 5.16364 8.83545 5.79763 8.56777 6.4607C8.38488 6.93678 8.2879 7.44248 8.28147 7.95354C8.28424 8.66034 8.5422 9.34149 9.00636 9.8676L10.2673 11.3511C10.9243 12.0361 11.3047 12.9468 11.3333 13.9032C11.3301 14.304 11.2409 14.6992 11.072 15.0613C10.9032 15.4235 10.6587 15.7438 10.3556 16H9.99015C9.9826 15.3339 9.72932 14.695 9.28048 14.2098L3.36863 7.30314C2.35337 7.8214 1.84574 8.48729 1.84574 9.30081C1.85162 9.63687 1.94065 9.96601 2.10463 10.2578C2.28154 10.5844 2.49516 10.8889 2.7412 11.1653L3.49351 12.0108C3.74034 12.2938 3.95303 12.6057 4.12703 12.94C4.29117 13.2374 4.38013 13.5715 4.38592 13.9125C4.38101 14.3735 4.22204 14.8191 3.93514 15.1762C3.83256 15.3419 3.69368 15.4812 3.52936 15.5833C3.36504 15.6854 3.17972 15.7474 2.98791 15.7646H0.368539V15.3744C0.913733 15.2288 1.18481 14.9098 1.18481 14.4173C1.18481 14.1696 0.989877 13.5656 0.593926 12.6086C0.197975 11.6516 0 10.9547 0 10.5304C0 9.08504 0.950282 7.81417 2.85085 6.71777L1.18785 4.78823C0.546117 4.10172 0.176262 3.19842 0.149243 2.25165C0.161747 1.40736 0.494576 0.600638 1.0782 0L1.42847 0C1.41566 0.714047 1.66673 1.407 2.13204 1.94193L7.26722 7.86372C7.49975 6.64711 7.96659 5.48927 8.64087 4.45683C7.28651 4.24003 6.60933 3.39244 6.60933 1.91405C6.5943 1.23208 6.79034 0.562546 7.16976 0L7.52307 0C7.57899 0.261827 7.70779 0.501774 7.8941 0.69123C8.08041 0.880686 8.31638 1.01166 8.57386 1.06853L9.74648 1.35037Z" fill="#666666"/> +</svg> diff --git a/static/js/AdminEditor.jsx b/static/js/AdminEditor.jsx index 7061b4b7bf..5c51c32ea1 100644 --- a/static/js/AdminEditor.jsx +++ b/static/js/AdminEditor.jsx @@ -183,6 +183,11 @@ const AdminEditor = ({title, data, close, catMenu, updateData, savingStatus, {obj} </div>; } + const confirmDelete = () => { + if (confirm("Are you sure you want to delete?")) { + deleteObj(); + } + } return <div className="editTextInfo"> <div className="static"> @@ -202,7 +207,7 @@ const AdminEditor = ({title, data, close, catMenu, updateData, savingStatus, })} {extras} {!isNew && - <div onClick={deleteObj} id="deleteTopic" className="button small deleteTopic" tabIndex="0" + <div onClick={confirmDelete} id="deleteTopic" className="button small deleteTopic" tabIndex="0" role="button"> <InterfaceText>Delete</InterfaceText> </div>} diff --git a/static/js/CategoryEditor.jsx b/static/js/CategoryEditor.jsx index 5cfafa8180..61f5b2c80e 100644 --- a/static/js/CategoryEditor.jsx +++ b/static/js/CategoryEditor.jsx @@ -22,7 +22,7 @@ const Reorder = ({subcategoriesAndBooks, updateOrder, displayType, updateParentC const clickHandler = (dir, child) => { const index = subcategoriesAndBooks.indexOf(child); let index_to_swap = -1; - if (dir === 'down' && index < subcategoriesAndBooks.length) + if (dir === 'down' && index < subcategoriesAndBooks.length - 1) { index_to_swap = index + 1; } diff --git a/static/js/Footer.jsx b/static/js/Footer.jsx index a5b2e6d6ae..ad341725f3 100644 --- a/static/js/Footer.jsx +++ b/static/js/Footer.jsx @@ -2,7 +2,8 @@ import React from 'react'; import Sefaria from './sefaria/sefaria'; import PropTypes from'prop-types'; import $ from './sefaria/sefariaJquery'; -import { InterfaceText, NewsletterSignUpForm, DonateLink } from './Misc'; +import { InterfaceText, DonateLink } from './Misc'; +import {NewsletterSignUpForm} from "./NewsletterSignUpForm"; import Component from 'react-class'; const Section = ({en, he, children}) => ( diff --git a/static/js/Header.jsx b/static/js/Header.jsx index e2b87d861a..f252297076 100644 --- a/static/js/Header.jsx +++ b/static/js/Header.jsx @@ -234,7 +234,7 @@ class SearchBar extends Component { }); if (comps.length > 0) { const q = `${this._searchOverridePre}${request.term}${this._searchOverridePost}`; - response(comps.concat([{value: "SEARCH_OVERRIDE", label: q, type: "search"}])); + response([{value: "SEARCH_OVERRIDE", label: q, type: "search"}].concat(comps)); } else { response([]) } @@ -306,8 +306,14 @@ class SearchBar extends Component { Sefaria.getName(query) .then(d => { // If the query isn't recognized as a ref, but only for reasons of capitalization. Resubmit with recognizable caps. - if (Sefaria.isACaseVariant(query, d)) { - this.submitSearch(Sefaria.repairCaseVariant(query, d)); + const repairedCaseVariant = Sefaria.repairCaseVariant(query, d); + if (repairedCaseVariant !== query) { + this.submitSearch(repairedCaseVariant); + return; + } + const repairedQuery = Sefaria.repairGershayimVariant(query, d); + if (repairedQuery !== query) { + this.submitSearch(repairedQuery); return; } @@ -326,7 +332,6 @@ class SearchBar extends Component { } else if (d["type"] === "Person" || d["type"] === "Collection" || d["type"] === "TocCategory") { this.redirectToObject(d["type"], d["key"]); - } else { Sefaria.track.event("Search", "Search Box Search", query); this.closeSearchAutocomplete(); diff --git a/static/js/Misc.jsx b/static/js/Misc.jsx index 367a778584..4e591c3ee8 100644 --- a/static/js/Misc.jsx +++ b/static/js/Misc.jsx @@ -1342,10 +1342,18 @@ class CloseButton extends Component { class DisplaySettingsButton extends Component { render() { - var style = this.props.placeholder ? {visibility: "hidden"} : {}; - var icon = Sefaria._siteSettings.TORAH_SPECIFIC ? - <img src="/static/img/ayealeph.svg" alt="Toggle Reader Menu Display Settings" style={style} /> : - <span className="textIcon">Aa</span>; + let style = this.props.placeholder ? {visibility: "hidden"} : {}; + let icon; + + if (Sefaria._siteSettings.TORAH_SPECIFIC) { + icon = + <InterfaceText> + <EnglishText> <img src="/static/img/lang_icon_english.svg" alt="Toggle Reader Menu Display Settings"/></EnglishText> + <HebrewText><img src="/static/img/lang_icon_hebrew.svg" alt="Toggle Reader Menu Display Settings"/></HebrewText> + </InterfaceText>; + } else { + icon = <span className="textIcon">Aa</span>; + } return (<a className="readerOptions" tabIndex="0" @@ -1884,145 +1892,6 @@ Note.propTypes = { isMyNote: PropTypes.bool, editNote: PropTypes.func }; -function NewsletterSignUpForm(props) { - const {contextName, includeEducatorOption} = props; - const [email, setEmail] = useState(''); - const [firstName, setFirstName] = useState(''); - const [lastName, setLastName] = useState(''); - const [educatorCheck, setEducatorCheck] = useState(false); - const [subscribeMessage, setSubscribeMessage] = useState(null); - const [showNameInputs, setShowNameInputs] = useState(false); - - function handleSubscribeKeyUp(e) { - if (e.keyCode === 13) { - handleSubscribe(); - } - } - - function handleSubscribe() { - if (showNameInputs === true) { // submit - if (firstName.length > 0 & lastName.length > 0) { - setSubscribeMessage("Subscribing..."); - const request = new Request( - '/api/subscribe/'+email, - {headers: {'X-CSRFToken': Cookies.get('csrftoken')}, - 'Content-Type': 'application/json'} - ); - fetch(request, - { - method: "POST", - mode: 'same-origin', - credentials: 'same-origin', - body: JSON.stringify({ - language: Sefaria.interfaceLang === "hebrew" ? "he" : "en", - educator: educatorCheck, - firstName: firstName, - lastName: lastName - }) - } - ).then(res => { - if ("error" in res) { - setSubscribeMessage(res.error); - setShowNameInputs(false); - } else { - setSubscribeMessage("Subscribed! Welcome to our list."); - Sefaria.track.event("Newsletter", "Subscribe from " + contextName, ""); - } - }).catch(data => { - setSubscribeMessage("Sorry, there was an error."); - setShowNameInputs(false); - }); - } else { - setSubscribeMessage("Please enter a valid first and last name");// get he copy - } - } else if (Sefaria.util.isValidEmailAddress(email)) { - setShowNameInputs(true); - } else { - setShowNameInputs(false); - setSubscribeMessage("Please enter a valid email address."); - } - } - - return ( - <div className="newsletterSignUpBox"> - <span className="int-en"> - <input - className="newsletterInput" - placeholder="Sign up for Newsletter" - value={email} - onChange={e => setEmail(e.target.value)} - onKeyUp={handleSubscribeKeyUp}/> - </span> - <span className="int-he"> - <input - className="newsletterInput" - placeholder="הרשמו לניוזלטר" - value={email} - onChange={e => setEmail(e.target.value)} - onKeyUp={handleSubscribeKeyUp}/> - </span> - {!showNameInputs ? <img src="/static/img/circled-arrow-right.svg" onClick={handleSubscribe}/> : null} - {showNameInputs ? - <><span className="int-en"> - <input - className="newsletterInput firstNameInput" - placeholder="First Name" - value={firstName} - autoFocus - onChange={e => setFirstName(e.target.value)} - onKeyUp={handleSubscribeKeyUp}/> - </span> - <span className="int-he"> - <input - className="newsletterInput firstNameInput" - placeholder="שם פרטי" - value={firstName} - onChange={e => setFirstName(e.target.value)} - onKeyUp={handleSubscribeKeyUp}/> - </span> - <span className="int-en"> - <input - className="newsletterInput" - placeholder="Last Name" - value={lastName} - onChange={e => setLastName(e.target.value)} - onKeyUp={handleSubscribeKeyUp}/> - </span> - <span className="int-he"> - <input - className="newsletterInput" - placeholder="שם משפחה" - value={lastName} - onChange={e => setLastName(e.target.value)} - onKeyUp={handleSubscribeKeyUp}/> - </span> - <div className="newsletterEducatorOption"> - <span className="int-en"> - <input - type="checkbox" - className="educatorNewsletterInput" - checked={educatorCheck} - onChange={e => setEducatorCheck(!!e.target.checked)}/> - <span> I am an educator</span> - </span> - <span className="int-he"> - <input - type="checkbox" - className="educatorNewsletterInput" - checked={educatorCheck} - onChange={e => setEducatorCheck(!!e.target.checked)}/> - <span> מורים/ אנשי הוראה</span> - </span> - <img src="/static/img/circled-arrow-right.svg" onClick={handleSubscribe}/> - </div> - </> - : null} - {subscribeMessage ? - <div className="subscribeMessage">{Sefaria._(subscribeMessage)}</div> - : null} - </div> - ); -} class LoginPrompt extends Component { @@ -2217,8 +2086,16 @@ const InterruptingMessage = ({ shouldShowModal = true; else if (noUserKindIsSet) shouldShowModal = true; if (!shouldShowModal) return false; - + // Don't show the modal on pages where the button link goes to since you're already there const excludedPaths = ["/donate", "/mobile", "/app", "/ways-to-give"]; + if (strapi.modal.buttonURL) { + if (strapi.modal.buttonURL.en) { + excludedPaths.push(new URL(strapi.modal.buttonURL.en).pathname); + } + if (strapi.modal.buttonURL.he) { + excludedPaths.push(new URL(strapi.modal.buttonURL.he).pathname); + } + } return excludedPaths.indexOf(window.location.pathname) === -1; }; @@ -2377,6 +2254,15 @@ const Banner = ({ onClose }) => { if (!shouldShowBanner) return false; const excludedPaths = ["/donate", "/mobile", "/app", "/ways-to-give"]; + // Don't show the banner on pages where the button link goes to since you're already there + if (strapi.banner.buttonURL) { + if (strapi.banner.buttonURL.en) { + excludedPaths.push(new URL(strapi.banner.buttonURL.en).pathname); + } + if (strapi.banner.buttonURL.he) { + excludedPaths.push(new URL(strapi.banner.buttonURL.he).pathname); + } + } return excludedPaths.indexOf(window.location.pathname) === -1; }; @@ -2426,18 +2312,29 @@ const Banner = ({ onClose }) => { <a className="button white int-en" href={strapi.banner.buttonURL.en} + onClick={() => { + closeBanner("banner_button_clicked"); + }} > <span>{strapi.banner.buttonText.en}</span> </a> <a className="button white int-he" href={strapi.banner.buttonURL.he} + onClick={() => { + closeBanner("banner_button_clicked"); + }} > <span>{strapi.banner.buttonText.he}</span> </a> </div> </div> - <div id="bannerMessageClose" onClick={closeBanner}> + <div + id="bannerMessageClose" + onClick={() => { + closeBanner("close_clicked"); + }} + > × </div> </div> @@ -3272,7 +3169,33 @@ const Autocompleter = ({getSuggestions, showSuggestionsOnSelect, inputPlaceholde ) } +const ImageWithCaption = ({photoLink, caption }) => { + + return ( + <div> + <img class="imageWithCaptionPhoto" src={photoLink}/> + <div class="imageCaption"> + <InterfaceText text={caption} /> + </div> + </div>); +} + +const AppStoreButton = ({ platform, href, altText }) => { + const isIOS = platform === 'ios'; + const aClasses = classNames({button: 1, small: 1, white: 1, appButton: 1, ios: isIOS}); + const iconSrc = `/static/icons/${isIOS ? 'ios' : 'android'}.svg`; + const text = isIOS ? 'iOS' : 'Android'; + return ( + <a target="_blank" className={aClasses} href={href}> + <img src={iconSrc} alt={altText} /> + <InterfaceText>{text}</InterfaceText> + </a> + ); +}; + + export { + AppStoreButton, CategoryHeader, SimpleInterfaceBlock, DangerousInterfaceBlock, @@ -3305,7 +3228,6 @@ export { LoadingRing, LoginPrompt, NBox, - NewsletterSignUpForm, Note, ProfileListing, ProfilePic, @@ -3336,5 +3258,6 @@ export { CategoryChooser, TitleVariants, requestWithCallBack, - OnInView + OnInView, + ImageWithCaption }; diff --git a/static/js/NavSidebar.jsx b/static/js/NavSidebar.jsx index 83643d7ca5..69976798fe 100644 --- a/static/js/NavSidebar.jsx +++ b/static/js/NavSidebar.jsx @@ -1,8 +1,8 @@ import React, { useState, useEffect } from 'react'; import classNames from 'classnames'; -import PropTypes from 'prop-types'; import Sefaria from './sefaria/sefaria'; -import {DonateLink, EnglishText, HebrewText, NewsletterSignUpForm} from './Misc' +import {AppStoreButton, DonateLink, EnglishText, HebrewText, ImageWithCaption} from './Misc' +import {NewsletterSignUpForm} from "./NewsletterSignUpForm"; import {InterfaceText, ProfileListing, Dropdown} from './Misc'; import { Promotions } from './Promotions' @@ -50,6 +50,10 @@ const Modules = ({type, props}) => { "WhoToFollow": WhoToFollow, "Image": Image, "Wrapper": Wrapper, + "PortalAbout": PortalAbout, + "PortalMobile": PortalMobile, + "PortalOrganization": PortalOrganization, + "PortalNewsletter": PortalNewsletter, }; if (!type) { return null; } const ModuleType = moduleTypes[type]; @@ -486,12 +490,11 @@ const AboutTopics = ({hideTitle}) => ( <ModuleTitle>About Topics</ModuleTitle> } <InterfaceText> <HebrewText> -בדפי הנושא מלוקטים מקורות נבחרים ודפי מקורות של משתמשים על נושא מסוים. המקורות המופיעים בדפי הנושא נאספים ממאגרים קיימים של ספרות יהודית (דוגמת 'אספקלריא') ומתוך דפי מקורות פומביים של משתמשי ספריא. +דפי הנושא מציגים מקורות נבחרים מארון הספרים היהודי עבור אלפי נושאים. ניתן לדפדף לפי קטגוריה או לחפש לפי נושא ספציפי, ובסרגל הצד מוצגים הנושאים הפופולריים ביותר ואלה הקשורים אליהם. הקליקו ושוטטו בין הנושאים השונים כדי ללמוד עוד. </HebrewText> <EnglishText> - Topics bring you straight to selections of texts and user created source sheets about thousands of subjects. Sources that appear are drawn from existing indices of Jewish texts (like Aspaklaria) and from the sources our users include on their public source sheets. + Topics Pages present a curated selection of various genres of sources on thousands of chosen subjects. You can browse by category, search for something specific, or view the most popular topics — and related topics — on the sidebar. Explore and click through to learn more. </EnglishText> - </InterfaceText> </Module> ); @@ -577,14 +580,16 @@ const GetTheApp = () => ( <ModuleTitle>Get the Mobile App</ModuleTitle> <InterfaceText>Access the Jewish library anywhere and anytime with the</InterfaceText> <a href="/mobile" className="inTextLink"><InterfaceText>Sefaria mobile app.</InterfaceText></a> <br /> - <a target="_blank" className="button small white appButton ios" href="https://itunes.apple.com/us/app/sefaria/id1163273965?ls=1&mt=8"> - <img src="/static/icons/ios.svg" alt={Sefaria._("Sefaria app on IOS")} /> - <InterfaceText>iOS</InterfaceText> - </a> - <a target="_blank" className="button small white appButton" href="https://play.google.com/store/apps/details?id=org.sefaria.sefaria"> - <img src="/static/icons/android.svg" alt={Sefaria._("Sefaria app on Android")} /> - <InterfaceText>Android</InterfaceText> - </a> + <AppStoreButton + href="https://itunes.apple.com/us/app/sefaria/id1163273965?ls=1&mt=8" + platform='ios' + altText={Sefaria._("Sefaria app on IOS")} + /> + <AppStoreButton + href="https://play.google.com/store/apps/details?id=org.sefaria.sefaria" + platform='android' + altText={Sefaria._("Sefaria app on Android")} + /> </Module> ); @@ -792,6 +797,58 @@ const DownloadVersions = ({sref}) => { }; +const PortalAbout = ({title, description, image_uri, image_caption}) => { + return( + <Module> + <ModuleTitle en={title.en} he={title.he} /> + <div className="portalTopicImageWrapper"> + <ImageWithCaption photoLink={image_uri} caption={image_caption} /> + </div> + <InterfaceText markdown={{en: description.en, he: description.he}} /> + </Module> + ) +}; + + +const PortalMobile = ({title, description, android_link, ios_link}) => { + return( + <Module> + <div className="portalMobile"> + <ModuleTitle en={title.en} he={title.he} /> + {description && <InterfaceText markdown={{en: description.en, he: description.he}} />} + <AppStoreButton href={ios_link} platform={'ios'} altText='Steinsaltz app on iOS' /> + <AppStoreButton href={android_link} platform={'android'} altText='Steinsaltz app on Android' /> + </div> + </Module> + ) +}; +const PortalOrganization = ({title, description}) => { + return( + <Module> + <ModuleTitle en={title.en} he={title.he} /> + {description && <InterfaceText markdown={{en: description.en, he: description.he}} />} + </Module> + ) +}; + + +const PortalNewsletter = ({title, description}) => { + let titleElement = <ModuleTitle en={title.en} he={title.he} />; + + return( + <Module> + {titleElement} + <InterfaceText markdown={{en: description.en, he: description.he}} /> + <NewsletterSignUpForm + includeEducatorOption={false} + emailPlaceholder={{en: "Email Address", he: "כתובת מייל"}} + subscribe={Sefaria.subscribeSefariaAndSteinsaltzNewsletter} + /> + </Module> + ) +}; + + export { NavSidebar, Modules, diff --git a/static/js/NewsletterSignUpForm.jsx b/static/js/NewsletterSignUpForm.jsx new file mode 100644 index 0000000000..e705109347 --- /dev/null +++ b/static/js/NewsletterSignUpForm.jsx @@ -0,0 +1,132 @@ +import React, {useState} from 'react'; +import Sefaria from './sefaria/sefaria'; + +export function NewsletterSignUpForm({ + contextName, + includeEducatorOption = true, + emailPlaceholder = {en: 'Sign up for Newsletter', he: "הרשמו לניוזלטר"}, + subscribe=Sefaria.subscribeSefariaNewsletter, // function which sends form data to API to subscribe + }) { + const [email, setEmail] = useState(''); + const [firstName, setFirstName] = useState(''); + const [lastName, setLastName] = useState(''); + const [educatorCheck, setEducatorCheck] = useState(false); + const [subscribeMessage, setSubscribeMessage] = useState(null); + const [showNameInputs, setShowNameInputs] = useState(false); + + function handleSubscribeKeyUp(e) { + if (e.keyCode === 13) { + handleSubscribe(); + } + } + + function handleSubscribe() { + if (showNameInputs === true) { // submit + if (firstName.length > 0 && lastName.length > 0) { + setSubscribeMessage("Subscribing..."); + subscribe(firstName, lastName, email, educatorCheck).then(res => { + setSubscribeMessage("Subscribed! Welcome to our list."); + Sefaria.track.event("Newsletter", "Subscribe from " + contextName, ""); + }).catch(error => { + setSubscribeMessage(error?.error || "Sorry, there was an error."); + setShowNameInputs(false); + }); + } else { + setSubscribeMessage("Please enter a valid first and last name");// get he copy + } + } else if (Sefaria.util.isValidEmailAddress(email)) { + setShowNameInputs(true); + } else { + setShowNameInputs(false); + setSubscribeMessage("Please enter a valid email address."); + } + } + + return ( + <div className="newsletterSignUpBox"> + <span className="int-en"> + <input + className="newsletterInput" + placeholder={emailPlaceholder.en} + value={email} + onChange={e => setEmail(e.target.value)} + onKeyUp={handleSubscribeKeyUp}/> + </span> + <span className="int-he"> + <input + className="newsletterInput" + placeholder={emailPlaceholder.he} + value={email} + onChange={e => setEmail(e.target.value)} + onKeyUp={handleSubscribeKeyUp}/> + </span> + {!showNameInputs ? <img src="/static/img/circled-arrow-right.svg" onClick={handleSubscribe}/> : null} + {showNameInputs ? + <><span className="int-en"> + <input + className="newsletterInput firstNameInput" + placeholder="First Name" + value={firstName} + autoFocus + onChange={e => setFirstName(e.target.value)} + onKeyUp={handleSubscribeKeyUp}/> + </span> + <span className="int-he"> + <input + className="newsletterInput firstNameInput" + placeholder="שם פרטי" + value={firstName} + onChange={e => setFirstName(e.target.value)} + onKeyUp={handleSubscribeKeyUp}/> + </span> + <span className="int-en"> + <input + className="newsletterInput" + placeholder="Last Name" + value={lastName} + onChange={e => setLastName(e.target.value)} + onKeyUp={handleSubscribeKeyUp}/> + </span> + <span className="int-he"> + <input + className="newsletterInput" + placeholder="שם משפחה" + value={lastName} + onChange={e => setLastName(e.target.value)} + onKeyUp={handleSubscribeKeyUp}/> + </span> + {includeEducatorOption ? + <EducatorCheckbox educatorCheck={educatorCheck} setEducatorCheck={setEducatorCheck}/> : null} + <img src="/static/img/circled-arrow-right.svg" onClick={handleSubscribe}/> + </> + : null} + {subscribeMessage ? + <div className="subscribeMessage">{Sefaria._(subscribeMessage)}</div> + : null} + </div> + ); +} + + +const EducatorCheckbox = ({educatorCheck, setEducatorCheck}) => { + return ( + <div className="newsletterEducatorOption"> + <span className="int-en"> + <input + type="checkbox" + className="educatorNewsletterInput" + checked={educatorCheck} + onChange={e => setEducatorCheck(!!e.target.checked)}/> + <span> I am an educator</span> + </span> + <span className="int-he"> + <input + type="checkbox" + className="educatorNewsletterInput" + checked={educatorCheck} + onChange={e => setEducatorCheck(!!e.target.checked)}/> + <span> מורים/ אנשי הוראה</span> + </span> + </div> + ); +}; diff --git a/static/js/StaticPages.jsx b/static/js/StaticPages.jsx index 4b2bc394d0..0af80e9a67 100644 --- a/static/js/StaticPages.jsx +++ b/static/js/StaticPages.jsx @@ -1,11 +1,11 @@ import React, {useState, useRef} from 'react'; import { SimpleInterfaceBlock, - NewsletterSignUpForm, TwoOrThreeBox, ResponsiveNBox, NBox, InterfaceText, } from './Misc'; +import {NewsletterSignUpForm} from "./NewsletterSignUpForm"; import palette from './sefaria/palette'; import classNames from 'classnames'; import Cookies from 'js-cookie'; diff --git a/static/js/TopicPage.jsx b/static/js/TopicPage.jsx index dc6293d958..5d767d83a2 100644 --- a/static/js/TopicPage.jsx +++ b/static/js/TopicPage.jsx @@ -23,7 +23,9 @@ import { ToolTipped, SimpleLinkedBlock, CategoryHeader, + ImageWithCaption } from './Misc'; +import {ContentText} from "./ContentText"; /* @@ -193,7 +195,6 @@ const sheetRenderWrapper = (toggleSignUpModal) => item => ( - const TopicCategory = ({topic, topicTitle, setTopic, setNavTopic, compare, initialWidth, openDisplaySettings, openSearch}) => { const [topicData, setTopicData] = useState(Sefaria.getTopicFromCache(topic) || {primaryTitle: topicTitle}); @@ -311,12 +312,15 @@ const TopicSponsorship = ({topic_slug}) => { ); } -const TopicHeader = ({ topic, topicData, multiPanel, isCat, setNavTopic, openDisplaySettings, openSearch }) => { +const TopicHeader = ({ topic, topicData, topicTitle, multiPanel, isCat, setNavTopic, openDisplaySettings, openSearch, topicImage }) => { const { en, he } = !!topicData && topicData.primaryTitle ? topicData.primaryTitle : {en: "Loading...", he: "טוען..."}; const isTransliteration = !!topicData ? topicData.primaryTitleIsTransliteration : {en: false, he: false}; const category = !!topicData ? Sefaria.topicTocCategory(topicData.slug) : null; + + const tpTopImg = !multiPanel && topicImage ? <TopicImage photoLink={topicImage.image_uri} caption={topicImage.image_caption}/> : null; return ( <div> + <div className="navTitle tight"> <CategoryHeader type="topics" data={topicData} buttonsToDisplay={["source", "edit", "reorder"]}> <h1> @@ -340,6 +344,7 @@ const TopicHeader = ({ topic, topicData, multiPanel, isCat, setNavTopic, openDis <InterfaceText markdown={{en: topicData.description.en, he: topicData.description.he}}/> </div> : null} + {tpTopImg} {topicData && topicData.ref ? <a href={`/${topicData.ref.url}`} className="resourcesLink button blue"> <img src="/static/icons/book-icon-black.svg" alt="Book Icon" /> @@ -351,13 +356,27 @@ const TopicHeader = ({ topic, topicData, multiPanel, isCat, setNavTopic, openDis <div> <div className="sectionTitleText authorIndexTitle"><InterfaceText>Works on Sefaria</InterfaceText></div> <div className="authorIndexList"> - {topicData.indexes.map(({text, url}) => <SimpleLinkedBlock key={url} {...text} url={url} classes="authorIndex" />)} + {topicData.indexes.map(({url, title, description}) => <AuthorIndexItem key={url} url={url} title={title} description={description}/>)} </div> </div> : null} </div> );} +const AuthorIndexItem = ({url, title, description}) => { + return ( + <div className="authorIndex" > + <a href={url} className="navBlockTitle"> + <ContentText text={title} defaultToInterfaceOnBilingual /> + </a> + <div className="navBlockDescription"> + <ContentText text={description} defaultToInterfaceOnBilingual /> + </div> + </div> + ); +}; + + const useTabDisplayData = (translationLanguagePreference) => { const getTabDisplayData = useCallback(() => [ { @@ -388,6 +407,26 @@ const useTabDisplayData = (translationLanguagePreference) => { return getTabDisplayData(); }; +const PortalNavSideBar = ({portal, entriesToDisplayList}) => { + const portalModuleTypeMap = { + "about": "PortalAbout", + "mobile": "PortalMobile", + "organization": "PortalOrganization", + "newsletter": "PortalNewsletter" + } + const modules = []; + for (let key of entriesToDisplayList) { + if (!portal[key]) { continue; } + modules.push({ + type: portalModuleTypeMap[key], + props: portal[key], + }); + } + return( + <NavSidebar modules={modules} /> + ) +}; + const TopicPage = ({ tab, topic, topicTitle, setTopic, setNavTopic, openTopics, multiPanel, showBaseText, navHome, toggleSignUpModal, openDisplaySettings, setTab, openSearch, translationLanguagePreference, versionPref, @@ -399,8 +438,9 @@ const TopicPage = ({ const [refsToFetchByTab, setRefsToFetchByTab] = useState({}); const [parashaData, setParashaData] = useState(null); const [showFilterHeader, setShowFilterHeader] = useState(false); + const [portal, setPortal] = useState(null); const tabDisplayData = useTabDisplayData(translationLanguagePreference, versionPref); - + const topicImage = topicData.image; const scrollableElement = useRef(); const clearAndSetTopic = (topic, topicTitle) => {setTopic(topic, topicTitle)}; @@ -467,11 +507,40 @@ const TopicPage = ({ onClickFilterIndex = displayTabs.length - 1; } const classStr = classNames({topicPanel: 1, readerNavMenu: 1}); + let sidebar = null; + if (topicData) { + if (topicData.portal_slug) { + Sefaria.getPortal(topicData.portal_slug).then(setPortal); + if (portal) { + sidebar = <PortalNavSideBar portal={portal} entriesToDisplayList={["about"]}/> // "mobile", "organization", "newsletter"]}/> + } + } else { + sidebar = ( + <div className="sideColumn"> + <TopicSideColumn + key={topic} + slug={topic} + links={topicData.links} + clearAndSetTopic={clearAndSetTopic} + setNavTopic={setNavTopic} + parashaData={parashaData} + tref={topicData.ref} + timePeriod={topicData.timePeriod} + properties={topicData.properties} + topicTitle={topicTitle} + multiPanel={multiPanel} + topicImage={topicImage} + /> + {!topicData.isLoading && <Promotions/>} + </div> + ); + } + } return <div className={classStr}> <div className="content noOverflowX" ref={scrollableElement}> <div className="columnLayout"> <div className="mainColumn storyFeedInner"> - <TopicHeader topic={topic} topicData={topicData} multiPanel={multiPanel} setNavTopic={setNavTopic} openSearch={openSearch} openDisplaySettings={openDisplaySettings} /> + <TopicHeader topic={topic} topicData={topicData} topicTitle={topicTitle} multiPanel={multiPanel} setNavTopic={setNavTopic} openSearch={openSearch} openDisplaySettings={openDisplaySettings} topicImage={topicImage} /> {(!topicData.isLoading && displayTabs.length) ? <TabView currTabName={tab} @@ -515,24 +584,7 @@ const TopicPage = ({ </TabView> : (topicData.isLoading ? <LoadingMessage /> : null) } </div> - <div className="sideColumn"> - {topicData ? ( - <> - <TopicSideColumn - key={topic} - slug={topic} - links={topicData.links} - clearAndSetTopic={clearAndSetTopic} - setNavTopic={setNavTopic} - parashaData={parashaData} - tref={topicData.ref} - timePeriod={topicData.timePeriod} - properties={topicData.properties} - /> - {!topicData.isLoading && <Promotions/>} - </> - ) : null} - </div> + {sidebar} </div> <Footer /> </div> @@ -600,7 +652,7 @@ TopicLink.propTypes = { }; -const TopicSideColumn = ({ slug, links, clearAndSetTopic, parashaData, tref, setNavTopic, timePeriod, properties }) => { +const TopicSideColumn = ({ slug, links, clearAndSetTopic, parashaData, tref, setNavTopic, timePeriod, properties, topicTitle, multiPanel, topicImage }) => { const category = Sefaria.topicTocCategory(slug); const linkTypeArray = links ? Object.values(links).filter(linkType => !!linkType && linkType.shouldDisplay && linkType.links.filter(l => l.shouldDisplay !== false).length > 0) : []; if (linkTypeArray.length === 0) { @@ -620,7 +672,7 @@ const TopicSideColumn = ({ slug, links, clearAndSetTopic, parashaData, tref, set const readingsComponent = hasReadings ? ( <ReadingsComponent parashaData={parashaData} tref={tref} /> ) : null; - const topicMetaData = <TopicMetaData timePeriod={timePeriod} properties={properties} />; + const topicMetaData = <TopicMetaData timePeriod={timePeriod} properties={properties} topicTitle={topicTitle} multiPanel={multiPanel} topicImage={topicImage}/>; const linksComponent = ( links ? linkTypeArray.sort((a, b) => { @@ -705,6 +757,14 @@ const TopicSideSection = ({ title, children, hasMore }) => { ); } +const TopicImage = ({photoLink, caption }) => { + + return ( + <div class="topicImage"> + <ImageWithCaption photoLink={photoLink} caption={caption} /> + </div>); +} + const ReadingsComponent = ({ parashaData, tref }) => ( <div className="readings link-section"> @@ -760,7 +820,9 @@ const propKeys = [ {en: 'jeLink', he: 'jeLink', title: 'Jewish Encyclopedia'}, {en: 'enNliLink', he: 'heNliLink', title: 'National Library of Israel'}, ]; -const TopicMetaData = ({ timePeriod, properties={} }) => { + + +const TopicMetaData = ({ topicTitle, timePeriod, multiPanel, topicImage, properties={} }) => { const tpSection = !!timePeriod ? ( <TopicSideSection title={{en: "Lived", he: "תקופת פעילות"}}> <div className="systemText topicMetaData"><InterfaceText text={timePeriod.name} /></div> @@ -776,30 +838,33 @@ const TopicMetaData = ({ timePeriod, properties={} }) => { })); const hasProps = propValues.reduce((accum, curr) => accum || curr.url.en || curr.url.he, false); const propsSection = hasProps ? ( - <TopicSideSection title={{en: "Learn More", he: "לקריאה נוספת"}}> - { - propValues.map(propObj => { - let url, urlExists = true; - if (Sefaria.interfaceLang === 'hebrew') { - if (!propObj.url.he) { urlExists = false; } - url = propObj.url.he || propObj.url.en; - } else { - if (!propObj.url.en) { urlExists = false; } - url = propObj.url.en || propObj.url.he; - } - if (!url) { return null; } - return ( - <SimpleLinkedBlock - key={url} en={propObj.title + (urlExists ? "" : " (Hebrew)")} he={Sefaria._(propObj.title) + (urlExists ? "" : ` (${Sefaria._("English")})`)} - url={url} aclasses={"systemText topicMetaData"} openInNewTab - /> - ); - }) - } - </TopicSideSection> + <TopicSideSection title={{en: "Learn More", he: "לקריאה נוספת"}}> + { + propValues.map(propObj => { + let url, urlExists = true; + if (Sefaria.interfaceLang === 'hebrew') { + if (!propObj.url.he) { urlExists = false; } + url = propObj.url.he || propObj.url.en; + } else { + if (!propObj.url.en) { urlExists = false; } + url = propObj.url.en || propObj.url.he; + } + if (!url) { return null; } + return ( + <SimpleLinkedBlock + key={url} en={propObj.title + (urlExists ? "" : " (Hebrew)")} he={Sefaria._(propObj.title) + (urlExists ? "" : ` (${Sefaria._("English")})`)} + url={url} aclasses={"systemText topicMetaData"} openInNewTab + /> + ); + }) + } + </TopicSideSection> ) : null; + + const tpSidebarImg = multiPanel && topicImage ? <TopicImage photoLink={topicImage.image_uri} caption={topicImage.image_caption}/> : null; return ( <> + {tpSidebarImg} { tpSection } { propsSection } </> @@ -810,5 +875,6 @@ const TopicMetaData = ({ timePeriod, properties={} }) => { export { TopicPage, TopicCategory, - refSort + refSort, + TopicImage } diff --git a/static/js/sefaria/sefaria.js b/static/js/sefaria/sefaria.js index 196dea6430..3da5849570 100644 --- a/static/js/sefaria/sefaria.js +++ b/static/js/sefaria/sefaria.js @@ -9,8 +9,7 @@ import Track from './track'; import Hebrew from './hebrew'; import Util from './util'; import $ from './sefariaJquery'; -import {useContext} from "react"; -import {ContentLanguageContext} from "../context"; +import Cookies from 'js-cookie'; let Sefaria = Sefaria || { @@ -569,6 +568,64 @@ Sefaria = extend(Sefaria, { } return Promise.resolve(this._versions[ref]); }, + _portals: {}, + getPortal: async function(portalSlug) { + const cachedPortal = Sefaria._portals[portalSlug]; + if (cachedPortal) { + return cachedPortal; + } + const response = await this._ApiPromise(`${Sefaria.apiHost}/api/portals/${portalSlug}`); + Sefaria._portals[portalSlug] = response; + return response; + }, + subscribeSefariaNewsletter: async function(firstName, lastName, email, educatorCheck) { + const response = await fetch(`/api/subscribe/${email}`, + { + method: "POST", + mode: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': Cookies.get('csrftoken'), + }, + credentials: 'same-origin', + body: JSON.stringify({ + language: Sefaria.interfaceLang === "hebrew" ? "he" : "en", + educator: educatorCheck, + firstName: firstName, + lastName: lastName + }) + } + ); + if (!response.ok) { throw "error"; } + const json = await response.json(); + if (json.error) { throw json; } + return json; + }, + subscribeSteinsaltzNewsletter: async function(firstName, lastName, email) { + const response = await fetch(`/api/subscribe/steinsaltz/${email}`, + { + method: "POST", + mode: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': Cookies.get('csrftoken'), + }, + credentials: 'same-origin', + body: JSON.stringify({firstName, lastName}), + } + ); + if (!response.ok) { throw "error"; } + const json = await response.json(); + if (json.error) { throw json; } + return json; + }, + subscribeSefariaAndSteinsaltzNewsletter: async function(firstName, lastName, email, educatorCheck) { + const responses = await Promise.all([ + Sefaria.subscribeSefariaNewsletter(firstName, lastName, email, educatorCheck), + Sefaria.subscribeSteinsaltzNewsletter(firstName, lastName, email), + ]); + return {status: "ok"}; + }, filterVersionsObjByLangs: function(versionsObj, langs, includeFilter) { /** * @versionsObj {object} whode keys are language codes ('he', 'en' etc.) and values are version objects (like the object that getVersions returns) @@ -1944,8 +2001,36 @@ _media: {}, data["completions"][0] != query.slice(0, data["completions"][0].length)) }, repairCaseVariant: function(query, data) { - // Used when isACaseVariant() is true to prepare the alternative - return data["completions"][0] + query.slice(data["completions"][0].length); + if (Sefaria.isACaseVariant(query, data)) { + const completionArray = data["completion_objects"].map(x => x.title); + let normalizedQuery = query.toLowerCase(); + let bestMatch = ""; + let bestMatchLength = 0; + + completionArray.forEach((completion) => { + let normalizedCompletion = completion.toLowerCase(); + if (normalizedQuery.includes(normalizedCompletion) && normalizedCompletion.length > bestMatchLength) { + bestMatch = completion; + bestMatchLength = completion.length; + } + }); + return bestMatch + query.slice(bestMatch.length); + } + return query; + }, + repairGershayimVariant: function(query, data) { + if (!data["is_ref"] && data.completions && !data.completions.includes(query)) { + function normalize_gershayim(string) { + return string.replace('״', '"'); + } + const normalized_query = normalize_gershayim(query); + for (let c of data.completions) { + if (normalize_gershayim(c) === normalized_query) { + return c; + } + } + } + return query; }, makeSegments: function(data, withContext, sheets=false) { // Returns a flat list of annotated segment objects, diff --git a/templates/delete-sheet.html b/templates/delete-sheet.html new file mode 100644 index 0000000000..7ea7165ab1 --- /dev/null +++ b/templates/delete-sheet.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} +{% load i18n %} + +{% block title %}{% trans "Admin: Delete a Sheet" %} | {% trans "Sefaria" %}{% endblock %} + +{% block content %} + +<div id="deleteSheet" class="static"> + <div class="inner"> + <h1> + <span class="int-en">Delete Sheet</span> + </h1> + <h2> CAUTION THIS WILL DELETE THE SHEET ID YOU INPUT</h2> + <h3>IT CANNOT BE UNDONE.</h3> + <form method="post" autocomplete="off" if="delete-form" action="{% url "delete/sheet" %}"> + {% csrf_token %} + {{ form.as_p }} + <button type="submit" class="button control-elem"> + <span class="int-en">Delete Sheet</span> + </button> + </form> + </div> +</div> +{% endblock %} diff --git a/templates/static/en/team.html b/templates/static/en/team.html index 13196684eb..f3b57e7156 100644 --- a/templates/static/en/team.html +++ b/templates/static/en/team.html @@ -1,3 +1,4 @@ + {% extends "base.html" %} {% load i18n static %} @@ -109,6 +110,19 @@ <h1> </div> </div> </div> + <div class="teamMember"> + <div class="teamMemberImage"> + <img src="{% static 'img/headshots/yedida.png' %}" alt="Headshot of Yedida Eisenstat"> + </div> + <div class="teamMemberDetails"> + <div class="teamName"> + <span class="int-en">Yedida Eisenstat</span> + </div> + <div class="teamTitle"> + <span class="int-en">Project Manager</span> + </div> + </div> + </div> <div class="teamMember"> <div class="teamMemberImage"> <img src="{% static 'img/headshots/michael-f.png' %}" alt="Headshot of Michael Fankhauser"> @@ -131,7 +145,7 @@ <h1> <span class="int-en">Olivia Gerber</span> </div> <div class="teamTitle"> - <span class="int-en">Marketing Associate</span> + <span class="int-en">Marketing & Communications Project Manager</span> </div> </div> </div> @@ -278,19 +292,6 @@ <h1> </div> </div> </div> - <div class="teamMember"> - <div class="teamMemberImage"> - <img src="{% static 'img/headshots/nissa.jpg' %}" alt="Headshot of Nissa Mai-Rose"> - </div> - <div class="teamMemberDetails"> - <div class="teamName"> - <span class="int-en">Nissa Mai-Rose</span> - </div> - <div class="teamTitle"> - <span class="int-en">Sr. Software Engineer</span> - </div> - </div> - </div> <div class="teamMember"> <div class="teamMemberImage"> <img src="{% static 'img/headshots/Amanda_Minsky_Photo.png' %}" alt="Headshot of Amanda Minsky"> @@ -366,7 +367,7 @@ <h1> <span class="int-en">Elise Ringo</span> </div> <div class="teamTitle"> - <span class="int-en">Associate Digital Marketing Manager</span> + <span class="int-en">Database Administrator</span> </div> </div> </div> @@ -379,7 +380,7 @@ <h1> <span class="int-en">Shanee Rosen</span> </div> <div class="teamTitle"> - <span class="int-en">Sr. Software Engineer</span> + <span class="int-en">Sr. Software Engineer and Data Analytics Lead</span> </div> </div> </div> @@ -501,7 +502,6 @@ <h1> </div> </div> <div class="teamMember placeholder"></div> - <div class="teamMember placeholder"></div> </section> <header> <h2> diff --git a/templates/static/he/team.html b/templates/static/he/team.html index 45345dd75a..a34e881fa1 100644 --- a/templates/static/he/team.html +++ b/templates/static/he/team.html @@ -15,6 +15,19 @@ <h1> </header> <div class="row static-text"> <section class="main-text team-members"> + <div class="teamMember"> + <div class="teamMemberImage"> + <img src="{% static 'img/headshots/yedida.png' %}" alt="Headshot of Yedida Eisenstat"> + </div> + <div class="teamMemberDetails"> + <div class="teamName"> + <span class="int-he">ידידה אייזנשטט</span> + </div> + <div class="teamTitle"> + <span class="int-he">מנהלת פרוייקט</span> + </div> + </div> + </div> <div class="teamMember"> <div class="teamMemberImage"> <img src="{% static 'img/headshots/rachel.png' %}" alt="Headshot of Rachel Buckman"> @@ -89,7 +102,7 @@ <h1> <span class="int-he">אוליביה גרבר</span> </div> <div class="teamTitle"> - <span class="int-he">מנהלת שיווק</span> + <span class="int-he">מנהלת פרוייקטים - שיווק ותקשורת</span> </div> </div> </div> @@ -265,19 +278,6 @@ <h1> </div> </div> </div> - <div class="teamMember"> - <div class="teamMemberImage"> - <img src="{% static 'img/headshots/nissa.jpg' %}" alt="Headshot of Nissa Mai-Rose"> - </div> - <div class="teamMemberDetails"> - <div class="teamName sans"> - <span class="int-he">נסה מאי-רוז</span> - </div> - <div class="teamTitle"> - <span class="int-he">מהנדסת תוכנה בכירה</span> - </div> - </div> - </div> <div class="teamMember"> <div class="teamMemberImage"> <img src="{% static 'img/headshots/Amanda_Minsky_Photo.png' %}" alt="Headshot of Amanda Minsky"> @@ -443,7 +443,7 @@ <h1> <span class="int-he">שני רוזן</span> </div> <div class="teamTitle"> - <span class="int-he">מהנדסת תוכנה בכירה</span> + <span class="int-he">מהנדסת תוכנה בכירה וראש תחום דאטה</span> </div> </div> </div> @@ -456,7 +456,7 @@ <h1> <span class="int-he">יעל רינגו</span> </div> <div class="teamTitle"> - <span class="int-he">רכזת שיווק דיגיטלי</span> + <span class="int-he">מנהלת מאגר נתונים</span> </div> </div> </div> diff --git a/templates/static/jobs.html b/templates/static/jobs.html index 1f7d84ce39..0b341814f7 100644 --- a/templates/static/jobs.html +++ b/templates/static/jobs.html @@ -16,7 +16,7 @@ <h1 class="serif"> </h1> <!-- Comment out when jobs page has no content --> -<!-- <h2> + <!-- <h2> <span class="int-en">About Sefaria</span> <span class="int-he">אודות ספריא</span> </h2> @@ -31,7 +31,7 @@ <h1 class="serif"> ספריא היא ארגון ללא מטרות רווח שמטרתו יצירת הדור הבא של לימוד התורה באופן פתוח ומשותף. אנחנו בספריא מרכיבים ספרייה חיה וחופשית של טקסטים יהודיים וכלל הקישורים ביניהם, בשפת המקור ובתרגומים. </span> - </p> --> + </p> --> <!-- Comment out when jobs page has no content --> </header> @@ -52,12 +52,13 @@ <h2 class="anchorable">Engineering</h2> <section class="jobsListForDepartment"> </section> </section> - <section class="section department englishOnly"> + <section class="section department englishOnly"> <header> - <h2 class="anchorable">Marketing and Communications</h2> + <h2 class="anchorable">Learning</h2> </header> <section class="jobsListForDepartment"> - <div class="job"><a class="" target="_blank" href=""></a></div> + <div class="job"><a class="jobLink" target="_blank" href=""></a></div> + <div class="job"><a class="jobLink" target="_blank" href=""></a></div> </section> </section> <section class="section department englishOnly"> @@ -68,14 +69,10 @@ <h2 class="anchorable">HR and Operations</h2> <div class="job"><a class="" target="_blank" href=""></a></div> </section> </section> --> - <section class="section department englishOnly"> - <header> - <h2 class="anchorable">Learning</h2> - </header> - <section class="jobsListForDepartment"> - <div class="job"><a class="jobLink" target="_blank" href="https://sefaria.breezy.hr/p/c693c3ab1b78-hebrew-editorial-associate-part-time">Hebrew Editorial Associate (Part-Time)</a></div> - </section> - </section> + + + + <!-- <section class="section department englishOnly"> <header> @@ -94,7 +91,7 @@ <h2 class="anchorable">Israel Team</h2> </header> </section>---> - <!-- + <div class="section nothing"> <p> <span class="int-en"> @@ -109,7 +106,7 @@ <h2 class="anchorable">Israel Team</h2> </span> </p> </div> - --> + <aside class="solicitationNotice"> <p>