Compare commits

..

8 Commits

Author SHA1 Message Date
Stephan Meijer
5eca3deefc ♻️(backend) stylistic and consistency changes
Refactored converter services based on PR #1609 review comments:
- Renamed parameter to `data` across all convert methods for consistency
- Replaced recursive call with explicit sequential calls for readability
- Hardcoded CONVERSION_API_SECURE=True in Production class for security
- Removed unused YdocConverter import from viewsets.py
- Updated tests to match new error message wording

Signed-off-by: Stephan Meijer <me@stephanmeijer.com>
2026-01-08 12:30:09 +01:00
Stephan Meijer
f5d10360ae Merge branch 'main' into feature/doc-import 2026-01-08 12:11:45 +01:00
Stephan Meijer
afa93e4cdd Merge remote-tracking branch 'origin/main' into feature/doc-import 2026-01-08 12:02:35 +01:00
Stephan Meijer
970fcd53d6 (backend) add tests for document import feature
Added comprehensive tests covering DocSpec converter service,
converter orchestration, and document creation with file uploads.

Tests validate DOCX and Markdown conversion workflows, error
handling, service availability, and edge cases including empty
files and Unicode filenames.

Signed-off-by: Stephan Meijer <me@stephanmeijer.com>
2025-12-05 23:02:22 +01:00
Stephan Meijer
eeeaa0c76d ⬆️(docker) upgrade docspec api to version 2.4.4
Updated docspec service image from 2.0.0 to 2.4.4 to
include latest features and bug fixes.

Signed-off-by: Stephan Meijer <me@stephanmeijer.com>
2025-12-03 15:44:54 +01:00
Anthony LC
9af753a28f (frontend) add import document area in docs grid
Add import document area with drag and drop
support in the docs grid component.
We can now import docx and and md files just
by dropping them into the designated area.

We are using the `react-dropzone` library to
handle the drag and drop functionality.
2025-12-03 15:44:54 +01:00
Anthony LC
75fe73c34b 💄(frontend) adapt the docs grid title bar
Adapt the docs grid title bar to align with the
new design. We will add a upload button in a
future iteration.
2025-12-03 15:10:07 +01:00
Stephan Meijer
aebe7d06d2 (backend) Import of documents
We can now import documents in formats .docx and .md.
To do so we added a new container "docspec", which
uses the docspec service to convert
these formats to Blocknote format.

More here: #1567 #1569.
2025-12-03 15:10:06 +01:00
168 changed files with 7130 additions and 7744 deletions

View File

