mirror of
https://github.com/suitenumerique/docs.git
synced 2026-05-10 09:02:35 +02:00
Compare commits
6 Commits
fix/modal-
...
feature/do
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5cf508b35b | ||
|
|
13ce791c6a | ||
|
|
be90c621b1 | ||
|
|
d3088b82d7 | ||
|
|
0fdc42fa5f | ||
|
|
7e17cf1c47 |
15
CHANGELOG.md
15
CHANGELOG.md
@@ -8,26 +8,14 @@ and this project adheres to
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(frontend) add import document area in docs grid #1567
|
||||
- ✨(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
|
||||
|
||||
### Changed
|
||||
|
||||
- ♿(frontend) improve accessibility:
|
||||
- ♿(frontend) make html export accessible to screen reader users #1743
|
||||
|
||||
### Fixed
|
||||
|
||||
- ✅(backend) reduce flakiness on backend test #1769
|
||||
- 🐛(backend) fix TRASHBIN_CUTOFF_DAYS type error #1778
|
||||
- 🚸(frontend) remove blocking modal on save in Firefox #1787
|
||||
|
||||
### Security
|
||||
|
||||
- 🔒️(backend) validate more strictly url used by cors-proxy endpoint #1768
|
||||
|
||||
## [4.3.0] - 2026-01-05
|
||||
|
||||
@@ -73,6 +61,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
|
||||
|
||||
1
Makefile
1
Makefile
@@ -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
|
||||
|
||||
@@ -231,6 +231,11 @@ services:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
|
||||
docspec:
|
||||
image: ghcr.io/docspecio/api:2.4.4
|
||||
ports:
|
||||
- "4000:4000"
|
||||
|
||||
networks:
|
||||
lasuite:
|
||||
name: lasuite-network
|
||||
|
||||
@@ -64,9 +64,6 @@ These are the environment variables you can set for the `impress-backend` contai
|
||||
| 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_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 |
|
||||
@@ -116,6 +113,7 @@ These are the environment variables you can set for the `impress-backend` contai
|
||||
| USER_OIDC_ESSENTIAL_CLAIMS | Essential claims in OIDC token | [] |
|
||||
| Y_PROVIDER_API_BASE_URL | Y Provider url | |
|
||||
| Y_PROVIDER_API_KEY | Y provider API key | |
|
||||
| DOCSPEC_API_URL | URL to endpoint of DocSpec conversion API | |
|
||||
|
||||
|
||||
## impress-frontend image
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -25,12 +25,6 @@
|
||||
"matchPackageNames": ["pylint"],
|
||||
"allowedVersions": "<4.0.0"
|
||||
},
|
||||
{
|
||||
"groupName": "allowed django versions",
|
||||
"matchManagers": ["pep621"],
|
||||
"matchPackageNames": ["django"],
|
||||
"allowedVersions": "<6.0.0"
|
||||
},
|
||||
{
|
||||
"enabled": false,
|
||||
"groupName": "ignored js dependencies",
|
||||
|
||||
@@ -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"]}
|
||||
|
||||
@@ -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,15 @@ 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,
|
||||
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 +523,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 +1675,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 +1708,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 +1716,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 +1740,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 +1775,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
|
||||
|
||||
@@ -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. "
|
||||
|
||||
@@ -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
|
||||
|
||||
8
src/backend/core/services/mime_types.py
Normal file
8
src/backend/core/services/mime_types.py
Normal 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"
|
||||
@@ -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
|
||||
):
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"]}
|
||||
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
117
src/backend/core/tests/test_services_docspec_converter.py
Normal file
117
src/backend/core/tests/test_services_docspec_converter.py
Normal 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,
|
||||
)
|
||||
@@ -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",
|
||||
|
||||
@@ -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.6.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
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,60 @@
|
||||

