From c018c6fcf5bee63020c2a73082a21d3bfeefc74f Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Tue, 21 Jan 2025 14:16:00 +0100 Subject: [PATCH 01/16] =?UTF-8?q?=F0=9F=94=A7(backend)=20add=20posthog=20c?= =?UTF-8?q?onfiguration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We add the posthog configuration to the project. We will expose the posthog configuration to the frontend. --- src/backend/core/api/viewsets.py | 1 + src/backend/core/tests/test_api_config.py | 2 ++ src/backend/impress/settings.py | 5 +++++ 3 files changed, 8 insertions(+) diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 0f83e1e5..02b0f277 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -1124,6 +1124,7 @@ class ConfigView(drf.views.APIView): "ENVIRONMENT", "FRONTEND_THEME", "MEDIA_BASE_URL", + "POSTHOG_KEY", "LANGUAGES", "LANGUAGE_CODE", "SENTRY_DSN", diff --git a/src/backend/core/tests/test_api_config.py b/src/backend/core/tests/test_api_config.py index a5eb151f..a625260e 100644 --- a/src/backend/core/tests/test_api_config.py +++ b/src/backend/core/tests/test_api_config.py @@ -20,6 +20,7 @@ pytestmark = pytest.mark.django_db CRISP_WEBSITE_ID="123", FRONTEND_THEME="test-theme", MEDIA_BASE_URL="http://testserver/", + POSTHOG_KEY={"id": "132456", "host": "https://eu.i.posthog-test.com"}, SENTRY_DSN="https://sentry.test/123", ) @pytest.mark.parametrize("is_authenticated", [False, True]) @@ -41,5 +42,6 @@ def test_api_config(is_authenticated): "LANGUAGES": [["en-us", "English"], ["fr-fr", "French"], ["de-de", "German"]], "LANGUAGE_CODE": "en-us", "MEDIA_BASE_URL": "http://testserver/", + "POSTHOG_KEY": {"id": "132456", "host": "https://eu.i.posthog-test.com"}, "SENTRY_DSN": "https://sentry.test/123", } diff --git a/src/backend/impress/settings.py b/src/backend/impress/settings.py index 495ec4bc..df1cd39b 100755 --- a/src/backend/impress/settings.py +++ b/src/backend/impress/settings.py @@ -390,6 +390,11 @@ class Base(Configuration): None, environ_name="FRONTEND_THEME", environ_prefix=None ) + # Posthog + POSTHOG_KEY = values.DictValue( + None, environ_name="POSTHOG_KEY", environ_prefix=None + ) + # Crisp CRISP_WEBSITE_ID = values.Value( None, environ_name="CRISP_WEBSITE_ID", environ_prefix=None From 97cfa2c1ad13f0051954c87cc3ca259a98dfec8a Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Tue, 21 Jan 2025 14:18:44 +0100 Subject: [PATCH 02/16] =?UTF-8?q?=E2=9C=A8(frontend)=20integrate=20posthog?= =?UTF-8?q?=20analytics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We integrate posthog, it will help us to track user behavior and improve the product. We get the configuration from the backend config endpoint. --- .../e2e/__tests__/app-impress/config.spec.ts | 1 + src/frontend/apps/impress/package.json | 1 + .../src/core/config/ConfigProvider.tsx | 4 +- .../impress/src/core/config/api/useConfig.tsx | 2 + .../apps/impress/src/services/Posthog.tsx | 46 +++++++++++++++++++ .../apps/impress/src/services/index.ts | 1 + src/frontend/yarn.lock | 27 ++++++++++- 7 files changed, 79 insertions(+), 3 deletions(-) create mode 100644 src/frontend/apps/impress/src/services/Posthog.tsx diff --git a/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts index cbc9a025..042c6287 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts @@ -16,6 +16,7 @@ const config = { ['de-de', 'German'], ], LANGUAGE_CODE: 'en-us', + POSTHOG_KEY: {}, SENTRY_DSN: null, }; diff --git a/src/frontend/apps/impress/package.json b/src/frontend/apps/impress/package.json index 658588e5..7ae5efa6 100644 --- a/src/frontend/apps/impress/package.json +++ b/src/frontend/apps/impress/package.json @@ -31,6 +31,7 @@ "lodash": "4.17.21", "luxon": "3.5.0", "next": "15.1.3", + "posthog-js": "1.204.0", "react": "*", "react-aria-components": "1.5.0", "react-dom": "*", diff --git a/src/frontend/apps/impress/src/core/config/ConfigProvider.tsx b/src/frontend/apps/impress/src/core/config/ConfigProvider.tsx index 57033110..8d021b14 100644 --- a/src/frontend/apps/impress/src/core/config/ConfigProvider.tsx +++ b/src/frontend/apps/impress/src/core/config/ConfigProvider.tsx @@ -3,7 +3,7 @@ import { PropsWithChildren, useEffect } from 'react'; import { Box } from '@/components'; import { useCunninghamTheme } from '@/cunningham'; -import { configureCrispSession } from '@/services'; +import { PostHogProvider, configureCrispSession } from '@/services'; import { useSentryStore } from '@/stores/useSentryStore'; import { useConfig } from './api/useConfig'; @@ -45,5 +45,5 @@ export const ConfigProvider = ({ children }: PropsWithChildren) => { ); } - return children; + return {children}; }; diff --git a/src/frontend/apps/impress/src/core/config/api/useConfig.tsx b/src/frontend/apps/impress/src/core/config/api/useConfig.tsx index 5cb6d3a9..7f26e9a8 100644 --- a/src/frontend/apps/impress/src/core/config/api/useConfig.tsx +++ b/src/frontend/apps/impress/src/core/config/api/useConfig.tsx @@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query'; import { APIError, errorCauses, fetchAPI } from '@/api'; import { Theme } from '@/cunningham/'; +import { PostHogConf } from '@/services'; interface ConfigResponse { LANGUAGES: [string, string][]; @@ -11,6 +12,7 @@ interface ConfigResponse { CRISP_WEBSITE_ID?: string; FRONTEND_THEME?: Theme; MEDIA_BASE_URL?: string; + POSTHOG_KEY?: PostHogConf; SENTRY_DSN?: string; } diff --git a/src/frontend/apps/impress/src/services/Posthog.tsx b/src/frontend/apps/impress/src/services/Posthog.tsx new file mode 100644 index 00000000..a8b83719 --- /dev/null +++ b/src/frontend/apps/impress/src/services/Posthog.tsx @@ -0,0 +1,46 @@ +import { Router } from 'next/router'; +import posthog from 'posthog-js'; +import { PostHogProvider as PHProvider } from 'posthog-js/react'; +import { PropsWithChildren, useEffect } from 'react'; + +export interface PostHogConf { + id: string; + host: string; +} + +interface PostHogProviderProps { + conf?: PostHogConf; +} + +export function PostHogProvider({ + children, + conf, +}: PropsWithChildren) { + useEffect(() => { + if (!conf?.id || !conf?.host || posthog.__loaded) { + return; + } + + posthog.init(conf.id, { + api_host: conf.host, + person_profiles: 'always', + loaded: (posthog) => { + if (process.env.NODE_ENV === 'development') { + posthog.debug(); + } + }, + capture_pageview: false, + capture_pageleave: true, + }); + + const handleRouteChange = () => posthog?.capture('$pageview'); + + Router.events.on('routeChangeComplete', handleRouteChange); + + return () => { + Router.events.off('routeChangeComplete', handleRouteChange); + }; + }, [conf?.host, conf?.id]); + + return {children}; +} diff --git a/src/frontend/apps/impress/src/services/index.ts b/src/frontend/apps/impress/src/services/index.ts index 08bbf631..967ebd48 100644 --- a/src/frontend/apps/impress/src/services/index.ts +++ b/src/frontend/apps/impress/src/services/index.ts @@ -1 +1,2 @@ export * from './Crisp'; +export * from './Posthog'; diff --git a/src/frontend/yarn.lock b/src/frontend/yarn.lock index 14bdb369..8b5d18b3 100644 --- a/src/frontend/yarn.lock +++ b/src/frontend/yarn.lock @@ -6286,7 +6286,7 @@ core-js-compat@^3.38.0, core-js-compat@^3.38.1: dependencies: browserslist "^4.24.2" -core-js@^3.0.0: +core-js@^3.0.0, core-js@^3.38.1: version "3.39.0" resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.39.0.tgz#57f7647f4d2d030c32a72ea23a0555b2eaa30f83" integrity sha512-raM0ew0/jJUqkJ0E6e8UDtl+y/7ktFivgWvqw8dNSQeNWoSDLvQ1H/RN3aPXB9tBd4/FhyR4RDPGhsNIMsAn7g== @@ -7536,6 +7536,11 @@ fetch-mock@9.11.0: querystring "^0.2.0" whatwg-url "^6.5.0" +fflate@^0.4.8: + version "0.4.8" + resolved "https://registry.yarnpkg.com/fflate/-/fflate-0.4.8.tgz#f90b82aefbd8ac174213abb338bd7ef848f0f5ae" + integrity sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA== + figlet@1.7.0: version "1.7.0" resolved "https://registry.yarnpkg.com/figlet/-/figlet-1.7.0.tgz#46903a04603fd19c3e380358418bb2703587a72e" @@ -10921,6 +10926,21 @@ postgres-range@^1.1.1: resolved "https://registry.yarnpkg.com/postgres-range/-/postgres-range-1.1.4.tgz#a59c5f9520909bcec5e63e8cf913a92e4c952863" integrity sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w== +posthog-js@1.204.0: + version "1.204.0" + resolved "https://registry.yarnpkg.com/posthog-js/-/posthog-js-1.204.0.tgz#73843af471fcc484ca1e8e1bcc927887cf81b4ba" + integrity sha512-wVt948wKPPztCZ3OeDq8y0dtaPbhbY8vFuEVBUNHOn7PohbTXr7HZ4CNhH8fXgFkx5COEzz/20wWJmEsSU5oCA== + dependencies: + core-js "^3.38.1" + fflate "^0.4.8" + preact "^10.19.3" + web-vitals "^4.2.0" + +preact@^10.19.3: + version "10.25.4" + resolved "https://registry.yarnpkg.com/preact/-/preact-10.25.4.tgz#c1d00bee9d7b9dcd06a2311d9951973b506ae8ac" + integrity sha512-jLdZDb+Q+odkHJ+MpW/9U5cODzqnB+fy2EiHSZES7ldV5LK7yjlVzTp7R8Xy6W6y75kfK8iWYtFVH7lvjwrCMA== + prelude-ls@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" @@ -13622,6 +13642,11 @@ web-namespaces@^2.0.0: resolved "https://registry.yarnpkg.com/web-namespaces/-/web-namespaces-2.0.1.tgz#1010ff7c650eccb2592cebeeaf9a1b253fd40692" integrity sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ== +web-vitals@^4.2.0: + version "4.2.4" + resolved "https://registry.yarnpkg.com/web-vitals/-/web-vitals-4.2.4.tgz#1d20bc8590a37769bd0902b289550936069184b7" + integrity sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw== + webidl-conversions@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" From b8be01038948b8b60a128b985d8a70c911708924 Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Mon, 27 Jan 2025 15:41:39 +0100 Subject: [PATCH 03/16] =?UTF-8?q?=F0=9F=9A=9A(helm)=20add=20posthog=20prox?= =?UTF-8?q?y?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit To contourn ads blocker, we add a proxy to the posthog service. This way, we can access the service from the same domain as the frontend. --- CHANGELOG.md | 2 + docs/examples/impress.values.yaml | 7 ++ src/helm/env.d/dev/values.impress.yaml.gotmpl | 7 ++ src/helm/impress/Chart.yaml | 2 +- src/helm/impress/README.md | 31 +++++++ src/helm/impress/templates/_helpers.tpl | 9 ++ .../impress/templates/ingress_posthog.yaml | 86 +++++++++++++++++++ .../templates/ingress_posthog_assets.yaml | 66 ++++++++++++++ .../impress/templates/posthog_assets_svc.yaml | 24 ++++++ src/helm/impress/templates/posthog_svc.yaml | 24 ++++++ src/helm/impress/values.yaml | 71 +++++++++++++++ 11 files changed, 328 insertions(+), 1 deletion(-) create mode 100644 src/helm/impress/templates/ingress_posthog.yaml create mode 100644 src/helm/impress/templates/ingress_posthog_assets.yaml create mode 100644 src/helm/impress/templates/posthog_assets_svc.yaml create mode 100644 src/helm/impress/templates/posthog_svc.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index c4b6ccc3..0805656a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ and this project adheres to ## Added - github actions to managed Crowdin workflow +- 📈Integrate Posthog #540 + ## Changed diff --git a/docs/examples/impress.values.yaml b/docs/examples/impress.values.yaml index e21e7ec3..bc090450 100644 --- a/docs/examples/impress.values.yaml +++ b/docs/examples/impress.values.yaml @@ -40,6 +40,7 @@ backend: LOGIN_REDIRECT_URL: https://impress.127.0.0.1.nip.io LOGIN_REDIRECT_URL_FAILURE: https://impress.127.0.0.1.nip.io LOGOUT_REDIRECT_URL: https://impress.127.0.0.1.nip.io + POSTHOG_KEY: "{'id': 'posthog_key', 'host': 'https://product.impress.127.0.0.1.nip.io'}" DB_HOST: postgresql DB_NAME: impress DB_USER: dinum @@ -121,6 +122,12 @@ yProvider: COLLABORATION_SERVER_SECRET: my-secret Y_PROVIDER_API_KEY: my-secret +posthog: + ingress: + enabled: false + ingressAssets: + enabled: false + ingress: enabled: true host: impress.127.0.0.1.nip.io diff --git a/src/helm/env.d/dev/values.impress.yaml.gotmpl b/src/helm/env.d/dev/values.impress.yaml.gotmpl index 016f8f0e..df9f9fce 100644 --- a/src/helm/env.d/dev/values.impress.yaml.gotmpl +++ b/src/helm/env.d/dev/values.impress.yaml.gotmpl @@ -148,6 +148,13 @@ ingressAdmin: enabled: true host: impress.127.0.0.1.nip.io +posthog: + ingress: + enabled: false + + ingressAssets: + enabled: false + ingressMedia: enabled: true host: impress.127.0.0.1.nip.io diff --git a/src/helm/impress/Chart.yaml b/src/helm/impress/Chart.yaml index da70e774..749855d0 100644 --- a/src/helm/impress/Chart.yaml +++ b/src/helm/impress/Chart.yaml @@ -1,5 +1,5 @@ apiVersion: v2 type: application name: docs -version: 0.0.2 +version: 2.0.1-beta.7 appVersion: latest diff --git a/src/helm/impress/README.md b/src/helm/impress/README.md index 19b86429..6b5587ad 100644 --- a/src/helm/impress/README.md +++ b/src/helm/impress/README.md @@ -176,6 +176,37 @@ | `frontend.extraVolumeMounts` | Additional volumes to mount on the frontend. | `[]` | | `frontend.extraVolumes` | Additional volumes to mount on the frontend. | `[]` | +### posthog + +| Name | Description | Value | +| -------------------------------------- | ----------------------------------------------------------- | ------------------------- | +| `posthog.ingress.enabled` | Enable or disable the ingress resource creation | `false` | +| `posthog.ingress.className` | Kubernetes ingress class name to use (e.g., nginx, traefik) | `nil` | +| `posthog.ingress.host` | Primary hostname for the ingress resource | `impress.example.com` | +| `posthog.ingress.path` | URL path prefix for the ingress routes (e.g., /) | `/` | +| `posthog.ingress.hosts` | Additional hostnames array to be included in the ingress | `[]` | +| `posthog.ingress.tls.enabled` | Enable or disable TLS/HTTPS for the ingress | `true` | +| `posthog.ingress.tls.additional` | Additional TLS configurations for extra hosts/certificates | `[]` | +| `posthog.ingress.customBackends` | Custom backend service configurations for the ingress | `[]` | +| `posthog.ingress.annotations` | Additional Kubernetes annotations to apply to the ingress | `{}` | +| `posthog.ingressAssets.enabled` | Enable or disable the ingress resource creation | `false` | +| `posthog.ingressAssets.className` | Kubernetes ingress class name to use (e.g., nginx, traefik) | `nil` | +| `posthog.ingressAssets.host` | Primary hostname for the ingress resource | `impress.example.com` | +| `posthog.ingressAssets.paths` | URL paths prefix for the ingress routes (e.g., /static) | `["/static","/array"]` | +| `posthog.ingressAssets.hosts` | Additional hostnames array to be included in the ingress | `[]` | +| `posthog.ingressAssets.tls.enabled` | Enable or disable TLS/HTTPS for the ingress | `true` | +| `posthog.ingressAssets.tls.additional` | Additional TLS configurations for extra hosts/certificates | `[]` | +| `posthog.ingressAssets.customBackends` | Custom backend service configurations for the ingress | `[]` | +| `posthog.ingressAssets.annotations` | Additional Kubernetes annotations to apply to the ingress | `{}` | +| `posthog.service.type` | Service type (e.g. ExternalName, ClusterIP, LoadBalancer) | `ExternalName` | +| `posthog.service.externalName` | External service hostname when type is ExternalName | `eu.i.posthog.com` | +| `posthog.service.port` | Port number for the service | `443` | +| `posthog.service.annotations` | Additional annotations to apply to the service | `{}` | +| `posthog.assetsService.type` | Service type (e.g. ExternalName, ClusterIP, LoadBalancer) | `ExternalName` | +| `posthog.assetsService.externalName` | External service hostname when type is ExternalName | `eu-assets.i.posthog.com` | +| `posthog.assetsService.port` | Port number for the service | `443` | +| `posthog.assetsService.annotations` | Additional annotations to apply to the service | `{}` | + ### yProvider | Name | Description | Value | diff --git a/src/helm/impress/templates/_helpers.tpl b/src/helm/impress/templates/_helpers.tpl index 63188f3b..b56b9892 100644 --- a/src/helm/impress/templates/_helpers.tpl +++ b/src/helm/impress/templates/_helpers.tpl @@ -148,6 +148,15 @@ Requires top level scope {{ include "impress.fullname" . }}-frontend {{- end }} +{{/* +Full name for the Posthog + +Requires top level scope +*/}} +{{- define "impress.posthog.fullname" -}} +{{ include "impress.fullname" . }}-posthog +{{- end }} + {{/* Full name for the yProvider diff --git a/src/helm/impress/templates/ingress_posthog.yaml b/src/helm/impress/templates/ingress_posthog.yaml new file mode 100644 index 00000000..2c93725e --- /dev/null +++ b/src/helm/impress/templates/ingress_posthog.yaml @@ -0,0 +1,86 @@ +{{- if .Values.posthog.ingress.enabled -}} +{{- $fullName := include "impress.fullname" . -}} +{{- if and .Values.posthog.ingress.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }} + {{- if not (hasKey .Values.posthog.ingress.annotations "kubernetes.io/ingress.class") }} + {{- $_ := set .Values.posthog.ingress.annotations "kubernetes.io/ingress.class" .Values.posthog.ingress.className}} + {{- end }} +{{- end }} +{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}} +apiVersion: networking.k8s.io/v1 +{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}} +apiVersion: networking.k8s.io/v1beta1 +{{- else -}} +apiVersion: extensions/v1beta1 +{{- end }} +kind: Ingress +metadata: + name: {{ $fullName }}-posthog + namespace: {{ .Release.Namespace | quote }} + labels: + {{- include "impress.labels" . | nindent 4 }} + {{- with .Values.posthog.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if and .Values.posthog.ingress.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }} + ingressClassName: {{ .Values.posthog.ingress.className }} + {{- end }} + {{- if .Values.posthog.ingress.tls.enabled }} + tls: + {{- if .Values.posthog.ingress.host }} + - secretName: {{ $fullName }}-posthog-tls + hosts: + - {{ .Values.posthog.ingress.host | quote }} + {{- end }} + {{- range .Values.posthog.ingress.tls.additional }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} + {{- end }} + rules: + {{- if .Values.posthog.ingress.host }} + - host: {{ .Values.posthog.ingress.host | quote }} + http: + paths: + - path: {{ .Values.posthog.ingress.path }} + {{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }} + pathType: Prefix + {{- end }} + backend: + {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }} + service: + name: {{ include "impress.posthog.fullname" . }}-proxy + port: + number: {{ .Values.posthog.service.port }} + {{- else }} + serviceName: {{ include "impress.posthog.fullname" . }}-proxy + servicePort: {{ .Values.posthog.service.port }} + {{- end }} + {{- end }} + {{- range .Values.posthog.ingress.hosts }} + - host: {{ . | quote }} + http: + paths: + - path: {{ $.Values.posthog.ingress.path | quote }} + {{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }} + pathType: Prefix + {{- end }} + backend: + {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }} + service: + name: {{ include "impress.posthog.fullname" . }}-proxy + port: + number: {{ $.Values.posthog.service.port }} + {{- else }} + serviceName: {{ include "impress.posthog.fullname" . }}-proxy + servicePort: {{ $.Values.posthog.service.port }} + {{- end }} + {{- with $.Values.posthog.service.customBackends }} + {{- toYaml . | nindent 10 }} + {{- end }} + {{- end }} +{{- end }} diff --git a/src/helm/impress/templates/ingress_posthog_assets.yaml b/src/helm/impress/templates/ingress_posthog_assets.yaml new file mode 100644 index 00000000..4a49bfa5 --- /dev/null +++ b/src/helm/impress/templates/ingress_posthog_assets.yaml @@ -0,0 +1,66 @@ +{{- if .Values.posthog.ingressAssets.enabled -}} +{{- $fullName := include "impress.fullname" . -}} +{{- if and .Values.posthog.ingressAssets.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }} + {{- if not (hasKey .Values.posthog.ingressAssets.annotations "kubernetes.io/ingress.class") }} + {{- $_ := set .Values.posthog.ingressAssets.annotations "kubernetes.io/ingress.class" .Values.posthog.ingressAssets.className}} + {{- end }} +{{- end }} +{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}} +apiVersion: networking.k8s.io/v1 +{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}} +apiVersion: networking.k8s.io/v1beta1 +{{- else -}} +apiVersion: extensions/v1beta1 +{{- end }} +kind: Ingress +metadata: + name: {{ $fullName }}-posthog-assets + namespace: {{ .Release.Namespace | quote }} + labels: + {{- include "impress.labels" . | nindent 4 }} + {{- with .Values.posthog.ingressAssets.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if and .Values.posthog.ingressAssets.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }} + ingressClassName: {{ .Values.posthog.ingressAssets.className }} + {{- end }} + {{- if .Values.posthog.ingressAssets.tls.enabled }} + tls: + {{- if .Values.posthog.ingressAssets.host }} + - secretName: {{ $fullName }}-posthog-tls + hosts: + - {{ .Values.posthog.ingressAssets.host | quote }} + {{- end }} + {{- range .Values.posthog.ingressAssets.tls.additional }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} + {{- end }} + rules: + {{- if .Values.posthog.ingressAssets.host }} + - host: {{ .Values.posthog.ingressAssets.host | quote }} + http: + paths: + {{- range .Values.posthog.ingressAssets.paths }} + - path: {{ . | quote }} + {{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }} + pathType: Prefix + {{- end }} + backend: + {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }} + service: + name: {{ include "impress.posthog.fullname" $ }}-assets-proxy + port: + number: {{ $.Values.posthog.assetsService.port }} + {{- else }} + serviceName: {{ include "impress.posthog.fullname" $ }}-assets-proxy + servicePort: {{ $.Values.posthog.assetsService.port }} + {{- end }} + {{- end }} + {{- end }} +{{- end }} diff --git a/src/helm/impress/templates/posthog_assets_svc.yaml b/src/helm/impress/templates/posthog_assets_svc.yaml new file mode 100644 index 00000000..d80ea3b3 --- /dev/null +++ b/src/helm/impress/templates/posthog_assets_svc.yaml @@ -0,0 +1,24 @@ +{{- if .Values.posthog.ingressAssets.enabled -}} +{{- $envVars := include "impress.common.env" (list . .Values.posthog) -}} +{{- $fullName := include "impress.posthog.fullname" . -}} +{{- $component := "posthog" -}} +apiVersion: v1 +kind: Service +metadata: + name: {{ $fullName }}-assets-proxy + namespace: {{ .Release.Namespace | quote }} + labels: + {{- include "impress.common.labels" (list . $component) | nindent 4 }} + annotations: + {{- toYaml $.Values.posthog.assetsService.annotations | nindent 4 }} +spec: + type: {{ .Values.posthog.assetsService.type }} + externalName: {{ .Values.posthog.assetsService.externalName }} + ports: + - port: {{ .Values.posthog.assetsService.port }} + targetPort: {{ .Values.posthog.assetsService.targetPort }} + protocol: TCP + name: https + selector: + {{- include "impress.common.selectorLabels" (list . $component) | nindent 4 }} +{{- end }} diff --git a/src/helm/impress/templates/posthog_svc.yaml b/src/helm/impress/templates/posthog_svc.yaml new file mode 100644 index 00000000..1fae600b --- /dev/null +++ b/src/helm/impress/templates/posthog_svc.yaml @@ -0,0 +1,24 @@ +{{- if .Values.posthog.ingress.enabled -}} +{{- $envVars := include "impress.common.env" (list . .Values.posthog) -}} +{{- $fullName := include "impress.posthog.fullname" . -}} +{{- $component := "posthog" -}} +apiVersion: v1 +kind: Service +metadata: + name: {{ $fullName }}-proxy + namespace: {{ .Release.Namespace | quote }} + labels: + {{- include "impress.common.labels" (list . $component) | nindent 4 }} + annotations: + {{- toYaml $.Values.posthog.service.annotations | nindent 4 }} +spec: + type: {{ .Values.posthog.service.type }} + externalName: {{ .Values.posthog.service.externalName }} + ports: + - port: {{ .Values.posthog.service.port }} + targetPort: {{ .Values.posthog.service.targetPort }} + protocol: TCP + name: https + selector: + {{- include "impress.common.selectorLabels" (list . $component) | nindent 4 }} +{{- end }} diff --git a/src/helm/impress/values.yaml b/src/helm/impress/values.yaml index fdaffd7a..9f6924bb 100644 --- a/src/helm/impress/values.yaml +++ b/src/helm/impress/values.yaml @@ -390,6 +390,77 @@ frontend: ## @param frontend.extraVolumes Additional volumes to mount on the frontend. extraVolumes: [] +## @section posthog + +posthog: + + ## @param posthog.ingress.enabled Enable or disable the ingress resource creation + ## @param posthog.ingress.className Kubernetes ingress class name to use (e.g., nginx, traefik) + ## @param posthog.ingress.host Primary hostname for the ingress resource + ## @param posthog.ingress.path URL path prefix for the ingress routes (e.g., /) + ## @param posthog.ingress.hosts Additional hostnames array to be included in the ingress + ## @param posthog.ingress.tls.enabled Enable or disable TLS/HTTPS for the ingress + ## @param posthog.ingress.tls.additional Additional TLS configurations for extra hosts/certificates + ## @param posthog.ingress.customBackends Custom backend service configurations for the ingress + ## @param posthog.ingress.annotations Additional Kubernetes annotations to apply to the ingress + ingress: + enabled: false + className: null + host: impress.example.com + path: / + hosts: [ ] + tls: + enabled: true + additional: [ ] + + customBackends: [ ] + annotations: {} + + ## @param posthog.ingressAssets.enabled Enable or disable the ingress resource creation + ## @param posthog.ingressAssets.className Kubernetes ingress class name to use (e.g., nginx, traefik) + ## @param posthog.ingressAssets.host Primary hostname for the ingress resource + ## @param posthog.ingressAssets.paths URL paths prefix for the ingress routes (e.g., /static) + ## @param posthog.ingressAssets.hosts Additional hostnames array to be included in the ingress + ## @param posthog.ingressAssets.tls.enabled Enable or disable TLS/HTTPS for the ingress + ## @param posthog.ingressAssets.tls.additional Additional TLS configurations for extra hosts/certificates + ## @param posthog.ingressAssets.customBackends Custom backend service configurations for the ingress + ## @param posthog.ingressAssets.annotations Additional Kubernetes annotations to apply to the ingress + ingressAssets: + enabled: false + className: null + host: impress.example.com + paths: + - /static + - /array + hosts: [ ] + tls: + enabled: true + additional: [ ] + + customBackends: [ ] + annotations: {} + + ## @param posthog.service.type Service type (e.g. ExternalName, ClusterIP, LoadBalancer) + ## @param posthog.service.externalName External service hostname when type is ExternalName + ## @param posthog.service.port Port number for the service + ## @param posthog.service.annotations Additional annotations to apply to the service + service: + type: ExternalName + externalName: eu.i.posthog.com + port: 443 + annotations: {} + + ## @param posthog.assetsService.type Service type (e.g. ExternalName, ClusterIP, LoadBalancer) + ## @param posthog.assetsService.externalName External service hostname when type is ExternalName + ## @param posthog.assetsService.port Port number for the service + ## @param posthog.assetsService.annotations Additional annotations to apply to the service + assetsService: + type: ExternalName + externalName: eu-assets.i.posthog.com + port: 443 + annotations: {} + + ## @section yProvider yProvider: From 5b4b100e9072413f98551b08fbf4970f9469d97a Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Wed, 15 Jan 2025 15:58:46 +0100 Subject: [PATCH 04/16] =?UTF-8?q?=F0=9F=8F=B7=EF=B8=8F(backend)=20add=20co?= =?UTF-8?q?ntent-type=20to=20uploaded=20files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All the uploaded files had the content-type set to `application/octet-stream`. It create issues when the file is downloaded from the frontend because the browser doesn't know how to handle the file. We now determine the content-type of the file and set it to the file object. --- .github/workflows/impress.yml | 5 +-- CHANGELOG.md | 2 +- Dockerfile | 2 ++ src/backend/core/api/serializers.py | 2 ++ src/backend/core/api/viewsets.py | 5 ++- .../test_api_documents_attachment_upload.py | 31 ++++++++++++++----- 6 files changed, 35 insertions(+), 12 deletions(-) diff --git a/.github/workflows/impress.yml b/.github/workflows/impress.yml index 896f07ef..385aa633 100644 --- a/.github/workflows/impress.yml +++ b/.github/workflows/impress.yml @@ -206,10 +206,11 @@ jobs: - name: Install development dependencies run: pip install --user .[dev] - - name: Install gettext (required to compile messages) + - name: Install gettext (required to compile messages) and MIME support run: | sudo apt-get update - sudo apt-get install -y gettext pandoc + sudo apt-get install -y gettext pandoc shared-mime-info + sudo wget https://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types -O /etc/mime.types - name: Generate a MO file from strings extracted from the project run: python manage.py compilemessages diff --git a/CHANGELOG.md b/CHANGELOG.md index 0805656a..da6a6826 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to - github actions to managed Crowdin workflow - 📈Integrate Posthog #540 - +- 🏷️(backend) add content-type to uploaded files #552 ## Changed diff --git a/Dockerfile b/Dockerfile index 941f0747..6547f0b6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -76,6 +76,8 @@ RUN apk add \ pango \ shared-mime-info +RUN wget https://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types -O /etc/mime.types + # Copy entrypoint COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index d5b0d4c7..e2369f49 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -388,6 +388,7 @@ class FileUploadSerializer(serializers.Serializer): raise serializers.ValidationError("Could not determine file extension.") self.context["expected_extension"] = extension + self.context["content_type"] = magic_mime_type return file @@ -395,6 +396,7 @@ class FileUploadSerializer(serializers.Serializer): """Override validate to add the computed extension to validated_data.""" attrs["expected_extension"] = self.context["expected_extension"] attrs["is_unsafe"] = self.context["is_unsafe"] + attrs["content_type"] = self.context["content_type"] return attrs diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 02b0f277..10adee35 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -605,7 +605,10 @@ class DocumentViewSet( key = f"{document.key_base}/{ATTACHMENTS_FOLDER:s}/{file_id!s}.{extension:s}" # Prepare metadata for storage - extra_args = {"Metadata": {"owner": str(request.user.id)}} + extra_args = { + "Metadata": {"owner": str(request.user.id)}, + "ContentType": serializer.validated_data["content_type"], + } if serializer.validated_data["is_unsafe"]: extra_args["Metadata"]["is_unsafe"] = "true" diff --git a/src/backend/core/tests/documents/test_api_documents_attachment_upload.py b/src/backend/core/tests/documents/test_api_documents_attachment_upload.py index 1288f8ca..4a6564d6 100644 --- a/src/backend/core/tests/documents/test_api_documents_attachment_upload.py +++ b/src/backend/core/tests/documents/test_api_documents_attachment_upload.py @@ -64,12 +64,22 @@ def test_api_documents_attachment_upload_anonymous_success(): assert response.status_code == 201 pattern = re.compile(rf"^/media/{document.id!s}/attachments/(.*)\.png") - match = pattern.search(response.json()["file"]) + file_path = response.json()["file"] + match = pattern.search(file_path) file_id = match.group(1) # Validate that file_id is a valid UUID uuid.UUID(file_id) + # Now, check the metadata of the uploaded file + key = file_path.replace("/media", "") + file_head = default_storage.connection.meta.client.head_object( + Bucket=default_storage.bucket_name, Key=key + ) + + assert file_head["Metadata"] == {"owner": "None"} + assert file_head["ContentType"] == "image/png" + @pytest.mark.parametrize( "reach, role", @@ -206,6 +216,7 @@ def test_api_documents_attachment_upload_success(via, role, mock_user_teams): Bucket=default_storage.bucket_name, Key=key ) assert file_head["Metadata"] == {"owner": str(user.id)} + assert file_head["ContentType"] == "image/png" def test_api_documents_attachment_upload_invalid(client): @@ -247,16 +258,18 @@ def test_api_documents_attachment_upload_size_limit_exceeded(settings): @pytest.mark.parametrize( - "name,content,extension", + "name,content,extension,content_type", [ - ("test.exe", b"text", "exe"), - ("test", b"text", "txt"), - ("test.aaaaaa", b"test", "txt"), - ("test.txt", PIXEL, "txt"), - ("test.py", b"#!/usr/bin/python", "py"), + ("test.exe", b"text", "exe", "text/plain"), + ("test", b"text", "txt", "text/plain"), + ("test.aaaaaa", b"test", "txt", "text/plain"), + ("test.txt", PIXEL, "txt", "image/png"), + ("test.py", b"#!/usr/bin/python", "py", "text/plain"), ], ) -def test_api_documents_attachment_upload_fix_extension(name, content, extension): +def test_api_documents_attachment_upload_fix_extension( + name, content, extension, content_type +): """ A file with no extension or a wrong extension is accepted and the extension is corrected in storage. @@ -287,6 +300,7 @@ def test_api_documents_attachment_upload_fix_extension(name, content, extension) Bucket=default_storage.bucket_name, Key=key ) assert file_head["Metadata"] == {"owner": str(user.id), "is_unsafe": "true"} + assert file_head["ContentType"] == content_type def test_api_documents_attachment_upload_empty_file(): @@ -335,3 +349,4 @@ def test_api_documents_attachment_upload_unsafe(): Bucket=default_storage.bucket_name, Key=key ) assert file_head["Metadata"] == {"owner": str(user.id), "is_unsafe": "true"} + assert file_head["ContentType"] == "application/octet-stream" From 67dc7feb98466090422add289531905f39c7665a Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Mon, 20 Jan 2025 17:20:51 +0100 Subject: [PATCH 05/16] =?UTF-8?q?=F0=9F=9A=91=EF=B8=8F(backend)=20command?= =?UTF-8?q?=20to=20update=20attachment=20content-type?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The uploaded files in the system are missing the content-type. We add a command to update the content-type of the existing uploaded files. This command will run one time when we will deploy to the environments. --- src/backend/core/management/__init__.py | 0 .../core/management/commands/__init__.py | 0 .../update_files_content_type_metadata.py | 95 +++++++++++++++++++ ...test_update_files_content_type_metadata.py | 50 ++++++++++ 4 files changed, 145 insertions(+) create mode 100644 src/backend/core/management/__init__.py create mode 100644 src/backend/core/management/commands/__init__.py create mode 100644 src/backend/core/management/commands/update_files_content_type_metadata.py create mode 100644 src/backend/core/tests/commands/test_update_files_content_type_metadata.py diff --git a/src/backend/core/management/__init__.py b/src/backend/core/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/backend/core/management/commands/__init__.py b/src/backend/core/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/backend/core/management/commands/update_files_content_type_metadata.py b/src/backend/core/management/commands/update_files_content_type_metadata.py new file mode 100644 index 00000000..bb2e5253 --- /dev/null +++ b/src/backend/core/management/commands/update_files_content_type_metadata.py @@ -0,0 +1,95 @@ +"""Management command updating the metadata for all the files in the MinIO bucket.""" + +from django.core.files.storage import default_storage +from django.core.management.base import BaseCommand + +import magic + +from core.models import Document + +# pylint: disable=too-many-locals, broad-exception-caught + + +class Command(BaseCommand): + """Update the metadata for all the files in the MinIO bucket.""" + + help = __doc__ + + def handle(self, *args, **options): + """Execute management command.""" + s3_client = default_storage.connection.meta.client + bucket_name = default_storage.bucket_name + + mime_detector = magic.Magic(mime=True) + + documents = Document.objects.all() + self.stdout.write( + f"[INFO] Found {documents.count()} documents. Starting ContentType fix..." + ) + + for doc in documents: + doc_id_str = str(doc.id) + prefix = f"{doc_id_str}/attachments/" + self.stdout.write( + f"[INFO] Processing attachments under prefix '{prefix}' ..." + ) + + continuation_token = None + total_updated = 0 + + while True: + list_kwargs = {"Bucket": bucket_name, "Prefix": prefix} + if continuation_token: + list_kwargs["ContinuationToken"] = continuation_token + + response = s3_client.list_objects_v2(**list_kwargs) + + # If no objects found under this prefix, break out of the loop + if "Contents" not in response: + break + + for obj in response["Contents"]: + key = obj["Key"] + + # Skip if it's a folder + if key.endswith("/"): + continue + + try: + # Get existing metadata + head_resp = s3_client.head_object(Bucket=bucket_name, Key=key) + + # Read first ~1KB for MIME detection + partial_obj = s3_client.get_object( + Bucket=bucket_name, Key=key, Range="bytes=0-1023" + ) + partial_data = partial_obj["Body"].read() + + # Detect MIME type + magic_mime_type = mime_detector.from_buffer(partial_data) + + # Update ContentType + s3_client.copy_object( + Bucket=bucket_name, + CopySource={"Bucket": bucket_name, "Key": key}, + Key=key, + ContentType=magic_mime_type, + Metadata=head_resp.get("Metadata", {}), + MetadataDirective="REPLACE", + ) + total_updated += 1 + + except Exception as exc: # noqa + self.stderr.write( + f"[ERROR] Could not update ContentType for {key}: {exc}" + ) + + if response.get("IsTruncated"): + continuation_token = response.get("NextContinuationToken") + else: + break + + if total_updated > 0: + self.stdout.write( + f"[INFO] -> Updated {total_updated} objects for Document {doc_id_str}." + ) diff --git a/src/backend/core/tests/commands/test_update_files_content_type_metadata.py b/src/backend/core/tests/commands/test_update_files_content_type_metadata.py new file mode 100644 index 00000000..4ece3614 --- /dev/null +++ b/src/backend/core/tests/commands/test_update_files_content_type_metadata.py @@ -0,0 +1,50 @@ +""" +Unit test for `update_files_content_type_metadata` command. +""" + +import uuid + +from django.core.files.storage import default_storage +from django.core.management import call_command + +import pytest + +from core import factories + + +@pytest.mark.django_db +def test_update_files_content_type_metadata(): + """ + Test that the command `update_files_content_type_metadata` + fixes the ContentType of attachment in the storage. + """ + s3_client = default_storage.connection.meta.client + bucket_name = default_storage.bucket_name + + # Create files with a wrong ContentType + keys = [] + for _ in range(10): + doc_id = uuid.uuid4() + factories.DocumentFactory(id=doc_id) + key = f"{doc_id}/attachments/testfile.png" + keys.append(key) + fake_png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR..." + s3_client.put_object( + Bucket=bucket_name, + Key=key, + Body=fake_png, + ContentType="text/plain", + Metadata={"owner": "None"}, + ) + + # Call the command that fixes the ContentType + call_command("update_files_content_type_metadata") + + for key in keys: + head_resp = s3_client.head_object(Bucket=bucket_name, Key=key) + assert ( + head_resp["ContentType"] == "image/png" + ), f"ContentType not fixed, got {head_resp['ContentType']!r}" + + # Check that original metadata was preserved + assert head_resp["Metadata"].get("owner") == "None" From 0d7d42254bda127190486ffce25388bcb275a89c Mon Sep 17 00:00:00 2001 From: Manuel Raynaud Date: Fri, 24 Jan 2025 20:28:27 +0100 Subject: [PATCH 06/16] =?UTF-8?q?=E2=9C=A8(helm)=20add=20a=20job=20allowin?= =?UTF-8?q?g=20to=20run=20arbitrary=20management=20command?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For a specific deployment we may need to run a specific management command, like the one added previously updating all files content-type. A template is added responsible to manage this case. The job will be created only if the backend.job.command is set. --- src/helm/impress/Chart.yaml | 2 +- src/helm/impress/README.md | 5 + src/helm/impress/templates/backend_job.yml | 124 ++++++++++++++++++ ...kend_job.yaml => backend_job_migrate.yaml} | 0 src/helm/impress/values.yaml | 13 ++ 5 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 src/helm/impress/templates/backend_job.yml rename src/helm/impress/templates/{backend_job.yaml => backend_job_migrate.yaml} (100%) diff --git a/src/helm/impress/Chart.yaml b/src/helm/impress/Chart.yaml index 749855d0..80115ecb 100644 --- a/src/helm/impress/Chart.yaml +++ b/src/helm/impress/Chart.yaml @@ -1,5 +1,5 @@ apiVersion: v2 type: application name: docs -version: 2.0.1-beta.7 +version: 2.0.1-beta.8 appVersion: latest diff --git a/src/helm/impress/README.md b/src/helm/impress/README.md index 6b5587ad..999dc479 100644 --- a/src/helm/impress/README.md +++ b/src/helm/impress/README.md @@ -104,6 +104,11 @@ | `backend.migrate.restartPolicy` | backend migrate job restart policy | `Never` | | `backend.createsuperuser.command` | backend migrate command | `["/bin/sh","-c","python manage.py createsuperuser --email $DJANGO_SUPERUSER_EMAIL --password $DJANGO_SUPERUSER_PASSWORD\n"]` | | `backend.createsuperuser.restartPolicy` | backend migrate job restart policy | `Never` | +| `backend.job` | job dedicated to run a random management command, for example after a deployment | | +| `backend.job.name` | The name to use to describe this job | `""` | +| `backend.job.command` | The management command to execute | `[]` | +| `backend.job.restartPolicy` | The restart policy for the job. | `Never` | +| `backend.job.annotations` | Annotations to add to the job [default: argocd.argoproj.io/hook: PostSync] | | | `backend.probes.liveness.path` | Configure path for backend HTTP liveness probe | `/__heartbeat__` | | `backend.probes.liveness.targetPort` | Configure port for backend HTTP liveness probe | `undefined` | | `backend.probes.liveness.initialDelaySeconds` | Configure initial delay for backend liveness probe | `10` | diff --git a/src/helm/impress/templates/backend_job.yml b/src/helm/impress/templates/backend_job.yml new file mode 100644 index 00000000..4888a904 --- /dev/null +++ b/src/helm/impress/templates/backend_job.yml @@ -0,0 +1,124 @@ +{{- if .Values.backend.job.command -}} +{{- $envVars := include "impress.common.env" (list . .Values.backend) -}} +{{- $fullName := include "impress.backend.fullname" . -}} +{{- $component := "backend" -}} +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ $fullName }}-{{ .Values.backend.job.name | default "random" | replace "_" "-" }} + namespace: {{ .Release.Namespace | quote }} + annotations: + argocd.argoproj.io/sync-options: Replace=true,Force=true + {{- with .Values.backend.job.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} + labels: + {{- include "impress.common.labels" (list . $component) | nindent 4 }} +spec: + template: + metadata: + annotations: + {{- with .Values.backend.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "impress.common.selectorLabels" (list . $component) | nindent 8 }} + spec: + {{- if $.Values.image.credentials }} + imagePullSecrets: + - name: {{ include "impress.secret.dockerconfigjson.name" (dict "fullname" (include "impress.fullname" .) "imageCredentials" $.Values.image.credentials) }} + {{- end}} + shareProcessNamespace: {{ .Values.backend.shareProcessNamespace }} + containers: + {{- with .Values.backend.sidecars }} + {{- toYaml . | nindent 8 }} + {{- end }} + - name: {{ .Chart.Name }} + image: "{{ (.Values.backend.image | default dict).repository | default .Values.image.repository }}:{{ (.Values.backend.image | default dict).tag | default .Values.image.tag }}" + imagePullPolicy: {{ (.Values.backend.image | default dict).pullPolicy | default .Values.image.pullPolicy }} + {{- with .Values.backend.job.command }} + command: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.backend.args }} + args: + {{- toYaml . | nindent 12 }} + {{- end }} + env: + {{- if $envVars}} + {{- $envVars | indent 12 }} + {{- end }} + {{- with .Values.backend.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.backend.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + volumeMounts: + {{- range $index, $value := .Values.mountFiles }} + - name: "files-{{ $index }}" + mountPath: {{ $value.path }} + subPath: content + {{- end }} + {{- range $name, $volume := .Values.backend.persistence }} + - name: "{{ $name }}" + mountPath: "{{ $volume.mountPath }}" + {{- end }} + {{- range .Values.backend.extraVolumeMounts }} + - name: {{ .name }} + mountPath: {{ .mountPath }} + subPath: {{ .subPath | default "" }} + readOnly: {{ .readOnly }} + {{- end }} + {{- with .Values.backend.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.backend.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.backend.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + restartPolicy: {{ .Values.backend.job.restartPolicy }} + volumes: + {{- range $index, $value := .Values.mountFiles }} + - name: "files-{{ $index }}" + configMap: + name: "{{ include "impress.fullname" $ }}-files-{{ $index }}" + {{- end }} + {{- range $name, $volume := .Values.backend.persistence }} + - name: "{{ $name }}" + {{- if eq $volume.type "emptyDir" }} + emptyDir: {} + {{- else }} + persistentVolumeClaim: + claimName: "{{ $fullName }}-{{ $name }}" + {{- end }} + {{- end }} + {{- range .Values.backend.extraVolumes }} + - name: {{ .name }} + {{- if .existingClaim }} + persistentVolumeClaim: + claimName: {{ .existingClaim }} + {{- else if .hostPath }} + hostPath: + {{ toYaml .hostPath | nindent 12 }} + {{- else if .csi }} + csi: + {{- toYaml .csi | nindent 12 }} + {{- else if .configMap }} + configMap: + {{- toYaml .configMap | nindent 12 }} + {{- else if .emptyDir }} + emptyDir: + {{- toYaml .emptyDir | nindent 12 }} + {{- else }} + emptyDir: {} + {{- end }} + {{- end }} +{{- end }} diff --git a/src/helm/impress/templates/backend_job.yaml b/src/helm/impress/templates/backend_job_migrate.yaml similarity index 100% rename from src/helm/impress/templates/backend_job.yaml rename to src/helm/impress/templates/backend_job_migrate.yaml diff --git a/src/helm/impress/values.yaml b/src/helm/impress/values.yaml index 9f6924bb..d429937f 100644 --- a/src/helm/impress/values.yaml +++ b/src/helm/impress/values.yaml @@ -251,6 +251,19 @@ backend: python manage.py createsuperuser --email $DJANGO_SUPERUSER_EMAIL --password $DJANGO_SUPERUSER_PASSWORD restartPolicy: Never + ## @extra backend.job job dedicated to run a random management command, for example after a deployment + ## @param backend.job.name The name to use to describe this job + ## @param backend.job.command The management command to execute + ## @param backend.job.restartPolicy The restart policy for the job. + ## @extra backend.job.annotations Annotations to add to the job [default: argocd.argoproj.io/hook: PostSync] + ## @skip backend.job.annotations.argocd.argoproj.io/hook + job: + name: "" + command: [] + restartPolicy: Never + annotations: + argocd.argoproj.io/hook: PostSync + ## @param backend.probes.liveness.path [nullable] Configure path for backend HTTP liveness probe ## @param backend.probes.liveness.targetPort [nullable] Configure port for backend HTTP liveness probe ## @param backend.probes.liveness.initialDelaySeconds [nullable] Configure initial delay for backend liveness probe From 40c1107959ac50a458fd9768dae4331392a23070 Mon Sep 17 00:00:00 2001 From: lunika <767834+lunika@users.noreply.github.com> Date: Mon, 27 Jan 2025 09:28:13 +0000 Subject: [PATCH 07/16] =?UTF-8?q?=F0=9F=8C=90(i18n)=20update=20translated?= =?UTF-8?q?=20strings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update translated files with new translations --- .../locale/de_DE/LC_MESSAGES/django.po | 2 +- .../locale/en_US/LC_MESSAGES/django.po | 234 +++++++----------- .../locale/fr_FR/LC_MESSAGES/django.po | 2 +- .../locale/nl_NL/LC_MESSAGES/django.po | 2 +- .../apps/impress/src/i18n/translations.json | 36 ++- 5 files changed, 123 insertions(+), 153 deletions(-) diff --git a/src/backend/locale/de_DE/LC_MESSAGES/django.po b/src/backend/locale/de_DE/LC_MESSAGES/django.po index a9db616c..31bc9062 100644 --- a/src/backend/locale/de_DE/LC_MESSAGES/django.po +++ b/src/backend/locale/de_DE/LC_MESSAGES/django.po @@ -3,7 +3,7 @@ msgstr "" "Project-Id-Version: lasuite-docs\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2025-01-15 21:00+0000\n" -"PO-Revision-Date: 2025-01-16 19:53\n" +"PO-Revision-Date: 2025-01-27 09:27\n" "Last-Translator: \n" "Language-Team: German\n" "Language: de_DE\n" diff --git a/src/backend/locale/en_US/LC_MESSAGES/django.po b/src/backend/locale/en_US/LC_MESSAGES/django.po index 1b2f03c3..34382019 100644 --- a/src/backend/locale/en_US/LC_MESSAGES/django.po +++ b/src/backend/locale/en_US/LC_MESSAGES/django.po @@ -1,9 +1,9 @@ msgid "" msgstr "" -"Project-Id-Version: lasuite-people\n" +"Project-Id-Version: lasuite-docs\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-12-17 15:50+0000\n" -"PO-Revision-Date: 2024-12-17 15:53\n" +"POT-Creation-Date: 2025-01-15 21:00+0000\n" +"PO-Revision-Date: 2025-01-27 09:27\n" "Last-Translator: \n" "Language-Team: English\n" "Language: en_US\n" @@ -11,384 +11,342 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -"X-Crowdin-Project: lasuite-people\n" -"X-Crowdin-Project-ID: 637934\n" +"X-Crowdin-Project: lasuite-docs\n" +"X-Crowdin-Project-ID: 754523\n" "X-Crowdin-Language: en\n" "X-Crowdin-File: backend-impress.pot\n" -"X-Crowdin-File-ID: 8\n" +"X-Crowdin-File-ID: 18\n" -#: core/admin.py:33 +#: build/lib/core/admin.py:33 core/admin.py:33 msgid "Personal info" msgstr "" -#: core/admin.py:46 +#: build/lib/core/admin.py:46 core/admin.py:46 msgid "Permissions" msgstr "" -#: core/admin.py:58 +#: build/lib/core/admin.py:58 core/admin.py:58 msgid "Important dates" msgstr "" -#: core/api/filters.py:16 +#: build/lib/core/api/filters.py:16 core/api/filters.py:16 msgid "Creator is me" msgstr "" -#: core/api/filters.py:19 +#: build/lib/core/api/filters.py:19 core/api/filters.py:19 msgid "Favorite" msgstr "" -#: core/api/filters.py:22 +#: build/lib/core/api/filters.py:22 core/api/filters.py:22 msgid "Title" msgstr "" -#: core/api/serializers.py:307 +#: build/lib/core/api/serializers.py:317 core/api/serializers.py:317 msgid "A new document was created on your behalf!" msgstr "" -#: core/api/serializers.py:311 +#: build/lib/core/api/serializers.py:321 core/api/serializers.py:321 msgid "You have been granted ownership of a new document:" msgstr "" -#: core/api/serializers.py:414 +#: build/lib/core/api/serializers.py:422 core/api/serializers.py:422 msgid "Body" msgstr "" -#: core/api/serializers.py:417 +#: build/lib/core/api/serializers.py:425 core/api/serializers.py:425 msgid "Body type" msgstr "" -#: core/api/serializers.py:423 +#: build/lib/core/api/serializers.py:431 core/api/serializers.py:431 msgid "Format" msgstr "" -#: core/authentication/backends.py:57 +#: build/lib/core/authentication/backends.py:61 +#: core/authentication/backends.py:61 msgid "Invalid response format or token verification failed" msgstr "" -#: core/authentication/backends.py:81 -msgid "User info contained no recognizable user identification" -msgstr "" - -#: core/authentication/backends.py:88 +#: build/lib/core/authentication/backends.py:108 +#: core/authentication/backends.py:108 msgid "User account is disabled" msgstr "" -#: core/models.py:62 core/models.py:69 +#: build/lib/core/models.py:63 build/lib/core/models.py:70 core/models.py:63 +#: core/models.py:70 msgid "Reader" msgstr "" -#: core/models.py:63 core/models.py:70 +#: build/lib/core/models.py:64 build/lib/core/models.py:71 core/models.py:64 +#: core/models.py:71 msgid "Editor" msgstr "" -#: core/models.py:71 +#: build/lib/core/models.py:72 core/models.py:72 msgid "Administrator" msgstr "" -#: core/models.py:72 +#: build/lib/core/models.py:73 core/models.py:73 msgid "Owner" msgstr "" -#: core/models.py:83 +#: build/lib/core/models.py:84 core/models.py:84 msgid "Restricted" msgstr "" -#: core/models.py:87 +#: build/lib/core/models.py:88 core/models.py:88 msgid "Authenticated" msgstr "" -#: core/models.py:89 +#: build/lib/core/models.py:90 core/models.py:90 msgid "Public" msgstr "" -#: core/models.py:101 +#: build/lib/core/models.py:112 core/models.py:112 msgid "id" msgstr "" -#: core/models.py:102 +#: build/lib/core/models.py:113 core/models.py:113 msgid "primary key for the record as UUID" msgstr "" -#: core/models.py:108 +#: build/lib/core/models.py:119 core/models.py:119 msgid "created on" msgstr "" -#: core/models.py:109 +#: build/lib/core/models.py:120 core/models.py:120 msgid "date and time at which a record was created" msgstr "" -#: core/models.py:114 +#: build/lib/core/models.py:125 core/models.py:125 msgid "updated on" msgstr "" -#: core/models.py:115 +#: build/lib/core/models.py:126 core/models.py:126 msgid "date and time at which a record was last updated" msgstr "" -#: core/models.py:135 +#: build/lib/core/models.py:162 core/models.py:162 +msgid "We couldn't find a user with this sub but the email is already associated with a registered user." +msgstr "" + +#: build/lib/core/models.py:175 core/models.py:175 msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters." msgstr "" -#: core/models.py:141 +#: build/lib/core/models.py:181 core/models.py:181 msgid "sub" msgstr "" -#: core/models.py:143 +#: build/lib/core/models.py:183 core/models.py:183 msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only." msgstr "" -#: core/models.py:152 +#: build/lib/core/models.py:192 core/models.py:192 msgid "full name" msgstr "" -#: core/models.py:153 +#: build/lib/core/models.py:193 core/models.py:193 msgid "short name" msgstr "" -#: core/models.py:155 +#: build/lib/core/models.py:195 core/models.py:195 msgid "identity email address" msgstr "" -#: core/models.py:160 +#: build/lib/core/models.py:200 core/models.py:200 msgid "admin email address" msgstr "" -#: core/models.py:167 +#: build/lib/core/models.py:207 core/models.py:207 msgid "language" msgstr "" -#: core/models.py:168 +#: build/lib/core/models.py:208 core/models.py:208 msgid "The language in which the user wants to see the interface." msgstr "" -#: core/models.py:174 +#: build/lib/core/models.py:214 core/models.py:214 msgid "The timezone in which the user wants to see times." msgstr "" -#: core/models.py:177 +#: build/lib/core/models.py:217 core/models.py:217 msgid "device" msgstr "" -#: core/models.py:179 +#: build/lib/core/models.py:219 core/models.py:219 msgid "Whether the user is a device or a real user." msgstr "" -#: core/models.py:182 +#: build/lib/core/models.py:222 core/models.py:222 msgid "staff status" msgstr "" -#: core/models.py:184 +#: build/lib/core/models.py:224 core/models.py:224 msgid "Whether the user can log into this admin site." msgstr "" -#: core/models.py:187 +#: build/lib/core/models.py:227 core/models.py:227 msgid "active" msgstr "" -#: core/models.py:190 +#: build/lib/core/models.py:230 core/models.py:230 msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts." msgstr "" -#: core/models.py:202 +#: build/lib/core/models.py:242 core/models.py:242 msgid "user" msgstr "" -#: core/models.py:203 +#: build/lib/core/models.py:243 core/models.py:243 msgid "users" msgstr "" -#: core/models.py:342 core/models.py:718 +#: build/lib/core/models.py:382 build/lib/core/models.py:758 core/models.py:382 +#: core/models.py:758 msgid "title" msgstr "" -#: core/models.py:364 +#: build/lib/core/models.py:404 core/models.py:404 msgid "Document" msgstr "" -#: core/models.py:365 +#: build/lib/core/models.py:405 core/models.py:405 msgid "Documents" msgstr "" -#: core/models.py:368 +#: build/lib/core/models.py:408 core/models.py:408 msgid "Untitled Document" msgstr "" -#: core/models.py:593 +#: build/lib/core/models.py:633 core/models.py:633 #, python-brace-format msgid "{name} shared a document with you!" msgstr "" -#: core/models.py:597 +#: build/lib/core/models.py:637 core/models.py:637 #, python-brace-format msgid "{name} invited you with the role \"{role}\" on the following document:" msgstr "" -#: core/models.py:600 +#: build/lib/core/models.py:640 core/models.py:640 #, python-brace-format msgid "{name} shared a document with you: {title}" msgstr "" -#: core/models.py:623 +#: build/lib/core/models.py:663 core/models.py:663 msgid "Document/user link trace" msgstr "" -#: core/models.py:624 +#: build/lib/core/models.py:664 core/models.py:664 msgid "Document/user link traces" msgstr "" -#: core/models.py:630 +#: build/lib/core/models.py:670 core/models.py:670 msgid "A link trace already exists for this document/user." msgstr "" -#: core/models.py:653 +#: build/lib/core/models.py:693 core/models.py:693 msgid "Document favorite" msgstr "" -#: core/models.py:654 +#: build/lib/core/models.py:694 core/models.py:694 msgid "Document favorites" msgstr "" -#: core/models.py:660 +#: build/lib/core/models.py:700 core/models.py:700 msgid "This document is already targeted by a favorite relation instance for the same user." msgstr "" -#: core/models.py:682 +#: build/lib/core/models.py:722 core/models.py:722 msgid "Document/user relation" msgstr "" -#: core/models.py:683 +#: build/lib/core/models.py:723 core/models.py:723 msgid "Document/user relations" msgstr "" -#: core/models.py:689 +#: build/lib/core/models.py:729 core/models.py:729 msgid "This user is already in this document." msgstr "" -#: core/models.py:695 +#: build/lib/core/models.py:735 core/models.py:735 msgid "This team is already in this document." msgstr "" -#: core/models.py:701 core/models.py:890 +#: build/lib/core/models.py:741 build/lib/core/models.py:930 core/models.py:741 +#: core/models.py:930 msgid "Either user or team must be set, not both." msgstr "" -#: core/models.py:719 +#: build/lib/core/models.py:759 core/models.py:759 msgid "description" msgstr "" -#: core/models.py:720 +#: build/lib/core/models.py:760 core/models.py:760 msgid "code" msgstr "" -#: core/models.py:721 +#: build/lib/core/models.py:761 core/models.py:761 msgid "css" msgstr "" -#: core/models.py:723 +#: build/lib/core/models.py:763 core/models.py:763 msgid "public" msgstr "" -#: core/models.py:725 +#: build/lib/core/models.py:765 core/models.py:765 msgid "Whether this template is public for anyone to use." msgstr "" -#: core/models.py:731 +#: build/lib/core/models.py:771 core/models.py:771 msgid "Template" msgstr "" -#: core/models.py:732 +#: build/lib/core/models.py:772 core/models.py:772 msgid "Templates" msgstr "" -#: core/models.py:871 +#: build/lib/core/models.py:911 core/models.py:911 msgid "Template/user relation" msgstr "" -#: core/models.py:872 +#: build/lib/core/models.py:912 core/models.py:912 msgid "Template/user relations" msgstr "" -#: core/models.py:878 +#: build/lib/core/models.py:918 core/models.py:918 msgid "This user is already in this template." msgstr "" -#: core/models.py:884 +#: build/lib/core/models.py:924 core/models.py:924 msgid "This team is already in this template." msgstr "" -#: core/models.py:907 +#: build/lib/core/models.py:947 core/models.py:947 msgid "email address" msgstr "" -#: core/models.py:926 +#: build/lib/core/models.py:966 core/models.py:966 msgid "Document invitation" msgstr "" -#: core/models.py:927 +#: build/lib/core/models.py:967 core/models.py:967 msgid "Document invitations" msgstr "" -#: core/models.py:944 +#: build/lib/core/models.py:987 core/models.py:987 msgid "This email is already associated to a registered user." msgstr "" -#: core/templates/mail/html/hello.html:159 core/templates/mail/text/hello.txt:3 -msgid "Company logo" -msgstr "" - -#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5 -#, python-format -msgid "Hello %(name)s" -msgstr "" - -#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5 -msgid "Hello" -msgstr "" - -#: core/templates/mail/html/hello.html:189 core/templates/mail/text/hello.txt:6 -msgid "Thank you very much for your visit!" -msgstr "" - -#: core/templates/mail/html/hello.html:221 -#, python-format -msgid "This mail has been sent to %(email)s by %(name)s" -msgstr "" - -#: core/templates/mail/html/invitation.html:162 -#: core/templates/mail/text/invitation.txt:3 -msgid "Logo email" -msgstr "" - -#: core/templates/mail/html/invitation.html:209 -#: core/templates/mail/text/invitation.txt:10 -msgid "Open" -msgstr "" - -#: core/templates/mail/html/invitation.html:226 -#: core/templates/mail/text/invitation.txt:14 -msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. " -msgstr "" - -#: core/templates/mail/html/invitation.html:233 -#: core/templates/mail/text/invitation.txt:16 -#, python-format -msgid " Brought to you by %(brandname)s " -msgstr "" - -#: core/templates/mail/text/hello.txt:8 -#, python-format -msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]" -msgstr "" - -#: impress/settings.py:236 +#: build/lib/impress/settings.py:236 impress/settings.py:236 msgid "English" msgstr "" -#: impress/settings.py:237 +#: build/lib/impress/settings.py:237 impress/settings.py:237 msgid "French" msgstr "" -#: impress/settings.py:238 +#: build/lib/impress/settings.py:238 impress/settings.py:238 msgid "German" msgstr "" diff --git a/src/backend/locale/fr_FR/LC_MESSAGES/django.po b/src/backend/locale/fr_FR/LC_MESSAGES/django.po index 27272fd9..a93a965a 100644 --- a/src/backend/locale/fr_FR/LC_MESSAGES/django.po +++ b/src/backend/locale/fr_FR/LC_MESSAGES/django.po @@ -3,7 +3,7 @@ msgstr "" "Project-Id-Version: lasuite-docs\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2025-01-15 21:00+0000\n" -"PO-Revision-Date: 2025-01-16 19:53\n" +"PO-Revision-Date: 2025-01-27 09:27\n" "Last-Translator: \n" "Language-Team: French\n" "Language: fr_FR\n" diff --git a/src/backend/locale/nl_NL/LC_MESSAGES/django.po b/src/backend/locale/nl_NL/LC_MESSAGES/django.po index f979f7bb..a0c45428 100644 --- a/src/backend/locale/nl_NL/LC_MESSAGES/django.po +++ b/src/backend/locale/nl_NL/LC_MESSAGES/django.po @@ -3,7 +3,7 @@ msgstr "" "Project-Id-Version: lasuite-docs\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2025-01-15 21:00+0000\n" -"PO-Revision-Date: 2025-01-16 19:53\n" +"PO-Revision-Date: 2025-01-27 09:27\n" "Last-Translator: \n" "Language-Team: Dutch\n" "Language: nl_NL\n" diff --git a/src/frontend/apps/impress/src/i18n/translations.json b/src/frontend/apps/impress/src/i18n/translations.json index bc2ed988..3a7a535e 100644 --- a/src/frontend/apps/impress/src/i18n/translations.json +++ b/src/frontend/apps/impress/src/i18n/translations.json @@ -9,7 +9,6 @@ "Accessibility statement": "Erklärung zur Barrierefreiheit", "Add": "Hinzufügen", "Address:": "Anschrift:", - "Administrator": "Administrator", "All docs": "Alle Dokumente", "Anonymous": "Gast", "Anyone with the link can edit the document": "Jeder mit dem Link kann das Dokument bearbeiten", @@ -27,6 +26,7 @@ "Content modal to delete document": "Inhalts-Modal zum Löschen des Dokuments", "Content modal to export the document": "Inhalte zum Exportieren des Dokuments", "Convert Markdown": "Markdown konvertieren", + "Cookies placed": "Cookies gesetzt", "Copied to clipboard": "In die Zwischenablage kopiert", "Copy as {{format}}": "Als {{format}} kopieren", "Copy link": "Link kopieren", @@ -35,33 +35,33 @@ "Delete a doc": "Dokument löschen", "Delete document": "Dokument löschen", "Doc visibility card": "Dokumenten-Sichtbarkeitskarte", - "Docs": "Docs", - "Docs Logo": "Docs Logo", "Docs: Your new companion to collaborate on documents efficiently, intuitively, and securely.": "Pages: Ihr neuer Begleiter für eine effiziente, intuitive und sichere Zusammenarbeit bei Dokumenten.", "Document owner": "Besitzer des Dokuments", "Document title updated successfully": "Titel des Dokuments erfolgreich aktualisiert", "Download": "Herunterladen", "E-mail:": "E-Mail:", "Edition": "Bearbeiten", - "Editor": "Editor", "Editor unavailable": "Editor nicht verfügbar", "Error during delete invitation": "Fehler beim Löschen der Einladung", "Error during invitation update": "Fehler beim Aktualisieren der Einladung", "Error during update invitation": "Fehler beim Aktualisieren der Einladung", "Error while deleting invitation": "Fehler beim Löschen der Einladung", + "Established on December 20, 2023.": "Gegründet am 20. Dezember 2023.", "Export": "Exportieren", "Failed to add the member in the document.": "Fehler beim Hinzufügen des Mitglieds zum Dokument.", "Failed to copy link": "Link konnte nicht kopiert werden", "Failed to copy to clipboard": "Fehler beim Kopieren in die Zwischenablage", "Failed to create the invitation for {{email}}.": "Fehler beim Erstellen der Einladung für {{email}}.", - "Format": "Format", "History": "Versionsverlauf", "If a member is editing, his works can be lost.": "Wenn ein Mitglied editiert, können seine Änderungen verloren gehen.", + "If you are unable to access a content or a service, you can contact the person responsible for https://lasuite.numerique.gouv.fr to be directed to an accessible alternative or to obtain the content in another form.": "Wenn Sie keinen Zugriff auf Inhalte oder einen Service haben, können Sie sich an die für https://lasuite.numerique.gouv.fr verantwortliche Person wenden, um eine zugängliche Alternative zu erhalten oder den Inhalt in einer anderen Form zu erhalten.", + "Illustration:": "Abbildung:", "Improvement and contact": "Verbesserungen und Kontakt", "Invite": "Einladen", "It is the card information about the document.": "Es handelt sich um die Karteninformationen zum Dokument.", "It is the document title": "Es ist der Titel des Dokuments", "It seems that the page you are looking for does not exist or cannot be displayed correctly.": "Es scheint, dass die von Ihnen gesuchte Seite nicht existiert oder nicht korrekt angezeigt werden kann.", + "It's true, you didn't have to click on a block that covers half the page to say you agree to the placement of cookies — even if you don't know what it means!": "Es stimmt, Sie mussten nicht auf einen Block klicken, der die halbe Seite bedeckt, um zu sagen, dass Sie der Platzierung von Cookies zustimmen - auch wenn Sie nicht wissen, was es bedeutet!", "Language": "Sprache", "Last update: {{update}}": "Zuletzt aktualisiert: {{update}}", "Legal Notice": "Impressum", @@ -75,22 +75,21 @@ "Logout": "Abmelden", "Modal confirmation to restore the version": "Modale Bestätigung um die Version wiederherzustellen", "More docs": "Weitere Dokumente", + "More info?": "Mehr Informationen", "My docs": "Meine Dokumente", - "Name": "Name", "New doc": "Neues Dokument", "No active search": "Keine aktive Suche", "No document found": "Kein Dokument gefunden", "No documents found": "Keine Dokumente gefunden", "No editor found": "Kein Editor gefunden", "No versions": "Keine Versionen", - "OK": "OK", + "Nothing exceptional, no special privileges related to a .gouv.fr.": "Nichts Außergewöhnliches, keine besonderen Privilegien im Zusammenhang mit .gouv.fr.", "Offline ?!": "Offline?!", "Only invited people can access": "Nur eingeladene Personen haben Zugriff", "Open the document options": "Öffnen Sie die Dokumentoptionen", "Open the header menu": "Öffne das Kopfzeilen-Menü", "Ouch !": "Autsch!", "Owner": "Besitzer", - "PDF": "PDF", "Pending invitations": "Ausstehende Einladungen", "Personal data and cookies": "Personenbezogene Daten und Cookies", "Pin": "Anheften", @@ -98,9 +97,12 @@ "Private": "Privat", "Public": "Öffentlich", "Public document": "Öffentliches Dokument", + "Publication Director": "Verantwortlicher Herausgeber", + "Publisher": "Herausgeber", "Quick search input": "Schnellsuche-Eingabe", "Reader": "Leser", "Reading": "Lesen", + "Remedies": "Rechtsbehelfe", "Remove": "Löschen", "Rename": "Umbenennen", "Rephrase": "Umformulieren", @@ -110,6 +112,7 @@ "Search user result": "Suchergebnis", "Select a document": "Dokument auswählen", "Select a version on the right to restore": "Wählen Sie rechts eine Version zum Wiederherstellen aus", + "Send a letter by post (free of charge, no stamp needed):": "Senden Sie einen Brief per Post (kostenlos, kein Porto erforderlich):", "Share": "Teilen", "Share modal": "Teilen-Modal", "Share the document": "Dokument teilen", @@ -118,13 +121,18 @@ "Share with {{count}} users_other": "Teilen mit {{count}} Benutzern", "Shared with me": "Mit mir geteilt", "Something bad happens, please retry.": "Etwas ist schiefgelaufen, bitte versuchen Sie es erneut.", + "Stéphanie Schaer: Interministerial Digital Director (DINUM).": "Stéphanie Schaer: Interministerielle Digitaldirektorin (DINUM).", "Summarize": "Zusammenfassen", "Summary": "Zusammenfassung", "Template": "Vorlage", "The document has been deleted.": "Das Dokument wurde gelöscht.", "The document visibility has been updated.": "Die Sichtbarkeit des Dokuments wurde aktualisiert.", + "The team in charge of the digital workspace \"La Suite numérique\" can be contacted directly at": "Das Team, das für den digitalen Arbeitsbereich \"La Suite numérique\" zuständig ist, kann direkt kontaktiert werden unter", "This accessibility statement applies to the site hosted on": "Diese Erklärung zur Barrierefreiheit gilt für die gehostete Seite", - "This site does not display a cookie consent banner, why?": "", + "This allows us to measure the number of visits and understand which pages are the most viewed.": "Dies ermöglicht es uns, die Anzahl der Besuche zu messen und zu verstehen, welche Seiten am häufigsten angesehen werden.", + "This procedure should be used in the following case:": "Dieses Verfahren sollte in folgendem Fall verwendet werden:", + "This site places a small text file (a \"cookie\") on your computer when you visit it.": "Diese Website platziert beim Besuch auf Ihrem Computer eine kleine Textdatei (ein \"Cookie\").", + "This will protect your privacy, but will also prevent the owner from learning from your actions and creating a better experience for you and other users.": "Dies schützt Ihre Privatsphäre, verhindert jedoch auch, dass der Eigentümer aus Ihren Aktionen lernt und eine bessere Erfahrung für Sie und andere Benutzer schafft.", "Too many requests. Please wait 60 seconds.": "Zu viele Anfragen. Bitte warten Sie 60 Sekunden.", "Type a name or email": "Geben Sie einen Namen oder eine E-Mail-Adresse ein", "Type the name of a document": "Geben Sie den Namen eines Dokuments ein", @@ -139,12 +147,15 @@ "Visibility": "Sichtbarkeit", "Visibility mode": "Sichtbarkeitseinstellungen", "Warning": "Warnung", + "We simply comply with the law, which states that certain audience measurement tools, properly configured to respect privacy, are exempt from prior authorization.": "Wir halten uns einfach an das Gesetz, das besagt, dass bestimmte Publikumsmessungstools, die ordnungsgemäß konfiguriert sind, um die Privatsphäre zu respektieren, von einer vorherigen Genehmigung befreit sind.", "We try to respond within 2 working days.": "Wir versuchen, innerhalb von 2 Arbeitstagen zu antworten.", - "Word / Open Office": "Word / Open Office", "You are the sole owner of this group, make another member the group owner before you can change your own role or be removed from your document.": "Sie sind der einzige Besitzer dieser Gruppe. Machen Sie ein anderes Mitglied zum Gruppenbesitzer, bevor Sie Ihre eigene Rolle ändern oder aus Ihrem Dokument entfernen können.", + "You can oppose the tracking of your browsing on this website.": "Sie können der Verfolgung Ihres Surfverhaltens auf dieser Website widersprechen.", + "You can:": "Sie können:", "You cannot update the role or remove other owner.": "Sie können die Rolle nicht aktualisieren oder einen anderen Besitzer entfernen.", "Your current document will revert to this version.": "Ihr aktuelles Dokument wird auf diese Version zurückgesetzt.", - "Your {{format}} was downloaded succesfully": "Ihr {{format}} wurde erfolgreich heruntergeladen" + "Your {{format}} was downloaded succesfully": "Ihr {{format}} wurde erfolgreich heruntergeladen", + "you have reported to the website manager a lack of accessibility that prevents you from accessing content or one of the services of the portal and you have not received a satisfactory response.": "sie haben dem Website-Manager einen Mangel an Barrierefreiheit gemeldet, der Ihnen den Zugriff auf Inhalte oder einen der Dienste des Portals verwehrt, und Sie haben keine zufriedenstellende Antwort erhalten." } }, "en": { "translation": {} }, @@ -323,5 +334,6 @@ "accessibility-not-audit": "docs.numerique.gouv.fr n'est pas en conformité avec le RGAA 4.1. Le site n'a pas encore été audité.", "you have reported to the website manager a lack of accessibility that prevents you from accessing content or one of the services of the portal and you have not received a satisfactory response.": "vous avez signalé au responsable du site internet un défaut d'accessibilité qui vous empêche d'accéder à un contenu ou à un des services du portail et vous n'avez pas obtenu de réponse satisfaisante." } - } + }, + "nl": { "translation": {} } } From 81837aff2bcf873cb68eb42bbd318a24bbb09b1a Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Mon, 6 Jan 2025 16:15:45 +0100 Subject: [PATCH 08/16] =?UTF-8?q?=E2=9C=A8(frontend)=20export=20pdf=20docx?= =?UTF-8?q?=20front=20side?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We have added the export to pdf and docx feature to the front side. Thanks to that, the images are now correctly exported even when the doc is private. To be able to export the doc, the data must be in blocknote format, for legacy purpose, we have to convert the template to blocknote format before exporting it. --- CHANGELOG.md | 1 + .../__tests__/app-impress/doc-export.spec.ts | 177 ++----- src/frontend/apps/e2e/package.json | 1 - src/frontend/apps/impress/package.json | 4 + .../docs/doc-header/components/DocToolBox.tsx | 12 +- .../doc-header/components/ModalExport.tsx | 153 +++--- .../src/features/docs/doc-header/utils.ts | 144 +----- src/frontend/yarn.lock | 481 ++++++++++++++++-- 8 files changed, 610 insertions(+), 363 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da6a6826..4721091c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to - github actions to managed Crowdin workflow - 📈Integrate Posthog #540 - 🏷️(backend) add content-type to uploaded files #552 +- ✨(frontend) export pdf docx front side #537 ## Changed diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-export.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-export.spec.ts index 6e62d3ad..eff9592a 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-export.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-export.spec.ts @@ -1,6 +1,7 @@ +import path from 'path'; + import { expect, test } from '@playwright/test'; import cs from 'convert-stream'; -import jsdom from 'jsdom'; import pdf from 'pdf-parse'; import { createDoc, verifyDocName } from './common'; @@ -41,10 +42,8 @@ test.describe('Doc Export', () => { ).toBeVisible(); await expect(page.getByRole('button', { name: 'Download' })).toBeVisible(); }); - test('it converts the doc to pdf with a template integrated', async ({ - page, - browserName, - }) => { + + test('it exports the doc to pdf', async ({ page, browserName }) => { const [randomDoc] = await createDoc(page, 'doc-editor', browserName, 1); const downloadPromise = page.waitForEvent('download', (download) => { @@ -77,10 +76,7 @@ test.describe('Doc Export', () => { expect(pdfText).toContain('Hello World'); // This is the doc text }); - test('it converts the doc to docx with a template integrated', async ({ - page, - browserName, - }) => { + test('it exports the doc to docx', async ({ page, browserName }) => { const [randomDoc] = await createDoc(page, 'doc-editor', browserName, 1); const downloadPromise = page.waitForEvent('download', (download) => { @@ -111,152 +107,75 @@ test.describe('Doc Export', () => { expect(download.suggestedFilename()).toBe(`${randomDoc}.docx`); }); - test('it converts the blocknote json in correct html for the export', async ({ - page, - browserName, - }) => { - test.setTimeout(60000); - + /** + * This test tell us that the export to pdf is working with images + * but it does not tell us if the images are beeing displayed correctly + * in the pdf. + * + * TODO: Check if the images are displayed correctly in the pdf + */ + test('it exports the docs with images', async ({ page, browserName }) => { const [randomDoc] = await createDoc(page, 'doc-editor', browserName, 1); - let body = ''; - await page.route('**/templates/*/generate-document/', async (route) => { - const request = route.request(); - body = request.postDataJSON().body; - - await route.continue(); + const fileChooserPromise = page.waitForEvent('filechooser'); + const downloadPromise = page.waitForEvent('download', (download) => { + return download.suggestedFilename().includes(`${randomDoc}.pdf`); }); await verifyDocName(page, randomDoc); - await page.locator('.bn-block-outer').last().fill('Hello World'); - await page.locator('.bn-block-outer').last().click(); - await page.keyboard.press('Enter'); - await page.keyboard.press('Enter'); - await page.locator('.bn-block-outer').last().fill('Break'); - await expect(page.getByText('Break')).toBeVisible(); + await page.locator('.ProseMirror.bn-editor').click(); + await page.locator('.ProseMirror.bn-editor').fill('Hello World'); - // Center the text - await page.getByText('Break').dblclick(); - await page.locator('button[data-test="alignTextCenter"]').click(); - - // Change the background color - await page.locator('button[data-test="colors"]').click(); - await page.locator('button[data-test="background-color-brown"]').click(); - - // Change the text color - await page.getByText('Break').dblclick(); - await page.locator('button[data-test="colors"]').click(); - await page.locator('button[data-test="text-color-orange"]').click(); - - // Add a list - await page.locator('.bn-block-outer').last().click(); await page.keyboard.press('Enter'); await page.locator('.bn-block-outer').last().fill('/'); - await page.getByText('Bullet List').click(); - await page - .locator('.bn-block-content[data-content-type="bulletListItem"] p') - .last() - .fill('Test List 1'); - // eslint-disable-next-line playwright/no-wait-for-timeout - await page.waitForTimeout(300); - await page.keyboard.press('Enter'); - await page - .locator('.bn-block-content[data-content-type="bulletListItem"] p') - .last() - .fill('Test List 2'); - await page.keyboard.press('Enter'); - await page - .locator('.bn-block-content[data-content-type="bulletListItem"] p') - .last() - .fill('Test List 3'); + await page.getByText('Resizable image with caption').click(); + await page.getByText('Upload image').click(); - await page.keyboard.press('Enter'); - await page.keyboard.press('Backspace'); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles( + path.join(__dirname, 'assets/logo-suite-numerique.png'), + ); - // Add a number list - await page.locator('.bn-block-outer').last().click(); - await page.keyboard.press('Enter'); - await page.locator('.bn-block-outer').last().fill('/'); - await page.getByText('Numbered List').click(); - await page - .locator('.bn-block-content[data-content-type="numberedListItem"] p') - .last() - .fill('Test Number 1'); - // eslint-disable-next-line playwright/no-wait-for-timeout - await page.waitForTimeout(300); - await page.keyboard.press('Enter'); - await page - .locator('.bn-block-content[data-content-type="numberedListItem"] p') - .last() - .fill('Test Number 2'); - await page.keyboard.press('Enter'); - await page - .locator('.bn-block-content[data-content-type="numberedListItem"] p') - .last() - .fill('Test Number 3'); + const image = page.getByRole('img', { name: 'logo-suite-numerique.png' }); - // Add img - await page.locator('.bn-block-outer').last().click(); - await page.keyboard.press('Enter'); - await page.locator('.bn-block-outer').last().fill('/'); - await page - .getByRole('option', { - name: 'Image', - }) - .click(); - await page - .getByRole('tab', { - name: 'Embed', - }) - .click(); - await page - .getByPlaceholder('Enter URL') - .fill('https://example.com/image.jpg'); - await page - .getByRole('button', { - name: 'Embed image', - }) - .click(); + await expect(image).toBeVisible(); - // Download await page .getByRole('button', { name: 'download', }) .click(); + await page + .getByRole('combobox', { + name: 'Template', + }) + .click(); + + await page + .getByRole('option', { + name: 'Demo Template', + }) + .click({ + delay: 100, + }); + + await new Promise((resolve) => setTimeout(resolve, 1000)); + await page .getByRole('button', { name: 'Download', }) .click(); - // Empty paragraph should be replaced by a
- expect(body.match(/
/g)?.length).toBeGreaterThanOrEqual(2); - expect(body).toContain('style="color: orange;"'); - expect(body).toContain('custom-style="center"'); - expect(body).toContain('style="background-color: brown;"'); + const download = await downloadPromise; + expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`); - const { JSDOM } = jsdom; - const DOMParser = new JSDOM().window.DOMParser; - const parser = new DOMParser(); - const html = parser.parseFromString(body, 'text/html'); + const pdfBuffer = await cs.toBuffer(await download.createReadStream()); + const pdfExport = await pdf(pdfBuffer); + const pdfText = pdfExport.text; - const ulLis = html.querySelectorAll('ul li'); - expect(ulLis.length).toBe(3); - expect(ulLis[0].textContent).toBe('Test List 1'); - expect(ulLis[1].textContent).toBe('Test List 2'); - expect(ulLis[2].textContent).toBe('Test List 3'); - - const olLis = html.querySelectorAll('ol li'); - expect(olLis.length).toBe(3); - expect(olLis[0].textContent).toBe('Test Number 1'); - expect(olLis[1].textContent).toBe('Test Number 2'); - expect(olLis[2].textContent).toBe('Test Number 3'); - - const img = html.querySelectorAll('img'); - expect(img.length).toBe(1); - expect(img[0].src).toBe('https://example.com/image.jpg'); + expect(pdfText).toContain('Hello World'); }); }); diff --git a/src/frontend/apps/e2e/package.json b/src/frontend/apps/e2e/package.json index b46958ca..b9dcad2d 100644 --- a/src/frontend/apps/e2e/package.json +++ b/src/frontend/apps/e2e/package.json @@ -22,7 +22,6 @@ }, "dependencies": { "convert-stream": "1.0.2", - "jsdom": "25.0.1", "pdf-parse": "1.1.1" } } diff --git a/src/frontend/apps/impress/package.json b/src/frontend/apps/impress/package.json index 7ae5efa6..a4735a57 100644 --- a/src/frontend/apps/impress/package.json +++ b/src/frontend/apps/impress/package.json @@ -18,13 +18,17 @@ "@blocknote/core": "0.21.0", "@blocknote/mantine": "0.21.0", "@blocknote/react": "0.21.0", + "@blocknote/xl-docx-exporter": "0.21.0", + "@blocknote/xl-pdf-exporter": "0.21.0", "@gouvfr-lasuite/integration": "1.0.2", "@hocuspocus/provider": "2.15.0", "@openfun/cunningham-react": "2.9.4", + "@react-pdf/renderer": "4.1.6", "@sentry/nextjs": "8.47.0", "@tanstack/react-query": "5.62.11", "cmdk": "1.0.4", "crisp-sdk-web": "1.0.25", + "docx": "9.1.0", "i18next": "24.2.0", "i18next-browser-languagedetector": "8.0.2", "idb": "8.0.1", diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx index cdfdacaf..30786e3e 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx @@ -26,7 +26,7 @@ import { } from '@/features/docs/doc-versioning'; import { useResponsiveStore } from '@/stores'; -import { ModalPDF } from './ModalExport'; +import { ModalExport } from './ModalExport'; interface DocToolBoxProps { doc: Doc; @@ -43,7 +43,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => { const colors = colorsTokens(); const [isModalRemoveOpen, setIsModalRemoveOpen] = useState(false); - const [isModalPDFOpen, setIsModalPDFOpen] = useState(false); + const [isModalExportOpen, setIsModalExportOpen] = useState(false); const selectHistoryModal = useModal(); const modalShare = useModal(); @@ -63,7 +63,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => { label: t('Export'), icon: 'download', callback: () => { - setIsModalPDFOpen(true); + setIsModalExportOpen(true); }, }, ] @@ -198,7 +198,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => { } onClick={() => { - setIsModalPDFOpen(true); + setIsModalExportOpen(true); }} size={isSmallMobile ? 'small' : 'medium'} /> @@ -228,8 +228,8 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => { {modalShare.isOpen && ( modalShare.close()} doc={doc} /> )} - {isModalPDFOpen && ( - setIsModalPDFOpen(false)} doc={doc} /> + {isModalExportOpen && ( + setIsModalExportOpen(false)} doc={doc} /> )} {isModalRemoveOpen && ( setIsModalRemoveOpen(false)} doc={doc} /> diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/components/ModalExport.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/ModalExport.tsx index e08ac21d..4e72a6eb 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-header/components/ModalExport.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-header/components/ModalExport.tsx @@ -1,3 +1,11 @@ +import { + DOCXExporter, + docxDefaultSchemaMappings, +} from '@blocknote/xl-docx-exporter'; +import { + PDFExporter, + pdfDefaultSchemaMappings, +} from '@blocknote/xl-pdf-exporter'; import { Button, Loader, @@ -7,91 +15,116 @@ import { VariantType, useToastProvider, } from '@openfun/cunningham-react'; -import { useEffect, useMemo, useState } from 'react'; +import { pdf } from '@react-pdf/renderer'; +import { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { css } from 'styled-components'; import { Box, Text } from '@/components'; import { useEditorStore } from '@/features/docs/doc-editor'; import { Doc } from '@/features/docs/doc-management'; -import { useExport } from '../api/useExport'; import { TemplatesOrdering, useTemplates } from '../api/useTemplates'; -import { adaptBlockNoteHTML, downloadFile } from '../utils'; +import { downloadFile, exportResolveFileUrl } from '../utils'; -export enum DocDownloadFormat { +enum DocDownloadFormat { PDF = 'pdf', DOCX = 'docx', } -interface ModalPDFProps { +interface ModalExportProps { onClose: () => void; doc: Doc; } -export const ModalPDF = ({ onClose, doc }: ModalPDFProps) => { +export const ModalExport = ({ onClose, doc }: ModalExportProps) => { const { t } = useTranslation(); const { data: templates } = useTemplates({ ordering: TemplatesOrdering.BY_CREATED_ON_DESC, }); const { toast } = useToastProvider(); const { editor } = useEditorStore(); - const { - mutate: createExport, - data: documentGenerated, - isSuccess, - isPending, - error, - } = useExport(); - const [templateIdSelected, setTemplateIdSelected] = useState(); + const [templateSelected, setTemplateSelected] = useState(''); + const [isExporting, setIsExporting] = useState(false); const [format, setFormat] = useState( DocDownloadFormat.PDF, ); const templateOptions = useMemo(() => { - if (!templates?.pages) { - return []; - } - - const templateOptions = templates.pages + const templateOptions = (templates?.pages || []) .map((page) => page.results.map((template) => ({ label: template.title, - value: template.id, + value: template.code, })), ) .flat(); - if (templateOptions.length) { - setTemplateIdSelected(templateOptions[0].value); - } + templateOptions.unshift({ + label: t('Empty template'), + value: '', + }); return templateOptions; - }, [templates?.pages]); + }, [t, templates?.pages]); - useEffect(() => { - if (!error) { + async function onSubmit() { + if (!editor) { + toast(t('The export failed'), VariantType.ERROR); return; } - toast(error.message, VariantType.ERROR); + setIsExporting(true); - onClose(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [error, t]); - - useEffect(() => { - if (!documentGenerated || !isSuccess) { - return; - } - - // normalize title const title = doc.title .toLowerCase() .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .replace(/\s/g, '-'); - downloadFile(documentGenerated, `${title}.${format}`); + const html = templateSelected; + let exportDocument = editor.document; + if (html) { + const blockTemplate = await editor.tryParseHTMLToBlocks(html); + exportDocument = [...blockTemplate, ...editor.document]; + } + + let blobExport: Blob; + if (format === DocDownloadFormat.PDF) { + const defaultExporter = new PDFExporter( + editor.schema, + pdfDefaultSchemaMappings, + ); + + const exporter = new PDFExporter( + editor.schema, + pdfDefaultSchemaMappings, + { + resolveFileUrl: async (url) => + exportResolveFileUrl(url, defaultExporter.options.resolveFileUrl), + }, + ); + const pdfDocument = await exporter.toReactPDFDocument(exportDocument); + blobExport = await pdf(pdfDocument).toBlob(); + } else { + const defaultExporter = new DOCXExporter( + editor.schema, + docxDefaultSchemaMappings, + ); + + const exporter = new DOCXExporter( + editor.schema, + docxDefaultSchemaMappings, + { + resolveFileUrl: async (url) => + exportResolveFileUrl(url, defaultExporter.options.resolveFileUrl), + }, + ); + + blobExport = await exporter.toBlob(exportDocument); + } + + downloadFile(blobExport, `${title}.${format}`); toast( t('Your {{format}} was downloaded succesfully', { @@ -100,29 +133,9 @@ export const ModalPDF = ({ onClose, doc }: ModalPDFProps) => { VariantType.SUCCESS, ); + setIsExporting(false); + onClose(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [documentGenerated, isSuccess, t]); - - async function onSubmit() { - if (!templateIdSelected || !format) { - return; - } - - if (!editor) { - toast(t('No editor found'), VariantType.ERROR); - return; - } - - let body = await editor.blocksToFullHTML(editor.document); - body = adaptBlockNoteHTML(body); - - createExport({ - templateId: templateIdSelected, - body, - body_type: 'html', - format, - }); } return ( @@ -138,6 +151,7 @@ export const ModalPDF = ({ onClose, doc }: ModalPDFProps) => { color="secondary" fullWidth onClick={() => onClose()} + disabled={isExporting} > {t('Cancel')} @@ -146,7 +160,7 @@ export const ModalPDF = ({ onClose, doc }: ModalPDFProps) => { color="primary" fullWidth onClick={() => void onSubmit()} - disabled={isPending || !templateIdSelected} + disabled={isExporting} > {t('Download')} @@ -173,9 +187,9 @@ export const ModalPDF = ({ onClose, doc }: ModalPDFProps) => { clearable={false} label={t('Template')} options={templateOptions} - value={templateIdSelected} + value={templateSelected} onChange={(options) => - setTemplateIdSelected(options.target.value as string) + setTemplateSelected(options.target.value as string) } />