@@ -6,49 +6,11 @@ and this project adheres to
## [Unreleased]
### Added
- ✨(frontend) integrate configurable Waffle #1795
### Fixed
- ✅(e2e) fix e2e test for other browsers #1799
- 🐛(frontend) add fallback for unsupported Blocknote languages #1810
- 🐛(frontend) fix emojipicker closing in tree #1808
### Changed
- ♿(frontend) improve accessibility:
- ♿️(frontend) fix subdoc opening and emoji pick focus #1745
- ♿️(frontend) Keyboard focus Fixes for docs Tree/Editor #1816
## [4.4.0] - 2026-01-13
### Added
- ✨ Import of documents #7765
- ✨(backend) add documents/all endpoint with descendants #1553
- ✅(export) add PDF regression tests #1762
- 📝(docs) Add language configuration documentation #1757
- 🔒(helm) Set default security context #1750
- ✨(backend) use langfuse to monitor AI actions #1776
### Changed
- ♿(frontend) improve accessibility:
- ♿(frontend) make html export accessible to screen reader users #1743
- ♿(frontend) add missing label and fix Axes errors to improve a11y #1693
### Fixed
- ✅(backend) reduce flakiness on backend test #1769
- 🐛(frontend) fix clickable main content regression #1773
- 🐛(backend) fix TRASHBIN_CUTOFF_DAYS type error #1778
- 💄(frontend) fix icon position in callout block #1779
### Security
- 🔒️(backend) validate more strictly url used by cors-proxy endpoint #1768
- 🔒️(frontend) fix props vulnerability in Interlinking #1792
## [4.3.0] - 2026-01-05
@@ -68,6 +30,7 @@ and this project adheres to
- 🐛(frontend) fix tables deletion #1739
- 🐛(frontend) fix children not display when first resize #1753
- 🐛(frontend) fix clickable main content regression #1773
## [4.2.0] - 2025-12-17
@@ -93,6 +56,7 @@ and this project adheres to
- 🐛(frontend) Select text + Go back one page crash the app #1733
- 🐛(frontend) fix versioning conflict #1742
## [4.1.0] - 2025-12-09
### Added
@@ -1006,8 +970,7 @@ and this project adheres to
- ✨(frontend) Coming Soon page (#67)
- 🚀 Impress, project to manage your documents easily and collaboratively.
[unreleased]: https://github.com/suitenumerique/docs/compare/v4.4.0...main
[v4.4.0]: https://github.com/suitenumerique/docs/releases/v4.4.0
[unreleased]: https://github.com/suitenumerique/docs/compare/v4.3.0...main
[v4.3.0]: https://github.com/suitenumerique/docs/releases/v4.3.0
[v4.2.0]: https://github.com/suitenumerique/docs/releases/v4.2.0
[v4.1.0]: https://github.com/suitenumerique/docs/releases/v4.1.0

View File

@@ -213,6 +213,7 @@ logs: ## display app-dev logs (follow mode)
.PHONY: logs
run-backend: ## Start only the backend application and all needed services
@$(COMPOSE) up --force-recreate -d docspec
@$(COMPOSE) up --force-recreate -d celery-dev
@$(COMPOSE) up --force-recreate -d y-provider-development
@$(COMPOSE) up --force-recreate -d nginx

View File

@@ -231,6 +231,14 @@ services:
condition: service_healthy
restart: true
docspec:
image: ghcr.io/docspecio/api:2.6.0
ports:
- "4000:4000"
networks:
- lasuite
- default
networks:
lasuite:
name: lasuite-network

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

View File

@@ -7,7 +7,7 @@ Here we describe all environment variables that can be set for the docs applicat
These are the environment variables you can set for the `impress-backend` container.
| Option | Description | default |
|-------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------|
| ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
| AI_ALLOW_REACH_FROM | Users that can use AI must be this level. options are "public", "authenticated", "restricted" | authenticated |
| AI_API_KEY | AI key to be used for AI Base url | |
| AI_BASE_URL | OpenAI compatible AI base url | |
@@ -58,16 +58,14 @@ These are the environment variables you can set for the `impress-backend` contai
| DJANGO_EMAIL_USE_TLS | Use tls for email host connection | false |
| DJANGO_SECRET_KEY | Secret key | |
| DJANGO_SERVER_TO_SERVER_API_TOKENS | | [] |
| DOCSPEC_API_URL | URL to endpoint of DocSpec conversion API | |
| DOCUMENT_IMAGE_MAX_SIZE | Maximum size of document in bytes | 10485760 |
| FRONTEND_CSS_URL | To add a external css file to the app | |
| FRONTEND_JS_URL | To add a external js file to the app | |
| FRONTEND_JS_URL | To add a external js file to the app | |
| FRONTEND_HOMEPAGE_FEATURE_ENABLED | Frontend feature flag to display the homepage | false |
| FRONTEND_THEME | Frontend theme to use | |
| LANGUAGE_CODE | Default language | en-us |
| LANGFUSE_SECRET_KEY | The Langfuse secret key used by the sdk | None |
| LANGFUSE_PUBLIC_KEY | The Langfuse public key used by the sdk | None |
| LANGFUSE_BASE_URL | The Langfuse base url used by the sdk | None |
| LASUITE_MARKETING_BACKEND | Backend used when SIGNUP_NEW_USER_TO_MARKETING_EMAIL is True. See https://github.com/suitenumerique/django-lasuite/blob/main/documentation/how-to-use-marketing-backend.md | lasuite.marketing.backends.dummy.DummyBackend |
| LASUITE_MARKETING_BACKEND | Backend used when SIGNUP_NEW_USER_TO_MARKETING_EMAIL is True. See https://github.com/suitenumerique/django-lasuite/blob/main/documentation/how-to-use-marketing-backend.md | lasuite.marketing.backends.dummy.DummyBackend |
| LASUITE_MARKETING_PARAMETERS | The parameters to configure LASUITE_MARKETING_BACKEND. See https://github.com/suitenumerique/django-lasuite/blob/main/documentation/how-to-use-marketing-backend.md | {} |
| LOGGING_LEVEL_LOGGERS_APP | Application logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO |
| LOGGING_LEVEL_LOGGERS_ROOT | Default logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO |
@@ -117,7 +115,6 @@ These are the environment variables you can set for the `impress-backend` contai
| Y_PROVIDER_API_BASE_URL | Y Provider url | |
| Y_PROVIDER_API_KEY | Y provider API key | |
## impress-frontend image
These are the environment variables you can set to build the `impress-frontend` image.
@@ -130,7 +127,7 @@ Example:
```
docker build -f src/frontend/Dockerfile --target frontend-production --build-arg PUBLISH_AS_MIT=false docs-frontend:latest
```
```
If you want to build the front-end application using the yarn build command, you can edit the file `src/frontend/apps/impress/.env` with the `NODE_ENV=production` environment variable and modify it. Alternatively, you can use the listed environment variables with the prefix `NEXT_PUBLIC_` (for example, `NEXT_PUBLIC_PUBLISH_AS_MIT=false`).
@@ -141,18 +138,18 @@ cd src/frontend/apps/impress
NODE_ENV=production NEXT_PUBLIC_PUBLISH_AS_MIT=false yarn build
```
| Option | Description | default |
| ----------------------------------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
| API_ORIGIN | backend domain - it uses the current domain if not initialized | |
| SW_DEACTIVATED | To not install the service worker | |
| PUBLISH_AS_MIT | Removes packages whose licences are incompatible with the MIT licence (see below) | true |
| Option | Description | default |
| -------------- | --------------------------------------------------------------------------------- | ------- |
| API_ORIGIN | backend domain - it uses the current domain if not initialized | |
| SW_DEACTIVATED | To not install the service worker | |
| PUBLISH_AS_MIT | Removes packages whose licences are incompatible with the MIT licence (see below) | true |
Packages with licences incompatible with the MIT licence:
* `xl-docx-exporter`: [GPL](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-docx-exporter/LICENSE),
* `xl-pdf-exporter`: [GPL](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-pdf-exporter/LICENSE),
* `xl-multi-column`: [GPL](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-multi-column/LICENSE).
- `xl-docx-exporter`: [GPL](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-docx-exporter/LICENSE),
- `xl-pdf-exporter`: [GPL](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-pdf-exporter/LICENSE),
- `xl-multi-column`: [GPL](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-multi-column/LICENSE).
In `.env.development`, `PUBLISH_AS_MIT` is set to `false`, allowing developers to test Docs with all its features.
⚠️ If you run Docs in production with `PUBLISH_AS_MIT` set to `false` make sure you fulfill your BlockNote licensing or [subscription](https://www.blocknotejs.org/about#partner-with-us) obligations.

View File

@@ -1,180 +0,0 @@
# Language Configuration (2025-12)
This document explains how to configure and override the available languages in the Docs application.
## Default Languages
By default, the application supports the following languages (in priority order):
- English (en-us)
- French (fr-fr)
- German (de-de)
- Dutch (nl-nl)
- Spanish (es-es)
The default configuration is defined in `src/backend/impress/settings.py`:
```python
LANGUAGES = values.SingleNestedTupleValue(
(
("en-us", "English"),
("fr-fr", "Français"),
("de-de", "Deutsch"),
("nl-nl", "Nederlands"),
("es-es", "Español"),
)
)
```
## Overriding Languages
### Using Environment Variables
You can override the available languages by setting the `DJANGO_LANGUAGES` environment variable. This is the recommended approach for customizing language support without modifying the source code.
#### Format
The `DJANGO_LANGUAGES` variable expects a semicolon-separated list of language configurations, where each language is defined as `code,Display Name`:
```
DJANGO_LANGUAGES=code1,Name1;code2,Name2;code3,Name3
```
#### Example Configurations
**Example 1: English and French only**
```bash
DJANGO_LANGUAGES=en-us,English;fr-fr,Français
```
**Example 2: Add Italian and Chinese**
```bash
DJANGO_LANGUAGES=en-us,English;fr-fr,Français;de-de,Deutsch;it-it,Italiano;zh-cn,中文
```
**Example 3: Custom subset of languages**
```bash
DJANGO_LANGUAGES=fr-fr,Français;de-de,Deutsch;es-es,Español
```
### Configuration Files
#### Development Environment
For local development, you can set the `DJANGO_LANGUAGES` variable in your environment configuration file:
**File:** `env.d/development/common.local`
```bash
DJANGO_LANGUAGES=en-us,English;fr-fr,Français;de-de,Deutsch;it-it,Italiano;zh-cn,中文;
```
#### Production Environment
For production deployments, add the variable to your production environment configuration:
**File:** `env.d/production.dist/common`
```bash
DJANGO_LANGUAGES=en-us,English;fr-fr,Français
```
#### Docker Compose
When using Docker Compose, you can set the environment variable in your `compose.yml` or `compose.override.yml` file:
```yaml
services:
app:
environment:
- DJANGO_LANGUAGES=en-us,English;fr-fr,Français;de-de,Deutsch
```
## Important Considerations
### Language Codes
- Use standard language codes (ISO 639-1 with optional region codes)
- Format: `language-region` (e.g., `en-us`, `fr-fr`, `de-de`)
- Use lowercase for language codes and region identifiers
### Priority Order
Languages are listed in priority order. The first language in the list is used as the fallback language throughout the application when a specific translation is not available.
### Translation Availability
Before adding a new language, ensure that:
1. Translation files exist for that language in the `src/backend/locale/` directory
2. The frontend application has corresponding translation files
3. All required messages have been translated
#### Available Languages
The following languages have translation files available in `src/backend/locale/`:
- `br_FR` - Breton (France)
- `cn_CN` - Chinese (China) - *Note: Use `zh-cn` in DJANGO_LANGUAGES*
- `de_DE` - German (Germany) - Use `de-de`
- `en_US` - English (United States) - Use `en-us`
- `es_ES` - Spanish (Spain) - Use `es-es`
- `fr_FR` - French (France) - Use `fr-fr`
- `it_IT` - Italian (Italy) - Use `it-it`
- `nl_NL` - Dutch (Netherlands) - Use `nl-nl`
- `pt_PT` - Portuguese (Portugal) - Use `pt-pt`
- `ru_RU` - Russian (Russia) - Use `ru-ru`
- `sl_SI` - Slovenian (Slovenia) - Use `sl-si`
- `sv_SE` - Swedish (Sweden) - Use `sv-se`
- `tr_TR` - Turkish (Turkey) - Use `tr-tr`
- `uk_UA` - Ukrainian (Ukraine) - Use `uk-ua`
- `zh_CN` - Chinese (China) - Use `zh-cn`
**Note:** When configuring `DJANGO_LANGUAGES`, use lowercase with hyphens (e.g., `pt-pt`, `ru-ru`) rather than the directory name format.
### Translation Management
We use [Crowdin](https://crowdin.com/) to manage translations for the Docs application. Crowdin allows our community to contribute translations and helps maintain consistency across all supported languages.
**Want to add a new language or improve existing translations?**
If you would like us to support a new language or want to contribute to translations, please get in touch with the project maintainers. We can add new languages to our Crowdin project and coordinate translation efforts with the community.
### Cookie and Session
The application stores the user's language preference in a cookie named `docs_language`. The cookie path is set to `/` by default.
## Testing Language Configuration
After changing the language configuration:
1. Restart the application services
2. Verify the language selector displays the correct languages
3. Test switching between different languages
4. Confirm that content is displayed in the selected language
## Troubleshooting
### Languages not appearing
- Verify the environment variable is correctly formatted (semicolon-separated, comma between code and name)
- Check that there are no trailing spaces in language codes or names
- Ensure the application was restarted after changing the configuration
### Missing translations
If you add a new language but see untranslated text:
1. Check if translation files exist in `src/backend/locale/<language_code>/LC_MESSAGES/`
2. Run Django's `makemessages` and `compilemessages` commands to generate/update translations
3. Verify frontend translation files are available
## Related Configuration
- `LANGUAGE_CODE`: Default language code (default: `en-us`)
- `LANGUAGE_COOKIE_NAME`: Cookie name for storing user language preference (default: `docs_language`)
- `LANGUAGE_COOKIE_PATH`: Cookie path (default: `/`)

View File

@@ -1,6 +1,4 @@
# Customization Guide 🛠
## Runtime Theming 🎨
# Runtime Theming 🎨
### How to Use
@@ -34,7 +32,7 @@ Then, set the `FRONTEND_CSS_URL` environment variable to the URL of your custom
----
## Runtime JavaScript Injection 🚀
# Runtime JavaScript Injection 🚀
### How to Use
@@ -89,7 +87,7 @@ Then, set the `FRONTEND_JS_URL` environment variable to the URL of your custom J
----
## **Your Docs icon** 📝
# **Your Docs icon** 📝
You can add your own Docs icon in the header from the theme customization file.
@@ -107,7 +105,7 @@ This configuration is optional. If not set, the default icon will be used.
----
## **Footer Configuration** 📝
# **Footer Configuration** 📝
The footer is configurable from the theme customization file.
@@ -130,7 +128,7 @@ Below is a visual example of a configured footer ⬇️:
----
## **Custom Translations** 📝
# **Custom Translations** 📝
The translations can be partially overridden from the theme customization file.
@@ -142,36 +140,4 @@ THEME_CUSTOMIZATION_FILE_PATH=<path>
### Example of JSON
The json must follow some rules: https://github.com/suitenumerique/docs/blob/main/src/helm/env.d/dev/configuration/theme/demo.json
----
## **Waffle Configuration** 🧇
The Waffle (La Gaufre) is a widget that displays a grid of services.
![Waffle Configuration Example](./assets/waffle.png)
### Settings 🔧
```shellscript
THEME_CUSTOMIZATION_FILE_PATH=<path>
```
### Configuration
The Waffle can be configured in the theme customization file with the `waffle` key.
### Available Properties
See: [LaGaufreV2Props](https://github.com/suitenumerique/ui-kit/blob/main/src/components/la-gaufre/LaGaufreV2.tsx#L49)
### Complete Example
From the theme customization file: https://github.com/suitenumerique/docs/blob/main/src/helm/env.d/dev/configuration/theme/demo.json
### Behavior
- If `data.services` is provided, the Waffle will display those services statically
- If no data is provided, services can be fetched dynamically from an API endpoint thanks to the `apiUrl` property
The json must follow some rules: https://github.com/suitenumerique/docs/blob/main/src/helm/env.d/dev/configuration/theme/demo.json

View File

@@ -76,6 +76,8 @@ DJANGO_SERVER_TO_SERVER_API_TOKENS=server-api-token
Y_PROVIDER_API_BASE_URL=http://y-provider-development:4444/api/
Y_PROVIDER_API_KEY=yprovider-api-key
DOCSPEC_API_URL=http://docspec:4000/conversion
# Theme customization
THEME_CUSTOMIZATION_CACHE_TIMEOUT=15

View File

@@ -6,4 +6,4 @@ Y_PROVIDER_API_BASE_URL=http://y-provider:4444/api/
# Throttle
API_DOCUMENT_THROTTLE_RATE=1000/min
API_CONFIG_THROTTLE_RATE=1000/min
API_CONFIG_THROTTLE_RATE=1000/min

View File

@@ -25,19 +25,6 @@
"matchPackageNames": ["pylint"],
"allowedVersions": "<4.0.0"
},
{
"groupName": "allowed django versions",
"matchManagers": ["pep621"],
"matchPackageNames": ["django"],
"allowedVersions": "<6.0.0"
},
{
"groupName": "allowed celery versions",
"matchManagers": ["pep621"],
"matchPackageNames": ["celery"],
"allowedVersions": "<5.6.0"
},
{
"enabled": false,
"groupName": "ignored js dependencies",

View File

@@ -15,10 +15,11 @@ import magic
from rest_framework import serializers
from core import choices, enums, models, utils, validators
from core.services import mime_types
from core.services.ai_services import AI_ACTIONS
from core.services.converter_services import (
ConversionError,
YdocConverter,
Converter,
)
@@ -188,6 +189,7 @@ class DocumentSerializer(ListDocumentSerializer):
content = serializers.CharField(required=False)
websocket = serializers.BooleanField(required=False, write_only=True)
file = serializers.FileField(required=False, write_only=True, allow_null=True)
class Meta:
model = models.Document
@@ -204,6 +206,7 @@ class DocumentSerializer(ListDocumentSerializer):
"deleted_at",
"depth",
"excerpt",
"file",
"is_favorite",
"link_role",
"link_reach",
@@ -461,7 +464,9 @@ class ServerCreateDocumentSerializer(serializers.Serializer):
language = user.language or language
try:
document_content = YdocConverter().convert(validated_data["content"])
document_content = Converter().convert(
validated_data["content"], mime_types.MARKDOWN, mime_types.YJS
)
except ConversionError as err:
raise serializers.ValidationError(
{"content": ["Could not convert content"]}

View File

@@ -3,10 +3,8 @@
# pylint: disable=too-many-lines
import base64
import ipaddress
import json
import logging
import socket
import uuid
from collections import defaultdict
from urllib.parse import unquote, urlencode, urlparse
@@ -43,17 +41,19 @@ from rest_framework.permissions import AllowAny
from core import authentication, choices, enums, models
from core.api.filters import remove_accents
from core.services import mime_types
from core.services.ai_services import AIService
from core.services.collaboration_services import CollaborationService
from core.services.converter_services import (
ConversionError,
Converter,
)
from core.services.converter_services import (
ServiceUnavailableError as YProviderServiceUnavailableError,
)
from core.services.converter_services import (
ValidationError as YProviderValidationError,
)
from core.services.converter_services import (
YdocConverter,
)
from core.services.search_indexers import (
get_document_indexer,
get_visited_document_ids_of,
@@ -527,6 +527,28 @@ class DocumentViewSet(
"IN SHARE ROW EXCLUSIVE MODE;"
)
# Remove file from validated_data as it's not a model field
# Process it if present
uploaded_file = serializer.validated_data.pop("file", None)
# If a file is uploaded, convert it to Yjs format and set as content
if uploaded_file:
try:
file_content = uploaded_file.read()
converter = Converter()
converted_content = converter.convert(
file_content,
content_type=uploaded_file.content_type,
accept=mime_types.YJS,
)
serializer.validated_data["content"] = converted_content
serializer.validated_data["title"] = uploaded_file.name
except ConversionError as err:
raise drf.exceptions.ValidationError(
{"file": ["Could not convert file content"]}
) from err
obj = models.Document.add_root(
creator=self.request.user,
**serializer.validated_data,
@@ -1657,101 +1679,6 @@ class DocumentViewSet(
return drf.response.Response(response, status=drf.status.HTTP_200_OK)
def _reject_invalid_ips(self, ips):
"""
Check if an IP address is safe from SSRF attacks.
Raises:
drf.exceptions.ValidationError: If the IP is unsafe
"""
for ip in ips:
# Block loopback addresses (check before private,
# as 127.0.0.1 might be considered private)
if ip.is_loopback:
raise drf.exceptions.ValidationError(
"Access to loopback addresses is not allowed"
)
# Block link-local addresses (169.254.0.0/16) - check before private
if ip.is_link_local:
raise drf.exceptions.ValidationError(
"Access to link-local addresses is not allowed"
)
# Block private IP ranges
if ip.is_private:
raise drf.exceptions.ValidationError(
"Access to private IP addresses is not allowed"
)
# Block multicast addresses
if ip.is_multicast:
raise drf.exceptions.ValidationError(
"Access to multicast addresses is not allowed"
)
# Block reserved addresses (including 0.0.0.0)
if ip.is_reserved:
raise drf.exceptions.ValidationError(
"Access to reserved IP addresses is not allowed"
)
def _validate_url_against_ssrf(self, url):
"""
Validate that a URL is safe from SSRF (Server-Side Request Forgery) attacks.
Blocks:
- localhost and its variations
- Private IP ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)
- Link-local addresses (169.254.0.0/16)
- Loopback addresses
Raises:
drf.exceptions.ValidationError: If the URL is unsafe
"""
parsed = urlparse(url)
hostname = parsed.hostname
if not hostname:
raise drf.exceptions.ValidationError("Invalid hostname")
# Resolve hostname to IP address(es)
# Check all resolved IPs to prevent DNS rebinding attacks
try:
# Try to parse as IP address first (if hostname is already an IP)
try:
ip = ipaddress.ip_address(hostname)
resolved_ips = [ip]
except ValueError:
# Resolve hostname to IP addresses (supports both IPv4 and IPv6)
resolved_ips = []
try:
# Get all address info (IPv4 and IPv6)
addr_info = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC)
for family, _, _, _, sockaddr in addr_info:
if family == socket.AF_INET:
# IPv4
ip = ipaddress.ip_address(sockaddr[0])
resolved_ips.append(ip)
elif family == socket.AF_INET6:
# IPv6
ip = ipaddress.ip_address(sockaddr[0])
resolved_ips.append(ip)
except (socket.gaierror, OSError) as e:
raise drf.exceptions.ValidationError(
f"Failed to resolve hostname: {str(e)}"
) from e
if not resolved_ips:
raise drf.exceptions.ValidationError(
"No IP addresses found for hostname"
) from None
except ValueError as e:
raise drf.exceptions.ValidationError(f"Invalid IP address: {str(e)}") from e
# Check all resolved IPs to ensure none are private/internal
self._reject_invalid_ips(resolved_ips)
@drf.decorators.action(
detail=True,
methods=["get"],
@@ -1785,16 +1712,6 @@ class DocumentViewSet(
status=drf.status.HTTP_400_BAD_REQUEST,
)
# Validate URL against SSRF attacks
try:
self._validate_url_against_ssrf(url)
except drf.exceptions.ValidationError as e:
logger.error("Potential SSRF attack detected: %s", e)
return drf.response.Response(
{"detail": "Invalid URL used."},
status=drf.status.HTTP_400_BAD_REQUEST,
)
try:
response = requests.get(
url,
@@ -1803,15 +1720,13 @@ class DocumentViewSet(
"User-Agent": request.headers.get("User-Agent", ""),
"Accept": request.headers.get("Accept", ""),
},
allow_redirects=False,
timeout=10,
)
response.raise_for_status()
content_type = response.headers.get("Content-Type", "")
if not content_type.startswith("image/"):
return drf.response.Response(
{"detail": "Invalid URL used."}, status=status.HTTP_400_BAD_REQUEST
status=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE
)
# Use StreamingHttpResponse with the response's iter_content to properly stream the data
@@ -1829,7 +1744,7 @@ class DocumentViewSet(
except requests.RequestException as e:
logger.exception(e)
return drf.response.Response(
{"detail": "Invalid URL used."},
{"error": f"Failed to fetch resource from {url}"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -1864,14 +1779,14 @@ class DocumentViewSet(
if base64_content is not None:
# Convert using the y-provider service
try:
yprovider = YdocConverter()
yprovider = Converter()
result = yprovider.convert(
base64.b64decode(base64_content),
"application/vnd.yjs.doc",
mime_types.YJS,
{
"markdown": "text/markdown",
"html": "text/html",
"json": "application/json",
"markdown": mime_types.MARKDOWN,
"html": mime_types.HTML,
"json": mime_types.JSON,
}[content_format],
)
content = result

View File

@@ -3,14 +3,10 @@
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from openai import OpenAI
from core import enums
if settings.LANGFUSE_PUBLIC_KEY:
from langfuse.openai import OpenAI
else:
from openai import OpenAI
AI_ACTIONS = {
"prompt": (
"Answer the prompt using markdown formatting for structure and emphasis. "

View File

@@ -1,11 +1,14 @@
"""Y-Provider API services."""
import typing
from base64 import b64encode
from django.conf import settings
import requests
from core.services import mime_types
class ConversionError(Exception):
"""Base exception for conversion-related errors."""
@@ -19,8 +22,73 @@ class ServiceUnavailableError(ConversionError):
"""Raised when the conversion service is unavailable."""
class ConverterProtocol(typing.Protocol):
"""Protocol for converter classes."""
def convert(self, data, content_type, accept):
"""Convert content from one format to another."""
class Converter:
"""Orchestrates conversion between different formats using specialized converters."""
docspec: ConverterProtocol
ydoc: ConverterProtocol
def __init__(self):
self.docspec = DocSpecConverter()
self.ydoc = YdocConverter()
def convert(self, data, content_type, accept):
"""Convert input into other formats using external microservices."""
if content_type == mime_types.DOCX and accept == mime_types.YJS:
blocknote_data = self.docspec.convert(
data, mime_types.DOCX, mime_types.BLOCKNOTE
)
return self.ydoc.convert(
blocknote_data, mime_types.BLOCKNOTE, mime_types.YJS
)
return self.ydoc.convert(data, content_type, accept)
class DocSpecConverter:
"""Service class for DocSpec conversion-related operations."""
def _request(self, url, data, content_type):
"""Make a request to the DocSpec API."""
response = requests.post(
url,
headers={"Accept": mime_types.BLOCKNOTE},
files={"file": ("document.docx", data, content_type)},
timeout=settings.CONVERSION_API_TIMEOUT,
verify=settings.CONVERSION_API_SECURE,
)
response.raise_for_status()
return response
def convert(self, data, content_type, accept):
"""Convert a Document to BlockNote."""
if not data:
raise ValidationError("Input data cannot be empty")
if content_type != mime_types.DOCX or accept != mime_types.BLOCKNOTE:
raise ValidationError(
f"Conversion from {content_type} to {accept} is not supported."
)
try:
return self._request(settings.DOCSPEC_API_URL, data, content_type).content
except requests.RequestException as err:
raise ServiceUnavailableError(
"Failed to connect to DocSpec conversion service",
) from err
class YdocConverter:
"""Service class for conversion-related operations."""
"""Service class for YDoc conversion-related operations."""
@property
def auth_header(self):
@@ -44,29 +112,27 @@ class YdocConverter:
response.raise_for_status()
return response
def convert(
self, text, content_type="text/markdown", accept="application/vnd.yjs.doc"
):
def convert(self, data, content_type=mime_types.MARKDOWN, accept=mime_types.YJS):
"""Convert a Markdown text into our internal format using an external microservice."""
if not text:
raise ValidationError("Input text cannot be empty")
if not data:
raise ValidationError("Input data cannot be empty")
try:
response = self._request(
f"{settings.Y_PROVIDER_API_BASE_URL}{settings.CONVERSION_API_ENDPOINT}/",
text,
data,
content_type,
accept,
)
if accept == "application/vnd.yjs.doc":
if accept == mime_types.YJS:
return b64encode(response.content).decode("utf-8")
if accept in {"text/markdown", "text/html"}:
if accept in {mime_types.MARKDOWN, "text/html"}:
return response.text
if accept == "application/json":
if accept == mime_types.JSON:
return response.json()
raise ValidationError("Unsupported format")
except requests.RequestException as err:
raise ServiceUnavailableError(
"Failed to connect to conversion service",
f"Failed to connect to YDoc conversion service {content_type}, {accept}",
) from err

View File

@@ -0,0 +1,8 @@
"""MIME type constants for document conversion."""
BLOCKNOTE = "application/vnd.blocknote+json"
YJS = "application/vnd.yjs.doc"
MARKDOWN = "text/markdown"
JSON = "application/json"
DOCX = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
HTML = "text/html"

View File

@@ -5,6 +5,7 @@ import re
from unittest import mock
from django.core.exceptions import SuspiciousOperation
from django.test.utils import override_settings
import pytest
import responses
@@ -322,6 +323,85 @@ def test_authentication_getter_new_user_with_email(monkeypatch):
assert models.User.objects.count() == 1
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
@responses.activate
def test_authentication_get_userinfo_json_response():
"""Test get_userinfo method with a JSON response."""
responses.add(
responses.GET,
re.compile(r".*/userinfo"),
json={
"first_name": "John",
"last_name": "Doe",
"email": "john.doe@example.com",
},
status=200,
)
oidc_backend = OIDCAuthenticationBackend()
result = oidc_backend.get_userinfo("fake_access_token", None, None)
assert result["first_name"] == "John"
assert result["last_name"] == "Doe"
assert result["email"] == "john.doe@example.com"
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
@responses.activate
def test_authentication_get_userinfo_token_response(monkeypatch, settings):
"""Test get_userinfo method with a token response."""
settings.OIDC_RP_SIGN_ALGO = "HS256" # disable JWKS URL call
responses.add(
responses.GET,
re.compile(r".*/userinfo"),
body="fake.jwt.token",
status=200,
content_type="application/jwt",
)
def mock_verify_token(self, token): # pylint: disable=unused-argument
return {
"first_name": "Jane",
"last_name": "Doe",
"email": "jane.doe@example.com",
}
monkeypatch.setattr(OIDCAuthenticationBackend, "verify_token", mock_verify_token)
oidc_backend = OIDCAuthenticationBackend()
result = oidc_backend.get_userinfo("fake_access_token", None, None)
assert result["first_name"] == "Jane"
assert result["last_name"] == "Doe"
assert result["email"] == "jane.doe@example.com"
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
@responses.activate
def test_authentication_get_userinfo_invalid_response(settings):
"""
Test get_userinfo method with an invalid JWT response that
causes verify_token to raise an error.
"""
settings.OIDC_RP_SIGN_ALGO = "HS256" # disable JWKS URL call
responses.add(
responses.GET,
re.compile(r".*/userinfo"),
body="fake.jwt.token",
status=200,
content_type="application/jwt",
)
oidc_backend = OIDCAuthenticationBackend()
with pytest.raises(
SuspiciousOperation,
match="User info response was not valid JWT",
):
oidc_backend.get_userinfo("fake_access_token", None, None)
def test_authentication_getter_existing_disabled_user_via_sub(
django_assert_num_queries, monkeypatch
):

View File

@@ -1,8 +1,5 @@
"""Test on the CORS proxy API for documents."""
import socket
import unittest.mock
import pytest
import responses
from requests.exceptions import RequestException
@@ -13,17 +10,11 @@ from core import factories
pytestmark = pytest.mark.django_db
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
@responses.activate
def test_api_docs_cors_proxy_valid_url(mock_getaddrinfo):
def test_api_docs_cors_proxy_valid_url():
"""Test the CORS proxy API for documents with a valid URL."""
document = factories.DocumentFactory(link_reach="public")
# Mock DNS resolution to return a public IP address
mock_getaddrinfo.return_value = [
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("8.8.8.8", 0))
]
client = APIClient()
url_to_fetch = "https://external-url.com/assets/logo-gouv.png"
responses.get(url_to_fetch, body=b"", status=200, content_type="image/png")
@@ -65,17 +56,11 @@ def test_api_docs_cors_proxy_without_url_query_string():
assert response.json() == {"detail": "Missing 'url' query parameter"}
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
@responses.activate
def test_api_docs_cors_proxy_anonymous_document_not_public(mock_getaddrinfo):
def test_api_docs_cors_proxy_anonymous_document_not_public():
"""Test the CORS proxy API for documents with an anonymous user and a non-public document."""
document = factories.DocumentFactory(link_reach="authenticated")
# Mock DNS resolution to return a public IP address
mock_getaddrinfo.return_value = [
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("8.8.8.8", 0))
]
client = APIClient()
url_to_fetch = "https://external-url.com/assets/logo-gouv.png"
responses.get(url_to_fetch, body=b"", status=200, content_type="image/png")
@@ -88,22 +73,14 @@ def test_api_docs_cors_proxy_anonymous_document_not_public(mock_getaddrinfo):
}
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
@responses.activate
def test_api_docs_cors_proxy_authenticated_user_accessing_protected_doc(
mock_getaddrinfo,
):
def test_api_docs_cors_proxy_authenticated_user_accessing_protected_doc():
"""
Test the CORS proxy API for documents with an authenticated user accessing a protected
document.
"""
document = factories.DocumentFactory(link_reach="authenticated")
# Mock DNS resolution to return a public IP address
mock_getaddrinfo.return_value = [
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("8.8.8.8", 0))
]
user = factories.UserFactory()
client = APIClient()
@@ -138,22 +115,14 @@ def test_api_docs_cors_proxy_authenticated_user_accessing_protected_doc(
assert response.streaming_content
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
@responses.activate
def test_api_docs_cors_proxy_authenticated_not_accessing_restricted_doc(
mock_getaddrinfo,
):
def test_api_docs_cors_proxy_authenticated_not_accessing_restricted_doc():
"""
Test the CORS proxy API for documents with an authenticated user not accessing a restricted
document.
"""
document = factories.DocumentFactory(link_reach="restricted")
# Mock DNS resolution to return a public IP address
mock_getaddrinfo.return_value = [
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("8.8.8.8", 0))
]
user = factories.UserFactory()
client = APIClient()
@@ -169,72 +138,18 @@ def test_api_docs_cors_proxy_authenticated_not_accessing_restricted_doc(
}
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
@responses.activate
def test_api_docs_cors_proxy_unsupported_media_type(mock_getaddrinfo):
def test_api_docs_cors_proxy_unsupported_media_type():
"""Test the CORS proxy API for documents with an unsupported media type."""
document = factories.DocumentFactory(link_reach="public")
# Mock DNS resolution to return a public IP address
mock_getaddrinfo.return_value = [
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("8.8.8.8", 0))
]
client = APIClient()
url_to_fetch = "https://external-url.com/assets/index.html"
responses.get(url_to_fetch, body=b"", status=200, content_type="text/html")
response = client.get(
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
)
assert response.status_code == 400
assert response.json() == {"detail": "Invalid URL used."}
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
@responses.activate
def test_api_docs_cors_proxy_redirect(mock_getaddrinfo):
"""Test the CORS proxy API for documents with a redirect."""
document = factories.DocumentFactory(link_reach="public")
# Mock DNS resolution to return a public IP address
mock_getaddrinfo.return_value = [
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("8.8.8.8", 0))
]
client = APIClient()
url_to_fetch = "https://external-url.com/assets/index.html"
responses.get(
url_to_fetch,
body=b"",
status=302,
headers={"Location": "https://external-url.com/other/assets/index.html"},
)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
)
assert response.status_code == 400
assert response.json() == {"detail": "Invalid URL used."}
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
@responses.activate
def test_api_docs_cors_proxy_url_not_returning_200(mock_getaddrinfo):
"""Test the CORS proxy API for documents with a URL that does not return 200."""
document = factories.DocumentFactory(link_reach="public")
# Mock DNS resolution to return a public IP address
mock_getaddrinfo.return_value = [
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("8.8.8.8", 0))
]
client = APIClient()
url_to_fetch = "https://external-url.com/assets/index.html"
responses.get(url_to_fetch, body=b"", status=404)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
)
assert response.status_code == 400
assert response.json() == {"detail": "Invalid URL used."}
assert response.status_code == 415
@pytest.mark.parametrize(
@@ -258,17 +173,11 @@ def test_api_docs_cors_proxy_invalid_url(url_to_fetch):
assert response.json() == ["Enter a valid URL."]
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
@responses.activate
def test_api_docs_cors_proxy_request_failed(mock_getaddrinfo):
def test_api_docs_cors_proxy_request_failed():
"""Test the CORS proxy API for documents with a request failed."""
document = factories.DocumentFactory(link_reach="public")
# Mock DNS resolution to return a public IP address
mock_getaddrinfo.return_value = [
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("8.8.8.8", 0))
]
client = APIClient()
url_to_fetch = "https://external-url.com/assets/index.html"
responses.get(url_to_fetch, body=RequestException("Connection refused"))
@@ -276,164 +185,6 @@ def test_api_docs_cors_proxy_request_failed(mock_getaddrinfo):
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
)
assert response.status_code == 400
assert response.json() == {"detail": "Invalid URL used."}
@pytest.mark.parametrize(
"url_to_fetch",
[
"http://localhost/image.png",
"https://localhost/image.png",
"http://127.0.0.1/image.png",
"https://127.0.0.1/image.png",
"http://0.0.0.0/image.png",
"https://0.0.0.0/image.png",
"http://[::1]/image.png",
"https://[::1]/image.png",
"http://[0:0:0:0:0:0:0:1]/image.png",
"https://[0:0:0:0:0:0:0:1]/image.png",
],
)
def test_api_docs_cors_proxy_blocks_localhost(url_to_fetch):
"""Test that the CORS proxy API blocks localhost variations."""
document = factories.DocumentFactory(link_reach="public")
client = APIClient()
response = client.get(
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
)
assert response.status_code == 400
assert response.json()["detail"] == "Invalid URL used."
@pytest.mark.parametrize(
"url_to_fetch",
[
"http://10.0.0.1/image.png",
"https://10.0.0.1/image.png",
"http://172.16.0.1/image.png",
"https://172.16.0.1/image.png",
"http://192.168.1.1/image.png",
"https://192.168.1.1/image.png",
"http://10.255.255.255/image.png",
"https://10.255.255.255/image.png",
"http://172.31.255.255/image.png",
"https://172.31.255.255/image.png",
"http://192.168.255.255/image.png",
"https://192.168.255.255/image.png",
],
)
def test_api_docs_cors_proxy_blocks_private_ips(url_to_fetch):
"""Test that the CORS proxy API blocks private IP addresses."""
document = factories.DocumentFactory(link_reach="public")
client = APIClient()
response = client.get(
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
)
assert response.status_code == 400
assert response.json()["detail"] == "Invalid URL used."
@pytest.mark.parametrize(
"url_to_fetch",
[
"http://169.254.1.1/image.png",
"https://169.254.1.1/image.png",
"http://169.254.255.255/image.png",
"https://169.254.255.255/image.png",
],
)
def test_api_docs_cors_proxy_blocks_link_local(url_to_fetch):
"""Test that the CORS proxy API blocks link-local addresses."""
document = factories.DocumentFactory(link_reach="public")
client = APIClient()
response = client.get(
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
)
assert response.status_code == 400
assert response.json()["detail"] == "Invalid URL used."
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
@responses.activate
def test_api_docs_cors_proxy_blocks_dns_rebinding_to_private_ip(mock_getaddrinfo):
"""Test that the CORS proxy API blocks DNS rebinding attacks to private IPs."""
document = factories.DocumentFactory(link_reach="public")
# Mock DNS resolution to return a private IP address
mock_getaddrinfo.return_value = [
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("192.168.1.1", 0))
]
client = APIClient()
url_to_fetch = "https://malicious-domain.com/image.png"
response = client.get(
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
)
assert response.status_code == 400
assert response.json()["detail"] == "Invalid URL used."
mock_getaddrinfo.assert_called_once()
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
@responses.activate
def test_api_docs_cors_proxy_blocks_dns_rebinding_to_localhost(mock_getaddrinfo):
"""Test that the CORS proxy API blocks DNS rebinding attacks to localhost."""
document = factories.DocumentFactory(link_reach="public")
# Mock DNS resolution to return localhost
mock_getaddrinfo.return_value = [
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("127.0.0.1", 0))
]
client = APIClient()
url_to_fetch = "https://malicious-domain.com/image.png"
response = client.get(
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
)
assert response.status_code == 400
assert response.json()["detail"] == "Invalid URL used."
mock_getaddrinfo.assert_called_once()
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
def test_api_docs_cors_proxy_handles_dns_resolution_failure(mock_getaddrinfo):
"""Test that the CORS proxy API handles DNS resolution failures gracefully."""
document = factories.DocumentFactory(link_reach="public")
# Mock DNS resolution to fail
mock_getaddrinfo.side_effect = socket.gaierror("Name or service not known")
client = APIClient()
url_to_fetch = "https://nonexistent-domain-12345.com/image.png"
response = client.get(
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
)
assert response.status_code == 400
assert response.json()["detail"] == "Invalid URL used."
mock_getaddrinfo.assert_called_once()
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
def test_api_docs_cors_proxy_blocks_multiple_resolved_ips_if_any_private(
mock_getaddrinfo,
):
"""Test that the CORS proxy API blocks if any resolved IP is private."""
document = factories.DocumentFactory(link_reach="public")
# Mock DNS resolution to return both public and private IPs
mock_getaddrinfo.return_value = [
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("8.8.8.8", 0)),
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("192.168.1.1", 0)),
]
client = APIClient()
url_to_fetch = "https://example.com/image.png"
response = client.get(
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
)
assert response.status_code == 400
assert response.json()["detail"] == "Invalid URL used."
mock_getaddrinfo.assert_called_once()
assert response.json() == {
"error": "Failed to fetch resource from https://external-url.com/assets/index.html"
}