|
||||
|
||||
# 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.
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
172
src/frontend/apps/e2e/__tests__/app-impress/doc-import.spec.ts
Normal file
172
src/frontend/apps/e2e/__tests__/app-impress/doc-import.spec.ts
Normal 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 });
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defaultTokens } from '@gouvfr-lasuite/cunningham-react';
|
||||
import { cunninghamConfig as tokens } from '@gouvfr-lasuite/ui-kit';
|
||||
import { defaultTokens } from '@openfun/cunningham-react';
|
||||
import merge from 'lodash/merge';
|
||||
|
||||
// Uikit does not provide the full list of tokens.
|
||||
|
||||
@@ -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,32 +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",
|
||||
"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",
|
||||
@@ -90,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"
|
||||
|
||||
@@ -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'
|
||||
|
||||
20
src/frontend/apps/impress/src/assets/icons/doc-all.svg
Normal file
20
src/frontend/apps/impress/src/assets/icons/doc-all.svg
Normal 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 |
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CunninghamProvider } from '@gouvfr-lasuite/cunningham-react';
|
||||
import { CunninghamProvider } from '@openfun/cunningham-react';
|
||||
import {
|
||||
MutationCache,
|
||||
QueryClient,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as Y from 'yjs';
|
||||
|
||||
import { useUpdateDoc } from '@/docs/doc-management/';
|
||||
import { KEY_LIST_DOC_VERSIONS } from '@/docs/doc-versioning';
|
||||
import { isFirefox } from '@/utils/userAgent';
|
||||
|
||||
import { toBase64 } from '../utils';
|
||||
|
||||
@@ -61,8 +62,24 @@ export const useSaveDoc = (
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const onSave = () => {
|
||||
saveDoc();
|
||||
const onSave = (e?: Event) => {
|
||||
const isSaving = saveDoc();
|
||||
|
||||
/**
|
||||
* Firefox does not trigger the request every time the user leaves the page.
|
||||
* Plus the request is not intercepted by the service worker.
|
||||
* So we prevent the default behavior to have the popup asking the user
|
||||
* if he wants to leave the page, by adding the popup, we let the time to the
|
||||
* request to be sent, and intercepted by the service worker (for the offline part).
|
||||
*/
|
||||
if (
|
||||
isSaving &&
|
||||
typeof e !== 'undefined' &&
|
||||
e.preventDefault &&
|
||||
isFirefox()
|
||||
) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
// Save every minute
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
*/
|
||||
export * from './api';
|
||||
export * from './utils';
|
||||
export * from './utils_html';
|
||||
|
||||
import * as ModalExport from './components/ModalExport';
|
||||
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
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);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Tooltip } from '@gouvfr-lasuite/cunningham-react';
|
||||
import { Tooltip } from '@openfun/cunningham-react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Button, useModal } from '@gouvfr-lasuite/cunningham-react';
|
||||
import { useTreeContext } from '@gouvfr-lasuite/ui-kit';
|
||||
import { Button, useModal } from '@openfun/cunningham-react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
VariantType,
|
||||
useToastProvider,
|
||||
} from '@gouvfr-lasuite/cunningham-react';
|
||||
import { VariantType, useToastProvider } from '@openfun/cunningham-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useEditorStore } from '../../doc-editor';
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
VariantType,
|
||||
useToastProvider,
|
||||
} from '@gouvfr-lasuite/cunningham-react';
|
||||
import { VariantType, useToastProvider } from '@openfun/cunningham-react';
|
||||
import {
|
||||
UseMutationOptions,
|
||||
useMutation,
|
||||
|
||||
@@ -24,7 +24,6 @@ export const updateDoc = async ({
|
||||
body: JSON.stringify({
|
||||
...params,
|
||||
}),
|
||||
keepalive: true,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button } from '@gouvfr-lasuite/cunningham-react';
|
||||
import { Button } from '@openfun/cunningham-react';
|
||||
import Head from 'next/head';
|
||||
import Image from 'next/image';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
ModalSize,
|
||||
VariantType,
|
||||
useToastProvider,
|
||||
} from '@gouvfr-lasuite/cunningham-react';
|
||||
} from '@openfun/cunningham-react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button } from '@gouvfr-lasuite/cunningham-react';
|
||||
import { Button } from '@openfun/cunningham-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box } from '@/components';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react';
|
||||
import { Modal, ModalSize } from '@openfun/cunningham-react';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useState } from 'react';
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
Button,
|
||||
VariantType,
|
||||
useToastProvider,
|
||||
} from '@gouvfr-lasuite/cunningham-react';
|
||||
} from '@openfun/cunningham-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box, Card, Text } from '@/components';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button } from '@gouvfr-lasuite/cunningham-react';
|
||||
import { Button } from '@openfun/cunningham-react';
|
||||
import { Fragment } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
VariantType,
|
||||
useToastProvider,
|
||||
} from '@gouvfr-lasuite/cunningham-react';
|
||||
import { VariantType, useToastProvider } from '@openfun/cunningham-react';
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
ButtonProps,
|
||||
VariantType,
|
||||
useToastProvider,
|
||||
} from '@gouvfr-lasuite/cunningham-react';
|
||||
} from '@openfun/cunningham-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { createGlobalStyle } from 'styled-components';
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
Button,
|
||||
VariantType,
|
||||
useToastProvider,
|
||||
} from '@gouvfr-lasuite/cunningham-react';
|
||||
} from '@openfun/cunningham-react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
VariantType,
|
||||
useToastProvider,
|
||||
} from '@gouvfr-lasuite/cunningham-react';
|
||||
import { VariantType, useToastProvider } from '@openfun/cunningham-react';
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
VariantType,
|
||||
useToastProvider,
|
||||
} from '@gouvfr-lasuite/cunningham-react';
|
||||
import { VariantType, useToastProvider } from '@openfun/cunningham-react';
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react';
|
||||
import { Modal, ModalSize } from '@openfun/cunningham-react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
VariantType,
|
||||
useToastProvider,
|
||||
} from '@gouvfr-lasuite/cunningham-react';
|
||||
import { VariantType, useToastProvider } from '@openfun/cunningham-react';
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useModal } from '@gouvfr-lasuite/cunningham-react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuOption,
|
||||
useArrowRoving,
|
||||
useTreeContext,
|
||||
} from '@gouvfr-lasuite/ui-kit';
|
||||
import { useModal } from '@openfun/cunningham-react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Loader } from '@gouvfr-lasuite/cunningham-react';
|
||||
import { Loader } from '@openfun/cunningham-react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
ModalSize,
|
||||
VariantType,
|
||||
useToastProvider,
|
||||
} from '@gouvfr-lasuite/cunningham-react';
|
||||
} from '@openfun/cunningham-react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
Button,
|
||||
Modal,
|
||||
ModalSize,
|
||||
useModal,
|
||||
} from '@gouvfr-lasuite/cunningham-react';
|
||||
import { Button, Modal, ModalSize, useModal } from '@openfun/cunningham-react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { createGlobalStyle, css } from 'styled-components';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Loader } from '@gouvfr-lasuite/cunningham-react';
|
||||
import { Loader } from '@openfun/cunningham-react';
|
||||
import { DateTime } from 'luxon';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { VariantType, useToastProvider } from '@openfun/cunningham-react';
|
||||
import {
|
||||
UseMutationOptions,
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
} from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
APIError,
|
||||
UseInfiniteQueryResultAPI,
|
||||
errorCauses,
|
||||
fetchAPI,
|
||||
} from '@/api';
|
||||
import { Doc, DocsResponse, KEY_LIST_DOC } from '@/docs/doc-management';
|
||||
|
||||
enum ContentTypes {
|
||||
Docx = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
Markdown = 'text/markdown',
|
||||
OctetStream = 'application/octet-stream',
|
||||
}
|
||||
|
||||
export enum ContentTypesAllowed {
|
||||
Docx = ContentTypes.Docx,
|
||||
Markdown = ContentTypes.Markdown,
|
||||
}
|
||||
|
||||
const getMimeType = (file: File): string => {
|
||||
if (file.type) {
|
||||
return file.type;
|
||||
}
|
||||
|
||||
const extension = file.name.split('.').pop()?.toLowerCase();
|
||||
|
||||
switch (extension) {
|
||||
case 'md':
|
||||
return ContentTypes.Markdown;
|
||||
case 'markdown':
|
||||
return ContentTypes.Markdown;
|
||||
case 'docx':
|
||||
return ContentTypes.Docx;
|
||||
default:
|
||||
return ContentTypes.OctetStream;
|
||||
}
|
||||
};
|
||||
|
||||
export const importDoc = async (file: File): Promise<Doc> => {
|
||||
const form = new FormData();
|
||||
|
||||
form.append(
|
||||
'file',
|
||||
new File([file], file.name, {
|
||||
type: getMimeType(file),
|
||||
lastModified: file.lastModified,
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await fetchAPI(`documents/`, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
withoutContentType: true,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError('Failed to import the doc', await errorCauses(response));
|
||||
}
|
||||
|
||||
return response.json() as Promise<Doc>;
|
||||
};
|
||||
|
||||
type UseImportDocOptions = UseMutationOptions<Doc, APIError, File>;
|
||||
|
||||
export function useImportDoc(props?: UseImportDocOptions) {
|
||||
const { toast } = useToastProvider();
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<Doc, APIError, File>({
|
||||
mutationFn: importDoc,
|
||||
...props,
|
||||
onSuccess: (...successProps) => {
|
||||
queryClient.setQueriesData<UseInfiniteQueryResultAPI<DocsResponse>>(
|
||||
{ queryKey: [KEY_LIST_DOC] },
|
||||
(oldData) => {
|
||||
if (!oldData || oldData?.pages.length === 0) {
|
||||
return oldData;
|
||||
}
|
||||
|
||||
return {
|
||||
...oldData,
|
||||
pages: oldData.pages.map((page, index) => {
|
||||
// Add the new doc to the first page only
|
||||
if (index === 0) {
|
||||
return {
|
||||
...page,
|
||||
results: [successProps[0], ...page.results],
|
||||
};
|
||||
}
|
||||
return page;
|
||||
}),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
toast(
|
||||
t('The document "{{documentName}}" has been successfully imported', {
|
||||
documentName: successProps?.[0].title || '',
|
||||
}),
|
||||
VariantType.SUCCESS,
|
||||
);
|
||||
|
||||
props?.onSuccess?.(...successProps);
|
||||
},
|
||||
onError: (...errorProps) => {
|
||||
toast(
|
||||
t(`The document "{{documentName}}" import has failed`, {
|
||||
documentName: errorProps?.[1].name || '',
|
||||
}),
|
||||
VariantType.ERROR,
|
||||
);
|
||||
|
||||
props?.onError?.(...errorProps);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { DndContext, DragOverlay, Modifier } from '@dnd-kit/core';
|
||||
import { getEventCoordinates } from '@dnd-kit/utilities';
|
||||
import { useModal } from '@gouvfr-lasuite/cunningham-react';
|
||||
import { TreeViewMoveModeEnum } from '@gouvfr-lasuite/ui-kit';
|
||||
import { useModal } from '@openfun/cunningham-react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import { Button } from '@gouvfr-lasuite/cunningham-react';
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
Button,
|
||||
VariantType,
|
||||
useToastProvider,
|
||||
} from '@openfun/cunningham-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { InView } from 'react-intersection-observer';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { Box, Card, Text } from '@/components';
|
||||
import AllDocs from '@/assets/icons/doc-all.svg';
|
||||
import { Box, Card, Icon, Text } from '@/components';
|
||||
import { DocDefaultFilter, useInfiniteDocs } from '@/docs/doc-management';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
import { useInfiniteDocsTrashbin } from '../api';
|
||||
import { ContentTypesAllowed, useImportDoc } from '../api/useImportDoc';
|
||||
import { useResponsiveDocGrid } from '../hooks/useResponsiveDocGrid';
|
||||
|
||||
import {
|
||||
@@ -24,6 +31,44 @@ export const DocsGrid = ({
|
||||
target = DocDefaultFilter.ALL_DOCS,
|
||||
}: DocsGridProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const { toast } = useToastProvider();
|
||||
const { getRootProps, getInputProps, open } = useDropzone({
|
||||
accept: {
|
||||
[ContentTypesAllowed.Docx]: ['.docx'],
|
||||
[ContentTypesAllowed.Markdown]: ['.md'],
|
||||
},
|
||||
onDrop(acceptedFiles) {
|
||||
setIsDragOver(false);
|
||||
for (const file of acceptedFiles) {
|
||||
importDoc(file);
|
||||
}
|
||||
},
|
||||
onDragEnter: () => {
|
||||
setIsDragOver(true);
|
||||
},
|
||||
onDragLeave: () => {
|
||||
setIsDragOver(false);
|
||||
},
|
||||
onDropRejected(fileRejections) {
|
||||
toast(
|
||||
t(
|
||||
`The document "{{documentName}}" import has failed (only .docx and .md files are allowed)`,
|
||||
{
|
||||
documentName: fileRejections?.[0].file.name || '',
|
||||
},
|
||||
),
|
||||
VariantType.ERROR,
|
||||
);
|
||||
},
|
||||
noClick: true,
|
||||
});
|
||||
const { mutate: importDoc } = useImportDoc();
|
||||
|
||||
const withUpload =
|
||||
!target ||
|
||||
target === DocDefaultFilter.ALL_DOCS ||
|
||||
target === DocDefaultFilter.MY_DOCS;
|
||||
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
const { flexLeft, flexRight } = useResponsiveDocGrid();
|
||||
@@ -60,21 +105,6 @@ export const DocsGrid = ({
|
||||
void fetchNextPage();
|
||||
};
|
||||
|
||||
let title = t('All docs');
|
||||
switch (target) {
|
||||
case DocDefaultFilter.MY_DOCS:
|
||||
title = t('My docs');
|
||||
break;
|
||||
case DocDefaultFilter.SHARED_WITH_ME:
|
||||
title = t('Shared with me');
|
||||
break;
|
||||
case DocDefaultFilter.TRASHBIN:
|
||||
title = t('Trashbin');
|
||||
break;
|
||||
default:
|
||||
title = t('All docs');
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
$position="relative"
|
||||
@@ -91,16 +121,24 @@ export const DocsGrid = ({
|
||||
$width="100%"
|
||||
$css={css`
|
||||
${!isDesktop ? 'border: none;' : ''}
|
||||
${isDragOver
|
||||
? `
|
||||
border: 2px dashed var(--c--contextuals--border--semantic--brand--primary);
|
||||
background-color: var(--c--contextuals--background--semantic--brand--tertiary);
|
||||
`
|
||||
: ''}
|
||||
`}
|
||||
$padding={{
|
||||
top: 'base',
|
||||
horizontal: isDesktop ? 'md' : 'xs',
|
||||
bottom: 'md',
|
||||
}}
|
||||
{...(withUpload ? getRootProps({ className: 'dropzone' }) : {})}
|
||||
>
|
||||
<Text as="h2" $size="h4" $margin={{ top: '0px', bottom: '10px' }}>
|
||||
{title}
|
||||
</Text>
|
||||
{withUpload && <input {...getInputProps()} />}
|
||||
<DocGridTitleBar
|
||||
target={target}
|
||||
onUploadClick={open}
|
||||
withUpload={withUpload}
|
||||
/>
|
||||
|
||||
{!hasDocs && !loading && (
|
||||
<Box $padding={{ vertical: 'sm' }} $align="center" $justify="center">
|
||||
@@ -110,7 +148,11 @@ export const DocsGrid = ({
|
||||
</Box>
|
||||
)}
|
||||
{hasDocs && (
|
||||
<Box $gap="6px" $overflow="auto">
|
||||
<Box
|
||||
$gap="6px"
|
||||
$overflow="auto"
|
||||
$padding={{ vertical: 'sm', horizontal: isDesktop ? 'md' : 'xs' }}
|
||||
>
|
||||
<Box role="grid" aria-label={t('Documents grid')}>
|
||||
<Box role="rowgroup">
|
||||
<Box
|
||||
@@ -172,6 +214,73 @@ export const DocsGrid = ({
|
||||
);
|
||||
};
|
||||
|
||||
const DocGridTitleBar = ({
|
||||
target,
|
||||
onUploadClick,
|
||||
withUpload,
|
||||
}: {
|
||||
target: DocDefaultFilter;
|
||||
onUploadClick: () => void;
|
||||
withUpload: boolean;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
|
||||
let title = t('All docs');
|
||||
let icon = <Icon icon={<AllDocs width={24} height={24} />} />;
|
||||
switch (target) {
|
||||
case DocDefaultFilter.MY_DOCS:
|
||||
icon = <Icon iconName="lock" />;
|
||||
title = t('My docs');
|
||||
break;
|
||||
case DocDefaultFilter.SHARED_WITH_ME:
|
||||
icon = <Icon iconName="group" />;
|
||||
title = t('Shared with me');
|
||||
break;
|
||||
case DocDefaultFilter.TRASHBIN:
|
||||
icon = <Icon iconName="delete" />;
|
||||
title = t('Trashbin');
|
||||
break;
|
||||
default:
|
||||
title = t('All docs');
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
$direction="row"
|
||||
$padding={{
|
||||
vertical: 'md',
|
||||
horizontal: isDesktop ? 'md' : 'xs',
|
||||
}}
|
||||
$css={css`
|
||||
border-bottom: 1px solid var(--c--contextuals--border--surface--primary);
|
||||
`}
|
||||
$align="center"
|
||||
$justify="space-between"
|
||||
>
|
||||
<Box $direction="row" $gap="xs" $align="center">
|
||||
{icon}
|
||||
<Text as="h2" $size="h4" $margin="none">
|
||||
{title}
|
||||
</Text>
|
||||
</Box>
|
||||
{withUpload && (
|
||||
<Button
|
||||
color="brand"
|
||||
variant="tertiary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onUploadClick();
|
||||
}}
|
||||
aria-label={t('Open the upload dialog')}
|
||||
>
|
||||
<Icon iconName="upload_file" $withThemeInherited />
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const useDocsQuery = (target: DocDefaultFilter) => {
|
||||
const trashbinQuery = useInfiniteDocsTrashbin(
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useModal } from '@gouvfr-lasuite/cunningham-react';
|
||||
import { useModal } from '@openfun/cunningham-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Tooltip, useModal } from '@gouvfr-lasuite/cunningham-react';
|
||||
import { Tooltip, useModal } from '@openfun/cunningham-react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { KeyboardEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button, Tooltip } from '@gouvfr-lasuite/cunningham-react';
|
||||
import { Button, Tooltip } from '@openfun/cunningham-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box, Icon, Text } from '@/components';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Loader } from '@gouvfr-lasuite/cunningham-react';
|
||||
import { Loader } from '@openfun/cunningham-react';
|
||||
import { createGlobalStyle, css } from 'styled-components';
|
||||
|
||||
import { Box } from '@/components';
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
VariantType,
|
||||
useToastProvider,
|
||||
} from '@gouvfr-lasuite/cunningham-react';
|
||||
import { VariantType, useToastProvider } from '@openfun/cunningham-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button } from '@gouvfr-lasuite/cunningham-react';
|
||||
import { Button } from '@openfun/cunningham-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Icon } from '@/components/';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button } from '@gouvfr-lasuite/cunningham-react';
|
||||
import { Button } from '@openfun/cunningham-react';
|
||||
import Image from 'next/image';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button } from '@gouvfr-lasuite/cunningham-react';
|
||||
import { Button } from '@openfun/cunningham-react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { usePathname, useSearchParams } from 'next/navigation';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import AllDocs from '@/assets/icons/doc-all.svg';
|
||||
import { Box, Icon, StyledLink, Text } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { DocDefaultFilter } from '@/docs/doc-management';
|
||||
@@ -21,22 +22,22 @@ export const LeftPanelTargetFilters = () => {
|
||||
|
||||
const defaultQueries = [
|
||||
{
|
||||
icon: 'apps',
|
||||
icon: <Icon icon={<AllDocs width={24} height={24} />} />,
|
||||
label: t('All docs'),
|
||||
targetQuery: DocDefaultFilter.ALL_DOCS,
|
||||
},
|
||||
{
|
||||
icon: 'lock',
|
||||
icon: <Icon iconName="lock" />,
|
||||
label: t('My docs'),
|
||||
targetQuery: DocDefaultFilter.MY_DOCS,
|
||||
},
|
||||
{
|
||||
icon: 'group',
|
||||
icon: <Icon iconName="group" />,
|
||||
label: t('Shared with me'),
|
||||
targetQuery: DocDefaultFilter.SHARED_WITH_ME,
|
||||
},
|
||||
{
|
||||
icon: 'delete',
|
||||
icon: <Icon iconName="delete" />,
|
||||
label: t('Trashbin'),
|
||||
targetQuery: DocDefaultFilter.TRASHBIN,
|
||||
},
|
||||
@@ -96,7 +97,7 @@ export const LeftPanelTargetFilters = () => {
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Icon iconName={query.icon} />
|
||||
{query.icon}
|
||||
<Text $size="sm">{query.label}</Text>
|
||||
</StyledLink>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useModal } from '@gouvfr-lasuite/cunningham-react';
|
||||
import { useModal } from '@openfun/cunningham-react';
|
||||
import { t } from 'i18next';
|
||||
import { DateTime } from 'luxon';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button } from '@gouvfr-lasuite/cunningham-react';
|
||||
import { Button } from '@openfun/cunningham-react';
|
||||
import { t } from 'i18next';
|
||||
import { useRouter } from 'next/router';
|
||||
import { PropsWithChildren, useCallback, useState } from 'react';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button } from '@gouvfr-lasuite/cunningham-react';
|
||||
import { Button } from '@openfun/cunningham-react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
VariantType,
|
||||
useToastProvider,
|
||||
} from '@gouvfr-lasuite/cunningham-react';
|
||||
import { VariantType, useToastProvider } from '@openfun/cunningham-react';
|
||||
import { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button } from '@gouvfr-lasuite/cunningham-react';
|
||||
import { Button } from '@openfun/cunningham-react';
|
||||
import Head from 'next/head';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button } from '@gouvfr-lasuite/cunningham-react';
|
||||
import { Button } from '@openfun/cunningham-react';
|
||||
import Head from 'next/head';
|
||||
import Image from 'next/image';
|
||||
import { ReactElement } from 'react';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button } from '@gouvfr-lasuite/cunningham-react';
|
||||
import { Button } from '@openfun/cunningham-react';
|
||||
import * as Sentry from '@sentry/nextjs';
|
||||
import { NextPageContext } from 'next';
|
||||
import NextError from 'next/error';
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user