From 98784f83ce01047b7829d7ad873ac34edfd052cb Mon Sep 17 00:00:00 2001 From: "D. Ror" Date: Wed, 6 Sep 2023 16:28:57 -0400 Subject: [PATCH] Frontend font support and font deployment (#2493) Co-authored-by: Jim Grady --- .gitignore | 6 + .vscode/settings.json | 7 +- Dockerfile | 6 +- deploy/helm/aws-login/values.yaml | 2 +- .../templates/deployment-frontend.yaml | 22 +- .../templates/env-frontend-configmap.yaml | 4 +- .../thecombine/charts/frontend/values.yaml | 4 +- .../templates/cronjob-daily-backup.yaml | 2 +- .../templates/cronjob-weekly-get-fonts.yaml | 49 +++ .../templates/deployment-maintenance.yaml | 24 ++ .../templates/env-maintenance-configmap.yaml | 2 + .../maintenance/templates/get-fonts-hook.yaml | 55 +++ .../thecombine/charts/maintenance/values.yaml | 9 + .../font-storage-persistent-volume-claim.yaml | 13 + deploy/helm/thecombine/values.yaml | 4 + deploy/scripts/setup_combine.py | 40 +- .../scripts/setup_files/combine_config.yaml | 18 +- deploy/scripts/setup_files/profiles/nuc.yaml | 11 + .../setup_files/profiles/nuc_test.yaml | 30 ++ deploy/scripts/setup_files/profiles/prod.yaml | 5 + .../scripts/setup_files/profiles/staging.yaml | 1 + docs/deploy/README.md | 4 + docs/user_guide/config/en/mkdocs.yml | 1 + docs/user_guide/default/licenses/OFL.txt | 91 +++++ maintenance/Dockerfile | 26 +- .../mui_language_picker_font_map.json | 19 + .../font_lists/mui_language_picker_fonts.txt | 188 ++++++++++ maintenance/scripts/get_fonts.py | 351 ++++++++++++++++++ nginx/init/15-select-default-conf.sh | 13 - nginx/init/25-combine-runtime-config.sh | 1 + ...t_http_only.conf => default.conf.template} | 7 + nginx/templates/default_http_and_https.conf | 109 ------ public/index.html | 2 +- scripts/get_fonts_dev.py | 74 ++++ scripts/split_dictionary.py | 14 +- src/components/App/AppLoggedIn.tsx | 111 ++++-- .../GlossWithSuggestions.tsx | 9 +- .../VernWithSuggestions.tsx | 12 +- .../ImmutableExistingData.tsx | 21 +- .../DataEntry/ExistingDataTable/index.tsx | 2 +- .../tests/ImmutableExistingData.test.tsx | 5 +- .../DataEntry/tests/DataEntryHeader.test.tsx | 2 +- src/components/DataEntry/utilities.ts | 10 +- .../__snapshots__/Statistics.test.tsx.snap | 26 +- .../CharacterDetail/CharacterWords.tsx | 9 +- .../CharInv/CharacterEntry.tsx | 6 +- .../CharInv/CharacterList/CharacterCard.tsx | 10 +- .../MergeDuplicates/MergeDupsCompleted.tsx | 19 +- .../MergeDupsStep/MergeDragDrop/DropWord.tsx | 5 +- .../MergeDupsStep/SenseCardContent.tsx | 98 ++--- .../CellComponents/DefinitionCell.tsx | 6 +- .../CellComponents/GlossCell.tsx | 116 +++--- .../CellComponents/VernacularCell.tsx | 5 +- .../tests/ReviewEntriesComponent.test.tsx | 15 +- .../ReviewEntriesComponent/tests/WordsMock.ts | 23 +- src/types/runtimeConfig.ts | 19 +- src/types/theme.ts | 27 +- src/types/word.ts | 5 +- src/utilities/fontComponents.tsx | 66 ++++ src/utilities/fontContext.ts | 72 ++++ src/utilities/fontCssUtilities.ts | 115 ++++++ src/utilities/tests/fontContext.test.ts | 44 +++ 62 files changed, 1697 insertions(+), 375 deletions(-) create mode 100644 deploy/helm/thecombine/charts/maintenance/templates/cronjob-weekly-get-fonts.yaml create mode 100644 deploy/helm/thecombine/charts/maintenance/templates/get-fonts-hook.yaml create mode 100644 deploy/helm/thecombine/templates/font-storage-persistent-volume-claim.yaml create mode 100644 deploy/scripts/setup_files/profiles/nuc_test.yaml create mode 100644 docs/user_guide/default/licenses/OFL.txt create mode 100644 maintenance/scripts/font_lists/mui_language_picker_font_map.json create mode 100644 maintenance/scripts/font_lists/mui_language_picker_fonts.txt create mode 100755 maintenance/scripts/get_fonts.py delete mode 100755 nginx/init/15-select-default-conf.sh rename nginx/templates/{default_http_only.conf => default.conf.template} (93%) delete mode 100644 nginx/templates/default_http_and_https.conf create mode 100755 scripts/get_fonts_dev.py create mode 100644 src/utilities/fontComponents.tsx create mode 100644 src/utilities/fontContext.ts create mode 100644 src/utilities/fontCssUtilities.ts create mode 100644 src/utilities/tests/fontContext.test.ts diff --git a/.gitignore b/.gitignore index 136f2ca31b..c17a3a9fa8 100644 --- a/.gitignore +++ b/.gitignore @@ -68,6 +68,12 @@ __pycache__ *.pyc venv +# Font files +*.ttf +*.woff +*.woff2 +public/fonts + # Intermediate files for dictionary import scripts src/resources/dictionaries/*.aff src/resources/dictionaries/*.dic diff --git a/.vscode/settings.json b/.vscode/settings.json index d66374267c..855f4975d9 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -43,13 +43,16 @@ "axios", "bootable", "Bson", + "Charis", "containerd", "devs", + "Doulos", "Dups", "endcap", "globaltool", "Guids", "kubeconfig", + "langtags", "ldml", "letsencrypt", "Linq", @@ -58,6 +61,7 @@ "mongosh", "nerdctl", "notistack", + "Noto", "nspell", "oneshot", "openapi", @@ -69,7 +73,8 @@ "sched", "sillsdev", "Sldr", - "Subtag", + "subtag", + "subtags", "targetdir", "textfile", "thecombine", diff --git a/Dockerfile b/Dockerfile index 399da80619..1d7a30f112 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,11 +32,13 @@ FROM nginx:1.25 WORKDIR /app -ENV USER_GUIDE_HOST_DIR /usr/share/nginx/user_guide -ENV FRONTEND_HOST_DIR /usr/share/nginx/html +ENV HOST_DIR /usr/share/nginx +ENV USER_GUIDE_HOST_DIR ${HOST_DIR}/user_guide +ENV FRONTEND_HOST_DIR ${HOST_DIR}/html RUN mkdir /etc/nginx/templates RUN mkdir /etc/nginx/page_templates +RUN mkdir ${HOST_DIR}/fonts RUN mkdir ${FRONTEND_HOST_DIR}/scripts RUN mkdir ${FRONTEND_HOST_DIR}/url_moved diff --git a/deploy/helm/aws-login/values.yaml b/deploy/helm/aws-login/values.yaml index 291d9b7e28..2d81c4601a 100644 --- a/deploy/helm/aws-login/values.yaml +++ b/deploy/helm/aws-login/values.yaml @@ -22,7 +22,7 @@ awsEcr: cronJobName: ecr-cred-helper-cron dockerEmail: noreply@thecombine.app image: sillsdev/aws-kubectl - imageTag: "0.2.1" + imageTag: "0.3.0" jobName: ecr-cred-helper schedule: "0 */8 * * *" secretsName: aws-ecr-credentials diff --git a/deploy/helm/thecombine/charts/frontend/templates/deployment-frontend.yaml b/deploy/helm/thecombine/charts/frontend/templates/deployment-frontend.yaml index 63a464b3c6..02e50b9395 100644 --- a/deploy/helm/thecombine/charts/frontend/templates/deployment-frontend.yaml +++ b/deploy/helm/thecombine/charts/frontend/templates/deployment-frontend.yaml @@ -49,15 +49,20 @@ spec: configMapKeyRef: key: CONFIG_USE_CONNECTION_URL name: env-frontend - - name: SERVER_NAME + - name: CONFIG_OFFLINE valueFrom: configMapKeyRef: - key: SERVER_NAME + key: CONFIG_OFFLINE name: env-frontend - - name: ENV_HTTP_ONLY + - name: CONFIG_EMAIL_ENABLED valueFrom: configMapKeyRef: - key: ENV_HTTP_ONLY + key: CONFIG_EMAIL_ENABLED + name: env-frontend + - name: SERVER_NAME + valueFrom: + configMapKeyRef: + key: SERVER_NAME name: env-frontend {{- if .Values.configAnalyticsWriteKey }} - name: CONFIG_ANALYTICS_WRITE_KEY @@ -70,9 +75,18 @@ spec: - containerPort: 80 - containerPort: 443 resources: {} + volumeMounts: + - mountPath: /usr/share/nginx/fonts + name: font-data + readOnly: true restartPolicy: Always {{- if ne .Values.global.pullSecretName "None" }} imagePullSecrets: - name: {{ .Values.global.pullSecretName }} {{- end }} + volumes: + - name: font-data + persistentVolumeClaim: + claimName: font-data + status: {} diff --git a/deploy/helm/thecombine/charts/frontend/templates/env-frontend-configmap.yaml b/deploy/helm/thecombine/charts/frontend/templates/env-frontend-configmap.yaml index d7886b529b..5b9036f554 100644 --- a/deploy/helm/thecombine/charts/frontend/templates/env-frontend-configmap.yaml +++ b/deploy/helm/thecombine/charts/frontend/templates/env-frontend-configmap.yaml @@ -10,9 +10,9 @@ data: CONFIG_USE_CONNECTION_URL: "true" CONFIG_CAPTCHA_REQD: {{ .Values.configCaptchaRequired | quote }} CONFIG_CAPTCHA_SITE_KEY: {{ .Values.configCaptchaSiteKey | quote }} - CONFIG_EMAIL_ENABLED: {{ .Values.configEmailEnabled | quote }} + CONFIG_OFFLINE: {{ .Values.configOffline | quote }} + CONFIG_EMAIL_ENABLED: {{ and .Values.configEmailEnabled (empty .Values.global.combineSmtpUsername | not) | quote }} CONFIG_SHOW_CERT_EXPIRATION: {{ .Values.configShowCertExpiration | quote }} {{- if .Values.configAnalyticsWriteKey }} CONFIG_ANALYTICS_WRITE_KEY: {{ .Values.configAnalyticsWriteKey | quote }} {{- end }} - ENV_HTTP_ONLY: "yes" diff --git a/deploy/helm/thecombine/charts/frontend/values.yaml b/deploy/helm/thecombine/charts/frontend/values.yaml index fba58941c5..345ee3a1f6 100644 --- a/deploy/helm/thecombine/charts/frontend/values.yaml +++ b/deploy/helm/thecombine/charts/frontend/values.yaml @@ -3,6 +3,7 @@ # Declare variables to be passed into your templates. global: + combineSmtpUsername: "Override" serverName: localhost pullSecretName: aws-login-credentials # Update strategy should be "Recreate" or "Rolling Update" @@ -17,6 +18,7 @@ imageName: combine_frontend combineAddlDomainList: "" configCaptchaRequired: "false" configCaptchaSiteKey: "None - from frontend chart" -configEmailEnabled: "false" +configOffline: "false" +configEmailEnabled: "true" configShowCertExpiration: "false" configAnalyticsWriteKey: "" diff --git a/deploy/helm/thecombine/charts/maintenance/templates/cronjob-daily-backup.yaml b/deploy/helm/thecombine/charts/maintenance/templates/cronjob-daily-backup.yaml index 7aeda10e98..f92315e7cb 100644 --- a/deploy/helm/thecombine/charts/maintenance/templates/cronjob-daily-backup.yaml +++ b/deploy/helm/thecombine/charts/maintenance/templates/cronjob-daily-backup.yaml @@ -18,7 +18,7 @@ spec: spec: serviceAccountName: {{ .Values.serviceAccount.name }} containers: - - image: sillsdev/aws-kubectl:0.2.1 + - image: sillsdev/aws-kubectl:0.3.0 imagePullPolicy: Always name: daily-backup command: diff --git a/deploy/helm/thecombine/charts/maintenance/templates/cronjob-weekly-get-fonts.yaml b/deploy/helm/thecombine/charts/maintenance/templates/cronjob-weekly-get-fonts.yaml new file mode 100644 index 0000000000..8b306ec0c5 --- /dev/null +++ b/deploy/helm/thecombine/charts/maintenance/templates/cronjob-weekly-get-fonts.yaml @@ -0,0 +1,49 @@ +{{- if empty .Values.updateFontsSchedule | not }} +apiVersion: batch/v1 +kind: CronJob +metadata: + name: update-fonts + namespace: {{ .Release.Namespace | quote }} +spec: + concurrencyPolicy: Allow + failedJobsHistoryLimit: 1 + jobTemplate: + metadata: + creationTimestamp: null + spec: + ttlSecondsAfterFinished: 86400 + template: + metadata: + creationTimestamp: null + spec: + serviceAccountName: {{ .Values.serviceAccount.name }} + containers: + - image: sillsdev/aws-kubectl:0.3.0 + imagePullPolicy: Always + name: update-fonts + command: + - kubectl + args: + - -n + - thecombine + - exec + - deployment/maintenance + - -- + - get-fonts.sh + resources: {} + securityContext: + capabilities: {} + terminationMessagePath: /dev/termination-log + terminationMessagePolicy: File +{{- if ne .Values.global.pullSecretName "None" }} + imagePullSecrets: + - name: {{ .Values.global.pullSecretName }} +{{- end }} + dnsPolicy: ClusterFirst + restartPolicy: Never + schedulerName: default-scheduler + terminationGracePeriodSeconds: 30 + schedule: {{ .Values.updateFontsSchedule }} + successfulJobsHistoryLimit: 1 + suspend: false +{{- end }} diff --git a/deploy/helm/thecombine/charts/maintenance/templates/deployment-maintenance.yaml b/deploy/helm/thecombine/charts/maintenance/templates/deployment-maintenance.yaml index 64b128d7ce..cc8d69c6f8 100644 --- a/deploy/helm/thecombine/charts/maintenance/templates/deployment-maintenance.yaml +++ b/deploy/helm/thecombine/charts/maintenance/templates/deployment-maintenance.yaml @@ -31,7 +31,11 @@ spec: args: [ 'sleep infinity & PID=$! ; trap "kill $PID" INT TERM ; wait' ] image: {{ template "maintenance.containerImage" . }} imagePullPolicy: {{ template "maintenance.imagePullPolicy" . }} + securityContext: + runAsUser: 999 + runAsGroup: 999 env: + # values for AWS Access - name: AWS_ACCESS_KEY_ID valueFrom: secretKeyRef: @@ -57,6 +61,7 @@ spec: configMapKeyRef: key: aws_bucket name: env-maintenance + # values used for backup/restore - name: db_files_subdir valueFrom: configMapKeyRef: @@ -87,10 +92,29 @@ spec: configMapKeyRef: key: backup_filter name: env-maintenance + # values used for font caching + - name: font_dir + valueFrom: + configMapKeyRef: + key: font_dir + name: env-maintenance + - name: local_font_url + valueFrom: + configMapKeyRef: + key: local_font_url + name: env-maintenance resources: {} + volumeMounts: + - mountPath: {{ .Values.fontsDir }} + name: font-data + readOnly: false restartPolicy: Always {{- if ne .Values.global.pullSecretName "None" }} imagePullSecrets: - name: {{ .Values.global.pullSecretName }} {{- end }} + volumes: + - name: font-data + persistentVolumeClaim: + claimName: font-data status: {} diff --git a/deploy/helm/thecombine/charts/maintenance/templates/env-maintenance-configmap.yaml b/deploy/helm/thecombine/charts/maintenance/templates/env-maintenance-configmap.yaml index 326527a9b3..c8bcf0e530 100644 --- a/deploy/helm/thecombine/charts/maintenance/templates/env-maintenance-configmap.yaml +++ b/deploy/helm/thecombine/charts/maintenance/templates/env-maintenance-configmap.yaml @@ -17,3 +17,5 @@ data: backup_filter: {{ template "maintenance.backupNameFilter" . }} wait_time: {{ .Values.waitTime | quote }} max_backups: {{ .Values.maxBackups | quote }} + font_dir: {{ .Values.fontsDir | quote }} + local_font_url: {{ .Values.localFontUrl | quote }} diff --git a/deploy/helm/thecombine/charts/maintenance/templates/get-fonts-hook.yaml b/deploy/helm/thecombine/charts/maintenance/templates/get-fonts-hook.yaml new file mode 100644 index 0000000000..0c049fd392 --- /dev/null +++ b/deploy/helm/thecombine/charts/maintenance/templates/get-fonts-hook.yaml @@ -0,0 +1,55 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: "install-fonts" + namespace: {{ .Release.Namespace | quote }} + labels: + app.kubernetes.io/managed-by: {{ .Release.Service | quote }} + app.kubernetes.io/instance: {{ .Release.Name | quote }} + app.kubernetes.io/version: {{ .Chart.AppVersion }} + helm.sh/chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + annotations: + # This is what defines this resource as a hook. Without this line, the + # job is considered part of the release. + "helm.sh/hook": post-install, post-upgrade + "helm.sh/hook-delete-policy": before-hook-creation +spec: + template: + metadata: + creationTimestamp: null + name: "{{ .Release.Name }}" + labels: + app.kubernetes.io/managed-by: {{ .Release.Service | quote }} + app.kubernetes.io/instance: {{ .Release.Name | quote }} + helm.sh/chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + spec: + serviceAccountName: {{ .Values.serviceAccount.name }} + containers: + - image: sillsdev/aws-kubectl:0.3.0 + imagePullPolicy: Always + name: "install-fonts" + command: + - kubectl + args: + - -n + - thecombine + - exec + - deployment/maintenance + - -- + - /usr/bin/python3 + - /home/user/.local/bin/get_fonts.py +{{- if .Values.localLangList }} + - --langs + {{- range $lang := .Values.localLangList }} + - {{ $lang | quote }} + {{- end }} +{{- end }} + resources: {} + securityContext: + capabilities: {} + terminationMessagePath: /dev/termination-log + terminationMessagePolicy: File + dnsPolicy: Default + restartPolicy: Never + schedulerName: default-scheduler + terminationGracePeriodSeconds: 30 diff --git a/deploy/helm/thecombine/charts/maintenance/values.yaml b/deploy/helm/thecombine/charts/maintenance/values.yaml index 931ecc121d..83990d1b07 100644 --- a/deploy/helm/thecombine/charts/maintenance/values.yaml +++ b/deploy/helm/thecombine/charts/maintenance/values.yaml @@ -46,3 +46,12 @@ waitTime: "120" awsS3BackupLoc: backups dbFilesSubdir: dump backendFilesSubdir: ".CombineFiles" + +####################################### +# Variables controlling font updates +####################################### + +updateFontsSchedule: "" +fontsDir: "/home/user/fonts" +localFontUrl: "/fonts" +localLangList: [] diff --git a/deploy/helm/thecombine/templates/font-storage-persistent-volume-claim.yaml b/deploy/helm/thecombine/templates/font-storage-persistent-volume-claim.yaml new file mode 100644 index 0000000000..43d77b8c5e --- /dev/null +++ b/deploy/helm/thecombine/templates/font-storage-persistent-volume-claim.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + creationTimestamp: null + name: font-data + namespace: {{ .Release.Namespace }} +spec: + accessModes: + - {{ .Values.global.fontStorageAccessMode }} + resources: + requests: + storage: {{ .Values.global.fontStorageSize | quote }} +status: {} diff --git a/deploy/helm/thecombine/values.yaml b/deploy/helm/thecombine/values.yaml index 73e68bc11e..4e10cb7b5a 100644 --- a/deploy/helm/thecombine/values.yaml +++ b/deploy/helm/thecombine/values.yaml @@ -25,6 +25,9 @@ global: combineJwtSecretKey: "Override" combineSmtpUsername: "Override" combineSmtpPassword: "Override" + fontStorageAccessMode: "ReadWriteOnce" + fontStorageSize: 1Gi + offline: false imageTag: "latest" # Define the image registry to use (may be blank for local images) imageRegistry: awsEcr @@ -52,5 +55,6 @@ maintenance: backupSchedule: "" # Maximum number of backups to keep on AWS S3 service maxBackups: "3" + updateFontsSchedule: "" ingressClass: nginx diff --git a/deploy/scripts/setup_combine.py b/deploy/scripts/setup_combine.py index 1423eb2a39..14b332fec5 100755 --- a/deploy/scripts/setup_combine.py +++ b/deploy/scripts/setup_combine.py @@ -23,7 +23,7 @@ from pathlib import Path import sys import tempfile -from typing import Any, Dict, List +from typing import Any, Dict, List, Optional from app_release import get_release from aws_env import init_aws_environment @@ -43,7 +43,9 @@ def parse_args() -> argparse.Namespace: description="Generate Helm Charts for The Combine.", formatter_class=argparse.ArgumentDefaultsHelpFormatter, ) + # Arguments used by the Kubernetes tools add_kube_opts(parser) + # Arguments specific to setting up The Combine parser.add_argument( "--clean", action="store_true", help="Delete chart, if it exists, before installing." ) @@ -59,6 +61,18 @@ def parse_args() -> argparse.Namespace: help="Invoke the 'helm install' command with the '--dry-run' option.", dest="dry_run", ) + parser.add_argument( + "--langs", + "-l", + nargs="*", + metavar="LANG", + help="Language(s) that require fonts to be installed on the target cluster.", + ) + parser.add_argument( + "--list-targets", + action="store_true", + help="List the available targets and exit.", + ) parser.add_argument( "--wait", action="store_true", @@ -131,7 +145,7 @@ def create_secrets( if len(missing_env_vars) > 0: print("The following environment variables are not defined:") print(", ".join(missing_env_vars)) - if not env_vars_req and input("Continue?(y/N)").upper().startswith("Y"): + if not env_vars_req: return secrets_written sys.exit(ExitStatus.FAILURE.value) @@ -171,6 +185,21 @@ def add_override_values( helm_cmd.extend(["-f", str(override_file)]) +def add_language_overrides( + config: Dict[str, Any], + *, + chart: str, + langs: Optional[List[str]], +) -> None: + """Update override configuration with any languages specified on the command line.""" + override_config = config["override"][chart] + if langs: + if "maintenance" not in override_config: + override_config["maintenance"] = {"localLangList": langs} + else: + override_config["maintenance"]["localLangList"] = langs + + def add_profile_values( config: Dict[str, Any], *, profile_name: str, chart: str, temp_dir: Path, helm_cmd: List[str] ) -> None: @@ -204,6 +233,11 @@ def main() -> None: else: combine_charts.generate(get_release()) + if args.list_targets: + for target in config["targets"].keys(): + print(f" {target}") + sys.exit(ExitStatus.SUCCESS.value) + target = args.target while target not in config["targets"]: target = get_target(config) @@ -313,6 +347,8 @@ def main() -> None: ] ) + add_language_overrides(this_config, chart=chart, langs=args.langs) + add_override_values( this_config, chart=chart, diff --git a/deploy/scripts/setup_files/combine_config.yaml b/deploy/scripts/setup_files/combine_config.yaml index 77cf31ad58..2d0df36dab 100644 --- a/deploy/scripts/setup_files/combine_config.yaml +++ b/deploy/scripts/setup_files/combine_config.yaml @@ -20,7 +20,7 @@ targets: serverName: thecombine.localhost nuc1: profile: nuc - env_vars_required: true + env_vars_required: false override: # override values for 'thecombine' chart thecombine: @@ -28,7 +28,7 @@ targets: serverName: nuc1.thecombine.app nuc2: profile: nuc - env_vars_required: true + env_vars_required: false override: # override values for 'thecombine' chart thecombine: @@ -36,12 +36,20 @@ targets: serverName: nuc2.thecombine.app nuc3: profile: nuc - env_vars_required: true + env_vars_required: false override: # override values for 'thecombine' chart thecombine: global: serverName: nuc3.thecombine.app + nuc_test: + profile: nuc_test + env_vars_required: false + override: + # override values for 'thecombine' chart + thecombine: + global: + serverName: nuc-test.thecombine.app qa: profile: staging @@ -81,9 +89,13 @@ profiles: - thecombine nuc: # Profile for a NUC or a machine whose TLS certificate will be created by another # system and is downloaded from AWS S3 + # Container images must be stored in AWS ECR Public repositories charts: - thecombine - create-admin-user + nuc_test: # Profile for using a NUC running container images from the AWS ECR Private repositories + charts: + - thecombine staging: # Profile for the QA server charts: - thecombine diff --git a/deploy/scripts/setup_files/profiles/nuc.yaml b/deploy/scripts/setup_files/profiles/nuc.yaml index 2631cc2a03..e99e78a7c4 100644 --- a/deploy/scripts/setup_files/profiles/nuc.yaml +++ b/deploy/scripts/setup_files/profiles/nuc.yaml @@ -13,7 +13,18 @@ charts: enabled: false global: awsS3Location: prod.thecombine.app + combineSmtpUsername: "" pullSecretName: None + frontend: + configOffline: true + configEmailEnabled: false + maintenance: + localLangList: + - "ar" + - "en" + - "es" + - "fr" + - "pt" cert-proxy-client: enabled: true diff --git a/deploy/scripts/setup_files/profiles/nuc_test.yaml b/deploy/scripts/setup_files/profiles/nuc_test.yaml new file mode 100644 index 0000000000..cfe2135063 --- /dev/null +++ b/deploy/scripts/setup_files/profiles/nuc_test.yaml @@ -0,0 +1,30 @@ +--- +################################################ +# Profile specific configuration items +# +# Profile: nuc +################################################ + +charts: + thecombine: + aws-login: + enabled: true + global: + awsS3Location: dev.thecombine.app + combineSmtpUsername: "" + frontend: + configOffline: true + configEmailEnabled: false + maintenance: + localLangList: + - "ar" + - "en" + - "es" + - "fr" + - "pt" + + cert-proxy-client: + enabled: true + + certManager: + enabled: false diff --git a/deploy/scripts/setup_files/profiles/prod.yaml b/deploy/scripts/setup_files/profiles/prod.yaml index f96eecb625..348476f75c 100644 --- a/deploy/scripts/setup_files/profiles/prod.yaml +++ b/deploy/scripts/setup_files/profiles/prod.yaml @@ -23,8 +23,13 @@ charts: backupSchedule: "15 03 * * *" # Maximum number of backups to keep on AWS S3 service maxBackups: "3" + ####################################### + # Update font schedule + # Run every Sunday at 02:15 UTC + updateFontsSchedule: "15 02 * * 0" global: awsS3Location: prod.thecombine.app + fontStorageAccessMode: ReadWriteMany pullSecretName: None certManager: enabled: false diff --git a/deploy/scripts/setup_files/profiles/staging.yaml b/deploy/scripts/setup_files/profiles/staging.yaml index 4c79bb65fa..2adf4153f6 100644 --- a/deploy/scripts/setup_files/profiles/staging.yaml +++ b/deploy/scripts/setup_files/profiles/staging.yaml @@ -13,6 +13,7 @@ charts: configAnalyticsWriteKey: "AoebaDJNjSlOMRUH87EaNjvwkQpfLoyy" global: awsS3Location: prod.thecombine.app + fontStorageAccessMode: ReadWriteMany tlsSecretName: thecombine-app-tls certManager: enabled: false diff --git a/docs/deploy/README.md b/docs/deploy/README.md index a15449ceab..160bb61f8d 100644 --- a/docs/deploy/README.md +++ b/docs/deploy/README.md @@ -375,6 +375,10 @@ Where: Note that: + - When the `./setup_combine.py` script is used to install _The Combine_ on a NUC, it will install the fonts required + for Arabic, English, French, Portuguese, and Spanish. If additional fonts will be required, call the + `setup_combine.py` commands with the `--langs` option. Use the `--help` option to see the argument syntax. + - Starting with version 0.7.25, the tag will start with a ā€˜vā€™, even if the release does not (we are transitioning to the format where release versions start with a ā€˜vā€™). - You can see the version of the latest release on the GitHub page for The Combine, diff --git a/docs/user_guide/config/en/mkdocs.yml b/docs/user_guide/config/en/mkdocs.yml index 4648f6a1f3..a49e962edf 100644 --- a/docs/user_guide/config/en/mkdocs.yml +++ b/docs/user_guide/config/en/mkdocs.yml @@ -60,4 +60,5 @@ nav: - Admin: admin.md - Third-Party Licenses: - Backend: licenses/backend_licenses.txt + - Fonts: licenses/OFL.txt - Frontend: licenses/frontend_licenses.txt diff --git a/docs/user_guide/default/licenses/OFL.txt b/docs/user_guide/default/licenses/OFL.txt new file mode 100644 index 0000000000..d9a1e970dd --- /dev/null +++ b/docs/user_guide/default/licenses/OFL.txt @@ -0,0 +1,91 @@ +Unless stated otherwise, all fonts embedded in The Combine are licensed +under the SIL Open Font License, Version 1.1. This license is copied below, +and is also available with a FAQ at: http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/maintenance/Dockerfile b/maintenance/Dockerfile index 72c3be2ea6..7e2302eaa7 100644 --- a/maintenance/Dockerfile +++ b/maintenance/Dockerfile @@ -8,7 +8,7 @@ # secret accordingly. # The scripts are written in Python. -FROM sillsdev/aws-kubectl:0.2.1 +FROM sillsdev/aws-kubectl:0.3.0 USER root @@ -18,21 +18,27 @@ RUN apt-get update && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* -# Create a 'user' user so the program doesn't run as root. +# Use 'user' user (in sillsdev/aws-kubectl image) so the program +# doesn't run as root. ENV HOME=/home/user -RUN groupadd -r user && \ - useradd -r -g user -d $HOME -s /sbin/nologin -c "Docker image user" app +ENV SCRIPT_DIR=${HOME}/.local/bin +ENV FONT_DIR=/var/local/fonts +RUN mkdir -p ${FONT_DIR} +RUN chown user:user ${FONT_DIR} + +# switch to non-root user USER user WORKDIR ${HOME} -ENV PATH ${PATH}:${HOME}/.local/bin +ENV PATH ${PATH}:${SCRIPT_DIR} -COPY requirements.txt . +RUN mkdir -p ${SCRIPT_DIR}/font_lists -RUN pip3 install -r requirements.txt +COPY --chown=user:user requirements.txt . -RUN mkdir -p .local/bin +RUN pip3 install -r requirements.txt -COPY --chown=user:user scripts/*.py ./.local/bin/ -COPY --chown=user:user scripts/*.sh ./.local/bin/ +COPY --chown=user:user scripts/*.py ${SCRIPT_DIR} +COPY --chown=user:user scripts/*.sh ${SCRIPT_DIR} +COPY --chown=user:user scripts/font_lists/* ${SCRIPT_DIR}/font_lists diff --git a/maintenance/scripts/font_lists/mui_language_picker_font_map.json b/maintenance/scripts/font_lists/mui_language_picker_font_map.json new file mode 100644 index 0000000000..257fc91086 --- /dev/null +++ b/maintenance/scripts/font_lists/mui_language_picker_font_map.json @@ -0,0 +1,19 @@ +{ + "KNDABadami": "Badami", + "Galatia": "GalatiaSIL", + "Kyebogyi": "KyebogyiSIL", + "NamdhinggoSIL": "Namdhinggo", + "NotoSansCJKJP": "NotoSansJP", + "NotoSansCJKKR": "NotoSansKR", + "NotoSansCJKSC": "NotoSansSC", + "NotoSansCJKTC": "NotoSansTC", + "NotoSansLatin": "NotoSans", + "NotoSansTangut": "NotoSerifTangut", + "NotoSerifCJKJP": "NotoSerifJP", + "NotoSerifCJKKR": "NotoSerifKR", + "NotoSerifCJKSC": "NotoSerifSC", + "NotoSerifCJKTC": "NotoSerifTC", + "NotoSerifLatin": "NotoSerif", + "Scheherazade": "ScheherazadeNew", + "TAMLThiruValluvar": "ThiruValluvar" +} diff --git a/maintenance/scripts/font_lists/mui_language_picker_fonts.txt b/maintenance/scripts/font_lists/mui_language_picker_fonts.txt new file mode 100644 index 0000000000..1a8e38ed63 --- /dev/null +++ b/maintenance/scripts/font_lists/mui_language_picker_fonts.txt @@ -0,0 +1,188 @@ +Abyssinica SIL +Annapurna SIL +Awami Nastaliq +Charis SIL +Dai Banna SIL +DokChampa +Doulos SIL +Ebrima +Ezra SIL +Galatia +Gentium Plus +Harmattan +KNDA Badami +Kayahli +Khmer Mondulkiri +Kyebogyi +LisuTzimu +Mingzat +Namdhinggo SIL +Narnoor +Nokyung +Noto Sans CJK JP +Noto Sans CJK KR +Noto Sans CJK SC +Noto Sans CJK TC +Noto Serif CJK JP +Noto Serif CJK KR +Noto Serif CJK SC +Noto Serif CJK TC +NotoNastaliqUrdu +NotoSans +NotoSansAdlam +NotoSansAnatolianHieroglyphs +NotoSansArabic +NotoSansArmenian +NotoSansAvestan +NotoSansBalinese +NotoSansBamum +NotoSansBassaVah +NotoSansBatak +NotoSansBengali +NotoSansBhaiksuki +NotoSansBrahmi +NotoSansBuginese +NotoSansBuhid +NotoSansCanadianAboriginal +NotoSansCarian +NotoSansCaucasianAlbanian +NotoSansChakma +NotoSansCham +NotoSansCherokee +NotoSansCoptic +NotoSansCuneiform +NotoSansCypriot +NotoSansDeseret +NotoSansDevanagari +NotoSansDuployan +NotoSansEgyptianHieroglyphs +NotoSansElbasan +NotoSansEthiopic +NotoSansGeorgian +NotoSansGlagolitic +NotoSansGothic +NotoSansGrantha +NotoSansGujarati +NotoSansGurmukhi +NotoSansHanunoo +NotoSansHatran +NotoSansHebrew +NotoSansImperialAramaic +NotoSansInscriptionalPahlavi +NotoSansInscriptionalParthian +NotoSansJavanese +NotoSansKaithi +NotoSansKannada +NotoSansKayah Li +NotoSansKharoshthi +NotoSansKhmer +NotoSansKhojki +NotoSansKhudawadi +NotoSansLao +NotoSansLatin +NotoSansLeke +NotoSansLepcha +NotoSansLimbu +NotoSansLinearA +NotoSansLinearB +NotoSansLisu +NotoSansLycian +NotoSansLydian +NotoSansMahajani +NotoSansMalayalam +NotoSansMandaic +NotoSansManichaean +NotoSansMarchen +NotoSansMeeteiMayek +NotoSansMendeKikakui +NotoSansMeroitic +NotoSansMeroitic Cursive +NotoSansMiao +NotoSansModi +NotoSansMongolian +NotoSansMro +NotoSansMultani +NotoSansMyanmar +NotoSansNKo +NotoSansNabataean +NotoSansNandinagari +NotoSansNewTaiLue +NotoSansNewa +NotoSansOgham +NotoSansOl Chiki +NotoSansOld Permic +NotoSansOldHungarian +NotoSansOldItalic +NotoSansOldNorthArabian +NotoSansOldPersian +NotoSansOldSouthArabian +NotoSansOldTurkic +NotoSansOriya +NotoSansOsage +NotoSansOsmanya +NotoSansPahawhHmong +NotoSansPalmyrene +NotoSansPauCinHau +NotoSansPhagsPa +NotoSansPhoenician +NotoSansPsalterPahlavi +NotoSansRejang +NotoSansRunic +NotoSansSamaritan +NotoSansSaurashtra +NotoSansShuishu +NotoSansSiddham +NotoSansSinhala +NotoSansSora Sompeng +NotoSansSylotiNagri +NotoSansSyriac +NotoSansTagalog +NotoSansTagbanwa +NotoSansTaiLe +NotoSansTaiTham +NotoSansTaiViet +NotoSansTakri +NotoSansTamil +NotoSansTangut +NotoSansTelugu +NotoSansThaana +NotoSansThai +NotoSansTibetan +NotoSansTifinagh +NotoSansTirhuta +NotoSansUgaritic +NotoSansVai +NotoSansWarangCiti +NotoSansYi +NotoSerif +NotoSerifAhom +NotoSerifArmenian +NotoSerifBengali +NotoSerifDevanagari +NotoSerifEthiopic +NotoSerifGeorgian +NotoSerifGujarati +NotoSerifGurmukhi +NotoSerifHebrew +NotoSerifKannada +NotoSerifKhmer +NotoSerifLao +NotoSerifLatin +NotoSerifMalayalam +NotoSerifMyanmar +NotoSerifSinhala +NotoSerifTamil +NotoSerifTelugu +NotoSerifThai +NotoSerifTibetan +Nuosu SIL +Padauk +Saysettha MX +Scheherazade +ShiShan +Shimenkan +SimSun +Sophia Nubian +Surma +TAML ThiruValluvar +Tai Heritage Pro diff --git a/maintenance/scripts/get_fonts.py b/maintenance/scripts/get_fonts.py new file mode 100755 index 0000000000..f2f40056cd --- /dev/null +++ b/maintenance/scripts/get_fonts.py @@ -0,0 +1,351 @@ +#!/usr/bin/env python3 +""" +Generates font support for all SIL fonts used in Mui-Language-Picker. + +This script uses the following environment variables: + font_dir directory where the font-data persistent storage is mounted. +""" + +import argparse +from csv import reader +import json +import logging +import os +from pathlib import Path +from shutil import rmtree +from typing import Any, List + +import requests + +scripts_dir = Path(__file__).resolve().parent +file_name_fallback = "fallback.json" +font_lists_dir = scripts_dir / "font_lists" +mlp_font_list = font_lists_dir / "mui_language_picker_fonts.txt" +mlp_font_map = font_lists_dir / "mui_language_picker_font_map.json" +mlp_fonts_known_unavailable = ["NotoSansLeke", "NotoSansShuishu", "SimSun"] +url_font_families_info = "https://github.com/silnrsi/fonts/raw/main/families.json" +url_lang_tags_list = "https://ldml.api.sil.org/en?query=langtags" +url_script_font_table = ( + "https://raw.githubusercontent.com/silnrsi/langfontfinder/main/data/script2font.csv" +) +default_output_dir = os.getenv("font_dir", "/mnt/fonts") +default_local_font_url = os.getenv("local_font_url", "/fonts") + + +def parse_args() -> argparse.Namespace: + """Define command line arguments for parser.""" + parser = argparse.ArgumentParser( + description="Prepares all needed fonts.", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + parser.add_argument( + "--clean", "-c", action="store_true", help="Delete the contents of the fonts directory." + ) + parser.add_argument( + "--langs", + "-l", + nargs="*", + metavar="LANG", + help="List of language tags for which fonts should be downloaded.", + ) + parser.add_argument( + "--url", + "-u", + dest="local_font_url", + default=default_local_font_url, + help="URL for locally hosted fonts, for the css data used by the client.", + ) + parser.add_argument( + "--output", "-o", default=default_output_dir, help="Output directory for font data." + ) + parser.add_argument( + "--verbose", + "-v", + action="store_true", + help="Print intermediate values to aid in debugging.", + ) + args = parser.parse_args() + args.output = Path(args.output) + return args + + +def get_font_default(defaults: dict[str, str]) -> str: + """Determine which default file to use, with preference for woff2 if available.""" + keys = ["woff2", "woff", "ttf"] + for key in keys: + if key in defaults.keys(): + return defaults[key] + return "" + + +def check_font_info(font_info: dict[str, Any]) -> bool: + """Given an entry from the font families info file, check if the font family is usable.""" + family: str = font_info["family"] + + # Check that the font is current and licensed as expected. + if not font_info["distributable"]: + logging.debug(f"{family}: Not distributable") + return False + if "license" not in font_info.keys(): + logging.debug(f"{family}: No license") + return False + elif font_info["license"] != "OFL": + logging.debug(f"{family}: Non-OFL license: {font_info['license']}") + return False + if "source" not in font_info.keys(): + logging.debug(f"{family}: No source") + elif font_info["source"] not in ["Google", "SIL"]: + logging.debug(f"{family}: Non-Google, non-SIL source: {font_info['source']}") + if "status" not in font_info.keys(): + logging.debug(f"{family}: No status") + elif font_info["status"] != "current": + logging.debug(f"{family}: Non-current status: {font_info['status']}") + + if "defaults" not in font_info.keys() or get_font_default(font_info["defaults"]) == "": + logging.debug(f"{family}: No defaults") + return False + + if "files" not in font_info.keys(): + logging.debug(f"{family}: No file list") + return False + + return True + + +def extract_lang_subtags(langs: List[str]) -> List[str]: + """Given a (comma-separated) string langtags, return list of the initial lang subtags.""" + subtags = [tag.split("-")[0].lower() for tag in langs] + lang_list = [subtag for subtag in set(subtags) if subtag != ""] + lang_list.sort() + return lang_list + + +def fetch_scripts_for_langs(langs: List[str]) -> List[str]: + """Given a list of langtags, look up and return all script tags used with the languages.""" + langs = [lang.lower() for lang in langs] + scripts = [] + logging.debug(f"Downloading lang-tag list from {url_lang_tags_list}") + req = requests.get(url_lang_tags_list) + for line in req.iter_lines(): + tags: List[str] = line.decode("UTF-8").split(" = ") + if len(tags) == 0 or tags[0].split("-")[0].strip("*") not in langs: + continue + for tag in tags: + for subtag in tag.split("-"): + # Script tags always have length 4. + if len(subtag) == 4 and subtag not in scripts: + scripts.append(subtag) + + scripts.sort() + return scripts + + +def fetch_fonts_for_scripts(scripts: List[str]) -> List[str]: + """Given a list of script tags, look up the default fonts used with those scripts.""" + scripts = [script.capitalize() for script in scripts] + + # Always have the Mui-Language-Picker default/safe fonts (except proprietary "SimSun"). + fonts = ["AnnapurnaSIL", "CharisSIL", "DoulosSIL", "NotoSans", "ScheherazadeNew"] + + logging.debug(f"Downloading script font table from {url_script_font_table}") + req = requests.get(url_script_font_table) + script_font_table = reader( + req.content.decode("UTF-8").splitlines(), delimiter=",", quotechar='"' + ) + + # Use the font-column headers to determine all font-column indices. + script_font_table_font_columns = [ + "Default Font", + "WSTech primary", + "NLCI", + "Microsoft", + "Other", + "Noto Sans", + "Noto Serif", + "WSTech secondary", + ] + header_row: List[str] = next(script_font_table) + font_indices = [ + i for i in range(len(header_row)) if header_row[i] in script_font_table_font_columns + ] + + # Collect all fonts for the specified scripts. + for row in script_font_table: + if len(row) == 0 or row[0] not in scripts: + continue + for i in font_indices: + font = row[i].replace(" ", "") + if font != "" and font not in fonts: + fonts.append(font) + + fonts.sort() + return fonts + + +def fetch_font_families_info() -> dict[str, Any]: + logging.debug(f"Downloading font families info from {url_font_families_info}") + req = requests.get(url_font_families_info) + content: dict[str, Any] = json.loads(req.content) + return content + + +def main() -> None: + args = parse_args() + if args.verbose: + logging.basicConfig(format="%(levelname)s:%(message)s", level=logging.DEBUG) + else: + logging.basicConfig(format="%(levelname)s:%(message)s", level=logging.INFO) + + if not args.output.is_dir(): + logging.error(f"Invalid output directory: '{args.output}'") + exit(1) + + with open(mlp_font_list, "r") as mlp_fonts_list: + # MLP use of spaces in fonts is inconsistent, so remove all spaces for simplicity. + fonts = [f.strip().replace(" ", "") for f in mlp_fonts_list.readlines()] + + if args.langs: + logging.info(f"Language tags: {', '.join(args.langs)}") + + scripts = fetch_scripts_for_langs(args.langs) + logging.info(f"Scripts used for specified language tags: {', '.join(scripts)}") + + script_fonts = fetch_fonts_for_scripts(scripts) + logging.info( + f"Default fonts and fonts used for specified language tags: {', '.join(script_fonts)}" + ) + + if args.clean: + logging.info(f"Deleting contents of {args.output}") + for path in args.output.iterdir(): + logging.debug(f"Deleting {path}") + if path.name != "lost+found": + if path.is_dir(): + rmtree(path) + else: + path.unlink() + + families = fetch_font_families_info() + + with open(mlp_font_map, "r") as mlp_map_file: + mlp_map: dict[str, str] = json.load(mlp_map_file) + # Assumes no two keys map to the same value. + mlp_map_rev = {val: key for key, val in mlp_map.items()} + + # Fonts for which the frontend will get css files from Google's font API. + google_fallback: dict[str, str] = {} + + for font in fonts: + logging.debug(f"Font: {font}") + font_id: str = font.lower() + if font in mlp_map.keys(): + font_id = mlp_map[font].lower() + + # Get font family info from font families info, using fallback font if necessary. + while font_id != "" and font_id in families.keys(): + font_info = families[font_id] + family: str = font_info["family"] + from_google = ( + (not args.langs) + and "source" in font_info.keys() + and font_info["source"] == "Google" + ) + if check_font_info(font_info) or from_google: + # Font available. + break + if "fallback" in font_info.keys(): + font_id = font_info["fallback"] + logging.debug(f"{family}: Using fallback {font_id}") + else: + logging.debug(f"{family}: No fallback") + font_id = "" + + # When downloading, only download fonts used for scripts of the specified languages. + if args.langs and family.replace(" ", "") not in script_fonts: + logging.debug(f"Skipping font {font} as irrelevant for specified languages.") + continue + + # Check if font was determined available. + if font_id == "" or font_id not in families.keys(): + if font in mlp_fonts_known_unavailable: + logging.debug(f"Font {font} not available (but we knew that already)") + elif args.langs: + logging.warning(f"Font {font} not available for download") + else: + logging.warning(f"Font {font} css info not available") + continue + + # When not downloading, prefer fetching css info from Google when available. + if from_google: + google_fallback[font] = family + logging.debug(f"Using Google fallback for {font}: {google_fallback[font]}") + continue + + # Get the font's default file info. + file_name = get_font_default(font_info["defaults"]) + files: dict[str, Any] = font_info["files"] + if file_name not in files.keys(): + logging.error(f"{family}: Default file not in file list") + continue + file_info: dict[str, Any] = files[file_name] + + # Build the css info for this font in this style. + css_lines: List[str] = [] + css_lines.append("@font-face {\n") + css_lines.append(" font-display: swap;\n") + css_lines.append(f" font-family: '{font}';\n") + css_line_local = f"local('{family}'), local('{family} Regular')," + + # Build the url source, downloading if requested. + if "flourl" in file_info.keys(): + src = file_info["flourl"] + elif "url" in file_info.keys(): + src = file_info["url"] + else: + logging.warning(f"{file_name}: No 'flourl' or 'url' for this file") + continue + + if args.langs: + # With the https://fonts.languagetechnology.org "flourl" urls, + # urllib.request.urlretrieve() is denied (403), but requests.get() works. + req = requests.get(src) + dest = args.output / file_name + logging.debug(f"Downloading {src} to {dest}") + with open(dest, "wb") as out: + out.write(req.content) + css_lines.append( + f" src: {css_line_local} url('{args.local_font_url}/{file_name}');\n" + ) + else: + css_lines.append(f" src: {css_line_local} url('{src}');\n") + + # Finish the css info for this font and write to file. + css_lines.append("}\n") + css_file_path = args.output / f"{font}.css" + logging.debug(f"Writing {css_file_path}") + with open(css_file_path, "w") as css_file: + css_file.writelines(css_lines) + + # If the font corresponds to a different MPL font name, + # create a css file for that font name too. + if font in mlp_map_rev.keys(): + font = mlp_map_rev[font] + css_lines[2] = f" font-family: '{font}';\n" + css_file_path = args.output / f"{font}.css" + logging.debug(f"Writing {css_file_path}") + with open(css_file_path, "w") as css_file: + css_file.writelines(css_lines) + + if not args.langs: + fallback_lines = ['{\n "google": {\n'] + for key, val in google_fallback.items(): + fallback_lines.append(f' "{key}": "{val}",\n') + # Remove the final comma to satisfy Prettier. + fallback_lines[-1] = fallback_lines[-1].replace(",", "") + fallback_lines.append(" }\n}\n") + with open(args.output / file_name_fallback, "w") as fallback_file: + fallback_file.writelines(fallback_lines) + + +if __name__ == "__main__": + main() diff --git a/nginx/init/15-select-default-conf.sh b/nginx/init/15-select-default-conf.sh deleted file mode 100755 index e19f8e2ae0..0000000000 --- a/nginx/init/15-select-default-conf.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/bash - -echo "Creating default NGINX config template" - -ENV_HTTP_ONLY=${ENV_HTTP_ONLY:="no"} - -if [ "${ENV_HTTP_ONLY}" = "yes" ] ; then - echo "Installing default_http_only.conf as default template" - cp /etc/nginx/templates/default_http_only.conf /etc/nginx/templates/default.conf.template -else - echo "Installing default_http_and_https.conf as default template" - cp /etc/nginx/templates/default_http_and_https.conf /etc/nginx/templates/default.conf.template -fi diff --git a/nginx/init/25-combine-runtime-config.sh b/nginx/init/25-combine-runtime-config.sh index 093192b0a0..390c10c803 100755 --- a/nginx/init/25-combine-runtime-config.sh +++ b/nginx/init/25-combine-runtime-config.sh @@ -53,6 +53,7 @@ env_map=( ["CONFIG_CAPTCHA_REQD"]="captchaRequired" ["CONFIG_CAPTCHA_SITE_KEY"]="captchaSiteKey" ["CONFIG_ANALYTICS_WRITE_KEY"]="analyticsWriteKey" + ["CONFIG_OFFLINE"]="offline" ["CONFIG_EMAIL_ENABLED"]="emailServicesEnabled" ["CONFIG_SHOW_CERT_EXPIRATION"]="showCertExpiration" ) diff --git a/nginx/templates/default_http_only.conf b/nginx/templates/default.conf.template similarity index 93% rename from nginx/templates/default_http_only.conf rename to nginx/templates/default.conf.template index 13c453f86a..7d05c42e33 100644 --- a/nginx/templates/default_http_only.conf +++ b/nginx/templates/default.conf.template @@ -72,6 +72,13 @@ server { add_header Cache-Control "public, no-transform"; } + # Font static files. + location /fonts { + alias /usr/share/nginx/fonts; + expires 12h; + add_header Cache-Control "public, no-transform"; + } + location / { root /usr/share/nginx/html; index index.html index.htm; diff --git a/nginx/templates/default_http_and_https.conf b/nginx/templates/default_http_and_https.conf deleted file mode 100644 index 261d522591..0000000000 --- a/nginx/templates/default_http_and_https.conf +++ /dev/null @@ -1,109 +0,0 @@ -server { - listen 80; - server_name ${SERVER_NAME}; - server_tokens off; - - location /.well-known/acme-challenge/ { - root /var/www/certbot; - } - - location / { - return 301 https://$host$request_uri; - } -} - -server { - listen 443 ssl http2; - server_name ${SERVER_NAME}; - ssl_certificate ${SSL_CERTIFICATE}; - ssl_certificate_key ${SSL_PRIVATE_KEY}; - ssl_protocols TLSv1.2 TLSv1.3; - charset utf-8; - - # Allow clients to import large projects. - client_max_body_size 250m; - - # Gzip responses to decrease page loading time. - # https://www.digitalocean.com/community/tutorials/ - # how-to-increase-pagespeed-score-by-changing-your-nginx-configuration-on-ubuntu-16-04 - gzip on; - gzip_comp_level 5; - gzip_min_length 256; - gzip_proxied any; - gzip_vary on; - - gzip_types - application/atom+xml - application/javascript - application/json - application/ld+json - application/manifest+json - application/rss+xml - application/vnd.geo+json - application/vnd.ms-fontobject - application/x-font-ttf - application/x-web-app-manifest+json - application/xhtml+xml - application/xml - font/opentype - image/bmp - image/svg+xml - image/x-icon - text/cache-manifest - text/css - text/plain - text/vcard - text/vnd.rim.location.xloc - text/vtt - text/x-component - text/x-cross-domain-policy; - # text/html is always compressed by gzip module - - location /.well-known/acme-challenge/ { - root /var/www/certbot; - } - - location /v1 { - proxy_pass http://backend:5000; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection keep-alive; - proxy_set_header Host $host; - proxy_cache_bypass $http_upgrade; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - - # Websocket proxy. - # https://nginx.org/en/docs/http/websocket.html - location /hub { - proxy_pass http://backend:5000; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - } - - # User Guide static files. - location /docs { - alias /usr/share/nginx/user_guide/en; - index index.html index.htm; - expires 12h; - add_header Cache-Control "public, no-transform"; - } - - location / { - root /usr/share/nginx/html; - index index.html index.htm; - try_files $uri $uri/ /index.html; - # Set cache expiration so that if the server's frontend implementation is updated, - # clients will automatically pull in a fresh version at the beginning of the next day. - expires 12h; - add_header Cache-Control "public, no-transform"; - } - - error_page 500 502 503 504 /50x.html; - - location = /50x.html { - root /usr/share/nginx/html; - } -} diff --git a/public/index.html b/public/index.html index 1a1b9d8338..6df0b190be 100644 --- a/public/index.html +++ b/public/index.html @@ -85,7 +85,7 @@ The Combine diff --git a/scripts/get_fonts_dev.py b/scripts/get_fonts_dev.py new file mode 100755 index 0000000000..4ec7287248 --- /dev/null +++ b/scripts/get_fonts_dev.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 + +"""Runs maintenance/scripts/get_fonts.py with dev arguments for -f and -o""" + +import argparse +from pathlib import Path +import platform +import subprocess + +project_dir = Path(__file__).resolve().parent.parent +dev_output_dir = project_dir / "public" / "fonts" + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Arguments to pass to maintenance/scripts/get_fonts.py", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + parser.add_argument( + "--clean", "-c", action="store_true", help="Delete the contents of the fonts directory." + ) + parser.add_argument( + "--langs", + "-l", + nargs="*", + metavar="LANG", + help="List of language tags for which fonts should be downloaded.", + ) + parser.add_argument( + "--url", + "-u", + dest="local_font_url", + help="URL for locally hosted fonts, for the css data used by the client.", + ) + parser.add_argument( + "--output", "-o", default=dev_output_dir, help="Output directory for font data." + ) + parser.add_argument( + "--verbose", + "-v", + action="store_true", + help="Print intermediate values to aid in debugging.", + ) + args = parser.parse_args() + args.output = Path(args.output) + return args + + +def main() -> None: + args = parse_args() + + args.output.mkdir(mode=0o755, parents=True, exist_ok=True) + + command = [ + "python", + project_dir / "maintenance" / "scripts" / "get_fonts.py", + "-o", + args.output, + ] + if args.clean: + command.append("-c") + if args.local_font_url: + command.append("-f") + command.extend(args.local_font_url) + if args.langs: + command.append("-l") + command.extend(args.langs) + if args.verbose: + command.append("-v") + subprocess.run(command, shell=(platform.system() == "Windows"), check=True, text=True) + + +if __name__ == "__main__": + main() diff --git a/scripts/split_dictionary.py b/scripts/split_dictionary.py index 9ba40d6e01..00e008ecd1 100644 --- a/scripts/split_dictionary.py +++ b/scripts/split_dictionary.py @@ -30,39 +30,39 @@ def lang_tag_type(tag: str) -> str: formatter_class=argparse.ArgumentDefaultsHelpFormatter, ) parser.add_argument( - "-i", "--input", + "-i", help="Override the path of the .txt file to be split", ) parser.add_argument( - "-l", "--lang", help="2- or 3-letter language tag", required=True, type=lang_tag_type + "--lang", "-l", help="2- or 3-letter language tag", required=True, type=lang_tag_type ) parser.add_argument( - "-m", "--max", + "-m", help="Override the lang's default max word-length, or -1 to force no limit", type=int, ) parser.add_argument( - "-n", "--normalize", choices=["NFC", "NFD", "NFKC", "NFKD"], default="NFKD" + "--normalize", "-n", choices=["NFC", "NFD", "NFKC", "NFKD"], default="NFKD" ) parser.add_argument( - "-t", "--threshold", + "-t", default=2000, help="Minimum entry count for a word-start to have its own file", type=int, ) parser.add_argument( - "-T", "--Threshold", + "-T", default=20000, help="Minimum entry count for a word-start to be split into multiple files", type=int, ) parser.add_argument( - "-v", "--verbose", + "-v", action="store_true", help="Print intermediate values to aid in debugging.", ) diff --git a/src/components/App/AppLoggedIn.tsx b/src/components/App/AppLoggedIn.tsx index 66ad212763..9b3ceee085 100644 --- a/src/components/App/AppLoggedIn.tsx +++ b/src/components/App/AppLoggedIn.tsx @@ -1,5 +1,7 @@ import loadable from "@loadable/component"; -import { ReactElement, useEffect } from "react"; +import { CssBaseline } from "@mui/material"; +import { Theme, ThemeProvider, createTheme } from "@mui/material/styles"; +import { ReactElement, useEffect, useMemo, useState } from "react"; import { Route, Routes } from "react-router-dom"; import SignalRHub from "components/App/SignalRHub"; @@ -12,7 +14,11 @@ import Statistics from "components/Statistics/Statistics"; import UserSettings from "components/UserSettings/UserSettings"; import NextGoalScreen from "goals/DefaultGoal/NextGoalScreen"; import { updateLangFromUser } from "i18n"; +import { StoreState } from "types"; +import { useAppSelector } from "types/hooks"; import { Path } from "types/path"; +import FontContext, { ProjectFonts } from "utilities/fontContext"; +import { getProjCss } from "utilities/fontCssUtilities"; import { routerPath } from "utilities/pathUtilities"; const BaseGoalScreen = loadable( @@ -22,36 +28,91 @@ const DataEntry = loadable(() => import("components/DataEntry")); const GoalTimeline = loadable(() => import("components/GoalTimeline")); export default function AppWithBar(): ReactElement { + const proj = useAppSelector( + (state: StoreState) => state.currentProjectState.project, + (proj1, proj2) => + proj1.id === proj2.id && + proj1.analysisWritingSystems.length === + proj2.analysisWritingSystems.length && + proj1.analysisWritingSystems.every( + (ws, i) => + proj2.analysisWritingSystems[i].bcp47 === ws.bcp47 && + proj2.analysisWritingSystems[i].font === ws.font + ) + ); + + const projFonts = useMemo(() => new ProjectFonts(proj), [proj]); + + const [styleOverrides, setStyleOverrides] = useState(); + useEffect(updateLangFromUser, []); + useEffect(() => { + if (proj.id) { + getProjCss(proj).then((cssLines) => { + setStyleOverrides( + cssLines.join("\n").replaceAll("\r", "").replaceAll("\\", "/") + ); + }); + } + }, [proj]); + + const overrideThemeFont = (theme: Theme) => + styleOverrides + ? createTheme({ + ...theme, + components: { + ...theme.components, + MuiCssBaseline: { + ...theme.components?.MuiCssBaseline, + styleOverrides, + }, + }, + }) + : theme; + return ( <> - - } /> - } /> - } - /> - } /> - } /> - } - /> - } - /> - } /> - } - /> - } /> - + + + + + } /> + } /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } /> + + + ); } diff --git a/src/components/DataEntry/DataEntryTable/EntryCellComponents/GlossWithSuggestions.tsx b/src/components/DataEntry/DataEntryTable/EntryCellComponents/GlossWithSuggestions.tsx index 6148cefedc..6e09698e6b 100644 --- a/src/components/DataEntry/DataEntryTable/EntryCellComponents/GlossWithSuggestions.tsx +++ b/src/components/DataEntry/DataEntryTable/EntryCellComponents/GlossWithSuggestions.tsx @@ -1,8 +1,9 @@ -import { Autocomplete, TextField } from "@mui/material"; +import { Autocomplete } from "@mui/material"; import React, { ReactElement, useContext, useEffect } from "react"; import { Key } from "ts-key-enum"; -import { WritingSystem } from "api"; +import { WritingSystem } from "api/models"; +import { TextFieldWithFont } from "utilities/fontComponents"; import SpellCheckerContext from "utilities/spellCheckerContext"; interface GlossWithSuggestionsProps { @@ -61,12 +62,14 @@ export default function GlossWithSuggestions( props.updateGlossField(newInputValue); }} renderInput={(params) => ( - )} diff --git a/src/components/DataEntry/DataEntryTable/EntryCellComponents/VernWithSuggestions.tsx b/src/components/DataEntry/DataEntryTable/EntryCellComponents/VernWithSuggestions.tsx index f77dff446c..766d745590 100644 --- a/src/components/DataEntry/DataEntryTable/EntryCellComponents/VernWithSuggestions.tsx +++ b/src/components/DataEntry/DataEntryTable/EntryCellComponents/VernWithSuggestions.tsx @@ -1,12 +1,9 @@ -import { - Autocomplete, - AutocompleteCloseReason, - TextField, -} from "@mui/material"; +import { Autocomplete, AutocompleteCloseReason } from "@mui/material"; import React, { ReactElement, useEffect } from "react"; import { Key } from "ts-key-enum"; -import { WritingSystem } from "api"; +import { WritingSystem } from "api/models"; +import { TextFieldWithFont } from "utilities/fontComponents"; interface VernWithSuggestionsProps { isNew?: boolean; @@ -60,13 +57,14 @@ export default function VernWithSuggestions( }} onClose={props.onClose} renderInput={(params) => ( - )} /> diff --git a/src/components/DataEntry/ExistingDataTable/ImmutableExistingData.tsx b/src/components/DataEntry/ExistingDataTable/ImmutableExistingData.tsx index ecc45c2e11..cfe67a86e5 100644 --- a/src/components/DataEntry/ExistingDataTable/ImmutableExistingData.tsx +++ b/src/components/DataEntry/ExistingDataTable/ImmutableExistingData.tsx @@ -1,9 +1,12 @@ -import { Grid, Typography } from "@mui/material"; +import { Grid } from "@mui/material"; import { ReactElement } from "react"; +import { Gloss } from "api/models"; +import { TypographyWithFont } from "utilities/fontComponents"; + interface ImmutableExistingDataProps { + gloss: Gloss; vernacular: string; - gloss: string; } /** @@ -24,19 +27,27 @@ export default function ImmutableExistingData( position: "relative", }} > - {props.vernacular} + + {props.vernacular} + - {props.gloss} + + {props.gloss.def} + ); diff --git a/src/components/DataEntry/ExistingDataTable/index.tsx b/src/components/DataEntry/ExistingDataTable/index.tsx index d597359e4f..6404450829 100644 --- a/src/components/DataEntry/ExistingDataTable/index.tsx +++ b/src/components/DataEntry/ExistingDataTable/index.tsx @@ -38,8 +38,8 @@ export default function ExistingDataTable( {props.domainWords.map((domainWord) => ( ))} diff --git a/src/components/DataEntry/ExistingDataTable/tests/ImmutableExistingData.test.tsx b/src/components/DataEntry/ExistingDataTable/tests/ImmutableExistingData.test.tsx index 5d574787a7..8e0703af83 100644 --- a/src/components/DataEntry/ExistingDataTable/tests/ImmutableExistingData.test.tsx +++ b/src/components/DataEntry/ExistingDataTable/tests/ImmutableExistingData.test.tsx @@ -1,11 +1,14 @@ import renderer from "react-test-renderer"; import ImmutableExistingData from "components/DataEntry/ExistingDataTable/ImmutableExistingData"; +import { newGloss } from "types/word"; describe("ImmutableExistingData", () => { it("render without crashing", () => { renderer.act(() => { - renderer.create(); + renderer.create( + + ); }); }); }); diff --git a/src/components/DataEntry/tests/DataEntryHeader.test.tsx b/src/components/DataEntry/tests/DataEntryHeader.test.tsx index a7d23a2be7..ae7382782c 100644 --- a/src/components/DataEntry/tests/DataEntryHeader.test.tsx +++ b/src/components/DataEntry/tests/DataEntryHeader.test.tsx @@ -4,7 +4,7 @@ import configureMockStore from "redux-mock-store"; import "tests/reactI18nextMock"; -import { SemanticDomainFull } from "api"; +import { SemanticDomainFull } from "api/models"; import DataEntryHeader from "components/DataEntry/DataEntryHeader"; import { newSemanticDomain } from "types/semanticDomain"; diff --git a/src/components/DataEntry/utilities.ts b/src/components/DataEntry/utilities.ts index ee6421296a..b9d438a0e2 100644 --- a/src/components/DataEntry/utilities.ts +++ b/src/components/DataEntry/utilities.ts @@ -22,6 +22,7 @@ export function filterWordsByDomain( ); for (const sense of senses) { if (sense.semanticDomains.map((dom) => dom.id).includes(domainId)) { + // Only the first gloss is shown, and no definitions. domainWords.push(new DomainWord({ ...currentWord, senses: [sense] })); } } @@ -30,8 +31,9 @@ export function filterWordsByDomain( } export function sortDomainWordsByVern(words: DomainWord[]): DomainWord[] { - return words.sort((a, b) => { - const comp = a.vernacular.localeCompare(b.vernacular); - return comp !== 0 ? comp : a.gloss.localeCompare(b.gloss); - }); + return words.sort( + (a, b) => + a.vernacular.localeCompare(b.vernacular) || + a.gloss.def.localeCompare(b.gloss.def) + ); } diff --git a/src/components/Statistics/tests/__snapshots__/Statistics.test.tsx.snap b/src/components/Statistics/tests/__snapshots__/Statistics.test.tsx.snap index f5ea6a10d8..e49c48c0e1 100644 --- a/src/components/Statistics/tests/__snapshots__/Statistics.test.tsx.snap +++ b/src/components/Statistics/tests/__snapshots__/Statistics.test.tsx.snap @@ -33,7 +33,7 @@ Array [ className="MuiListItemText-root css-tlelie-MuiListItemText-root" > statistics.view.user @@ -67,7 +67,7 @@ Array [ className="MuiListItemText-root css-tlelie-MuiListItemText-root" > statistics.view.domain @@ -101,7 +101,7 @@ Array [ className="MuiListItemText-root css-tlelie-MuiListItemText-root" > statistics.view.day @@ -135,7 +135,7 @@ Array [ className="MuiListItemText-root css-tlelie-MuiListItemText-root" > statistics.view.workshop @@ -153,7 +153,7 @@ Array [ className="MuiGrid-root MuiGrid-item css-13i4rnv-MuiGrid-root" >

statistics.title

@@ -162,7 +162,7 @@ Array [ className="MuiGrid-root MuiGrid-item css-13i4rnv-MuiGrid-root" >
statistics.view.user
@@ -198,7 +198,7 @@ Array [ } >
statistics.column.username
@@ -214,7 +214,7 @@ Array [ } >
statistics.column.domainCount
@@ -230,7 +230,7 @@ Array [ } >
statistics.column.senseCount
@@ -258,7 +258,7 @@ Array [ className="MuiListItemText-root css-tlelie-MuiListItemText-root" > statistics.domainProgress @@ -290,7 +290,7 @@ Array [ className="MuiBox-root css-0" >

statistics.percent

@@ -308,7 +308,7 @@ Array [ className="MuiListItemText-root css-tlelie-MuiListItemText-root" > statistics.domainsCollected @@ -325,7 +325,7 @@ Array [ className="MuiListItemText-root css-tlelie-MuiListItemText-root" > statistics.wordsCollected diff --git a/src/goals/CharacterInventory/CharInv/CharacterDetail/CharacterWords.tsx b/src/goals/CharacterInventory/CharInv/CharacterDetail/CharacterWords.tsx index d6aad8dd7d..bb56bebaf4 100644 --- a/src/goals/CharacterInventory/CharInv/CharacterDetail/CharacterWords.tsx +++ b/src/goals/CharacterInventory/CharInv/CharacterDetail/CharacterWords.tsx @@ -5,6 +5,7 @@ import { useSelector } from "react-redux"; import { StoreState } from "types"; import { themeColors } from "types/theme"; +import { TypographyWithFont } from "utilities/fontComponents"; interface CharacterWordsProps { character: string; @@ -24,9 +25,13 @@ export default function CharacterWords( <> {t("charInventory.examples")} {words.map((word) => ( - + {highlightCharacterInWord(props.character, word)} - + ))} ); diff --git a/src/goals/CharacterInventory/CharInv/CharacterEntry.tsx b/src/goals/CharacterInventory/CharInv/CharacterEntry.tsx index 7ded32254e..a5f57655f4 100644 --- a/src/goals/CharacterInventory/CharInv/CharacterEntry.tsx +++ b/src/goals/CharacterInventory/CharInv/CharacterEntry.tsx @@ -1,5 +1,5 @@ import { KeyboardArrowDown } from "@mui/icons-material"; -import { Button, Collapse, Grid, TextField } from "@mui/material"; +import { Button, Collapse, Grid } from "@mui/material"; import { ReactElement, ReactNode, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -10,6 +10,7 @@ import { import { StoreState } from "types"; import { useAppDispatch, useAppSelector } from "types/hooks"; import theme from "types/theme"; +import { TextFieldWithFont } from "utilities/fontComponents"; /** * Allows for viewing and entering accepted and rejected characters in a @@ -84,7 +85,7 @@ interface CharactersInputProps { function CharactersInput(props: CharactersInputProps): ReactElement { return ( - ); } diff --git a/src/goals/CharacterInventory/CharInv/CharacterList/CharacterCard.tsx b/src/goals/CharacterInventory/CharInv/CharacterList/CharacterCard.tsx index c1ea6e70d8..e648e560a7 100644 --- a/src/goals/CharacterInventory/CharInv/CharacterList/CharacterCard.tsx +++ b/src/goals/CharacterInventory/CharInv/CharacterList/CharacterCard.tsx @@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next"; import CharacterStatusText from "goals/CharacterInventory/CharInv/CharacterList/CharacterStatusText"; import { CharacterStatus } from "goals/CharacterInventory/CharacterInventoryTypes"; import theme from "types/theme"; +import { TypographyWithFont } from "utilities/fontComponents"; interface CharacterCardProps { char: string; @@ -23,20 +24,21 @@ export default function CharacterCard(props: CharacterCardProps) { onClick={props.onClick} > - {props.char} {""} {/* There is a zero-width joiner here to make height consistent for non-printing characters. */} - + diff --git a/src/goals/MergeDuplicates/MergeDupsCompleted.tsx b/src/goals/MergeDuplicates/MergeDupsCompleted.tsx index f23d4ebc90..c078e1b4bd 100644 --- a/src/goals/MergeDuplicates/MergeDupsCompleted.tsx +++ b/src/goals/MergeDuplicates/MergeDupsCompleted.tsx @@ -1,6 +1,6 @@ import { ArrowRightAlt } from "@mui/icons-material"; import { Button, Card, Grid, Paper, Typography } from "@mui/material"; -import React, { ReactElement, useEffect, useState } from "react"; +import { ReactElement, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useSelector } from "react-redux"; @@ -13,6 +13,7 @@ import { MergesCompleted } from "goals/MergeDuplicates/MergeDupsTypes"; import { StoreState } from "types"; import theme from "types/theme"; import { newFlag } from "types/word"; +import { TypographyWithFont } from "utilities/fontComponents"; export default function MergeDupsCompleted(): ReactElement { const changes = useSelector( @@ -22,21 +23,13 @@ export default function MergeDupsCompleted(): ReactElement { const { t } = useTranslation(); return ( - + <> {t("mergeDups.title")} - {MergesMade(changes)} - - ); -} - -function MergesMade(changes: MergesCompleted): ReactElement { - return ( -
{MergesCount(changes)} {changes.merges?.map(MergeChange)} -
+ ); } @@ -188,7 +181,9 @@ function WordPaper(props: WordPaperProps): ReactElement { > - {word?.vernacular} + + {word?.vernacular} + diff --git a/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/DropWord.tsx b/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/DropWord.tsx index c892a28ea7..4fd9e7c98b 100644 --- a/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/DropWord.tsx +++ b/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/DropWord.tsx @@ -14,6 +14,7 @@ import { import { MergeTreeState } from "goals/MergeDuplicates/Redux/MergeDupsReduxTypes"; import { useAppDispatch } from "types/hooks"; import theme from "types/theme"; +import { TypographyWithFont } from "utilities/fontComponents"; interface DropWordProps { mergeState: MergeTreeState; @@ -73,7 +74,9 @@ export default function DropWord(props: DropWordProps): ReactElement { > {verns.map((vern) => ( - {vern} + + {vern} + ))} diff --git a/src/goals/MergeDuplicates/MergeDupsStep/SenseCardContent.tsx b/src/goals/MergeDuplicates/MergeDupsStep/SenseCardContent.tsx index 0e4d4b3664..adc68190c1 100644 --- a/src/goals/MergeDuplicates/MergeDupsStep/SenseCardContent.tsx +++ b/src/goals/MergeDuplicates/MergeDupsStep/SenseCardContent.tsx @@ -10,11 +10,12 @@ import { TableRow, Typography, } from "@mui/material"; -import { Fragment, ReactElement } from "react"; +import { ReactElement } from "react"; import { GramCatGroup, Sense, Status } from "api/models"; import { IconButtonWithTooltip, PartOfSpeechButton } from "components/Buttons"; import theme from "types/theme"; +import { TypographyWithFont } from "utilities/fontComponents"; interface SenseInLanguage { language: string; // bcp-47 code @@ -54,47 +55,54 @@ function getSenseInLanguages( return languages.map((l) => getSenseInLanguage(sense, l)); } -function senseText(senseInLangs: SenseInLanguage[]): ReactElement { +interface SenseTextRowsProps { + senseInLang: SenseInLanguage; +} + +function SenseTextRows(props: SenseTextRowsProps): ReactElement { + const lang = props.senseInLang.language; return ( - - - {senseInLangs.map((sInLang, index) => ( - - - - {`${sInLang.language}: `} - - - - {sInLang.glossText} - - - - {!!sInLang.definitionText && ( - - - -
- - {sInLang.definitionText} - -
-
-
- )} -
- ))} -
-
+ <> + + + + {lang} + {":"} + + + + + {props.senseInLang.glossText} + + + + {!!props.senseInLang.definitionText && ( + + + +
+ + {props.senseInLang.definitionText} + +
+
+
+ )} + ); } @@ -173,7 +181,13 @@ export default function SenseCardContent( )} {/* List glosses and (if any) definitions. */} - {senseText(senseTextInLangs)} + + + {senseTextInLangs.map((senseInLang, index) => ( + + ))} + +
{/* List semantic domains. */} {semDoms.map((dom) => ( diff --git a/src/goals/ReviewEntries/ReviewEntriesComponent/CellComponents/DefinitionCell.tsx b/src/goals/ReviewEntries/ReviewEntriesComponent/CellComponents/DefinitionCell.tsx index 68cbefa00c..83abb70f5b 100644 --- a/src/goals/ReviewEntries/ReviewEntriesComponent/CellComponents/DefinitionCell.tsx +++ b/src/goals/ReviewEntries/ReviewEntriesComponent/CellComponents/DefinitionCell.tsx @@ -1,4 +1,4 @@ -import { Input, TextField } from "@mui/material"; +import { Input } from "@mui/material"; import { ReactElement } from "react"; import { useTranslation } from "react-i18next"; import { useSelector } from "react-redux"; @@ -12,6 +12,7 @@ import { ReviewEntriesSense } from "goals/ReviewEntries/ReviewEntriesComponent/R import { StoreState } from "types"; import { themeColors } from "types/theme"; import { newDefinition } from "types/word"; +import { TextFieldWithFont } from "utilities/fontComponents"; interface DefinitionCellProps extends FieldParameterStandard { editable?: boolean; @@ -114,9 +115,10 @@ interface DefinitionFieldProps { function DefinitionField(props: DefinitionFieldProps): ReactElement { return ( - - state.currentProjectState.project.analysisWritingSystems[0].bcp47 + state.currentProjectState.project.analysisWritingSystems[0] ); - const { t } = useTranslation(); return ( - props.editable ? ( - - props.onRowDataChange && - props.onRowDataChange({ - ...props.rowData, - senses: [ - ...props.rowData.senses.slice(0, index), - { - ...sense, - glosses, - }, - ...props.rowData.senses.slice(index + 1), - ], - }) - } - /> - ) : ( - - ) - )} + contents={props.rowData.senses.map((sense, index) => ( + + props.onRowDataChange && + props.onRowDataChange({ + ...props.rowData, + senses: [ + ...props.rowData.senses.slice(0, index), + { ...sense, glosses }, + ...props.rowData.senses.slice(index + 1), + ], + }) + } + /> + ))} bottomCell={props.editable ? SPACER : undefined} /> ); } interface GlossListProps { + defaultLang: WritingSystem; + editable?: boolean; glosses: Gloss[]; - defaultLang: string; - keyPrefix: string; + idPrefix: string; onChange: (glosses: Gloss[]) => void; } function GlossList(props: GlossListProps): ReactElement { - const langs = props.glosses.map((g) => g.language); - const glosses = langs.includes(props.defaultLang) + const { t } = useTranslation(); + + if (!props.editable) { + if (!props.glosses.find((g) => g.def)) { + return {t("reviewEntries.noGloss")}; + } + return ( + <> + {props.glosses + .filter((g) => g.def) + .map((g, i) => ( + + {g.def} + + ))} + + ); + } + + const glosses = props.glosses.find( + (g) => g.language === props.defaultLang.bcp47 + ) ? props.glosses - : [...props.glosses, newGloss("", props.defaultLang)]; + : [...props.glosses, newGloss("", props.defaultLang.bcp47)]; return ( <> {glosses.map((g, i) => ( { const updatedGlosses = [...glosses]; updatedGlosses.splice(i, 1, gloss); @@ -112,19 +114,17 @@ interface GlossFieldProps { function GlossField(props: GlossFieldProps): ReactElement { return ( - - props.onChange({ - language: props.gloss.language, - def: event.target.value, - }) + props.onChange(newGloss(event.target.value, props.gloss.language)) } /> ); diff --git a/src/goals/ReviewEntries/ReviewEntriesComponent/CellComponents/VernacularCell.tsx b/src/goals/ReviewEntries/ReviewEntriesComponent/CellComponents/VernacularCell.tsx index dcb6f932ba..0ca5fff7d5 100644 --- a/src/goals/ReviewEntries/ReviewEntriesComponent/CellComponents/VernacularCell.tsx +++ b/src/goals/ReviewEntries/ReviewEntriesComponent/CellComponents/VernacularCell.tsx @@ -1,8 +1,8 @@ -import { TextField } from "@mui/material"; import { ReactElement } from "react"; import { useTranslation } from "react-i18next"; import { FieldParameterStandard } from "goals/ReviewEntries/ReviewEntriesComponent/CellColumns"; +import { TextFieldWithFont } from "utilities/fontComponents"; interface VernacularCellProps extends FieldParameterStandard { editable?: boolean; @@ -14,7 +14,7 @@ export default function VernacularCell( const { t } = useTranslation(); return ( - ); } diff --git a/src/goals/ReviewEntries/ReviewEntriesComponent/tests/ReviewEntriesComponent.test.tsx b/src/goals/ReviewEntries/ReviewEntriesComponent/tests/ReviewEntriesComponent.test.tsx index aa7c2d3252..81c8d95004 100644 --- a/src/goals/ReviewEntries/ReviewEntriesComponent/tests/ReviewEntriesComponent.test.tsx +++ b/src/goals/ReviewEntries/ReviewEntriesComponent/tests/ReviewEntriesComponent.test.tsx @@ -6,9 +6,11 @@ import configureMockStore from "redux-mock-store"; import "tests/reactI18nextMock"; import ReviewEntriesComponent from "goals/ReviewEntries/ReviewEntriesComponent/ReviewEntriesComponent"; +import { ReviewEntriesWord } from "goals/ReviewEntries/ReviewEntriesComponent/ReviewEntriesTypes"; import mockWords, { mockCreateWord, } from "goals/ReviewEntries/ReviewEntriesComponent/tests/WordsMock"; +import { defaultWritingSystem } from "types/writingSystem"; const mockGetFrontierWords = jest.fn(); const mockMaterialTable = jest.fn(); @@ -45,7 +47,11 @@ jest.mock("components/TreeView", () => "div"); const mockReviewEntryWords = mockWords(); const state = { currentProjectState: { - project: { definitionsEnabled: true }, + project: { + analysisWritingSystems: [defaultWritingSystem], + definitionsEnabled: true, + vernacularWritingSystem: defaultWritingSystem, + }, }, reviewEntriesState: { words: mockReviewEntryWords }, treeViewState: { @@ -89,6 +95,11 @@ beforeEach(async () => { describe("ReviewEntriesComponent", () => { it("Initializes correctly", () => { - expect(mockUpdateAllWords).toHaveBeenCalledWith(mockReviewEntryWords); + expect(mockUpdateAllWords).toHaveBeenCalledTimes(1); + const wordIds = mockUpdateAllWords.mock.calls[0][0].map( + (w: ReviewEntriesWord) => w.id + ); + expect(wordIds).toHaveLength(mockReviewEntryWords.length); + mockReviewEntryWords.forEach((w) => expect(wordIds).toContain(w.id)); }); }); diff --git a/src/goals/ReviewEntries/ReviewEntriesComponent/tests/WordsMock.ts b/src/goals/ReviewEntries/ReviewEntriesComponent/tests/WordsMock.ts index 57295adf1d..c2a70a592c 100644 --- a/src/goals/ReviewEntries/ReviewEntriesComponent/tests/WordsMock.ts +++ b/src/goals/ReviewEntries/ReviewEntriesComponent/tests/WordsMock.ts @@ -4,7 +4,14 @@ import { ReviewEntriesWord, } from "goals/ReviewEntries/ReviewEntriesComponent/ReviewEntriesTypes"; import { newSemanticDomain } from "types/semanticDomain"; -import { newFlag, newNote, newSense, newWord } from "types/word"; +import { + newDefinition, + newFlag, + newGloss, + newNote, + newSense, + newWord, +} from "types/word"; import { Bcp47Code } from "types/writingSystem"; export default function mockWords(): ReviewEntriesWord[] { @@ -16,14 +23,14 @@ export default function mockWords(): ReviewEntriesWord[] { senses: [ { ...new ReviewEntriesSense(), - guid: "1", + guid: "0.0", glosses: [ - { def: "bup", language: Bcp47Code.En }, - { def: "AHH", language: Bcp47Code.Es }, + newGloss("bup", Bcp47Code.En), + newGloss("AHH", Bcp47Code.Es), ], definitions: [ - { text: "bup-bup", language: Bcp47Code.Ar }, - { text: "AHH-AHH", language: Bcp47Code.Fr }, + newDefinition("bup-bup", Bcp47Code.Ar), + newDefinition("AHH-AHH", Bcp47Code.Fr), ], domains: [newSemanticDomain("number", "domain")], }, @@ -37,8 +44,8 @@ export default function mockWords(): ReviewEntriesWord[] { senses: [ { ...new ReviewEntriesSense(), - guid: "2", - glosses: [{ def: "gloss", language: Bcp47Code.En }], + guid: "1.1", + glosses: [newGloss("gloss", Bcp47Code.En)], domains: [newSemanticDomain("number", "domain")], partOfSpeech: { catGroup: GramCatGroup.Other, diff --git a/src/types/runtimeConfig.ts b/src/types/runtimeConfig.ts index ef20e7c5d0..b9ddcd58bb 100644 --- a/src/types/runtimeConfig.ts +++ b/src/types/runtimeConfig.ts @@ -2,6 +2,7 @@ interface RuntimeConfigItems { baseUrl: string; captchaRequired: boolean; captchaSiteKey: string; + offline: boolean; emailServicesEnabled: boolean; showCertExpiration: boolean; } @@ -17,6 +18,7 @@ const defaultConfig: RuntimeConfigItems = { baseUrl: "http://localhost:5000", captchaRequired: true, captchaSiteKey: "6Le6BL0UAAAAAMjSs1nINeB5hqDZ4m3mMg3k67x3", + offline: false, emailServicesEnabled: true, showCertExpiration: true, }; @@ -47,13 +49,10 @@ export class RuntimeConfig { return window.runtimeConfig.baseUrl; } - let baseUrl: string; if (window.runtimeConfig.hasOwnProperty("useConnectionBaseUrlForApi")) { - baseUrl = `${window.location.protocol}//${window.location.host}`; - } else { - baseUrl = defaultConfig.baseUrl; + return `${window.location.protocol}//${window.location.host}`; } - return baseUrl; + return defaultConfig.baseUrl; } public appRelease(): string { @@ -78,12 +77,22 @@ export class RuntimeConfig { } public emailServicesEnabled(): boolean { + if (RuntimeConfig._instance.isOffline()) { + return false; + } if (window.runtimeConfig.hasOwnProperty("emailServicesEnabled")) { return window.runtimeConfig.emailServicesEnabled; } return defaultConfig.emailServicesEnabled; } + public isOffline(): boolean { + if (window.runtimeConfig.hasOwnProperty("offline")) { + return window.runtimeConfig.offline; + } + return defaultConfig.offline; + } + public showCertExpiration(): boolean { if (window.runtimeConfig.hasOwnProperty("showCertExpiration")) { return window.runtimeConfig.showCertExpiration; diff --git a/src/types/theme.ts b/src/types/theme.ts index 87b928f61e..a8e839f6b4 100644 --- a/src/types/theme.ts +++ b/src/types/theme.ts @@ -4,7 +4,6 @@ import { responsiveFontSizes, PaletteOptions, } from "@mui/material/styles"; -import { TypographyOptions } from "@mui/material/styles/createTypography"; export type HEX = `#${string}`; @@ -23,7 +22,7 @@ export const themeColors: { [key: string]: HEX } = { }; // Constants used in multiple themes -const palette: Partial = { +const palette: PaletteOptions = { primary: { main: themeColors.primary }, secondary: { main: themeColors.secondary }, error: { main: themeColors.error }, @@ -32,27 +31,25 @@ const palette: Partial = { tonalOffset: 0.2, }; -const typography: Partial = { - // Copied from default theme - fontFamily: [ - '"Roboto"', - '"Noto Sans"', - '"Helvetica"', - '"Arial"', - "sans-serif", - ].join(","), -}; +const fontFamily: React.CSSProperties["fontFamily"] = [ + "'Noto Sans'", + "'Open Sans'", + "Roboto", + "Helvetica", + "Arial", + "sans-serif", +].join(","); const dynamicFontParams = { factor: 2 }; // Theme for the entire project const baseTheme = createTheme({ - typography: { ...typography }, - palette: { ...palette } as PaletteOptions, - spacing: 8, components: { MuiButtonBase: { styleOverrides: { root: { disableRipple: false } } }, }, + palette, + spacing: 8, + typography: { fontFamily }, }); // Can have a number of additional options passed in; here, sticks with defaults diff --git a/src/types/word.ts b/src/types/word.ts index 24d155ac09..68e0230423 100644 --- a/src/types/word.ts +++ b/src/types/word.ts @@ -78,15 +78,14 @@ export class DomainWord { wordGuid: string; vernacular: string; senseGuid: string; - gloss: string; + gloss: Gloss; constructor(word: Word, senseIndex = 0, glossIndex = 0) { const sense = word.senses[senseIndex] ?? newSense(); - const gloss = sense.glosses[glossIndex] ?? newGloss(); + this.gloss = sense.glosses[glossIndex] ?? newGloss(); this.wordGuid = word.guid; this.vernacular = word.vernacular; this.senseGuid = sense.guid; - this.gloss = gloss.def; } } diff --git a/src/utilities/fontComponents.tsx b/src/utilities/fontComponents.tsx new file mode 100644 index 0000000000..bd39782961 --- /dev/null +++ b/src/utilities/fontComponents.tsx @@ -0,0 +1,66 @@ +import { + TextField, + TextFieldProps, + Typography, + TypographyProps, +} from "@mui/material"; +import { ReactElement, useContext } from "react"; + +import FontContext, { WithFontProps } from "utilities/fontContext"; + +/* Various MUI components for use within a FontContext + * to add the appropriate font to that component. */ + +// Cannot use `interface` with `extends` because TextFieldProps isn't static. +type TextFieldWithFontProps = TextFieldProps & WithFontProps; + +/** + * TextField modified for use within a FontContext. + * Input props are extended with 3 optional props: + * analysis: bool? (used to apply the default analysis font); + * lang: string? (bcp47 lang-tag for applying the appropriate analysis font); + * vernacular: bool? (used to apply the vernacular font). + */ +export function TextFieldWithFont(props: TextFieldWithFontProps): ReactElement { + const fontContext = useContext(FontContext); + // Use spread to remove the custom props from what is passed into TextField. + const { analysis, lang, vernacular, ...textFieldProps } = props; + return ( + + ); +} + +type TypographyWithFontProps = TypographyProps & WithFontProps; + +/** + * Typography modified for use within a FontContext. + * Input props are extended with 3 optional props: + * analysis: bool? (used to apply the default analysis font); + * lang: string? (bcp47 lang-tag for applying the appropriate analysis font); + * vernacular: bool? (used to apply the vernacular font). + */ +export function TypographyWithFont( + props: TypographyWithFontProps +): ReactElement { + const fontContext = useContext(FontContext); + // Use spread to remove the custom props from what is passed into Typography. + const { analysis, lang, vernacular, ...typographyProps } = props; + return ( + + ); +} diff --git a/src/utilities/fontContext.ts b/src/utilities/fontContext.ts new file mode 100644 index 0000000000..ec395ca483 --- /dev/null +++ b/src/utilities/fontContext.ts @@ -0,0 +1,72 @@ +import { CSSProperties, createContext } from "react"; + +import { Project } from "api/models"; +import { Hash } from "types/hash"; + +export type WithFontProps = { + analysis?: boolean; + lang?: string; + vernacular?: boolean; +}; + +export class ProjectFonts { + readonly analysisFont: string; + private readonly inherit = "inherit"; + private readonly langMap: Hash = {}; + readonly vernacularFont: string; + + constructor(proj?: Project) { + this.analysisFont = this.inherit; + this.vernacularFont = this.inherit; + if (!proj) { + return; + } + + proj.analysisWritingSystems.reverse().forEach((ws) => { + const font = ws.font.replaceAll(" ", ""); + if (font) { + this.langMap[ws.bcp47] = font; + } + }); + + if (proj.analysisWritingSystems.length) { + this.analysisFont = + proj.analysisWritingSystems[0].font.replaceAll(" ", "") || this.inherit; + } + + const vernFont = proj.vernacularWritingSystem.font.replaceAll(" ", ""); + if (vernFont) { + this.vernacularFont = vernFont; + this.langMap[proj.vernacularWritingSystem.bcp47] = vernFont; + } + } + + getLangFont(bcp47: string): string { + if (bcp47 in this.langMap) { + return this.langMap[bcp47] || this.inherit; + } + return this.inherit; + } + + /** + * Conditionally adds a fontFamily to the style. + * Precedence, from highest to lowest, moving to the next one if falsy: + * vernacular; lang; analysis; fontFamily of input style; "inherit". + */ + addFontToStyle(props: WithFontProps, style?: CSSProperties): CSSProperties { + return { + ...style, + fontFamily: props.vernacular + ? this.vernacularFont + : props.lang + ? this.getLangFont(props.lang) + : props.analysis + ? this.analysisFont + : style?.fontFamily ?? this.inherit, + }; + } +} + +const FontContext = createContext(new ProjectFonts()); + +export default FontContext; diff --git a/src/utilities/fontCssUtilities.ts b/src/utilities/fontCssUtilities.ts new file mode 100644 index 0000000000..61e6934485 --- /dev/null +++ b/src/utilities/fontCssUtilities.ts @@ -0,0 +1,115 @@ +import { Project } from "api"; +import { Hash } from "types/hash"; +import { RuntimeConfig } from "types/runtimeConfig"; + +const fontDir = "/fonts"; +const fallbackFilePath = `${fontDir}/fallback.json`; + +/** Given a url, returns the text content of the result, or undefined if fetch fails. */ +async function fetchText(url: string): Promise { + const resp = await fetch(url); + if (resp.ok) { + return await (await resp.blob()).text(); + } + + if (RuntimeConfig.getInstance().isOffline()) { + // In an offline setting, all necessary fonts should be pre-loaded. + console.log( + `Failed to load file: ${url}\nPlease notify the admin this font is unavailable.` + ); + } else { + console.log( + `Checked if this file exists: ${url}\nIt does not and that is probably okay.` + ); + } +} + +/** Given a font and source, returns css info for the font from the source. + * If substitute is specified, sub that in as the "font-family". */ +async function fetchCss( + font: string, + source: string, + substitute?: string +): Promise { + var cssUrl = ""; + switch (source) { + case "local": + cssUrl = `${fontDir}/${font.replace(" ", "")}.css`; + break; + case "google": + cssUrl = `https://fonts.googleapis.com/css?dispay=swap&family=${font}`; + break; + default: + return; + } + + const cssText = await fetchText(cssUrl); + if (cssText && substitute) { + // This assumes the only place in the css info with the full, capitalized font name + // is the "font-family: ..." (as is the case from the Google api). + return cssText.replaceAll(font, substitute); + } + return cssText; +} + +/** Given a list of fonts with no local css files, + * returns css info from a remote source (default: google). */ +async function getFallbacks( + fonts: string[], + source = "google" +): Promise { + if (!fonts.length) { + return []; + } + const fallbackText = await fetchText(fallbackFilePath); + if (!fallbackText || fallbackText[0] !== "{") { + console.error(`Failed to load: ${fallbackFilePath}`); + return []; + } + const fallbackJson: Hash> = JSON.parse(fallbackText); + if (!(source in fallbackJson) || !fallbackJson[source]) { + console.error(`Source "${source}" not in file: ${fallbackFilePath}`); + return []; + } + const fallback = fallbackJson[source]; + const cssPromises = fonts + .filter((f) => f in fallback) + .map((f) => [f, fallback[f]]) + .map(async (fs) => await fetchCss(fs[1], source, fs[0])); + return (await Promise.all(cssPromises)).filter((css): css is string => !!css); +} + +/** Given an array of font names, returns css info for them all. */ +async function getCss(fonts: string[]) { + // Get local css files when available. + const cssDict: Hash = {}; + const cssPromises = fonts.map(async (f) => { + cssDict[f] = (await fetchCss(f, "local")) ?? ""; + }); + await Promise.all(cssPromises); + + // Get remote fallbacks for the rest. + const cssStrings: string[] = []; + const needFallback: string[] = []; + fonts.forEach((f) => { + const css = cssDict[f]; + if (css && css[0] === "@") { + cssStrings.push(css); + } else { + needFallback.push(f); + } + }); + // If no internet expected, don't execute this line with getFallbacks(). + if (!RuntimeConfig.getInstance().isOffline()) { + cssStrings.push(...(await getFallbacks(needFallback))); + } + return cssStrings; +} + +/** Given a project, returns css info for all project language fonts. */ +export async function getProjCss(proj: Project) { + const fonts = proj.analysisWritingSystems.map((ws) => ws.font); + fonts.push(proj.vernacularWritingSystem.font); + const filtered = [...new Set(fonts.filter((f) => f))]; + return await getCss(filtered); +} diff --git a/src/utilities/tests/fontContext.test.ts b/src/utilities/tests/fontContext.test.ts new file mode 100644 index 0000000000..bfe55f9ad7 --- /dev/null +++ b/src/utilities/tests/fontContext.test.ts @@ -0,0 +1,44 @@ +import { newProject } from "types/project"; +import { newWritingSystem } from "types/writingSystem"; +import { ProjectFonts } from "utilities/fontContext"; + +const inherit = "inherit"; + +describe("ProjectFonts", () => { + describe("constructor", () => { + it("handles no project input", () => { + const projFonts = new ProjectFonts(); + expect(projFonts.analysisFont === inherit); + expect(projFonts.getLangFont("") === inherit); + expect(projFonts.vernacularFont === inherit); + }); + + it("correctly defaults to 'inherit' for projects with no specified fonts", () => { + const projFonts = new ProjectFonts(newProject()); + expect(projFonts.analysisFont === inherit); + expect(projFonts.getLangFont("") === inherit); + expect(projFonts.vernacularFont === inherit); + }); + + it("correctly sets fonts", () => { + const aBcp = ["sns", "srf", "thr"]; + const aFont = ["Analysis Sans", "Analysis Serif", "Noto Other"]; + const vernFont = "Noto Sans Vern"; + + const proj = newProject(); + proj.analysisWritingSystems = aBcp.map((bcp, i) => + newWritingSystem(bcp, `name${i}`, aFont[i]) + ); + proj.vernacularWritingSystem.font = vernFont; + + const projFonts = new ProjectFonts(proj); + expect(projFonts.analysisFont === aFont[0].replaceAll(" ", "")); + expect(projFonts.getLangFont("") === inherit); + aBcp.forEach((bcp, i) => { + expect(projFonts.getLangFont(bcp) === aFont[i].replaceAll(" ", "")); + }); + expect(projFonts.getLangFont("not-a-lang-in-the-proj") === inherit); + expect(projFonts.vernacularFont === vernFont.replaceAll(" ", "")); + }); + }); +});