View File

@@ -16,6 +16,7 @@ from rest_framework.test import APIClient
from core import factories
from core.api.serializers import ServerCreateDocumentSerializer
from core.models import Document, Invitation, User
from core.services import mime_types
from core.services.converter_services import ConversionError, YdocConverter
pytestmark = pytest.mark.django_db
@@ -191,7 +192,9 @@ def test_api_documents_create_for_owner_existing(mock_convert_md):
assert response.status_code == 201
mock_convert_md.assert_called_once_with("Document content")
mock_convert_md.assert_called_once_with(
"Document content", mime_types.MARKDOWN, mime_types.YJS
)
document = Document.objects.get()
assert response.json() == {"id": str(document.id)}
@@ -236,7 +239,9 @@ def test_api_documents_create_for_owner_new_user(mock_convert_md):
assert response.status_code == 201
mock_convert_md.assert_called_once_with("Document content")
mock_convert_md.assert_called_once_with(
"Document content", mime_types.MARKDOWN, mime_types.YJS
)
document = Document.objects.get()
assert response.json() == {"id": str(document.id)}
@@ -297,7 +302,9 @@ def test_api_documents_create_for_owner_existing_user_email_no_sub_with_fallback
assert response.status_code == 201
mock_convert_md.assert_called_once_with("Document content")
mock_convert_md.assert_called_once_with(
"Document content", mime_types.MARKDOWN, mime_types.YJS
)
document = Document.objects.get()
assert response.json() == {"id": str(document.id)}
@@ -393,7 +400,9 @@ def test_api_documents_create_for_owner_new_user_no_sub_no_fallback_allow_duplic
HTTP_AUTHORIZATION="Bearer DummyToken",
)
assert response.status_code == 201
mock_convert_md.assert_called_once_with("Document content")
mock_convert_md.assert_called_once_with(
"Document content", mime_types.MARKDOWN, mime_types.YJS
)
document = Document.objects.get()
assert response.json() == {"id": str(document.id)}
@@ -474,7 +483,9 @@ def test_api_documents_create_for_owner_with_default_language(
)
assert response.status_code == 201
mock_convert_md.assert_called_once_with("Document content")
mock_convert_md.assert_called_once_with(
"Document content", mime_types.MARKDOWN, mime_types.YJS
)
assert mock_send.call_args[0][3] == "de-de"
@@ -501,7 +512,9 @@ def test_api_documents_create_for_owner_with_custom_language(mock_convert_md):
assert response.status_code == 201
mock_convert_md.assert_called_once_with("Document content")
mock_convert_md.assert_called_once_with(
"Document content", mime_types.MARKDOWN, mime_types.YJS
)
assert len(mail.outbox) == 1
email = mail.outbox[0]
@@ -537,7 +550,9 @@ def test_api_documents_create_for_owner_with_custom_subject_and_message(
assert response.status_code == 201
mock_convert_md.assert_called_once_with("Document content")
mock_convert_md.assert_called_once_with(
"Document content", mime_types.MARKDOWN, mime_types.YJS
)
assert len(mail.outbox) == 1
email = mail.outbox[0]
@@ -571,7 +586,9 @@ def test_api_documents_create_for_owner_with_converter_exception(
format="json",
HTTP_AUTHORIZATION="Bearer DummyToken",
)
mock_convert_md.assert_called_once_with("Document content")
mock_convert_md.assert_called_once_with(
"Document content", mime_types.MARKDOWN, mime_types.YJS
)
assert response.status_code == 400
assert response.json() == {"content": ["Could not convert content"]}

View File

@@ -0,0 +1,358 @@
"""
Tests for Documents API endpoint in impress's core app: create with file upload
"""
from base64 import b64decode, binascii
from io import BytesIO
from unittest.mock import patch
import pytest
from rest_framework.test import APIClient
from core import factories
from core.models import Document
from core.services import mime_types
from core.services.converter_services import (
ConversionError,
ServiceUnavailableError,
)
pytestmark = pytest.mark.django_db
def test_api_documents_create_with_file_anonymous():
"""Anonymous users should not be allowed to create documents with file upload."""
# Create a fake DOCX file
file_content = b"fake docx content"
file = BytesIO(file_content)
file.name = "test_document.docx"
response = APIClient().post(
"/api/v1.0/documents/",
{
"file": file,
},
format="multipart",
)
assert response.status_code == 401
assert not Document.objects.exists()
@patch("core.services.converter_services.Converter.convert")
def test_api_documents_create_with_docx_file_success(mock_convert):
"""
Authenticated users should be able to create documents by uploading a DOCX file.
The file should be converted to YJS format and the title should be set from filename.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Mock the conversion
converted_yjs = "base64encodedyjscontent"
mock_convert.return_value = converted_yjs
# Create a fake DOCX file
file_content = b"fake docx content"
file = BytesIO(file_content)
file.name = "My Important Document.docx"
response = client.post(
"/api/v1.0/documents/",
{
"file": file,
},
format="multipart",
)
assert response.status_code == 201
document = Document.objects.get()
assert document.title == "My Important Document.docx"
assert document.content == converted_yjs
assert document.accesses.filter(role="owner", user=user).exists()
# Verify the converter was called correctly
mock_convert.assert_called_once_with(
file_content,
content_type=mime_types.DOCX,
accept=mime_types.YJS,
)
@patch("core.services.converter_services.Converter.convert")
def test_api_documents_create_with_markdown_file_success(mock_convert):
"""
Authenticated users should be able to create documents by uploading a Markdown file.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Mock the conversion
converted_yjs = "base64encodedyjscontent"
mock_convert.return_value = converted_yjs
# Create a fake Markdown file
file_content = b"# Test Document\n\nThis is a test."
file = BytesIO(file_content)
file.name = "readme.md"
response = client.post(
"/api/v1.0/documents/",
{
"file": file,
},
format="multipart",
)
assert response.status_code == 201
document = Document.objects.get()
assert document.title == "readme.md"
assert document.content == converted_yjs
assert document.accesses.filter(role="owner", user=user).exists()
# Verify the converter was called correctly
mock_convert.assert_called_once_with(
file_content,
content_type=mime_types.MARKDOWN,
accept=mime_types.YJS,
)
@patch("core.services.converter_services.Converter.convert")
def test_api_documents_create_with_file_and_explicit_title(mock_convert):
"""
When both file and title are provided, the filename should override the title.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Mock the conversion
converted_yjs = "base64encodedyjscontent"
mock_convert.return_value = converted_yjs
# Create a fake DOCX file
file_content = b"fake docx content"
file = BytesIO(file_content)
file.name = "Uploaded Document.docx"
response = client.post(
"/api/v1.0/documents/",
{
"file": file,
"title": "This should be overridden",
},
format="multipart",
)
assert response.status_code == 201
document = Document.objects.get()
# The filename should take precedence
assert document.title == "Uploaded Document.docx"
def test_api_documents_create_with_empty_file():
"""
Creating a document with an empty file should fail with a validation error.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Create an empty file
file = BytesIO(b"")
file.name = "empty.docx"
response = client.post(
"/api/v1.0/documents/",
{
"file": file,
},
format="multipart",
)
assert response.status_code == 400
assert response.json() == {"file": ["The submitted file is empty."]}
assert not Document.objects.exists()
@patch("core.services.converter_services.Converter.convert")
def test_api_documents_create_with_file_conversion_error(mock_convert):
"""
When conversion fails, the API should return a 400 error with appropriate message.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Mock the conversion to raise an error
mock_convert.side_effect = ConversionError("Failed to convert document")
# Create a fake DOCX file
file_content = b"fake invalid docx content"
file = BytesIO(file_content)
file.name = "corrupted.docx"
response = client.post(
"/api/v1.0/documents/",
{
"file": file,
},
format="multipart",
)
assert response.status_code == 400
assert response.json() == {"file": ["Could not convert file content"]}
assert not Document.objects.exists()
@patch("core.services.converter_services.Converter.convert")
def test_api_documents_create_with_file_service_unavailable(mock_convert):
"""
When the conversion service is unavailable, appropriate error should be returned.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Mock the conversion to raise ServiceUnavailableError
mock_convert.side_effect = ServiceUnavailableError(
"Failed to connect to conversion service"
)
# Create a fake DOCX file
file_content = b"fake docx content"
file = BytesIO(file_content)
file.name = "document.docx"
response = client.post(
"/api/v1.0/documents/",
{
"file": file,
},
format="multipart",
)
assert response.status_code == 400
assert response.json() == {"file": ["Could not convert file content"]}
assert not Document.objects.exists()
def test_api_documents_create_without_file_still_works():
"""
Creating a document without a file should still work as before (backward compatibility).
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/documents/",
{
"title": "Regular document without file",
},
format="json",
)
assert response.status_code == 201
document = Document.objects.get()
assert document.title == "Regular document without file"
assert document.content is None
assert document.accesses.filter(role="owner", user=user).exists()
@patch("core.services.converter_services.Converter.convert")
def test_api_documents_create_with_file_null_value(mock_convert):
"""
Passing file=null should be treated as no file upload.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/documents/",
{
"title": "Document with null file",
"file": None,
},
format="json",
)
assert response.status_code == 201
document = Document.objects.get()
assert document.title == "Document with null file"
# Converter should not have been called
mock_convert.assert_not_called()
@patch("core.services.converter_services.Converter.convert")
def test_api_documents_create_with_file_preserves_content_format(mock_convert):
"""
Verify that the converted content is stored correctly in the document.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Mock the conversion with realistic base64-encoded YJS data
converted_yjs = "AQMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICA="
mock_convert.return_value = converted_yjs
# Create a fake DOCX file
file_content = b"fake docx with complex formatting"
file = BytesIO(file_content)
file.name = "complex_document.docx"
response = client.post(
"/api/v1.0/documents/",
{
"file": file,
},
format="multipart",
)
assert response.status_code == 201
document = Document.objects.get()
# Verify the content is stored as returned by the converter
assert document.content == converted_yjs
# Verify it's valid base64 (can be decoded)
try:
b64decode(converted_yjs)
except binascii.Error:
pytest.fail("Content should be valid base64-encoded data")
@patch("core.services.converter_services.Converter.convert")
def test_api_documents_create_with_file_unicode_filename(mock_convert):
"""
Test that Unicode characters in filenames are handled correctly.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Mock the conversion
converted_yjs = "base64encodedyjscontent"
mock_convert.return_value = converted_yjs
# Create a file with Unicode characters in the name
file_content = b"fake docx content"
file = BytesIO(file_content)
file.name = "文档-télécharger-документ.docx"
response = client.post(
"/api/v1.0/documents/",
{
"file": file,
},
format="multipart",
)
assert response.status_code == 201
document = Document.objects.get()
assert document.title == "文档-télécharger-документ.docx"

View File

@@ -311,7 +311,7 @@ def test_api_users_list_query_short_queries():
"""
Queries shorter than 5 characters should return an empty result set.
"""
user = factories.UserFactory(email="paul@example.com", full_name="Paul")
user = factories.UserFactory(email="paul@example.com")
client = APIClient()
client.force_login(user)

View File

@@ -1393,7 +1393,7 @@ def test_models_documents_restore_complex(django_assert_num_queries):
assert child2.ancestors_deleted_at == document.deleted_at
# Restore the item
with django_assert_num_queries(14):
with django_assert_num_queries(13):
document.restore()
document.refresh_from_db()
child1.refresh_from_db()

View File

@@ -0,0 +1,93 @@
"""Test Converter orchestration services."""
from unittest.mock import MagicMock, patch
from core.services import mime_types
from core.services.converter_services import Converter
@patch("core.services.converter_services.DocSpecConverter")
@patch("core.services.converter_services.YdocConverter")
def test_converter_docx_to_yjs_orchestration(mock_ydoc_class, mock_docspec_class):
"""Test that DOCX to YJS conversion uses both DocSpec and Ydoc converters."""
# Setup mocks
mock_docspec = MagicMock()
mock_ydoc = MagicMock()
mock_docspec_class.return_value = mock_docspec
mock_ydoc_class.return_value = mock_ydoc
# Mock the conversion chain: DOCX -> BlockNote -> YJS
blocknote_data = b'[{"type": "paragraph", "content": "test"}]'
yjs_data = "base64encodedyjs"
mock_docspec.convert.return_value = blocknote_data
mock_ydoc.convert.return_value = yjs_data
# Execute conversion
converter = Converter()
docx_data = b"fake docx data"
result = converter.convert(docx_data, mime_types.DOCX, mime_types.YJS)
# Verify the orchestration
mock_docspec.convert.assert_called_once_with(
docx_data, mime_types.DOCX, mime_types.BLOCKNOTE
)
mock_ydoc.convert.assert_called_once_with(
blocknote_data, mime_types.BLOCKNOTE, mime_types.YJS
)
assert result == yjs_data
@patch("core.services.converter_services.YdocConverter")
def test_converter_markdown_to_yjs_delegation(mock_ydoc_class):
"""Test that Markdown to YJS conversion is delegated to YdocConverter."""
mock_ydoc = MagicMock()
mock_ydoc_class.return_value = mock_ydoc
yjs_data = "base64encodedyjs"
mock_ydoc.convert.return_value = yjs_data
converter = Converter()
markdown_data = "# Test Document"
result = converter.convert(markdown_data, mime_types.MARKDOWN, mime_types.YJS)
mock_ydoc.convert.assert_called_once_with(
markdown_data, mime_types.MARKDOWN, mime_types.YJS
)
assert result == yjs_data
@patch("core.services.converter_services.YdocConverter")
def test_converter_yjs_to_html_delegation(mock_ydoc_class):
"""Test that YJS to HTML conversion is delegated to YdocConverter."""
mock_ydoc = MagicMock()
mock_ydoc_class.return_value = mock_ydoc
html_data = "<p>Test Document</p>"
mock_ydoc.convert.return_value = html_data
converter = Converter()
yjs_data = b"yjs binary data"
result = converter.convert(yjs_data, mime_types.YJS, mime_types.HTML)
mock_ydoc.convert.assert_called_once_with(yjs_data, mime_types.YJS, mime_types.HTML)
assert result == html_data
@patch("core.services.converter_services.YdocConverter")
def test_converter_blocknote_to_yjs_delegation(mock_ydoc_class):
"""Test that BlockNote to YJS conversion is delegated to YdocConverter."""
mock_ydoc = MagicMock()
mock_ydoc_class.return_value = mock_ydoc
yjs_data = "base64encodedyjs"
mock_ydoc.convert.return_value = yjs_data
converter = Converter()
blocknote_data = b'[{"type": "paragraph"}]'
result = converter.convert(blocknote_data, mime_types.BLOCKNOTE, mime_types.YJS)
mock_ydoc.convert.assert_called_once_with(
blocknote_data, mime_types.BLOCKNOTE, mime_types.YJS
)
assert result == yjs_data

View File

@@ -6,6 +6,7 @@ from unittest.mock import MagicMock, patch
import pytest
import requests
from core.services import mime_types
from core.services.converter_services import (
ServiceUnavailableError,
ValidationError,
@@ -21,9 +22,9 @@ def test_auth_header(settings):
def test_convert_empty_text():
"""Should raise ValidationError when text is empty."""
"""Should raise ValidationError when data is empty."""
converter = YdocConverter()
with pytest.raises(ValidationError, match="Input text cannot be empty"):
with pytest.raises(ValidationError, match="Input data cannot be empty"):
converter.convert("")
@@ -36,7 +37,7 @@ def test_convert_service_unavailable(mock_post):
with pytest.raises(
ServiceUnavailableError,
match="Failed to connect to conversion service",
match="Failed to connect to YDoc conversion service",
):
converter.convert("test text")
@@ -52,7 +53,7 @@ def test_convert_http_error(mock_post):
with pytest.raises(
ServiceUnavailableError,
match="Failed to connect to conversion service",
match="Failed to connect to YDoc conversion service",
):
converter.convert("test text")
@@ -83,8 +84,8 @@ def test_convert_full_integration(mock_post, settings):
data="test markdown",
headers={
"Authorization": "Bearer test-key",
"Content-Type": "text/markdown",
"Accept": "application/vnd.yjs.doc",
"Content-Type": mime_types.MARKDOWN,
"Accept": mime_types.YJS,
},
timeout=5,
verify=False,
@@ -108,9 +109,7 @@ def test_convert_full_integration_with_specific_headers(mock_post, settings):
mock_response.raise_for_status.return_value = None
mock_post.return_value = mock_response
result = converter.convert(
b"test_content", "application/vnd.yjs.doc", "text/markdown"
)
result = converter.convert(b"test_content", mime_types.YJS, mime_types.MARKDOWN)
assert result == expected_response
mock_post.assert_called_once_with(
@@ -118,8 +117,8 @@ def test_convert_full_integration_with_specific_headers(mock_post, settings):
data=b"test_content",
headers={
"Authorization": "Bearer test-key",
"Content-Type": "application/vnd.yjs.doc",
"Accept": "text/markdown",
"Content-Type": mime_types.YJS,
"Accept": mime_types.MARKDOWN,
},
timeout=5,
verify=False,
@@ -135,7 +134,7 @@ def test_convert_timeout(mock_post):
with pytest.raises(
ServiceUnavailableError,
match="Failed to connect to conversion service",
match="Failed to connect to YDoc conversion service",
):
converter.convert("test text")
@@ -144,5 +143,5 @@ def test_convert_none_input():
"""Should raise ValidationError when input is None."""
converter = YdocConverter()
with pytest.raises(ValidationError, match="Input text cannot be empty"):
with pytest.raises(ValidationError, match="Input data cannot be empty"):
converter.convert(None)

View File

@@ -0,0 +1,117 @@
"""Test DocSpec converter services."""
from unittest.mock import MagicMock, patch
import pytest
import requests
from core.services import mime_types
from core.services.converter_services import (
DocSpecConverter,
ServiceUnavailableError,
ValidationError,
)
def test_docspec_convert_empty_data():
"""Should raise ValidationError when data is empty."""
converter = DocSpecConverter()
with pytest.raises(ValidationError, match="Input data cannot be empty"):
converter.convert("", mime_types.DOCX, mime_types.BLOCKNOTE)
def test_docspec_convert_none_input():
"""Should raise ValidationError when input is None."""
converter = DocSpecConverter()
with pytest.raises(ValidationError, match="Input data cannot be empty"):
converter.convert(None, mime_types.DOCX, mime_types.BLOCKNOTE)
def test_docspec_convert_unsupported_content_type():
"""Should raise ValidationError when content type is not DOCX."""
converter = DocSpecConverter()
with pytest.raises(
ValidationError, match="Conversion from text/plain to .* is not supported"
):
converter.convert(b"test data", "text/plain", mime_types.BLOCKNOTE)
def test_docspec_convert_unsupported_accept():
"""Should raise ValidationError when accept type is not BLOCKNOTE."""
converter = DocSpecConverter()
with pytest.raises(
ValidationError,
match=f"Conversion from {mime_types.DOCX} to {mime_types.YJS} is not supported",
):
converter.convert(b"test data", mime_types.DOCX, mime_types.YJS)
@patch("requests.post")
def test_docspec_convert_service_unavailable(mock_post):
"""Should raise ServiceUnavailableError when service is unavailable."""
converter = DocSpecConverter()
mock_post.side_effect = requests.RequestException("Connection error")
with pytest.raises(
ServiceUnavailableError,
match="Failed to connect to DocSpec conversion service",
):
converter.convert(b"test data", mime_types.DOCX, mime_types.BLOCKNOTE)
@patch("requests.post")
def test_docspec_convert_http_error(mock_post):
"""Should raise ServiceUnavailableError when HTTP error occurs."""
converter = DocSpecConverter()
mock_response = MagicMock()
mock_response.raise_for_status.side_effect = requests.HTTPError("HTTP Error")
mock_post.return_value = mock_response
with pytest.raises(
ServiceUnavailableError,
match="Failed to connect to DocSpec conversion service",
):
converter.convert(b"test data", mime_types.DOCX, mime_types.BLOCKNOTE)
@patch("requests.post")
def test_docspec_convert_timeout(mock_post):
"""Should raise ServiceUnavailableError when request times out."""
converter = DocSpecConverter()
mock_post.side_effect = requests.Timeout("Request timed out")
with pytest.raises(
ServiceUnavailableError,
match="Failed to connect to DocSpec conversion service",
):
converter.convert(b"test data", mime_types.DOCX, mime_types.BLOCKNOTE)
@patch("requests.post")
def test_docspec_convert_success(mock_post, settings):
"""Test successful DOCX to BlockNote conversion."""
settings.DOCSPEC_API_URL = "http://docspec.test/convert"
settings.CONVERSION_API_TIMEOUT = 5
settings.CONVERSION_API_SECURE = False
converter = DocSpecConverter()
expected_content = b'[{"type": "paragraph", "content": "test"}]'
mock_response = MagicMock()
mock_response.content = expected_content
mock_response.raise_for_status.return_value = None
mock_post.return_value = mock_response
docx_data = b"fake docx binary data"
result = converter.convert(docx_data, mime_types.DOCX, mime_types.BLOCKNOTE)
assert result == expected_content
# Verify the request was made correctly
mock_post.assert_called_once_with(
"http://docspec.test/convert",
headers={"Accept": mime_types.BLOCKNOTE},
files={"file": ("document.docx", docx_data, mime_types.DOCX)},
timeout=5,
verify=False,
)

View File

@@ -453,7 +453,7 @@ class Base(Configuration):
"REDOC_DIST": "SIDECAR",
}
TRASHBIN_CUTOFF_DAYS = values.IntegerValue(
TRASHBIN_CUTOFF_DAYS = values.Value(
30, environ_name="TRASHBIN_CUTOFF_DAYS", environ_prefix=None
)
@@ -699,16 +699,6 @@ class Base(Configuration):
"day": 200,
}
LANGFUSE_SECRET_KEY = SecretFileValue(
None, environ_name="LANGFUSE_SECRET_KEY", environ_prefix=None
)
LANGFUSE_PUBLIC_KEY = values.Value(
None, environ_name="LANGFUSE_PUBLIC_KEY", environ_prefix=None
)
LANGFUSE_BASE_URL = values.Value(
None, environ_name="LANGFUSE_BASE_URL", environ_prefix=None
)
# Y provider microservice
Y_PROVIDER_API_KEY = SecretFileValue(
environ_name="Y_PROVIDER_API_KEY",
@@ -719,6 +709,9 @@ class Base(Configuration):
environ_prefix=None,
)
# DocSpec API microservice
DOCSPEC_API_URL = values.Value(environ_name="DOCSPEC_API_URL", environ_prefix=None)
# Conversion endpoint
CONVERSION_API_ENDPOINT = values.Value(
default="convert",
@@ -1064,6 +1057,9 @@ class Production(Base):
# Privacy
SECURE_REFERRER_POLICY = "same-origin"
# Conversion API: Always verify SSL in production
CONVERSION_API_SECURE = True
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"POT-Creation-Date: 2025-12-16 21:44+0000\n"
"PO-Revision-Date: 2026-01-05 08:21\n"
"Last-Translator: \n"
"Language-Team: Breton\n"
"Language: br_FR\n"
@@ -79,7 +79,7 @@ msgstr "Doare korf"
msgid "Format"
msgstr "Stumm"
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#: build/lib/core/api/viewsets.py:1024 core/api/viewsets.py:1024
#, python-brace-format
msgid "copy of {title}"
msgstr "eilenn {title}"

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"POT-Creation-Date: 2025-12-16 21:44+0000\n"
"PO-Revision-Date: 2026-01-05 08:21\n"
"Last-Translator: \n"
"Language-Team: German\n"
"Language: de_DE\n"
@@ -79,7 +79,7 @@ msgstr "Typ"
msgid "Format"
msgstr "Format"
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#: build/lib/core/api/viewsets.py:1024 core/api/viewsets.py:1024
#, python-brace-format
msgid "copy of {title}"
msgstr "Kopie von {title}"

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"POT-Creation-Date: 2025-12-16 21:44+0000\n"
"PO-Revision-Date: 2026-01-05 08:21\n"
"Last-Translator: \n"
"Language-Team: English\n"
"Language: en_US\n"
@@ -79,7 +79,7 @@ msgstr ""
msgid "Format"
msgstr ""
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#: build/lib/core/api/viewsets.py:1024 core/api/viewsets.py:1024
#, python-brace-format
msgid "copy of {title}"
msgstr ""

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"POT-Creation-Date: 2025-12-16 21:44+0000\n"
"PO-Revision-Date: 2026-01-05 08:21\n"
"Last-Translator: \n"
"Language-Team: Spanish\n"
"Language: es_ES\n"
@@ -79,7 +79,7 @@ msgstr "Tipo de Cuerpo"
msgid "Format"
msgstr "Formato"
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#: build/lib/core/api/viewsets.py:1024 core/api/viewsets.py:1024
#, python-brace-format
msgid "copy of {title}"
msgstr "copia de {title}"

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"POT-Creation-Date: 2025-12-16 21:44+0000\n"
"PO-Revision-Date: 2026-01-05 08:21\n"
"Last-Translator: \n"
"Language-Team: French\n"
"Language: fr_FR\n"
@@ -79,7 +79,7 @@ msgstr "Type de corps"
msgid "Format"
msgstr "Format"
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#: build/lib/core/api/viewsets.py:1024 core/api/viewsets.py:1024
#, python-brace-format
msgid "copy of {title}"
msgstr "copie de {title}"

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"POT-Creation-Date: 2025-12-16 21:44+0000\n"
"PO-Revision-Date: 2026-01-05 08:21\n"
"Last-Translator: \n"
"Language-Team: Italian\n"
"Language: it_IT\n"
@@ -79,7 +79,7 @@ msgstr ""
msgid "Format"
msgstr "Formato"
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#: build/lib/core/api/viewsets.py:1024 core/api/viewsets.py:1024
#, python-brace-format
msgid "copy of {title}"
msgstr "copia di {title}"

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"POT-Creation-Date: 2025-12-16 21:44+0000\n"
"PO-Revision-Date: 2026-01-05 08:21\n"
"Last-Translator: \n"
"Language-Team: Dutch\n"
"Language: nl_NL\n"
@@ -79,7 +79,7 @@ msgstr "Text type"
msgid "Format"
msgstr "Formaat"
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#: build/lib/core/api/viewsets.py:1024 core/api/viewsets.py:1024
#, python-brace-format
msgid "copy of {title}"
msgstr "kopie van {title}"

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"POT-Creation-Date: 2025-12-16 21:44+0000\n"
"PO-Revision-Date: 2026-01-05 08:21\n"
"Last-Translator: \n"
"Language-Team: Portuguese\n"
"Language: pt_PT\n"
@@ -79,7 +79,7 @@ msgstr "Tipo de corpo"
msgid "Format"
msgstr "Formato"
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#: build/lib/core/api/viewsets.py:1024 core/api/viewsets.py:1024
#, python-brace-format
msgid "copy of {title}"
msgstr "cópia de {title}"

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"POT-Creation-Date: 2025-12-16 21:44+0000\n"
"PO-Revision-Date: 2026-01-05 08:21\n"
"Last-Translator: \n"
"Language-Team: Russian\n"
"Language: ru_RU\n"
@@ -79,7 +79,7 @@ msgstr "Тип сообщения"
msgid "Format"
msgstr "Формат"
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#: build/lib/core/api/viewsets.py:1024 core/api/viewsets.py:1024
#, python-brace-format
msgid "copy of {title}"
msgstr "копия {title}"

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"POT-Creation-Date: 2025-12-16 21:44+0000\n"
"PO-Revision-Date: 2026-01-05 08:21\n"
"Last-Translator: \n"
"Language-Team: Slovenian\n"
"Language: sl_SI\n"
@@ -79,7 +79,7 @@ msgstr "Vrsta telesa"
msgid "Format"
msgstr "Oblika"
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#: build/lib/core/api/viewsets.py:1024 core/api/viewsets.py:1024
#, python-brace-format
msgid "copy of {title}"
msgstr ""

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"POT-Creation-Date: 2025-12-16 21:44+0000\n"
"PO-Revision-Date: 2026-01-05 08:21\n"
"Last-Translator: \n"
"Language-Team: Swedish\n"
"Language: sv_SE\n"
@@ -79,7 +79,7 @@ msgstr ""
msgid "Format"
msgstr "Format"
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#: build/lib/core/api/viewsets.py:1024 core/api/viewsets.py:1024
#, python-brace-format
msgid "copy of {title}"
msgstr ""

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"POT-Creation-Date: 2025-12-16 21:44+0000\n"
"PO-Revision-Date: 2026-01-05 08:21\n"
"Last-Translator: \n"
"Language-Team: Turkish\n"
"Language: tr_TR\n"
@@ -79,7 +79,7 @@ msgstr ""
msgid "Format"
msgstr ""
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#: build/lib/core/api/viewsets.py:1024 core/api/viewsets.py:1024
#, python-brace-format
msgid "copy of {title}"
msgstr ""

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"POT-Creation-Date: 2025-12-16 21:44+0000\n"
"PO-Revision-Date: 2026-01-05 08:21\n"
"Last-Translator: \n"
"Language-Team: Ukrainian\n"
"Language: uk_UA\n"
@@ -79,7 +79,7 @@ msgstr "Тип вмісту"
msgid "Format"
msgstr "Формат"
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#: build/lib/core/api/viewsets.py:1024 core/api/viewsets.py:1024
#, python-brace-format
msgid "copy of {title}"
msgstr "копія {title}"

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"POT-Creation-Date: 2025-12-16 21:44+0000\n"
"PO-Revision-Date: 2026-01-05 08:21\n"
"Last-Translator: \n"
"Language-Team: Chinese Simplified\n"
"Language: zh_CN\n"
@@ -79,7 +79,7 @@ msgstr "正文类型"
msgid "Format"
msgstr "格式"
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#: build/lib/core/api/viewsets.py:1024 core/api/viewsets.py:1024
#, python-brace-format
msgid "copy of {title}"
msgstr "{title} 的副本"

View File

@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "impress"
version = "4.4.0"
version = "4.3.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -25,13 +25,13 @@ license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"beautifulsoup4==4.14.3",
"boto3==1.42.17",
"beautifulsoup4==4.14.2",
"boto3==1.40.74",
"Brotli==1.2.0",
"celery[redis]==5.5.3",
"django-configurations==2.5.1",
"django-cors-headers==4.9.0",
"django-countries==8.2.0",
"django-countries==8.1.0",
"django-csp==4.0",
"django-filter==25.2",
"django-lasuite[all]==0.0.22",
@@ -39,8 +39,8 @@ dependencies = [
"django-redis==6.0.0",
"django-storages[s3]==1.14.6",
"django-timezone-field>=5.1",
"django<6.0.0",
"django-treebeard==4.8.0",
"django==5.2.9",
"django-treebeard==4.7.1",
"djangorestframework==3.16.1",
"drf_spectacular==0.29.0",
"dockerflow==2024.4.2",
@@ -48,19 +48,18 @@ dependencies = [
"factory_boy==3.3.3",
"gunicorn==23.0.0",
"jsonschema==4.25.1",
"langfuse==3.11.2",
"lxml==6.0.2",
"markdown==3.10",
"mozilla-django-oidc==5.0.2",
"mozilla-django-oidc==4.0.1",
"nested-multipart-parser==1.6.0",
"openai==2.14.0",
"psycopg[binary]==3.3.2",
"pycrdt==0.12.44",
"openai==2.8.0",
"psycopg[binary]==3.2.12",
"pycrdt==0.12.43",
"PyJWT==2.10.1",
"python-magic==0.4.27",
"redis<6.0.0",
"requests==2.32.5",
"sentry-sdk==2.48.0",
"sentry-sdk==2.44.0",
"whitenoise==6.11.0",
]
@@ -74,20 +73,20 @@ dependencies = [
dev = [
"django-extensions==4.1",
"django-test-migrations==1.5.0",
"drf-spectacular-sidecar==2025.12.1",
"drf-spectacular-sidecar==2025.10.1",
"freezegun==1.5.5",
"ipdb==0.13.13",
"ipython==9.8.0",
"pyfakefs==6.0.0",
"ipython==9.7.0",
"pyfakefs==5.10.2",
"pylint-django==2.6.1",
"pylint<4.0.0",
"pytest-cov==7.0.0",
"pytest-django==4.11.1",
"pytest==9.0.2",
"pytest==9.0.1",
"pytest-icdiff==0.9",
"pytest-xdist==3.8.0",
"responses==0.25.8",
"ruff==0.14.10",
"ruff==0.14.5",
"types-requests==2.32.4.20250913",
]

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,60 @@
![473389927-e4ff1794-69f3-460a-85f8-fec993cd74d6.png](http://localhost:3000/assets/logo-suite-numerique.png)![497094770-53e5f8e2-c93e-4a0b-a82f-cd184fd03f51.svg](http://localhost:3000/assets/icon-docs.svg)
# Lorem Ipsum import Document
## Introduction
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam auctor, nisl eget ultricies tincidunt, nisl nisl aliquam nisl, eget ultricies nisl nisl eget nisl.
### Subsection 1.1
* **Bold text**: Lorem ipsum dolor sit amet.
* *Italic text*: Consectetur adipiscing elit.
* ~~Strikethrough text~~: Nullam auctor, nisl eget ultricies tincidunt.
1. First item in an ordered list.
2. Second item in an ordered list.
* Indented bullet point.
* Another indented bullet point.
3. Third item in an ordered list.
### Subsection 1.2
**Code block:**
```js
const hello_world = () => {
console.log("Hello, world!");
}
```
**Blockquote:**
> Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam auctor, nisl eget ultricies tincidunt.
**Horizontal rule:**
***
**Table:**
| Syntax | Description |
| --------- | ----------- |
| Header | Title |
| Paragraph | Text |
**Inline code:**
Use the `printf()` function.
**Link:** [Example](http://localhost:3000/)
## Conclusion
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam auctor, nisl eget ultricies tincidunt, nisl nisl aliquam nisl, eget ultricies nisl nisl eget nisl.

View File

@@ -58,7 +58,7 @@ test.describe('Doc Comments', () => {
await page.getByRole('button', { name: '👍' }).click();
await expect(
thread.getByRole('img', { name: `E2E ${browserName}` }).first(),
thread.getByRole('img', { name: 'E2E Chromium' }).first(),
).toBeVisible();
await expect(thread.getByText('This is a comment').first()).toBeVisible();
await expect(thread.getByText(`E2E ${browserName}`).first()).toBeVisible();

View File

@@ -73,7 +73,7 @@ test.describe('Doc Editor', () => {
await page.keyboard.press('Escape');
await page.locator('.ProseMirror').focus();
await page.locator('.bn-block-outer').last().click();
await page.keyboard.press('Enter');

View File

@@ -327,13 +327,6 @@ test.describe('Doc Export', () => {
page,
browserName,
}) => {
// PDF Binary comparison is different depending on the browser used
// We only run this test on Chromium to avoid having to maintain
// multiple sets of PDF fixtures
if (browserName !== 'chromium') {
test.skip();
}
const randomDoc = await overrideDocContent({ page, browserName });
await page
@@ -478,8 +471,6 @@ export const overrideDocContent = async ({
await expect(image).toBeVisible();
await page.keyboard.press('Enter');
await page.waitForTimeout(1000);
// Add Image PNG
await openSuggestionMenu({ page });
await suggestionMenu.getByText('Resizable image with caption').click();
@@ -494,7 +485,5 @@ export const overrideDocContent = async ({
.first();
await expect(imagePng).toBeVisible();
await page.waitForTimeout(1000);
return randomDoc;
};

View File

@@ -0,0 +1,172 @@
import { readFileSync } from 'fs';
import path from 'path';
import { Page, expect, test } from '@playwright/test';
import { getEditor } from './utils-editor';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Doc Import', () => {
test('it imports 2 docs with the import icon', async ({ page }) => {
const fileChooserPromise = page.waitForEvent('filechooser');
await page.getByLabel('Open the upload dialog').click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(path.join(__dirname, 'assets/test_import.docx'));
await fileChooser.setFiles(path.join(__dirname, 'assets/test_import.md'));
await expect(
page.getByText(
'The document "test_import.docx" has been successfully imported',
),
).toBeVisible();
await expect(
page.getByText(
'The document "test_import.md" has been successfully imported',
),
).toBeVisible();
const docsGrid = page.getByTestId('docs-grid');
await expect(docsGrid.getByText('test_import.docx').first()).toBeVisible();
await expect(docsGrid.getByText('test_import.md').first()).toBeVisible();
// Check content of imported md
await docsGrid.getByText('test_import.md').first().click();
const editor = await getEditor({ page });
const contentCheck = async (isMDCheck = false) => {
await expect(
editor.getByRole('heading', {
name: 'Lorem Ipsum import Document',
level: 1,
}),
).toBeVisible();
await expect(
editor.getByRole('heading', {
name: 'Introduction',
level: 2,
}),
).toBeVisible();
await expect(
editor.getByRole('heading', {
name: 'Subsection 1.1',
level: 3,
}),
).toBeVisible();
await expect(
editor
.locator('div[data-content-type="bulletListItem"] strong')
.getByText('Bold text'),
).toBeVisible();
await expect(
editor
.locator('div[data-content-type="codeBlock"]')
.getByText('hello_world'),
).toBeVisible();
await expect(
editor
.locator('div[data-content-type="table"] td')
.getByText('Paragraph'),
).toBeVisible();
await expect(
editor.locator('a[href="http://localhost:3000/"]').getByText('Example'),
).toBeVisible();
/* eslint-disable playwright/no-conditional-expect */
if (isMDCheck) {
await expect(
editor.locator(
'img[src="http://localhost:3000/assets/logo-suite-numerique.png"]',
),
).toBeVisible();
await expect(
editor.locator(
'img[src="http://localhost:3000/assets/icon-docs.svg"]',
),
).toBeVisible();
} else {
await expect(editor.locator('img')).toHaveCount(2);
}
/* eslint-enable playwright/no-conditional-expect */
await expect(
editor.locator('div[data-content-type="divider"] hr'),
).toBeVisible();
};
await contentCheck();
// Check content of imported docx
await page.getByLabel('Back to homepage').first().click();
await docsGrid.getByText('test_import.docx').first().click();
await contentCheck();
});
test('it imports 2 docs with the drag and drop area', async ({ page }) => {
const docsGrid = page.getByTestId('docs-grid');
await expect(docsGrid).toBeVisible();
await dragAndDropFiles(page, "[data-testid='docs-grid']", [
{
filePath: path.join(__dirname, 'assets/test_import.docx'),
fileName: 'test_import.docx',
fileType:
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
},
{
filePath: path.join(__dirname, 'assets/test_import.md'),
fileName: 'test_import.md',
fileType: 'text/markdown',
},
]);
// Wait for success messages
await expect(
page.getByText(
'The document "test_import.docx" has been successfully imported',
),
).toBeVisible();
await expect(
page.getByText(
'The document "test_import.md" has been successfully imported',
),
).toBeVisible();
await expect(docsGrid.getByText('test_import.docx').first()).toBeVisible();
await expect(docsGrid.getByText('test_import.md').first()).toBeVisible();
});
});
const dragAndDropFiles = async (
page: Page,
selector: string,
files: Array<{ filePath: string; fileName: string; fileType?: string }>,
) => {
const filesData = files.map((file) => ({
bufferData: `data:application/octet-stream;base64,${readFileSync(file.filePath).toString('base64')}`,
fileName: file.fileName,
fileType: file.fileType || '',
}));
const dataTransfer = await page.evaluateHandle(async (filesInfo) => {
const dt = new DataTransfer();
for (const fileInfo of filesInfo) {
const blobData = await fetch(fileInfo.bufferData).then((res) =>
res.blob(),
);
const file = new File([blobData], fileInfo.fileName, {
type: fileInfo.fileType,
});
dt.items.add(file);
}
return dt;
}, filesData);
await page.dispatchEvent(selector, 'drop', { dataTransfer });
};

View File

@@ -21,7 +21,7 @@ test.describe('Inherited share accesses', () => {
`doc-share-member-row-user.test@${browserName}.test`,
);
await expect(user).toBeVisible();
await expect(user.getByText(`E2E ${browserName}`)).toBeVisible();
await expect(user.getByText('E2E Chromium')).toBeVisible();
await expect(user.getByText('Owner')).toBeVisible();
await page

View File

@@ -4,6 +4,7 @@ import {
createDoc,
expectLoginPage,
keyCloakSignIn,
randomName,
updateDocTitle,
verifyDocName,
} from './utils-common';
@@ -19,6 +20,50 @@ test.describe('Doc Tree', () => {
await page.goto('/');
});
test('create new sub pages', async ({ page, browserName }) => {
const [titleParent] = await createDoc(
page,
'doc-tree-content',
browserName,
1,
);
await verifyDocName(page, titleParent);
const addButton = page.getByTestId('new-doc-button');
const docTree = page.getByTestId('doc-tree');
await expect(addButton).toBeVisible();
// Wait for and intercept the POST request to create a new page
const responsePromise = page.waitForResponse(
(response) =>
response.url().includes('/documents/') &&
response.url().includes('/children/') &&
response.request().method() === 'POST',
);
await clickOnAddRootSubPage(page);
const response = await responsePromise;
expect(response.ok()).toBeTruthy();
const subPageJson = await response.json();
await expect(docTree).toBeVisible();
const subPageItem = docTree
.getByTestId(`doc-sub-page-item-${subPageJson.id}`)
.first();
await expect(subPageItem).toBeVisible();
await subPageItem.click();
await verifyDocName(page, '');
const input = page.getByRole('textbox', { name: 'Document title' });
await input.click();
const [randomDocName] = randomName('doc-tree-test', browserName, 1);
await input.fill(randomDocName);
await input.press('Enter');
await expect(subPageItem.getByText(randomDocName)).toBeVisible();
await page.reload();
await expect(subPageItem.getByText(randomDocName)).toBeVisible();
});
test('check the reorder of sub pages', async ({ page, browserName }) => {
await createDoc(page, 'doc-tree-content', browserName, 1);
const addButton = page.getByTestId('new-doc-button');

View File

@@ -59,90 +59,45 @@ test.describe('Header', () => {
).toBeVisible();
await expect(header.getByText('English')).toBeVisible();
await expect(
header.getByRole('button', {
name: 'Les services de La Suite numérique',
}),
).toBeVisible();
});
test('checks a custom waffle', async ({ page }) => {
test('checks La Gauffre interaction', async ({ page }) => {
await overrideConfig(page, {
theme_customization: {
waffle: {
data: {
services: [
{
name: 'Docs E2E Custom 1',
url: 'https://docs.numerique.gouv.fr/',
maturity: 'stable',
logo: 'https://lasuite.numerique.gouv.fr/assets/products/docs.svg',
},
{
name: 'Docs E2E Custom 2',
url: 'https://docs.numerique.gouv.fr/',
maturity: 'stable',
logo: 'https://lasuite.numerique.gouv.fr/assets/products/docs.svg',
},
],
},
showMoreLimit: 9,
},
},
FRONTEND_THEME: 'dsfr',
});
await page.goto('/');
const header = page.locator('header').first();
await expect(
header.getByRole('button', { name: 'Digital LaSuite services' }),
header.getByRole('button', {
name: 'Les services de La Suite numérique',
}),
).toBeVisible();
/**
* The Waffle loads a js file from a remote server,
* it takes some time to load the file and have the interaction available
*/
await page.waitForTimeout(1500);
await header
.getByRole('button', { name: 'Digital LaSuite services' })
.click();
await expect(
page.getByRole('link', { name: 'Docs E2E Custom 1' }),
).toBeVisible();
await expect(
page.getByRole('link', { name: 'Docs E2E Custom 2' }),
).toBeVisible();
});
test('checks the waffle dsfr', async ({ page }) => {
await overrideConfig(page, {
theme_customization: {
waffle: {
apiUrl: 'https://lasuite.numerique.gouv.fr/api/services',
showMoreLimit: 9,
},
},
});
await page.goto('/');
const header = page.locator('header').first();
await expect(
header.getByRole('button', { name: 'Digital LaSuite services' }),
).toBeVisible();
/**
* The Waffle loads a js file from a remote server,
* La gaufre load a js file from a remote server,
* it takes some time to load the file and have the interaction available
*/
await page.waitForTimeout(1500);
await header
.getByRole('button', {
name: 'Digital LaSuite services',
name: 'Les services de La Suite numérique',
})
.click();
await expect(page.getByRole('link', { name: 'Tchap' })).toBeVisible();
await expect(
page.getByRole('link', { name: 'France Transfert' }),
).toBeVisible();
await expect(page.getByRole('link', { name: 'Grist' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Visio' })).toBeVisible();
});
});
@@ -169,6 +124,11 @@ test.describe('Header mobile', () => {
await expect(header.getByLabel('Open the header menu')).toBeVisible();
await expect(header.getByTestId('header-icon-docs')).toBeVisible();
await expect(
header.getByRole('button', {
name: 'Les services de La Suite numérique',
}),
).toBeVisible();
});
});

View File

@@ -113,6 +113,9 @@ test.describe('Home page', () => {
});
await expect(languageButton).toBeVisible();
await expect(
header.getByRole('button', { name: 'Les services de La Suite numé' }),
).toBeVisible();
await expect(
header.getByRole('img', { name: 'Gouvernement Logo' }),
).toBeVisible();

View File

@@ -1,11 +1,6 @@
import { expect, test } from '@playwright/test';
import {
TestLanguage,
createDoc,
overrideConfig,
waitForLanguageSwitch,
} from './utils-common';
import { TestLanguage, createDoc, waitForLanguageSwitch } from './utils-common';
import { openSuggestionMenu } from './utils-editor';
test.describe('Language', () => {
@@ -112,15 +107,6 @@ test.describe('Language', () => {
page,
browserName,
}) => {
await overrideConfig(page, {
LANGUAGES: [
['en-us', 'English'],
['fr-fr', 'Français'],
['sv-se', 'Svenska'],
],
LANGUAGE_CODE: 'en-us',
});
await createDoc(page, 'doc-toolbar', browserName, 1);
const { editor, suggestionMenu } = await openSuggestionMenu({ page });
@@ -140,14 +126,5 @@ test.describe('Language', () => {
await expect(
suggestionMenu.getByText('Titres', { exact: true }),
).toBeVisible();
/**
* Swedish is not yet supported in the BlockNote locales, so it should fallback to English
*/
await waitForLanguageSwitch(page, TestLanguage.Swedish);
await openSuggestionMenu({ page });
await expect(
suggestionMenu.getByText('Headings', { exact: true }),
).toBeVisible();
});
});

View File

@@ -224,9 +224,7 @@ export const updateDocTitle = async (page: Page, title: string) => {
await expect(input).toHaveText('');
await expect(input).toBeVisible();
await input.click();
await input.fill(title, {
force: true,
});
await input.fill(title);
await input.click();
await input.blur();
await verifyDocName(page, title);
@@ -330,10 +328,6 @@ export const TestLanguage = {
label: 'Deutsch',
expectedLocale: ['de-de'],
},
Swedish: {
label: 'Svenska',
expectedLocale: ['sv-se'],
},
} as const;
type TestLanguageKey = keyof typeof TestLanguage;

View File

@@ -7,7 +7,9 @@ export const getEditor = async ({ page }: { page: Page }) => {
};
export const openSuggestionMenu = async ({ page }: { page: Page }) => {
const editor = await writeInEditor({ page, text: '/' });
const editor = await getEditor({ page });
await editor.click();
await writeInEditor({ page, text: '/' });
const suggestionMenu = page.locator('.bn-suggestion-menu');
@@ -22,11 +24,6 @@ export const writeInEditor = async ({
text: string;
}) => {
const editor = await getEditor({ page });
await editor
.locator('.bn-block-outer:last-child')
.last()
.locator('.bn-inline-content:last-child')
.last()
.fill(text);
await editor.locator('.bn-block-outer .bn-inline-content').last().fill(text);
return editor;
};

View File

@@ -1,6 +1,6 @@
{
"name": "app-e2e",
"version": "4.4.0",
"version": "4.3.0",
"repository": "https://github.com/suitenumerique/docs",
"author": "DINUM",
"license": "MIT",

View File

@@ -1,53 +1,58 @@
import {
dsfrGlobals,
getUIKitThemesFromGlobals,
whiteLabelGlobals,
} from '@gouvfr-lasuite/ui-kit';
import { cunninghamConfig as tokens } from '@gouvfr-lasuite/ui-kit';
import { defaultTokens } from '@openfun/cunningham-react';
import merge from 'lodash/merge';
const themeWhiteLabelLight = getUIKitThemesFromGlobals(whiteLabelGlobals, {
prefix: 'default',
variants: ['light'],
overrides: {
globals: {
spacing: {
'0': '0rem',
none: '0rem',
auto: 'auto',
bx: '2.2rem',
full: '100%',
'3xs': '0.25rem',
'2xs': '0.375rem',
},
},
components: {
logo: {
src: '',
alt: '',
widthHeader: '',
widthFooter: '',
},
'home-proconnect': false,
icon: {
src: '/assets/icon-docs.svg',
width: '32px',
height: 'auto',
},
favicon: {
'png-light': '/assets/favicon-light.png',
'png-dark': '/assets/favicon-dark.png',
// Uikit does not provide the full list of tokens.
// To be able to override correctly, we need to merge with the default tokens.
let mergedColors = merge(
defaultTokens.globals.colors,
tokens.themes.default.globals.colors,
);
mergedColors = {
...mergedColors,
'logo-1': '#2845C1',
};
tokens.themes.default.globals = {
...tokens.themes.default.globals,
...{
colors: mergedColors,
font: {
...tokens.themes.default.globals.font,
families: {
base: 'sans-serif',
accent: 'sans-serif',
},
},
},
});
const themeDefault = {
default: themeWhiteLabelLight['default-light'],
};
const themesDSFRLight = getUIKitThemesFromGlobals(dsfrGlobals, {
prefix: 'dsfr',
variants: ['light'],
overrides: {
tokens.themes.default.components = {
...tokens.themes.default.components,
...{
logo: {
src: '',
alt: '',
widthHeader: '',
widthFooter: '',
},
'la-gaufre': false,
'home-proconnect': false,
icon: {
src: '/assets/icon-docs.svg',
width: '32px',
height: 'auto',
},
favicon: {
'png-light': '/assets/favicon-light.png',
'png-dark': '/assets/favicon-dark.png',
},
},
};
const dsfrTheme = {
dsfr: {
globals: {
font: {
families: {
@@ -63,6 +68,7 @@ const themesDSFRLight = getUIKitThemesFromGlobals(dsfrGlobals, {
widthFooter: '220px',
alt: 'Gouvernement Logo',
},
'la-gaufre': true,
'home-proconnect': true,
icon: {
src: '/assets/icon-docs-dsfr.svg',
@@ -76,16 +82,317 @@ const themesDSFRLight = getUIKitThemesFromGlobals(dsfrGlobals, {
},
},
},
});
};
const themesDSFR = {
dsfr: themesDSFRLight['dsfr-light'],
const genericTheme = {
generic: {
globals: {
colors: {
'brand-050': '#EEF1FA',
'brand-100': '#DDE2F5',
'brand-150': '#CED3F1',
'brand-200': '#BEC5F0',
'brand-250': '#AFB5F1',
'brand-300': '#A0A5F6',
'brand-350': '#8F94FD',
'brand-400': '#8184FC',
'brand-450': '#7576EE',
'brand-500': '#6969DF',
'brand-550': '#5E5CD0',
'brand-600': '#534FC2',
'brand-650': '#4844AD',
'brand-700': '#3E3B98',
'brand-750': '#36347D',
'brand-800': '#2D2F5F',
'brand-850': '#262848',
'brand-900': '#1C1E32',
'brand-950': '#11131F',
'gray-000': '#FFFFFF',
'gray-025': '#F8F8F9',
'gray-050': '#F0F0F3',
'gray-100': '#E2E2EA',
'gray-150': '#D3D4E0',
'gray-200': '#C5C6D5',
'gray-250': '#B7B7CB',
'gray-300': '#A9A9BF',
'gray-350': '#9C9CB2',
'gray-400': '#8F8FA4',
'gray-450': '#828297',
'gray-500': '#75758A',
'gray-550': '#69697D',
'gray-600': '#5D5D70',
'gray-650': '#515164',
'gray-700': '#454558',
'gray-750': '#3A3A4C',
'gray-800': '#2F303D',
'gray-850': '#25252F',
'gray-900': '#1B1B23',
'gray-950': '#111114',
'gray-1000': '#000000',
'info-050': '#EAF2F9',
'info-100': '#D5E4F3',
'info-150': '#BFD7F0',
'info-200': '#A7CAEE',
'info-250': '#8DBDEF',
'info-300': '#6EB0F2',
'info-350': '#50A2F5',
'info-400': '#3593F4',
'info-450': '#1185ED',
'info-500': '#0077DE',
'info-550': '#0069CF',
'info-600': '#005BC0',
'info-650': '#0D4EAA',
'info-700': '#124394',
'info-750': '#163878',
'info-800': '#192F5A',
'info-850': '#192541',
'info-900': '#141B2D',
'info-950': '#0C111C',
'success-050': '#E8F1EA',
'success-100': '#CFE4D4',
'success-150': '#BAD9C1',
'success-200': '#A2CFAD',
'success-250': '#86C597',
'success-300': '#6CBA83',
'success-350': '#4FB070',
'success-400': '#40A363',
'success-450': '#309556',
'success-500': '#1E884A',
'success-550': '#027B3E',
'success-600': '#016D31',
'success-650': '#006024',
'success-700': '#005317',
'success-750': '#0D4511',
'success-800': '#11380E',
'success-850': '#132A11',
'success-900': '#101E0F',
'success-950': '#091209',
'warning-050': '#F8F0E9',
'warning-100': '#F1E0D3',
'warning-150': '#ECD0BC',
'warning-200': '#E8C0A4',
'warning-250': '#E8AE8A',
'warning-300': '#EB9970',
'warning-350': '#E98456',
'warning-400': '#E57036',
'warning-450': '#DA5E18',
'warning-500': '#CB5000',
'warning-550': '#BC4200',
'warning-600': '#AD3300',
'warning-650': '#9E2300',
'warning-700': '#882011',
'warning-750': '#731E16',
'warning-800': '#58201A',
'warning-850': '#401D18',
'warning-900': '#2E1714',
'warning-950': '#1D0F0D',
'error-050': '#F9EFEC',
'error-100': '#F4DFD9',
'error-150': '#F0CEC6',
'error-200': '#EEBCB2',
'error-250': '#EEA99D',
'error-300': '#EF9486',
'error-350': '#F37C6E',
'error-400': '#F65F53',
'error-450': '#F0463D',
'error-500': '#E82322',
'error-550': '#D7010E',
'error-600': '#C00100',
'error-650': '#AA0000',
'error-700': '#910C06',
'error-750': '#731E16',
'error-800': '#58201A',
'error-850': '#401D18',
'error-900': '#2E1714',
'error-950': '#1D0F0D',
'red-050': '#FAEFEE',
'red-100': '#F4DEDD',
'red-150': '#F1CDCB',
'red-200': '#EFBBBA',
'red-250': '#EEA8A8',
'red-300': '#F09394',
'red-350': '#F37B7E',
'red-400': '#EF6569',
'red-450': '#E94A55',
'red-500': '#DA3B49',
'red-550': '#CA2A3C',
'red-600': '#BB1330',
'red-650': '#A90021',
'red-700': '#910A13',
'red-750': '#731E16',
'red-800': '#58201A',
'red-850': '#411D18',
'red-900': '#2E1714',
'red-950': '#1D0F0D',
'orange-050': '#F8F0E9',
'orange-100': '#F1E0D3',
'orange-150': '#ECD0BD',
'orange-200': '#EABFA6',
'orange-250': '#EBAC90',
'orange-300': '#EC9772',
'orange-350': '#E5845A',
'orange-400': '#D6774D',
'orange-450': '#C86A40',
'orange-500': '#B95D33',
'orange-550': '#AB5025',
'orange-600': '#9D4315',
'orange-650': '#8F3600',
'orange-700': '#812900',
'orange-750': '#6C2511',
'orange-800': '#572017',
'orange-850': '#401D18',
'orange-900': '#2E1714',
'orange-950': '#1D0F0D',
'brown-050': '#F6F0E8',
'brown-100': '#F1E0D3',
'brown-150': '#EBD0BA',
'brown-200': '#E2C0A6',
'brown-250': '#D4B398',
'brown-300': '#C6A58B',
'brown-350': '#B8987E',
'brown-400': '#AA8B71',
'brown-450': '#9D7E65',
'brown-500': '#8F7158',
'brown-550': '#82654C',
'brown-600': '#765841',
'brown-650': '#694C35',
'brown-700': '#5D412A',
'brown-750': '#51361E',
'brown-800': '#452A13',
'brown-850': '#392008',
'brown-900': '#29180A',
'brown-950': '#1B0F08',
'yellow-050': '#F3F0E7',
'yellow-100': '#E9E2CF',
'yellow-150': '#E1D4B7',
'yellow-200': '#D9C599',
'yellow-250': '#D2B677',
'yellow-300': '#CAA756',
'yellow-350': '#C2972E',
'yellow-400': '#B98900',
'yellow-450': '#AB7B00',
'yellow-500': '#9D6E00',
'yellow-550': '#916100',
'yellow-600': '#855400',
'yellow-650': '#784700',
'yellow-700': '#6C3A00',
'yellow-750': '#5F2E00',
'yellow-800': '#512302',
'yellow-850': '#3E1D10',
'yellow-900': '#2D1711',
'yellow-950': '#1D0F0D',
'green-050': '#E6F1E9',
'green-100': '#CFE4D5',
'green-150': '#B8D8C1',
'green-200': '#A0CFAE',
'green-250': '#84C59A',
'green-300': '#65BA86',
'green-350': '#45B173',
'green-400': '#23A562',
'green-450': '#029755',
'green-500': '#008948',
'green-550': '#017B3B',
'green-600': '#006E2E',
'green-650': '#006022',
'green-700': '#005314',
'green-750': '#0D4510',
'green-800': '#11380E',
'green-850': '#132A11',
'green-900': '#101E0F',
'green-950': '#091209',
'blue1-050': '#EBF1F9',
'blue1-100': '#D6E4F4',
'blue1-150': '#C1D7F0',
'blue1-200': '#AACAEF',
'blue1-250': '#8FBCEF',
'blue1-300': '#7CAFEB',
'blue1-350': '#68A1E4',
'blue1-400': '#5B94D6',
'blue1-450': '#4E86C7',
'blue1-500': '#4279B9',
'blue1-550': '#356CAC',
'blue1-600': '#28609E',
'blue1-650': '#1B5390',
'blue1-700': '#0B4783',
'blue1-750': '#0F3C6E',
'blue1-800': '#133059',
'blue1-850': '#152641',
'blue1-900': '#121C2D',
'blue1-950': '#0B111C',
'blue2-050': '#E7F3F4',
'blue2-100': '#CEE7E9',
'blue2-150': '#B2DCE0',
'blue2-200': '#91D1D7',
'blue2-250': '#68C7D0',
'blue2-300': '#43BBC5',
'blue2-350': '#00AFBA',
'blue2-400': '#01A0AA',
'blue2-450': '#00929D',
'blue2-500': '#00848F',
'blue2-550': '#007682',
'blue2-600': '#016874',
'blue2-650': '#005B67',
'blue2-700': '#004E5A',
'blue2-750': '#00424E',
'blue2-800': '#003642',
'blue2-850': '#002A38',
'blue2-900': '#061E28',
'blue2-950': '#071219',
'purple-050': '#F7F0F6',
'purple-100': '#EEE0EE',
'purple-150': '#E7D1E7',
'purple-200': '#DBBFE4',
'purple-250': '#D3AEE2',
'purple-300': '#CB99E1',
'purple-350': '#C188D9',
'purple-400': '#B47BCB',
'purple-450': '#A66EBD',
'purple-500': '#9961AF',
'purple-550': '#8B55A1',
'purple-600': '#7E4894',
'purple-650': '#723C87',
'purple-700': '#633376',
'purple-750': '#552A65',
'purple-800': '#452551',
'purple-850': '#35213D',
'purple-900': '#261A2C',
'purple-950': '#17111C',
'pink-050': '#F8EFF4',
'pink-100': '#F0DFEA',
'pink-150': '#EACEDF',
'pink-200': '#E9BBD1',
'pink-250': '#E9A7C2',
'pink-300': '#E095B4',
'pink-350': '#D685A8',
'pink-400': '#C7799B',
'pink-450': '#B86C8D',
'pink-500': '#AA5F80',
'pink-550': '#9C5374',
'pink-600': '#8E4767',
'pink-650': '#813B5B',
'pink-700': '#732E4F',
'pink-750': '#632643',
'pink-800': '#521F38',
'pink-850': '#3E1C2B',
'pink-900': '#2D171F',
'pink-950': '#1C0E12',
},
font: {
families: {
base: 'Inter, Roboto Flex Variable, sans-serif',
accent: 'Inter, Roboto Flex Variable, sans-serif',
},
},
},
},
};
const docsTokens = {
...tokens,
themes: {
...themeDefault,
...themesDSFR,
...tokens.themes,
...dsfrTheme,
...genericTheme,
},
};

View File

@@ -1,6 +1,6 @@
{
"name": "app-impress",
"version": "4.4.0",
"version": "4.3.0",
"repository": "https://github.com/suitenumerique/docs",
"author": "DINUM",
"license": "MIT",
@@ -34,15 +34,15 @@
"@fontsource-variable/inter": "5.2.8",
"@fontsource-variable/material-symbols-outlined": "5.2.30",
"@fontsource/material-icons": "5.2.7",
"@gouvfr-lasuite/cunningham-react": "4.1.0",
"@gouvfr-lasuite/integration": "1.0.3",
"@gouvfr-lasuite/ui-kit": "0.18.6",
"@gouvfr-lasuite/ui-kit": "0.18.4",
"@hocuspocus/provider": "3.4.3",
"@mantine/core": "8.3.10",
"@mantine/hooks": "8.3.10",
"@openfun/cunningham-react": "4.0.0",
"@react-pdf/renderer": "4.3.1",
"@sentry/nextjs": "10.32.1",
"@tanstack/react-query": "5.90.16",
"@sentry/nextjs": "10.30.0",
"@tanstack/react-query": "5.90.12",
"@tiptap/extensions": "*",
"canvg": "4.0.3",
"clsx": "2.1.1",
@@ -52,33 +52,33 @@
"emoji-datasource-apple": "16.0.0",
"emoji-mart": "5.6.0",
"emoji-regex": "10.6.0",
"i18next": "25.7.3",
"i18next": "25.7.2",
"i18next-browser-languagedetector": "8.2.0",
"idb": "8.0.3",
"lodash": "4.17.21",
"luxon": "3.7.2",
"next": "15.5.9",
"posthog-js": "1.312.0",
"posthog-js": "1.306.1",
"react": "*",
"react-aria-components": "1.14.0",
"react-aria-components": "1.13.0",
"react-dom": "*",
"react-i18next": "16.5.1",
"react-dropzone": "14.3.8",
"react-i18next": "16.5.0",
"react-intersection-observer": "10.0.0",
"react-resizable-panels": "3.0.6",
"react-select": "5.10.2",
"styled-components": "6.1.19",
"use-debounce": "10.0.6",
"uuid": "13.0.0",
"y-protocols": "1.0.7",
"y-protocols": "1.0.6",
"yjs": "*",
"zustand": "5.0.9"
},
"devDependencies": {
"@svgr/webpack": "8.1.0",
"@tanstack/react-query-devtools": "5.91.2",
"@tanstack/react-query-devtools": "5.91.1",
"@testing-library/dom": "10.4.1",
"@testing-library/jest-dom": "6.9.1",
"@testing-library/react": "16.3.1",
"@testing-library/react": "16.3.0",
"@testing-library/user-event": "14.6.1",
"@types/lodash": "4.17.21",
"@types/luxon": "3.7.1",
@@ -91,16 +91,16 @@
"dotenv": "17.2.3",
"eslint-plugin-docs": "*",
"fetch-mock": "9.11.0",
"jsdom": "27.4.0",
"jsdom": "27.3.0",
"node-fetch": "2.7.0",
"prettier": "3.7.4",
"stylelint": "16.26.1",
"stylelint-config-standard": "39.0.1",
"stylelint-prettier": "5.0.3",
"typescript": "*",
"vite-tsconfig-paths": "6.0.3",
"vitest": "4.0.16",
"webpack": "5.104.1",
"vite-tsconfig-paths": "6.0.1",
"vitest": "4.0.15",
"webpack": "5.103.0",
"workbox-webpack-plugin": "7.1.0"
},
"packageManager": "yarn@1.22.22"

View File

@@ -20,7 +20,7 @@ export type DefinedInitialDataInfiniteOptionsAPI<
QueryKey,
TPageParam
>;
export type UseInfiniteQueryResultAPI<Q> = InfiniteData<Q>;
export type InfiniteQueryConfig<Q> = Omit<
DefinedInitialDataInfiniteOptionsAPI<Q>,
'queryKey' | 'initialData' | 'getNextPageParam' | 'initialPageParam'

View File

@@ -0,0 +1,20 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M6.12757 9.8486C5.98657 9.6993 5.91709 9.5143 5.91709 9.30858C5.91709 9.10284 5.98679 8.91775 6.13233 8.77221C6.28262 8.62192 6.47291 8.54842 6.68579 8.54842H13.1697C13.3775 8.54842 13.5623 8.62245 13.7061 8.77215C13.8559 8.91601 13.9299 9.10081 13.9299 9.30858C13.9299 9.51737 13.8553 9.70306 13.7085 9.8511C13.5643 10.0024 13.3787 10.0773 13.1697 10.0773H6.68579C6.47291 10.0773 6.28262 10.0038 6.13233 9.85349L6.13076 9.85192L6.12757 9.8486Z"
fill="currentColor"
/>
<path
d="M6.12757 12.83C5.98657 12.6807 5.91709 12.4957 5.91709 12.29C5.91709 12.0843 5.98679 11.8992 6.13233 11.7536C6.28262 11.6033 6.47291 11.5298 6.68579 11.5298H13.1697C13.3775 11.5298 13.5623 11.6039 13.7061 11.7536C13.8559 11.8974 13.9299 12.0822 13.9299 12.29C13.9299 12.4988 13.8553 12.6845 13.7085 12.8325C13.5643 12.9838 13.3787 13.0587 13.1697 13.0587H6.68579C6.47291 13.0587 6.28262 12.9852 6.13233 12.8349L6.13076 12.8333L6.12757 12.83Z"
fill="currentColor"
/>
<path
d="M5.91709 15.2885C5.91709 15.4912 5.98839 15.6726 6.12757 15.82L6.134 15.8266L6.13723 15.8296C6.28833 15.9723 6.47704 16.0401 6.68579 16.0401H9.75263C9.96123 16.0401 10.1502 15.9722 10.2975 15.8249C10.444 15.6784 10.5213 15.4956 10.5213 15.2885C10.5213 15.0768 10.4486 14.8874 10.2999 14.7374C10.1539 14.5842 9.96433 14.5113 9.75263 14.5113H6.68579C6.47293 14.5113 6.28257 14.5847 6.13226 14.735L6.12757 14.7399C5.98486 14.891 5.91709 15.0797 5.91709 15.2885Z"
fill="currentColor"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M7.37975 1.24597C7.88425 0.735004 8.61944 0.5 9.54031 0.5H18.6127C19.533 0.5 20.2661 0.734736 20.7653 1.24652C21.2686 1.75666 21.5 2.49628 21.5 3.42147V16.3808C21.5 17.3112 21.2688 18.0521 20.7638 18.5572C20.2645 19.0624 19.532 19.2937 18.6127 19.2937H17.347V20.5338C17.347 21.4641 17.1158 22.2051 16.6108 22.7102C16.1115 23.2153 15.3789 23.4467 14.4597 23.4467H5.3873C4.46721 23.4467 3.73242 23.2149 3.22781 22.7103C2.72908 22.2051 2.5 21.4635 2.5 20.5338V7.57442C2.5 6.64962 2.72942 5.90915 3.22673 5.39893C3.73123 4.88796 4.46643 4.65295 5.3873 4.65295H6.65302V3.42147C6.65302 2.49666 6.88244 1.7562 7.37975 1.24597ZM8.42319 4.65295H14.4597C15.38 4.65295 16.1131 4.88769 16.6122 5.39947C17.1156 5.90962 17.347 6.64923 17.347 7.57442V17.5236H18.5444C18.9636 17.5236 19.2496 17.4163 19.4324 17.2289L19.4337 17.2275C19.6238 17.0374 19.7298 16.7549 19.7298 16.3552V3.4471C19.7298 3.04734 19.6238 2.76485 19.4337 2.57481L19.431 2.57206C19.248 2.37972 18.9625 2.27017 18.5444 2.27017H9.60866C9.19081 2.27017 8.90126 2.37956 8.71212 2.57341C8.52701 2.76329 8.42319 3.04633 8.42319 3.4471V4.65295ZM5.45564 21.6765C5.03728 21.6765 4.74743 21.5697 4.55844 21.3811C4.37372 21.1913 4.27017 20.9084 4.27017 20.5081V7.60005C4.27017 7.19928 4.37399 6.91625 4.55911 6.72636C4.74825 6.53252 5.03779 6.42313 5.45564 6.42313H14.3913C14.8095 6.42313 15.095 6.53268 15.278 6.72501L15.2807 6.72776C15.4708 6.9178 15.5768 7.20029 15.5768 7.60005V20.5081C15.5768 20.9079 15.4708 21.1904 15.2807 21.3804L15.2793 21.3818C15.0966 21.5693 14.8105 21.6765 14.3913 21.6765H5.45564Z"
fill="currentColor"
/>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@@ -1,21 +1,34 @@
import clsx from 'clsx';
import React from 'react';
import { css } from 'styled-components';
import { Text, TextType } from '@/components';
type IconProps = TextType & {
type IconBase = TextType & {
disabled?: boolean;
};
type IconMaterialProps = IconBase & {
iconName: string;
variant?: 'filled' | 'outlined' | 'symbols-outlined';
icon?: never;
};
type IconSVGProps = IconBase & {
icon: React.ReactNode;
iconName?: never;
variant?: never;
};
export const Icon = ({
className,
iconName,
disabled,
iconName,
icon,
variant = 'outlined',
$theme = 'neutral',
...textProps
}: IconProps) => {
}: IconMaterialProps | IconSVGProps) => {
const hasLabel = 'aria-label' in textProps || 'aria-labelledby' in textProps;
const ariaHidden =
'aria-hidden' in textProps ? textProps['aria-hidden'] : !hasLabel;
@@ -24,15 +37,15 @@ export const Icon = ({
<Text
aria-hidden={ariaHidden}
className={clsx('--docs--icon-bg', className, {
'material-icons-filled': variant === 'filled',
'material-icons': variant === 'outlined',
'material-symbols-outlined': variant === 'symbols-outlined',
'material-icons-filled': variant === 'filled' && iconName,
'material-icons': variant === 'outlined' && iconName,
'material-symbols-outlined': variant === 'symbols-outlined' && iconName,
})}
$theme={disabled ? 'disabled' : $theme}
aria-disabled={disabled}
{...textProps}
>
{iconName}
{iconName ?? icon}
</Text>
);
};

View File

@@ -1,4 +1,4 @@
import { Button } from '@gouvfr-lasuite/cunningham-react';
import { Button } from '@openfun/cunningham-react';
import { PropsWithChildren } from 'react';
import { useTranslation } from 'react-i18next';
import { InView } from 'react-intersection-observer';

View File

@@ -1,4 +1,4 @@
import { Loader } from '@gouvfr-lasuite/cunningham-react';
import { Loader } from '@openfun/cunningham-react';
import { useTranslation } from 'react-i18next';
import { Box, BoxProps } from './Box';

View File

@@ -1,4 +1,4 @@
import { Button } from '@gouvfr-lasuite/cunningham-react';
import { Button } from '@openfun/cunningham-react';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';

View File

@@ -1,4 +1,4 @@
import { Alert, VariantType } from '@gouvfr-lasuite/cunningham-react';
import { Alert, VariantType } from '@openfun/cunningham-react';
import { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import styled from 'styled-components';

View File

@@ -3,8 +3,6 @@ export * from './BoxButton';
export * from './Card';
export * from './DropButton';
export * from './dropdown-menu/DropdownMenu';
export * from './Emoji/EmojiPicker';
export { default as emojidata } from './Emoji/initEmojiCallout';
export * from './quick-search';
export * from './Icon';
export * from './InfiniteScroll';

View File

@@ -1,4 +1,4 @@
import { Button, Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react';
import { Button, Modal, ModalSize } from '@openfun/cunningham-react';
import { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';

View File

@@ -1,4 +1,4 @@
import { Button, type ButtonProps } from '@gouvfr-lasuite/cunningham-react';
import { Button, type ButtonProps } from '@openfun/cunningham-react';
import React from 'react';
import { Icon } from '@/components';

View File

@@ -1,4 +1,4 @@
import { Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react';
import { Modal, ModalSize } from '@openfun/cunningham-react';
import { ComponentPropsWithRef, PropsWithChildren } from 'react';
import { createGlobalStyle } from 'styled-components';

View File

@@ -1,4 +1,4 @@
import { Loader } from '@gouvfr-lasuite/cunningham-react';
import { Loader } from '@openfun/cunningham-react';
import { Command } from 'cmdk';
import { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';

View File

@@ -1,4 +1,4 @@
import { CunninghamProvider } from '@gouvfr-lasuite/cunningham-react';
import { CunninghamProvider } from '@openfun/cunningham-react';
import {
MutationCache,
QueryClient,

View File

@@ -1,4 +1,4 @@
import { Loader } from '@gouvfr-lasuite/cunningham-react';
import { Loader } from '@openfun/cunningham-react';
import Head from 'next/head';
import Script from 'next/script';
import { PropsWithChildren, useEffect, useRef } from 'react';

View File

@@ -4,14 +4,13 @@ import { Resource } from 'i18next';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { Theme } from '@/cunningham/';
import { FooterType } from '@/features/footer';
import { HeaderType, WaffleType } from '@/features/header';
import { HeaderType } from '@/features/header';
import { PostHogConf } from '@/services';
interface ThemeCustomization {
footer?: FooterType;
translations?: Resource;
header?: HeaderType;
waffle?: WaffleType;
}
export interface ConfigResponse {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -28,7 +28,7 @@ const getMergedTokens = (theme: Theme) => {
return merge({}, tokens.themes['default'], tokens.themes[theme]);
};
const DEFAULT_THEME: Theme = 'default';
const DEFAULT_THEME: Theme = 'generic';
const defaultTokens = getMergedTokens(DEFAULT_THEME);
const initialState: ThemeStore = {

View File

@@ -1,4 +1,4 @@
import { Button } from '@gouvfr-lasuite/cunningham-react';
import { Button } from '@openfun/cunningham-react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';

View File

@@ -93,10 +93,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
useSaveDoc(doc.id, provider.document, isConnectedToCollabServer);
const { i18n } = useTranslation();
let lang = i18n.resolvedLanguage;
if (!lang || !(lang in locales)) {
lang = 'en';
}
const lang = i18n.resolvedLanguage;
const { uploadFile, errorAttachment } = useUploadFile(doc.id);
@@ -262,6 +259,7 @@ export const BlockNoteReader = ({
const { user } = useAuth();
const { setEditor } = useEditorStore();
const { threadStore } = useComments(docId, false, user);
const { t } = useTranslation();
const editor = useCreateBlockNote(
{
collaboration: {
@@ -307,6 +305,7 @@ export const BlockNoteReader = ({
editor={editor}
editable={false}
theme="light"
aria-label={t('Document viewer')}
formattingToolbar={false}
slashMenu={false}
comments={false}

View File

@@ -9,7 +9,7 @@ import {
Loader,
VariantType,
useToastProvider,
} from '@gouvfr-lasuite/cunningham-react';
} from '@openfun/cunningham-react';
import { PropsWithChildren, ReactNode, useMemo } from 'react';
import { useTranslation } from 'react-i18next';

View File

@@ -1,4 +1,4 @@
import { Button, Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react';
import { Button, Modal, ModalSize } from '@openfun/cunningham-react';
import { useTranslation } from 'react-i18next';
import { Box, Icon, Text } from '@/components';

View File

@@ -5,7 +5,6 @@ import { Box, Loading } from '@/components';
import { DocHeader } from '@/docs/doc-header/';
import {
Doc,
useDocFocusManagement,
useIsCollaborativeEditable,
useProviderStore,
} from '@/docs/doc-management';
@@ -83,9 +82,7 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
const readOnly =
!doc.abilities.partial_update || !isEditable || isLoading || isDeletedDoc;
const { setIsSkeletonVisible } = useSkeletonStore();
const isProviderReady = Boolean(isReady && provider);
useDocFocusManagement(doc.id, isProviderReady);
const isProviderReady = isReady && provider;
useEffect(() => {
if (isProviderReady) {

View File

@@ -5,14 +5,11 @@ import { useTranslation } from 'react-i18next';
import { Box } from '@/components';
export const PICKER_HEIGHT = 500;
interface EmojiPickerProps {
emojiData: EmojiMartData;
onClickOutside: () => void;
onEmojiSelect: ({ native }: { native: string }) => void;
withOverlay?: boolean;
onEscape?: () => void;
}
export const EmojiPicker = ({
@@ -20,26 +17,14 @@ export const EmojiPicker = ({
onClickOutside,
onEmojiSelect,
withOverlay = false,
onEscape,
}: EmojiPickerProps) => {
const { i18n } = useTranslation();
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'Escape') {
if (onEscape) {
onEscape();
} else {
onClickOutside();
}
}
};
const pickerContent = (
<Box $position="absolute" $zIndex={1000} onKeyDownCapture={handleKeyDown}>
<Box $position="absolute" $zIndex={1000} $margin="2rem 0 0 0">
<Picker
data={emojiData}
locale={i18n.resolvedLanguage}
autoFocus
onClickOutside={onClickOutside}
onEmojiSelect={onEmojiSelect}
previewPosition="none"

View File

@@ -12,9 +12,12 @@ import { TFunction } from 'i18next';
import React, { useEffect, useState } from 'react';
import { createGlobalStyle, css } from 'styled-components';
import { Box, BoxButton, EmojiPicker, Icon, emojidata } from '@/components';
import { Box, BoxButton, Icon } from '@/components';
import { DocsBlockNoteEditor } from '../../types';
import { EmojiPicker } from '../EmojiPicker';
import emojidata from './initEmojiCallout';
const CalloutBlockStyle = createGlobalStyle`
.bn-block-content[data-content-type="callout"][data-background-color] {
@@ -94,15 +97,15 @@ const CalloutComponent = ({
`}
>
<CalloutBlockStyle />
<Box
$position="relative"
$css={css`
align-self: start;
`}
>
<Box $position="relative">
<BoxButton
contentEditable={false}
onClick={toggleEmojiPicker}
onKeyDown={(e) => {
if (e.key === 'Escape' && openEmojiPicker) {
setOpenEmojiPicker(false);
}
}}
$css={css`
font-size: 1.125rem;
cursor: ${isEditable ? 'pointer' : 'default'};

View File

@@ -1,4 +1,5 @@
export * from './AccessibleImageBlock';
export * from './CalloutBlock';
export { default as emojidata } from './initEmojiCallout';
export * from './PdfBlock';
export * from './UploadLoaderBlock';

View File

@@ -1,23 +1,21 @@
import {
PartialCustomInlineContentFromConfig,
StyleSchema,
} from '@blocknote/core';
/* eslint-disable react-hooks/rules-of-hooks */
import { createReactInlineContentSpec } from '@blocknote/react';
import * as Sentry from '@sentry/nextjs';
import { useRouter } from 'next/router';
import { useEffect } from 'react';
import { css } from 'styled-components';
import { validate as uuidValidate } from 'uuid';
import { BoxButton, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import SelectedPageIcon from '@/docs/doc-editor/assets/doc-selected.svg';
import { getEmojiAndTitle, useDoc } from '@/docs/doc-management/';
import { getEmojiAndTitle, useDoc } from '@/docs/doc-management';
export const InterlinkingLinkInlineContent = createReactInlineContentSpec(
{
type: 'interlinkingLinkInline',
propSchema: {
url: {
default: '',
},
docId: {
default: '',
},
@@ -29,97 +27,46 @@ export const InterlinkingLinkInlineContent = createReactInlineContentSpec(
},
{
render: ({ editor, inlineContent, updateInlineContent }) => {
if (!inlineContent.props.docId) {
return null;
}
const { data: doc } = useDoc({ id: inlineContent.props.docId });
const isEditable = editor.isEditable;
/**
* Should not happen
* Update the content title if the referenced doc title changes
*/
if (!uuidValidate(inlineContent.props.docId)) {
Sentry.captureException(
new Error(`Invalid docId: ${inlineContent.props.docId}`),
{
extra: { info: 'InterlinkingLinkInlineContent' },
},
);
useEffect(() => {
if (
isEditable &&
doc?.title &&
doc.title !== inlineContent.props.title
) {
updateInlineContent({
type: 'interlinkingLinkInline',
props: {
...inlineContent.props,
title: doc.title,
},
});
}
updateInlineContent({
type: 'interlinkingLinkInline',
props: {
docId: '',
title: '',
},
});
/**
* ⚠️ When doing collaborative editing, doc?.title might be out of sync
* causing an infinite loop of updates.
* To prevent this, we only run this effect when doc?.title changes,
* not when inlineContent.props.title changes.
*/
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [doc?.title, isEditable]);
return null;
}
return (
<LinkSelected
docId={inlineContent.props.docId}
title={inlineContent.props.title}
isEditable={editor.isEditable}
updateInlineContent={updateInlineContent}
/>
);
return <LinkSelected {...inlineContent.props} />;
},
},
);
interface LinkSelectedProps {
docId: string;
url: string;
title: string;
isEditable: boolean;
updateInlineContent: (
update: PartialCustomInlineContentFromConfig<
{
readonly type: 'interlinkingLinkInline';
readonly propSchema: {
readonly docId: {
readonly default: '';
};
readonly title: {
readonly default: '';
};
};
readonly content: 'none';
},
StyleSchema
>,
) => void;
}
export const LinkSelected = ({
docId,
title,
isEditable,
updateInlineContent,
}: LinkSelectedProps) => {
const { data: doc } = useDoc({ id: docId });
/**
* Update the content title if the referenced doc title changes
*/
useEffect(() => {
if (isEditable && doc?.title && doc.title !== title) {
updateInlineContent({
type: 'interlinkingLinkInline',
props: {
docId,
title: doc.title,
},
});
}
/**
* ⚠️ When doing collaborative editing, doc?.title might be out of sync
* causing an infinite loop of updates.
* To prevent this, we only run this effect when doc?.title changes,
* not when inlineContent.props.title changes.
*/
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [doc?.title, docId, isEditable]);
const LinkSelected = ({ url, title }: LinkSelectedProps) => {
const { colorsTokens } = useCunninghamTheme();
const { emoji, titleWithoutEmoji } = getEmojiAndTitle(title);
@@ -127,7 +74,7 @@ export const LinkSelected = ({
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
e.preventDefault();
void router.push(`/docs/${docId}/`);
void router.push(url);
};
return (

View File

@@ -247,6 +247,7 @@ export const SearchPage = ({
{
type: 'interlinkingLinkInline',
props: {
url: `/docs/${doc.id}`,
docId: doc.id,
title: doc.title || untitledDocument,
},

View File

@@ -1,3 +1,4 @@
export * from './BlockNoteEditor';
export * from './DocEditor';
export * from './EmojiPicker';
export * from './custom-blocks/';

View File

@@ -1,4 +1,4 @@
import { deriveMediaFilename } from '../utils_html';
import { deriveMediaFilename } from '../utils';
describe('deriveMediaFilename', () => {
test('uses last URL segment when src is a valid URL', () => {

View File

@@ -184,75 +184,6 @@ s {
margin: 0;
}
/* Remove bullet points from checkbox lists */
ul.checklist,
ul:has(li input[type='checkbox']) {
list-style: none;
padding-left: 0;
margin-left: 0;
}
ul.checklist li,
ul:has(li input[type='checkbox']) li {
list-style: none;
display: flex;
align-items: center;
gap: 8px;
}
ul.checklist li input[type='checkbox'],
ul:has(li input[type='checkbox']) li input[type='checkbox'] {
margin: 0;
width: 16px;
height: 16px;
cursor: pointer;
flex-shrink: 0;
}
ul.checklist li p,
ul:has(li input[type='checkbox']) li p {
margin: 0;
flex: 1;
}
/* Native HTML Lists - remove default margins */
ol,
ul {
margin: 0;
padding-left: 24px;
}
ol {
list-style-type: decimal;
}
ul {
list-style-type: disc;
}
/* Nested lists */
ul ul {
list-style-type: circle;
}
/* Keep decimal numbering for nested ol (remove this if you want letters) */
ol ol {
list-style-type: decimal;
}
li {
margin: 0;
padding: 0;
line-height: 24px;
}
li p {
margin: 0;
display: inline;
}
/* Quotes */
blockquote,
.bn-block-content[data-content-type='quote'] blockquote {

View File

@@ -9,7 +9,7 @@ import {
Select,
VariantType,
useToastProvider,
} from '@gouvfr-lasuite/cunningham-react';
} from '@openfun/cunningham-react';
import { DocumentProps, pdf } from '@react-pdf/renderer';
import jsonemoji from 'emoji-datasource-apple' assert { type: 'json' };
import i18next from 'i18next';
@@ -29,12 +29,11 @@ import { TemplatesOrdering, useTemplates } from '../api/useTemplates';
import { docxDocsSchemaMappings } from '../mappingDocx';
import { odtDocsSchemaMappings } from '../mappingODT';
import { pdfDocsSchemaMappings } from '../mappingPDF';
import { downloadFile } from '../utils';
import {
addMediaFilesToZip,
downloadFile,
generateHtmlDocument,
improveHtmlAccessibility,
} from '../utils_html';
} from '../utils';
enum DocDownloadFormat {
HTML = 'html',
@@ -162,12 +161,10 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
const zip = new JSZip();
improveHtmlAccessibility(parsedDocument, documentTitle);
await addMediaFilesToZip(parsedDocument, zip, mediaUrl);
const lang = i18next.language || fallbackLng;
const body = parsedDocument.body;
const editorHtmlWithLocalMedia = body ? body.innerHTML : '';
const editorHtmlWithLocalMedia = parsedDocument.body.innerHTML;
const htmlContent = generateHtmlDocument(
documentTitle,

View File

@@ -5,7 +5,6 @@
*/
export * from './api';
export * from './utils';
export * from './utils_html';
import * as ModalExport from './components/ModalExport';

View File

@@ -1,24 +1,16 @@
import { ExternalHyperlink, TextRun } from 'docx';
import { getEmojiAndTitle } from '@/docs/doc-management';
import { DocsExporterDocx } from '../types';
export const inlineContentMappingInterlinkingLinkDocx: DocsExporterDocx['mappings']['inlineContentMapping']['interlinkingLinkInline'] =
(inline) => {
if (!inline.props.docId) {
return new TextRun('');
}
const { emoji, titleWithoutEmoji } = getEmojiAndTitle(inline.props.title);
return new ExternalHyperlink({
children: [
new TextRun({
text: `${emoji || '📄'}${titleWithoutEmoji}`,
text: `📄${inline.props.title}`,
bold: true,
}),
],
link: window.location.origin + `/docs/${inline.props.docId}/`,
link: window.location.origin + inline.props.url,
});
};

View File

@@ -1,17 +1,11 @@
import React from 'react';
import { getEmojiAndTitle } from '@/docs/doc-management';
import { DocsExporterODT } from '../types';
export const inlineContentMappingInterlinkingLinkODT: DocsExporterODT['mappings']['inlineContentMapping']['interlinkingLinkInline'] =
(inline) => {
if (!inline.props.docId) {
return null;
}
const { emoji, titleWithoutEmoji } = getEmojiAndTitle(inline.props.title);
const url = window.location.origin + `/docs/${inline.props.docId}/`;
const url = window.location.origin + inline.props.url;
const title = inline.props.title;
// Create ODT hyperlink using React.createElement to avoid TypeScript JSX namespace issues
// Uses the same structure as BlockNote's default link mapping
@@ -24,6 +18,6 @@ export const inlineContentMappingInterlinkingLinkODT: DocsExporterODT['mappings'
xlinkShow: 'replace',
xlinkHref: url,
},
`${emoji || '📄'}${titleWithoutEmoji}`,
`📄${title}`,
);
};

View File

@@ -1,29 +1,21 @@
import { Image, Link, Text } from '@react-pdf/renderer';
import { getEmojiAndTitle } from '@/docs/doc-management';
import DocSelectedIcon from '../assets/doc-selected.png';
import { DocsExporterPDF } from '../types';
export const inlineContentMappingInterlinkingLinkPDF: DocsExporterPDF['mappings']['inlineContentMapping']['interlinkingLinkInline'] =
(inline) => {
if (!inline.props.docId) {
return <></>;
}
const { emoji, titleWithoutEmoji } = getEmojiAndTitle(inline.props.title);
return (
<Link
src={window.location.origin + `/docs/${inline.props.docId}/`}
src={window.location.origin + inline.props.url}
style={{
textDecoration: 'none',
color: 'black',
}}
>
{' '}
{emoji || <Image src={DocSelectedIcon.src} />}{' '}
<Text>{titleWithoutEmoji}</Text>{' '}
<Image src={DocSelectedIcon.src} />{' '}
<Text>{inline.props.title}</Text>{' '}
</Link>
);
};

View File

@@ -5,8 +5,11 @@ import {
} from '@blocknote/core';
import { Canvg } from 'canvg';
import { IParagraphOptions, ShadingType } from 'docx';
import JSZip from 'jszip';
import React from 'react';
import { exportResolveFileUrl } from './api';
export function downloadFile(blob: Blob, filename: string) {
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
@@ -189,3 +192,172 @@ export function odtRegisterParagraphStyleForBlock(
return styleName;
}
// Escape user-provided text before injecting it into the exported HTML document.
export const escapeHtml = (value: string): string =>
value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
interface MediaFilenameParams {
src: string;
index: number;
blob: Blob;
}
/**
* Derives a stable, readable filename for media exported in the HTML ZIP.
*
* Rules:
* - Default base name is "media-{index+1}".
* - For non data: URLs, we reuse the last path segment when possible (e.g. 1-photo.png).
* - If the base name has no extension, we try to infer one from the blob MIME type.
*/
export const deriveMediaFilename = ({
src,
index,
blob,
}: MediaFilenameParams): string => {
// Default base name
let baseName = `media-${index + 1}`;
// Try to reuse the last path segment for non data URLs.
if (!src.startsWith('data:')) {
try {
const url = new URL(src, window.location.origin);
const lastSegment = url.pathname.split('/').pop();
if (lastSegment) {
baseName = `${index + 1}-${lastSegment}`;
}
} catch {
// Ignore invalid URLs, keep default baseName.
}
}
let filename = baseName;
// Ensure the filename has an extension consistent with the blob MIME type.
const mimeType = blob.type;
if (mimeType && !baseName.includes('.')) {
const slashIndex = mimeType.indexOf('/');
const rawSubtype =
slashIndex !== -1 && slashIndex < mimeType.length - 1
? mimeType.slice(slashIndex + 1)
: '';
let extension = '';
const subtype = rawSubtype.toLowerCase();
if (subtype.includes('svg')) {
extension = 'svg';
} else if (subtype.includes('jpeg') || subtype.includes('pjpeg')) {
extension = 'jpg';
} else if (subtype.includes('png')) {
extension = 'png';
} else if (subtype.includes('gif')) {
extension = 'gif';
} else if (subtype.includes('webp')) {
extension = 'webp';
} else if (subtype.includes('pdf')) {
extension = 'pdf';
} else if (subtype) {
extension = subtype.split('+')[0];
}
if (extension) {
filename = `${baseName}.${extension}`;
}
}
return filename;
};
/**
* Generates a complete HTML document structure for export.
*
* @param documentTitle - The title of the document (will be escaped)
* @param editorHtmlWithLocalMedia - The HTML content from the editor
* @param lang - The language code for the document (e.g., 'fr', 'en')
* @returns A complete HTML5 document string
*/
export const generateHtmlDocument = (
documentTitle: string,
editorHtmlWithLocalMedia: string,
lang: string,
): string => {
return `<!DOCTYPE html>
<html lang="${lang}">
<head>
<meta charset="utf-8" />
<title>${escapeHtml(documentTitle)}</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<main role="main">
${editorHtmlWithLocalMedia}
</main>
</body>
</html>`;
};
export const addMediaFilesToZip = async (
parsedDocument: Document,
zip: JSZip,
mediaUrl: string,
) => {
const mediaFiles: { filename: string; blob: Blob }[] = [];
const mediaElements = Array.from(
parsedDocument.querySelectorAll<
HTMLImageElement | HTMLVideoElement | HTMLAudioElement | HTMLSourceElement
>('img, video, audio, source'),
);
await Promise.all(
mediaElements.map(async (element, index) => {
const src = element.getAttribute('src');
if (!src) {
return;
}
// data: URLs are already embedded and work offline; no need to create separate files.
if (src.startsWith('data:')) {
return;
}
// Only download same-origin resources (internal media like /media/...).
// External URLs keep their original src and are not included in the ZIP
let url: URL | null = null;
try {
url = new URL(src, mediaUrl);
} catch {
url = null;
}
if (!url || url.origin !== mediaUrl) {
return;
}
const fetched = await exportResolveFileUrl(url.href);
if (!(fetched instanceof Blob)) {
return;
}
const filename = deriveMediaFilename({
src: url.href,
index,
blob: fetched,
});
element.setAttribute('src', filename);
mediaFiles.push({ filename, blob: fetched });
}),
);
mediaFiles.forEach(({ filename, blob }) => {
zip.file(filename, blob);
});
};

View File

@@ -1,434 +0,0 @@
import JSZip from 'jszip';
import { exportResolveFileUrl } from './api';
// Escape user-provided text before injecting it into the exported HTML document.
export const escapeHtml = (value: string): string =>
value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
/**
* Derives a stable, readable filename for media exported in the HTML ZIP.
*
* Rules:
* - Default base name is "media-{index+1}".
* - For non data: URLs, we reuse the last path segment when possible (e.g. 1-photo.png).
* - If the base name has no extension, we try to infer one from the blob MIME type.
*/
interface MediaFilenameParams {
src: string;
index: number;
blob: Blob;
}
export const deriveMediaFilename = ({
src,
index,
blob,
}: MediaFilenameParams): string => {
// Default base name
let baseName = `media-${index + 1}`;
// Try to reuse the last path segment for non data URLs.
if (!src.startsWith('data:')) {
try {
const url = new URL(src, window.location.origin);
const lastSegment = url.pathname.split('/').pop();
if (lastSegment) {
baseName = `${index + 1}-${lastSegment}`;
}
} catch {
// Ignore invalid URLs, keep default baseName.
}
}
let filename = baseName;
// Ensure the filename has an extension consistent with the blob MIME type.
const mimeType = blob.type;
if (mimeType && !baseName.includes('.')) {
const slashIndex = mimeType.indexOf('/');
const rawSubtype =
slashIndex !== -1 && slashIndex < mimeType.length - 1
? mimeType.slice(slashIndex + 1)
: '';
let extension = '';
const subtype = rawSubtype.toLowerCase();
if (subtype.includes('svg')) {
extension = 'svg';
} else if (subtype.includes('jpeg') || subtype.includes('pjpeg')) {
extension = 'jpg';
} else if (subtype.includes('png')) {
extension = 'png';
} else if (subtype.includes('gif')) {
extension = 'gif';
} else if (subtype.includes('webp')) {
extension = 'webp';
} else if (subtype.includes('pdf')) {
extension = 'pdf';
} else if (subtype) {
extension = subtype.split('+')[0];
}
if (extension) {
filename = `${baseName}.${extension}`;
}
}
return filename;
};
/**
* Generates a complete HTML document structure for export.
*
* @param documentTitle - The title of the document (will be escaped)
* @param editorHtmlWithLocalMedia - The HTML content from the editor
* @param lang - The language code for the document (e.g., 'fr', 'en')
* @returns A complete HTML5 document string
*/
export const generateHtmlDocument = (
documentTitle: string,
editorHtmlWithLocalMedia: string,
lang: string,
): string => {
return `<!DOCTYPE html>
<html lang="${lang}">
<head>
<meta charset="utf-8" />
<title>${escapeHtml(documentTitle)}</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<main role="main">
${editorHtmlWithLocalMedia}
</main>
</body>
</html>`;
};
/**
* Enrich the HTML produced by the editor with semantic tags and basic a11y defaults.
*
* Notes:
* - We work directly on the parsed Document so modifications are reflected before we zip files.
* - We keep the editor inner structure but upgrade the key block types to native elements.
*/
export const improveHtmlAccessibility = (
parsedDocument: Document,
documentTitle: string,
) => {
const body = parsedDocument.body;
if (!body) {
return;
}
// 1) Headings: convert heading blocks to h1-h6 based on data-level
const headingBlocks = Array.from(
body.querySelectorAll<HTMLElement>("[data-content-type='heading']"),
);
headingBlocks.forEach((block) => {
const rawLevel = Number(block.getAttribute('data-level')) || 1;
const level = Math.min(Math.max(rawLevel, 1), 6);
const heading = parsedDocument.createElement(`h${level}`);
heading.innerHTML = block.innerHTML;
block.replaceWith(heading);
});
// 2) Lists: convert to semantic OL/UL/LI elements for accessibility
const listItemSelector =
"[data-content-type='bulletListItem'], [data-content-type='numberedListItem']";
// Helper function to get nesting level by counting block-group ancestors
const getNestingLevel = (blockOuter: HTMLElement): number => {
let level = 0;
let parent = blockOuter.parentElement;
while (parent) {
if (parent.classList.contains('bn-block-group')) {
level++;
}
parent = parent.parentElement;
}
return level;
};
// Find all block-outer elements in document order
const allBlockOuters = Array.from(
body.querySelectorAll<HTMLElement>('.bn-block-outer'),
);
// Collect list items with their info before modifying DOM
interface ListItemInfo {
blockOuter: HTMLElement;
listItem: HTMLElement;
contentType: string;
level: number;
}
const listItemsInfo: ListItemInfo[] = [];
allBlockOuters.forEach((blockOuter) => {
const listItem = blockOuter.querySelector<HTMLElement>(listItemSelector);
if (listItem) {
const contentType = listItem.getAttribute('data-content-type');
if (contentType) {
const level = getNestingLevel(blockOuter);
listItemsInfo.push({
blockOuter,
listItem,
contentType,
level,
});
}
}
});
// Stack to track lists at each nesting level
const listStack: Array<{ list: HTMLElement; type: string; level: number }> =
[];
listItemsInfo.forEach((info, idx) => {
const { blockOuter, listItem, contentType, level } = info;
const isBullet = contentType === 'bulletListItem';
const listTag = isBullet ? 'ul' : 'ol';
// Check if previous item continues the same list (same type and level)
const previousInfo = idx > 0 ? listItemsInfo[idx - 1] : null;
const continuesPreviousList =
previousInfo &&
previousInfo.contentType === contentType &&
previousInfo.level === level;
// Find or create the appropriate list
let targetList: HTMLElement | null = null;
if (continuesPreviousList) {
// Continue with the list at this level from stack
const listAtLevel = listStack.find((item) => item.level === level);
targetList = listAtLevel?.list || null;
}
// If no list found, create a new one
if (!targetList) {
targetList = parsedDocument.createElement(listTag);
// Remove lists from stack that are at same or deeper level
while (
listStack.length > 0 &&
listStack[listStack.length - 1].level >= level
) {
listStack.pop();
}
// If we have a parent list, nest this list inside its last li
if (
listStack.length > 0 &&
listStack[listStack.length - 1].level < level
) {
const parentList = listStack[listStack.length - 1].list;
const lastLi = parentList.querySelector('li:last-child');
if (lastLi) {
lastLi.appendChild(targetList);
} else {
// No li yet, create one and add the nested list
const li = parsedDocument.createElement('li');
parentList.appendChild(li);
li.appendChild(targetList);
}
} else {
// Top-level list
blockOuter.parentElement?.insertBefore(targetList, blockOuter);
}
// Add to stack
listStack.push({ list: targetList, type: contentType, level });
}
// Create list item and add content
const li = parsedDocument.createElement('li');
li.innerHTML = listItem.innerHTML;
targetList.appendChild(li);
// Remove original block-outer
blockOuter.remove();
});
// 3) Quotes -> <blockquote>
const quoteBlocks = Array.from(
body.querySelectorAll<HTMLElement>("[data-content-type='quote']"),
);
quoteBlocks.forEach((block) => {
const quote = parsedDocument.createElement('blockquote');
quote.innerHTML = block.innerHTML;
block.replaceWith(quote);
});
// 4) Callouts -> <aside role="note">
const calloutBlocks = Array.from(
body.querySelectorAll<HTMLElement>("[data-content-type='callout']"),
);
calloutBlocks.forEach((block) => {
const aside = parsedDocument.createElement('aside');
aside.setAttribute('role', 'note');
aside.innerHTML = block.innerHTML;
block.replaceWith(aside);
});
// 5) Checklists -> list + checkbox semantics
const checkListItems = Array.from(
body.querySelectorAll<HTMLElement>("[data-content-type='checkListItem']"),
);
checkListItems.forEach((item) => {
const parent = item.parentElement;
if (!parent) {
return;
}
let previousSibling = item.previousElementSibling;
let listContainer: HTMLElement | null = null;
if (previousSibling?.tagName.toLowerCase() === 'ul') {
listContainer = previousSibling as HTMLElement;
} else {
listContainer = parsedDocument.createElement('ul');
listContainer.setAttribute('role', 'list');
listContainer.classList.add('checklist');
parent.insertBefore(listContainer, item);
}
const li = parsedDocument.createElement('li');
li.innerHTML = item.innerHTML;
// Ensure checkbox has an accessible state; fall back to aria-checked if missing.
const checkbox = li.querySelector<HTMLInputElement>(
"input[type='checkbox']",
);
if (checkbox && !checkbox.hasAttribute('aria-checked')) {
checkbox.setAttribute(
'aria-checked',
checkbox.checked ? 'true' : 'false',
);
}
listContainer.appendChild(li);
parent.removeChild(item);
});
// 6) Code blocks -> <pre><code>
const codeBlocks = Array.from(
body.querySelectorAll<HTMLElement>("[data-content-type='codeBlock']"),
);
codeBlocks.forEach((block) => {
const pre = parsedDocument.createElement('pre');
const code = parsedDocument.createElement('code');
// Preserve existing classes/attributes so the exported CSS (dark theme) still applies.
pre.className = block.className || '';
pre.setAttribute('data-content-type', 'codeBlock');
// Copy other data attributes from the original block to the new <pre>.
Array.from(block.attributes).forEach((attr) => {
if (attr.name.startsWith('data-') && attr.name !== 'data-content-type') {
pre.setAttribute(attr.name, attr.value);
}
});
// Move content inside <code>.
code.innerHTML = block.innerHTML;
pre.appendChild(code);
block.replaceWith(pre);
});
// 7) Ensure images have alt text (empty when not provided)
body.querySelectorAll<HTMLImageElement>('img').forEach((img) => {
if (!img.hasAttribute('alt')) {
img.setAttribute('alt', '');
}
});
// 8) Wrap content in an article with a title landmark if none exists
const existingH1 = body.querySelector('h1');
if (!existingH1) {
const titleHeading = parsedDocument.createElement('h1');
titleHeading.id = 'doc-title';
titleHeading.textContent = documentTitle;
body.insertBefore(titleHeading, body.firstChild);
}
// If there is no article, group the body content inside one for better semantics.
const hasArticle = body.querySelector('article');
if (!hasArticle) {
const article = parsedDocument.createElement('article');
article.setAttribute('role', 'document');
article.setAttribute('aria-labelledby', 'doc-title');
while (body.firstChild) {
article.appendChild(body.firstChild);
}
body.appendChild(article);
}
};
export const addMediaFilesToZip = async (
parsedDocument: Document,
zip: JSZip,
mediaUrl: string,
) => {
const mediaFiles: { filename: string; blob: Blob }[] = [];
const mediaElements = Array.from(
parsedDocument.querySelectorAll<
HTMLImageElement | HTMLVideoElement | HTMLAudioElement | HTMLSourceElement
>('img, video, audio, source'),
);
await Promise.all(
mediaElements.map(async (element, index) => {
const src = element.getAttribute('src');
if (!src) {
return;
}
// data: URLs are already embedded and work offline; no need to create separate files.
if (src.startsWith('data:')) {
return;
}
// Only download same-origin resources (internal media like /media/...).
// External URLs keep their original src and are not included in the ZIP
let url: URL | null = null;
try {
url = new URL(src, mediaUrl);
} catch {
url = null;
}
if (!url || url.origin !== mediaUrl) {
return;
}
const fetched = await exportResolveFileUrl(url.href);
if (!(fetched instanceof Blob)) {
return;
}
const filename = deriveMediaFilename({
src: url.href,
index,
blob: fetched,
});
element.setAttribute('src', filename);
mediaFiles.push({ filename, blob: fetched });
}),
);
mediaFiles.forEach(({ filename, blob }) => {
zip.file(filename, blob);
});
};

View File

@@ -1,4 +1,4 @@
import { Button, Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react';
import { Button, Modal, ModalSize } from '@openfun/cunningham-react';
import { t } from 'i18next';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';

View File

@@ -1,9 +1,9 @@
import { useTreeContext } from '@gouvfr-lasuite/ui-kit';
import {
Button,
VariantType,
useToastProvider,
} from '@gouvfr-lasuite/cunningham-react';
import { useTreeContext } from '@gouvfr-lasuite/ui-kit';
} from '@openfun/cunningham-react';
import { useTranslation } from 'react-i18next';
import { Box, Card, Icon } from '@/components';

View File

@@ -1,5 +1,5 @@
import { Button } from '@gouvfr-lasuite/cunningham-react';
import { useTreeContext } from '@gouvfr-lasuite/ui-kit';
import { Button } from '@openfun/cunningham-react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';

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